Draws your own prompts as right-aligned chat bubbles so they stand apart from Claude's replies

Claude Code plugins that change how the terminal UI looks. They only change rendering: what the model sees and does is untouched.
From your shell:
claude plugin marketplace add akilin/claude-plugins
claude plugin install better-tool-rows@akilin-plugins
claude plugin install prompt-bubbles@akilin-plugins
Then run /reload-plugins in any open session, or restart it.
From your shell:
claude plugin update better-tool-rows@akilin-plugins
claude plugin update prompt-bubbles@akilin-plugins
Then run /reload-plugins in any open session, or restart it. /plugin marketplace update akilin-plugins alone only refreshes the plugin list and leaves installed plugins as they are.
To get updates automatically, open /plugin → Marketplaces → akilin-plugins → Enable auto-update.
With both plugins:

The same session without them (zoomed out to fit on one screen):

Shorter, quieter tool rows.
cds elsewhere or /cd moves the project. Files outside the folder keep their full path. Windows paths match with either separator and in any case. In a VS Code dev container or codespace the file:// link is left off, since VS Code would open it on the machine outside the container; ctrl+click on the path then uses VS Code's own link, which opens the file inside.Update(notes.md) +3 -1. A path too long to fit on the line with its counts is cut to its end (Update(…/src/notes.md) +3 -1), still opening the full path on ctrl+click. An edit held for review rather than written is shown as usual.Read 7 lines), the blank line stays.… so Bash(command) always fits on one line, wide characters included.Created notes.md +10, Deleted notes.md -10, Updated notes.md +1 -1) instead of a diff.▸ 12 lines; unfolded, it shows up to 500 lines and counts the rest. If the command was cut or joined, the fold also holds the full command with syntax highlighting (▸ command, 12 lines); with 5 lines of output or fewer, the output stays as it is and the fold holds the command alone (▸ command).Your own prompts are drawn as right-aligned chat bubbles (a rounded box with a cornflower-blue border, as wide as the longest line and at most three quarters of the terminal) so they stand apart from Claude's replies. Task notifications and messages from other agents or sessions keep the usual drawing, and so does the desktop app, which already has bubbles.
The repo has a dev container (Node, TypeScript and Claude Code preinstalled). It keeps Claude Code's config in a volume of its own, so logins and settings survive a rebuild, and sets VS Code's chat to auto-approve tool calls (chat.permissions.default), which is only safe inside the container.
From a plugin's folder (e.g. plugins/better-tool-rows):
claude plugin validate .
claude plugin test .
npx -y -p typescript tsc -p tsconfig.json
tsc needs the API types Claude Code writes to .claude-plugin/types/ (gitignored) when it loads the plugin, so on a fresh clone load it once first, e.g. claude --plugin-dir plugins/better-tool-rows.
MIT
hooks/register.tsx 157 lines1import type { Register } from 'claude-code'
2
3// The share of the transcript's width a bubble may take, so even a long
4// prompt leaves a gap on the left and reads as right-aligned.
5const MAX_SHARE = 0.75
6// Columns the border and padding add around the text: `│ ` and ` │`.
7const FRAME = 4
8// The bubble's border color: cornflower.
9const BORDER = '#6495ed'
10
11// Rows the person wrote; notifications, peers' and teammates' messages keep
12// the engine's drawing.
13const OWN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
14
15// Code points a terminal draws 2 columns wide: East Asian wide and fullwidth
16// characters, and the emoji drawn as pictures by default. The widths here are
17// kept the same as better-tool-rows' hooks/utils.tsx, which a plugin cannot
18// import from.
19const WIDE: [number, number][] = [
20 [0x1100, 0x115f],
21 [0x231a, 0x231b],
22 [0x23e9, 0x23ec],
23 [0x23f0, 0x23f0],
24 [0x23f3, 0x23f3],
25 [0x25fd, 0x25fe],
26 [0x2614, 0x2615],
27 [0x2648, 0x2653],
28 [0x267f, 0x267f],
29 [0x2693, 0x2693],
30 [0x26a1, 0x26a1],
31 [0x26aa, 0x26ab],
32 [0x26bd, 0x26be],
33 [0x26c4, 0x26c5],
34 [0x26ce, 0x26ce],
35 [0x26d4, 0x26d4],
36 [0x26ea, 0x26ea],
37 [0x26f2, 0x26f3],
38 [0x26f5, 0x26f5],
39 [0x26fa, 0x26fa],
40 [0x26fd, 0x26fd],
41 [0x2705, 0x2705],
42 [0x270a, 0x270b],
43 [0x2728, 0x2728],
44 [0x274c, 0x274c],
45 [0x274e, 0x274e],
46 [0x2753, 0x2755],
47 [0x2757, 0x2757],
48 [0x2795, 0x2797],
49 [0x27b0, 0x27b0],
50 [0x27bf, 0x27bf],
51 [0x2b1b, 0x2b1c],
52 [0x2b50, 0x2b50],
53 [0x2b55, 0x2b55],
54 [0x2e80, 0x303e],
55 [0x3040, 0xa4cf],
56 [0xac00, 0xd7a3],
57 [0xf900, 0xfaff],
58 [0xfe30, 0xfe4f],
59 [0xff00, 0xff60],
60 [0xffe0, 0xffe6],
61 [0x1f004, 0x1f004],
62 [0x1f0cf, 0x1f0cf],
63 [0x1f18e, 0x1f18e],
64 [0x1f191, 0x1f19a],
65 [0x1f200, 0x1f202],
66 [0x1f210, 0x1f23b],
67 [0x1f240, 0x1f248],
68 [0x1f250, 0x1f251],
69 [0x1f260, 0x1f265],
70 [0x1f300, 0x1faff],
71 [0x20000, 0x3fffd],
72]
73
74const ZWJ = '\u200d'
75const EMOJI_STYLE = '\ufe0f'
76
77// The columns a character takes on a terminal on its own: 0 for a combining
78// mark, joiner or variation selector, 2 for a wide one, 1 otherwise.
79const charWidth = (char: string) => {
80 if (/^[\p{Mn}\p{Me}\u200b-\u200d\ufe00-\ufe0f]$/u.test(char)) {
81 return 0
82 }
83 const c = char.codePointAt(0) ?? 0
84 return WIDE.some(([from, to]) => c >= from && c <= to) ? 2 : 1
85}
86
87// Each character of `text` with the columns it adds on a terminal: a
88// character joined on by a ZWJ adds none (a family emoji is one picture), and
89// an emoji selector widens a narrow character it follows to 2 (a heart
90// followed by U+FE0F).
91const charWidths = function* (text: string): Generator<[string, number]> {
92 let previous = ''
93 let clusterWidth = 0
94 for (const char of text) {
95 const width =
96 previous === ZWJ ? 0 : char === EMOJI_STYLE ? (clusterWidth === 1 ? 1 : 0) : charWidth(char)
97 clusterWidth = previous === ZWJ || char === EMOJI_STYLE || width === 0 ? clusterWidth + width : width
98 previous = char
99 yield [char, width]
100 }
101}
102
103// The columns a line takes on a terminal.
104export const textWidth = (text: string) => {
105 let width = 0
106 for (const [, charColumns] of charWidths(text)) {
107 width += charColumns
108 }
109 return width
110}
111
112// How wide the bubble for `text` is in a transcript `columns` wide: its
113// longest line plus the frame, at most MAX_SHARE of the width.
114export const bubbleWidth = (text: string, columns: number) => {
115 const longest = Math.max(0, ...text.split('\n').map(textWidth))
116 const most = Math.max(FRAME + 1, Math.floor(columns * MAX_SHARE))
117 return Math.min(longest + FRAME, most)
118}
119
120// A paste reaches the model wrapped as `<pasted_content id="…">` …
121// `</pasted_content id="…">`, with tags of that name inside it escaped as
122// `<\pasted_content` and `<\/pasted_content`. The engine pads the wrapper with
123// blank lines, so the newlines around it are taken as part of it.
124const PASTE = /\n*<pasted_content id="([^"]*)">\n?([\s\S]*?)\n?<\/pasted_content id="\1">\n*/g
125const ESCAPED_TAG = /<\\(\\*\/?pasted_content\b)/g
126
127// The prompt as the person pasted it: each paste's wrapper and padding
128// dropped, the paste on lines of its own, and the tags inside it unescaped
129// once.
130export const unwrapPastes = (text: string) =>
131 text.replace(PASTE, (wrapped: string, _id, body: string, at: number) => {
132 const end = at + wrapped.length
133 const before = at > 0 ? '\n' : ''
134 const after = end < text.length && !text.startsWith('<pasted_content id="', end) ? '\n' : ''
135 return before + body.replace(ESCAPED_TAG, '<$1') + after
136 })
137
138export const register: Register = on => {
139 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
140 const text = unwrapPastes(e.props.text.replace(/\r\n?/g, '\n')).replace(/\s+$/, '')
141 if (e.surface !== 'terminal' || !OWN_ORIGINS.has(e.props.origin.kind) || text === '') {
142 return next(e)
143 }
144
145 const { Box, Text } = $.ui.resolve(e)
146 const columns = e.viewport?.columns ?? 80
147
148 return (
149 <Box width="100%" justifyContent="flex-end" marginTop={1}>
150 <Box key="bubble" width={bubbleWidth(text, columns)} borderStyle="round" borderColor={BORDER} paddingX={1}>
151 <Text wrap="wrap">{text}</Text>
152 </Box>
153 </Box>
154 )
155 })
156}
157