Shows Read, Edit and Write paths relative to the session directory, Edit/Write results as +/- counts instead of diffs, long Bash commands cut to one line and…

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.ts 12 lines1import type { Register } from 'claude-code'
2
3import { registerBash } from './bash'
4import { registerFiles } from './files'
5import { registerSpacing } from './spacing'
6
7export const register: Register = (on, options) => {
8 registerSpacing(on, options)
9 registerFiles(on, options)
10 registerBash(on, options)
11}
12hooks/bash.tsx 242 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { commandShape, commandSource, isShapeCut, shellLines, shortCommand, withOverwrites } from './shell'
5import type { CommandShape } from './shell'
6import {
7 drawNothing,
8 GROUP_INDENT,
9 gutterLine,
10 ROW_GUTTER,
11 shortPath,
12 textWidth,
13 toolLabel,
14 viewportColumns,
15 withInput,
16} from './utils'
17
18const isOutputOpen = atom({ plugin: 'better-tool-rows', key: 'isOutputOpen' } as const, false)
19
20// The shape of the command each Bash call was made with, for the result
21// beneath its row, which is not handed the call's input. The shape, not the
22// command, so a session's heredocs are not all kept.
23const commandShapeOf = atom({ plugin: 'better-tool-rows', key: 'commandShape' } as const, null)
24
25// The project root paths are shown relative to, as files.tsx records it. Not
26// imported from there: the engine only reads state through an atom declared
27// in the reading file, and does not follow `$` into an imported function.
28const startRoot = atom({ plugin: 'better-tool-rows', key: 'startRoot' } as const, null)
29
30// The most lines an output shows as the engine draws it; a longer one folds.
31export const FOLD_OVER = 5
32
33// The most lines an unfolded output draws; the rest are counted beneath.
34export const SHOWN_LINES = 500
35
36// The columns a command has on its row: the line, less `● Bash()` and a
37// group's indent.
38export const commandRoom = (columns: number) => columns - textWidth(toolLabel('Bash', '')) - GROUP_INDENT
39
40// Whether a Bash output is one the plugin can draw as the engine would: a
41// finished command's lines, or the text a failed or interrupted call read.
42// Not an interrupted command's, a backgrounded one's, an image, or one whose
43// full output was saved to a file: the engine marks each of those.
44const isPlain = (output: unknown) => {
45 if (typeof output === 'string') {
46 return true
47 }
48 if (typeof output !== 'object' || output === null) {
49 return false
50 }
51 const result = output as { interrupted?: unknown; isImage?: unknown; backgroundTaskId?: unknown; persistedOutputPath?: unknown }
52 return (
53 result.interrupted !== true &&
54 !result.isImage &&
55 result.backgroundTaskId === undefined &&
56 result.persistedOutputPath === undefined
57 )
58}
59
60// A Bash output as the engine is handed it: a plain one with the lines
61// carriage returns wrote over as a terminal leaves them, any other as it is.
62const forEngine = (output: unknown) => (isPlain(output) ? withOverwrites(output) : output)
63
64// The lines of a finished Bash call's output that its row draws beneath its
65// fold, the result beneath it then drawing nothing; undefined for an output
66// left to the engine. The row and the result both decide by this, from what
67// both are handed, so an output is never drawn twice or not at all:
68// - a long plain output is always drawn on the row, folded;
69// - a short plain one is when the command, as this session saw the call
70// made, is cut: the row folds the command above it. The engine draws a
71// group's output in its row and a standalone row's as its result, so only
72// a result that knows its command can tell to draw nothing;
73// - any other is the engine's.
74const rowLines = (output: unknown, shape: CommandShape | null, columns: number) => {
75 if (!isPlain(output)) {
76 return undefined
77 }
78 const lines = shellLines(output)
79 const isCut = shape !== null && isShapeCut(shape, commandRoom(columns))
80 return lines.length > FOLD_OVER || isCut ? lines : undefined
81}
82
83type EditDiff = {
84 files: { filePath: string; hunks?: { lines?: string[] }[]; created?: true; deleted?: true }[]
85 moreFiles?: number
86}
87
88// A Bash output without the files its command created, deleted or updated in
89// its diff, which the engine draws as every line each one has or had, or as
90// the hunks it changed, and those files with the lines added and removed, in
91// the diff's order. The diff stays, emptied, for the files it left uncounted.
92export const withoutFileDiffs = (output: unknown) => {
93 const diff = (output as { bashEditDiff?: EditDiff } | null)?.bashEditDiff
94 if (typeof output !== 'object' || output === null || !Array.isArray(diff?.files) || diff.files.length === 0) {
95 return { output, changedFiles: [] }
96 }
97 const { bashEditDiff: _, ...rest } = output as { bashEditDiff: EditDiff }
98 return {
99 output: diff.moreFiles ? { ...output, bashEditDiff: { ...diff, files: [] } } : rest,
100 changedFiles: diff.files.map(file => {
101 const changed = (file.hunks ?? []).flatMap(hunk => hunk.lines ?? [])
102 return {
103 path: file.filePath,
104 change: file.created === true ? 'Created' : file.deleted === true ? 'Deleted' : 'Updated',
105 added: changed.filter(line => line.startsWith('+')).length,
106 removed: changed.filter(line => line.startsWith('-')).length,
107 }
108 }),
109 }
110}
111
112export const registerBash: Register = on => {
113 // Each Bash call's command shape is kept by its tool_use_id as it is made.
114 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115 if (typeof e.command === 'string') {
116 const shape = commandShape(e.command)
117 await update($, memberOf(commandShapeOf, { requestId: e.tool_use_id }), () => shape)
118 }
119 return next(e)
120 })
121
122 // A Bash row's command fits on its line, cut with `…` when it is longer.
123 // Beneath the row of a finished call, a fold:
124 // - `▸ 12 lines` over a long output;
125 // - `▸ command, 12 lines` over a long output of a cut command, the full
126 // command drawn first when it opens;
127 // - `▸ command` for a cut command with a short output, the output drawn
128 // open beneath the fold (`(No output)` for none) when the row draws it,
129 // and the full command opening above it.
130 // Pressing the fold opens it (`▾ ...`) and pressing again folds it.
131 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
132 const input = e.props.input as { command?: unknown } | undefined
133 if (typeof input?.command !== 'string') {
134 return next(e)
135 }
136 const command = input.command
137 const columns = viewportColumns(e)
138 const { text, isCut } = shortCommand(command, commandRoom(columns))
139 const withCommand = withInput(e, { command: text })
140 if (e.props.isRunning) {
141 return next(withCommand)
142 }
143 const { output, changedFiles } = withoutFileDiffs(e.props.output)
144 const lines = rowLines(output, await read($, memberOf(commandShapeOf, { requestId: e.props.tool_use_id })), columns)
145 const short = { ...withCommand, props: { ...withCommand.props, output: forEngine(output) } }
146 const elements = $.ui.resolve(e)
147 const { Box, Button, Code, Text } = elements
148
149 // A file the command changed, as `Created lorem.txt +10`,
150 // `Deleted lorem.txt -10` or `Updated lorem.txt +1 -1` beneath the row, a
151 // side left out when it is zero.
152 const root = changedFiles.length > 0 ? ((await read($, startRoot)) ?? (await $.session.root())) : ''
153 const changedFileLines = changedFiles.map(file => {
154 const sides = [
155 { text: `+${file.added}`, color: 'success' as const, isShown: file.added > 0 },
156 { text: `-${file.removed}`, color: 'error' as const, isShown: file.removed > 0 },
157 ].filter(side => side.isShown)
158 return gutterLine(
159 elements,
160 <Text>
161 {file.change} {shortPath(file.path, root)}
162 {sides.map(side => (
163 <Text key={side.color} color={side.color}>
164 {` ${side.text}`}
165 </Text>
166 ))}
167 </Text>,
168 `${file.change}:${file.path}`,
169 )
170 })
171 if (!isCut && lines === undefined) {
172 return changedFiles.length === 0 ? next(short) : (
173 <Box flexDirection="column">
174 {await next(short)}
175 {changedFileLines}
176 </Box>
177 )
178 }
179
180 const isOpenRef = memberOf(isOutputOpen, e)
181 const isOpen = await read($, isOpenRef)
182 const lineCount = lines?.length ?? 0
183 const isFolded = lineCount > FOLD_OVER
184 const shown = lines === undefined || (isFolded && !isOpen) ? [] : lines.slice(0, SHOWN_LINES)
185 const hiddenCount = shown.length > 0 ? lineCount - shown.length : 0
186 const isEmpty = lines?.length === 0
187 // No fold over a short output of a command that fits its row, which the
188 // row draws when a plugin above made the call with a longer command.
189 const hasFold = isCut || isFolded
190 const label = isFolded ? `${isCut ? 'command, ' : ''}${lineCount} lines` : 'command'
191 return (
192 <Box flexDirection="column">
193 {await next(lines === undefined ? short : { ...short, props: { ...short.props, output: undefined } })}
194 {changedFileLines}
195 {hasFold &&
196 gutterLine(
197 elements,
198 <Button
199 key="output"
200 plain
201 dimColor
202 label={`${isOpen ? '▾' : '▸'} ${label}`}
203 onPress={() => update($, isOpenRef, was => !was)}
204 />,
205 )}
206 {(isOpen || shown.length > 0 || isEmpty) && (
207 <Box flexDirection="column" paddingLeft={ROW_GUTTER.length}>
208 {isOpen && isCut && (
209 <Box>
210 <Text dimColor>$ </Text>
211 <Code source={commandSource(command)} language="bash" />
212 </Box>
213 )}
214 {shown.map((line, i) => (
215 <Text key={String(i)} color={e.props.isErrored ? 'error' : undefined}>
216 {line === '' ? ' ' : line}
217 </Text>
218 ))}
219 {hiddenCount > 0 && <Text dimColor>… +{hiddenCount} lines</Text>}
220 {isEmpty && <Text dimColor>(No output)</Text>}
221 </Box>
222 )}
223 </Box>
224 )
225 })
226
227 // The output a Bash row draws, its result draws nothing in place of, as it
228 // does when the files the row says were changed were all the output had,
229 // rather than the engine's `Done`; the engine draws the rest without those
230 // files, and with the lines carriage returns wrote over as a terminal leaves
231 // them.
232 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e, next) => {
233 const shape = await read($, memberOf(commandShapeOf, { requestId: e.props.tool_use_id }))
234 const { output, changedFiles } = withoutFileDiffs(e.props.output)
235 const isOnlyChangedFiles =
236 changedFiles.length > 0 && isPlain(output) && !('bashEditDiff' in (output as object)) && shellLines(output).length === 0
237 return rowLines(output, shape, viewportColumns(e)) === undefined && !isOnlyChangedFiles
238 ? next({ ...e, props: { ...e.props, output: forEngine(output) } })
239 : drawNothing($.ui.resolve(e))
240 })
241}
242hooks/files.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import {
5 drawNothing,
6 fileUrl,
7 fitEnd,
8 GROUP_INDENT,
9 shortPath,
10 textWidth,
11 toolLabel,
12 viewportColumns,
13 withInput,
14} from './utils'
15
16// The session's project root when the plugin first started in it: the folder
17// Claude Code was started in, which the terminal resolves ctrl+click from.
18// Kept in the session's state, so a reload does not take a moved root.
19const startRoot = atom({ plugin: 'better-tool-rows', key: 'startRoot' } as const, null)
20
21// The root paths are shown relative to: the one at the start, or the project
22// root as it is now while the start is unrecorded.
23const rootOf = async ($: EngineInterface) => (await read($, startRoot)) ?? (await $.session.root())
24
25// The render event with its file_path relative to `root`, that path and the
26// full one; undefined for an input without one.
27const withShortPath = <E extends { props: { input?: unknown } }>(e: E, root: string) => {
28 const input = e.props.input as { file_path?: unknown } | undefined
29 if (typeof input?.file_path !== 'string') {
30 return undefined
31 }
32 const path = shortPath(input.file_path, root)
33 return { e: withInput(e, { file_path: path }), path, full: input.file_path }
34}
35
36// A shorter path as withShortPath makes it, cut to its end with `…` when it
37// is wider than `room` columns; the link still goes to the full one.
38const withPathIn = <S extends { e: { props: { input?: unknown } }; path: string }>(short: S, room: number): S => {
39 if (textWidth(short.path) <= room) {
40 return short
41 }
42 const path = `…${fitEnd(short.path, Math.max(room, 1) - 1)}`
43 return { ...short, e: withInput(short.e, { file_path: path }), path }
44}
45
46// Whether Claude Code runs in a dev container or a codespace, whose VS Code
47// terminal opens a file:// URL on the machine outside it, where the file is
48// not. Its own link on a path's text opens the file inside.
49const isInContainer = async ($: EngineInterface) =>
50 (await $.env.get('REMOTE_CONTAINERS')) === 'true' || (await $.env.get('CODESPACES')) === 'true'
51
52// The engine's row with its path linked to the full path, not the shorter one
53// it was handed: the same text laid over it as a link, so a terminal opens it
54// on ctrl+click wherever it resolves from. Left as it is off the terminal (a
55// remote surface links https: alone), for a path drawn in full, and for a
56// label too long for the line, whose path wraps out from under the link, and
57// for a path no URL can hold, and in a dev container.
58// Laid over the label's line, the row's second (it opens with a blank line),
59// not its last: an expanded group's row draws its output beneath it. Clipped
60// to the path, as a terminal without hyperlinks draws the URL after the text,
61// which would cover the rest of the row.
62const withFullPathLink = async (
63 $: EngineInterface,
64 e: RenderInput<'ToolUse'>,
65 row: RenderElement,
66 name: string,
67 short: { path: string; full: string },
68) => {
69 if (e.surface !== 'terminal' || short.path === short.full || (await isInContainer($))) {
70 return row
71 }
72 const href = fileUrl(short.full)
73 if (href === undefined || textWidth(toolLabel(name, short.path)) > viewportColumns(e) - GROUP_INDENT) {
74 return row
75 }
76 const { Box, Link } = $.ui.resolve(e)
77 return (
78 <Box>
79 {row}
80 <Box
81 position="absolute"
82 top={1}
83 left={textWidth(toolLabel(name, '')) - 1}
84 width={textWidth(short.path)}
85 height={1}
86 overflow="hidden"
87 >
88 <Link href={href}>{short.path}</Link>
89 </Box>
90 </Box>
91 )
92}
93
94type Patch = { lines: string[] }[]
95type FileChange = {
96 type?: 'create' | 'update'
97 content?: string
98 structuredPatch: Patch
99 gitDiff?: { additions: number; deletions: number }
100 staged?: boolean
101}
102
103// The lines a change added and removed, as `git diff --stat` counts them; a
104// new file counts every line it was written with. A change whose patch is
105// empty (its diff took too long, or the file was too large) counts from its
106// git diff where it has one.
107export const changeCounts = (output: FileChange) => {
108 if (output.type === 'create') {
109 return { added: output.content ? output.content.replace(/\n$/, '').split('\n').length : 0, removed: 0 }
110 }
111 if (output.structuredPatch.length === 0 && output.gitDiff) {
112 return { added: output.gitDiff.additions, removed: output.gitDiff.deletions }
113 }
114
115 const changed = output.structuredPatch.flatMap(hunk => hunk.lines)
116 return {
117 added: changed.filter(line => line.startsWith('+')).length,
118 removed: changed.filter(line => line.startsWith('-')).length,
119 }
120}
121
122// The counts a finished, successful Edit or Write shows; undefined for any
123// other result, and for one held for review instead of written, which the
124// engine then draws as usual.
125export const countsOf = (isErrored: boolean, output: unknown) => {
126 const change = output as FileChange | undefined
127 if (isErrored || !change || !Array.isArray(change.structuredPatch) || change.staged) {
128 return undefined
129 }
130
131 const counts = changeCounts(change)
132 return counts.added === 0 && counts.removed === 0 ? undefined : counts
133}
134
135// The name the engine draws an Edit or Write row under: `Update`, `Create`
136// for an Edit with nothing to replace, or `Write`.
137const rowName = (tool: string, input: { old_string?: unknown }) =>
138 tool === 'Write' ? 'Write' : input.old_string === '' ? 'Create' : 'Update'
139
140// The text the engine draws for an Edit or Write row: its status dot, then
141// `Update(potato.md)`.
142export const rowLabel = (tool: string, input: { old_string?: unknown }, path: string) =>
143 toolLabel(rowName(tool, input), path)
144
145export const registerFiles: Register = on => {
146 // `/cd` or a worktree move later moves the project root, but not the
147 // terminal, so the root at the start is the one paths stay relative to.
148 on('session.start', async ($, e, next) => {
149 if ((await read($, startRoot)) === null) {
150 const root = await $.session.root()
151 await update($, startRoot, () => root)
152 }
153 return next(e)
154 })
155
156 // The engine draws its own Read row; it is only handed the shorter path.
157 on('ui.render', { component: 'ToolUse', props: { tool: 'Read' } }, async ($, e, next) => {
158 const short = withShortPath(e, await rootOf($))
159 return short ? withFullPathLink($, e, await next(short.e), 'Read', short) : next(e)
160 })
161
162 for (const tool of ['Edit', 'Write']) {
163 on('ui.render', { component: 'ToolUse', props: { tool } }, async ($, e, next) => {
164 // As a Read row, then the counts.
165 const short = withShortPath(e, await rootOf($))
166 if (!short) {
167 return next(e)
168 }
169 const name = rowName(tool, e.props.input as { old_string?: unknown })
170 const counts = countsOf(e.props.isErrored, e.props.output)
171 if (!counts) {
172 return withFullPathLink($, e, await next(short.e), name, short)
173 }
174
175 // `Update(potato.md) +1 -1`, a side left out when it is zero. The
176 // engine's row is as wide as the line and opens with a blank line, so the
177 // counts are laid over its second line, 1 column past the end of its
178 // text: the label's, as for the link, not an expanded group's output.
179 // The row stays one line, as the spacing above the next row takes it:
180 // a path too long for the line (less a group's indent) with the counts
181 // beside it is cut to its end.
182 const { Box, Text } = $.ui.resolve(e)
183 const sides = [
184 { text: `+${counts.added}`, color: 'success' as const, isShown: counts.added > 0 },
185 { text: `-${counts.removed}`, color: 'error' as const, isShown: counts.removed > 0 },
186 ].filter(side => side.isShown)
187 const countsText = (
188 <Text>
189 {sides.flatMap((side, i) => [
190 i > 0 && ' ',
191 <Text key={side.color} color={side.color}>
192 {side.text}
193 </Text>,
194 ])}
195 </Text>
196 )
197 const countsWidth = textWidth(sides.map(side => side.text).join(' '))
198 const room = viewportColumns(e) - GROUP_INDENT - textWidth(toolLabel(name, '')) - 1 - countsWidth
199 const cut = withPathIn(short, room)
200 return (
201 <Box>
202 {await withFullPathLink($, e, await next(cut.e), name, cut)}
203 <Box position="absolute" top={1} left={textWidth(toolLabel(name, cut.path)) + 1}>
204 {countsText}
205 </Box>
206 </Box>
207 )
208 })
209
210 // The counts are on the row, so the result beneath it draws nothing.
211 on('ui.render', { component: 'ToolResult', props: { tool } }, ($, e, next) => {
212 return countsOf(e.props.isErrored, e.props.output) ? drawNothing($.ui.resolve(e)) : next(e)
213 })
214 }
215}
216hooks/spacing.tsx 117 lines1import type { EngineInterface, Register, SessionMessage, ToolUseSummary } from 'claude-code'
2
3import { countsOf } from './files'
4
5// Whether each tool row, by its tool_use_id, comes straight after a one-line
6// row; `isSettled` once that can no longer change. Only that is kept, not the
7// call above (whose result can hold a whole file), and kept in the module, so
8// a reload works them out again.
9const afterOneLine = new Map<string, { isAfterOneLine: boolean; isSettled: boolean }>()
10
11// The read (by its count) a finished row was first missing from, by its
12// tool_use_id.
13const missedIn = new Map<string, number>()
14
15// The transcript's calls above, read once for every row asking at a time,
16// and which read it was.
17let reads = 0
18let reading: Promise<{ calls: Map<string, ToolUseSummary | null>; read: number }> | undefined
19const readCallsAbove = ($: EngineInterface) => {
20 reading ??= $.session
21 .messages()
22 .then(messages => {
23 const calls = new Map<string, ToolUseSummary | null>()
24 recordCallsAbove(messages, calls)
25 return { calls, read: ++reads }
26 })
27 .finally(() => {
28 reading = undefined
29 })
30 return reading
31}
32
33// Records, for every tool call in `messages`, the call drawn straight above
34// it. A reply's text breaks a run, and so does a prompt; results (a user
35// message carrying tool results, and the reminders beside them) and thinking
36// draw nothing between the rows and are passed over. A message with both text
37// and calls draws its text first.
38export const recordCallsAbove = (messages: SessionMessage[], into: Map<string, ToolUseSummary | null>) => {
39 let above: ToolUseSummary | null = null
40 for (const message of messages) {
41 if (message.role === 'user') {
42 if (!message.toolResults?.length) {
43 above = null
44 }
45 continue
46 }
47 if (message.text.trim() !== '') {
48 above = null
49 }
50 for (const call of message.toolUses) {
51 into.set(call.tool_use_id, above)
52 above = call
53 }
54 }
55}
56
57// Whether a call's row is one line alone: an Edit or Write drawn with its
58// counts, its result drawing nothing. Any other row has lines beneath it.
59export const isOneLine = (call: ToolUseSummary) =>
60 (call.tool === 'Edit' || call.tool === 'Write') && countsOf(call.isError === true, call.result) !== undefined
61
62// Whether the row comes straight after a one-line row. The transcript is read
63// again until the call above has finished, as its result decides; one that
64// finishes after this row's last drawing leaves it with its gap, as does a
65// row whose transcript cannot be read (a throw would skip every row hook). A
66// finished row the transcript does not hold (older than the newest entries it
67// returns, or a subagent's) keeps its gap without reading it again once two
68// reads have missed it: one alone may have started before its call was added.
69const isAfterOneLine = async ($: EngineInterface, id: string, isRunning: boolean) => {
70 const known = afterOneLine.get(id)
71 if (known?.isSettled) {
72 return known.isAfterOneLine
73 }
74 let found: Awaited<ReturnType<typeof readCallsAbove>>
75 try {
76 found = await readCallsAbove($)
77 } catch {
78 return false
79 }
80 const { calls, read } = found
81 for (const [rowId, above] of calls) {
82 if (!afterOneLine.get(rowId)?.isSettled) {
83 afterOneLine.set(rowId, {
84 isAfterOneLine: above !== null && isOneLine(above),
85 isSettled: above === null || above.text !== undefined,
86 })
87 }
88 }
89 if (afterOneLine.has(id) || isRunning) {
90 missedIn.delete(id)
91 } else if (missedIn.get(id) === undefined) {
92 missedIn.set(id, read)
93 } else if (missedIn.get(id) !== read) {
94 missedIn.delete(id)
95 afterOneLine.set(id, { isAfterOneLine: false, isSettled: true })
96 }
97 return afterOneLine.get(id)?.isAfterOneLine ?? false
98}
99
100export const registerSpacing: Register = on => {
101 // The engine opens each tool row with a blank line. A row straight under
102 // a one-line Edit or Write is pulled up over it, so a run of edits reads as
103 // a list; under text, or a row with lines beneath it (Bash output, Read's
104 // `Read 7 lines`), it keeps the gap.
105 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
106 if (e.surface !== 'terminal' || !(await isAfterOneLine($, e.props.tool_use_id, e.props.isRunning))) {
107 return next(e)
108 }
109 const { Box } = $.ui.resolve(e)
110 return (
111 <Box flexDirection="column" marginTop={-1}>
112 {await next(e)}
113 </Box>
114 )
115 })
116}
117hooks/shell.ts 88 lines1import { fitWidth, stripControl, textWidth } from './utils'
2
3// Terminal escape sequences: CSI (colours, cursor moves), the strings (OSC
4// titles and hyperlinks, DCS, SOS, PM, APC) up to their BEL or ST, and the
5// short ones (charset switches and the like); CSI and the strings in their
6// 8-bit forms too.
7const ESCAPE =
8 /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|(?:\x1b[P\]X^_]|[\x90\x98\x9d\x9e\x9f])[^\x07\x1b\x9c\n]*(?:\x07|\x9c|\x1b\\)?|\x1b[ -/]*[0-~]/g
9
10// A line as a terminal leaves it: each carriage return goes back to its
11// start and the text after it writes over what was there (a progress bar's
12// last state; a CRLF's line unchanged). Counted in characters, so an emoji
13// is never cut in half.
14const overwrite = (line: string) =>
15 line.split('\r').reduce((shown, part) => part + [...shown].slice([...part].length).join(''), '')
16
17// What a Bash call printed, both streams (for a call that failed, the text
18// the model read), as the lines a Text can draw: no escape sequences or other
19// control characters, tabs as spaces, no trailing blank lines.
20export const shellLines = (output: unknown) => {
21 const { stdout, stderr } = (typeof output === 'string' ? { stdout: output } : (output ?? {})) as {
22 stdout?: unknown
23 stderr?: unknown
24 }
25 const lines = [stdout, stderr]
26 .filter((s): s is string => typeof s === 'string' && s.trim() !== '')
27 .map(s => s.replace(/\n+$/, ''))
28 .join('\n')
29 .replace(ESCAPE, '')
30 .split('\n')
31 .map(line => stripControl(overwrite(line).replace(/\t/g, ' ')))
32 while (lines.at(-1) === '') {
33 lines.pop()
34 }
35 return lines
36}
37
38// A Bash output as the engine is handed it to draw: a stream with a carriage
39// return outside a CRLF, which the engine would drop and so join what it
40// wrote over, as the lines a terminal leaves. An output with no such stream
41// is handed on as it is.
42export const withOverwrites = (output: unknown) => {
43 if (typeof output !== 'object' || output === null) {
44 return output
45 }
46 const isOverwritten = (s: unknown): s is string => typeof s === 'string' && /\r(?!\n)/.test(s)
47 const settled = Object.fromEntries(
48 (['stdout', 'stderr'] as const)
49 .map(stream => [stream, (output as Record<string, unknown>)[stream]] as const)
50 .filter(([, s]) => isOverwritten(s))
51 .map(([stream, s]) => [stream, shellLines(s as string).join('\n')]),
52 )
53 return Object.keys(settled).length === 0 ? output : { ...output, ...settled }
54}
55
56// A command's lines joined by spaces, as one row draws it.
57const flatCommand = (command: string) =>
58 stripControl(command.replace(/\s*\\?\r?\n\s*/g, ' ').replace(/\t/g, ' ')).trim()
59
60// A command as one row of `room` columns: its lines joined by spaces (a `\`
61// continuation's too, CRLF or not), cut with `…` past the room; `isCut` when
62// it is no longer the command as written.
63export const shortCommand = (command: string, room: number) => {
64 const flat = flatCommand(command)
65 const text = textWidth(flat) > room ? `${fitWidth(flat, Math.max(room, 1) - 1).trimEnd()}…` : flat
66 return { text, isCut: text !== command.trim() }
67}
68
69// What decides whether shortCommand cuts a command at any room, kept in place
70// of the command itself (which can hold a whole heredoc): the columns it takes
71// on one row, and whether putting it on one row already changed it.
72export type CommandShape = { width: number; isReshaped: boolean }
73export const commandShape = (command: string): CommandShape => {
74 const flat = flatCommand(command)
75 return { width: textWidth(flat), isReshaped: flat !== command.trim() }
76}
77
78// Whether shortCommand cuts a command of `shape` in `room` columns.
79export const isShapeCut = (shape: CommandShape, room: number) => shape.isReshaped || shape.width > room
80
81// A command as a Code can draw it: no control characters but tab and
82// newline, within its 10000 characters, not cut inside a surrogate pair.
83export const commandSource = (command: string) =>
84 stripControl(command, '\t\n')
85 .replace(/\n+$/, '')
86 .slice(0, 10000)
87 .replace(/[\ud800-\udbff]$/, '')
88hooks/utils.tsx 198 lines1import type { EngineInterface, RenderNode } from 'claude-code'
2
3// How the engine lays out a tool row, which no API reports: `● Name(arg)`,
4// up to GROUP_INDENT columns further in inside a group, and a result line
5// beneath it opening with ROW_GUTTER.
6export const toolLabel = (name: string, arg: string) => `● ${name}(${arg})`
7export const GROUP_INDENT = 6
8export const ROW_GUTTER = ' ⎿ '
9
10// The elements a surface draws with, as `$.ui.resolve(e)` hands them out.
11type Elements = ReturnType<EngineInterface['ui']['resolve']>
12
13// A line beneath a tool row, opening with its gutter; `key` for one in a list.
14export const gutterLine = ({ Box, Text }: Elements, content: RenderNode, key?: string) => (
15 <Box key={key}>
16 <Text dimColor>{ROW_GUTTER}</Text>
17 {content}
18 </Box>
19)
20
21// What a hook draws in place of a result its row already shows.
22export const drawNothing = ({ Box }: Elements) => <Box />
23
24// The surface's width in columns, 80 where it has not been measured.
25export const viewportColumns = (e: { viewport?: { columns: number } }) => e.viewport?.columns ?? 80
26
27// Code points a terminal draws 2 columns wide: East Asian wide and fullwidth
28// characters, and the emoji drawn as pictures by default.
29const WIDE: [number, number][] = [
30 [0x1100, 0x115f],
31 [0x231a, 0x231b],
32 [0x23e9, 0x23ec],
33 [0x23f0, 0x23f0],
34 [0x23f3, 0x23f3],
35 [0x25fd, 0x25fe],
36 [0x2614, 0x2615],
37 [0x2648, 0x2653],
38 [0x267f, 0x267f],
39 [0x2693, 0x2693],
40 [0x26a1, 0x26a1],
41 [0x26aa, 0x26ab],
42 [0x26bd, 0x26be],
43 [0x26c4, 0x26c5],
44 [0x26ce, 0x26ce],
45 [0x26d4, 0x26d4],
46 [0x26ea, 0x26ea],
47 [0x26f2, 0x26f3],
48 [0x26f5, 0x26f5],
49 [0x26fa, 0x26fa],
50 [0x26fd, 0x26fd],
51 [0x2705, 0x2705],
52 [0x270a, 0x270b],
53 [0x2728, 0x2728],
54 [0x274c, 0x274c],
55 [0x274e, 0x274e],
56 [0x2753, 0x2755],
57 [0x2757, 0x2757],
58 [0x2795, 0x2797],
59 [0x27b0, 0x27b0],
60 [0x27bf, 0x27bf],
61 [0x2b1b, 0x2b1c],
62 [0x2b50, 0x2b50],
63 [0x2b55, 0x2b55],
64 [0x2e80, 0x303e],
65 [0x3040, 0xa4cf],
66 [0xac00, 0xd7a3],
67 [0xf900, 0xfaff],
68 [0xfe30, 0xfe4f],
69 [0xff00, 0xff60],
70 [0xffe0, 0xffe6],
71 [0x1f004, 0x1f004],
72 [0x1f0cf, 0x1f0cf],
73 [0x1f18e, 0x1f18e],
74 [0x1f191, 0x1f19a],
75 [0x1f200, 0x1f202],
76 [0x1f210, 0x1f23b],
77 [0x1f240, 0x1f248],
78 [0x1f250, 0x1f251],
79 [0x1f260, 0x1f265],
80 [0x1f300, 0x1faff],
81 [0x20000, 0x3fffd],
82]
83
84const ZWJ = '\u200d'
85const EMOJI_STYLE = '\ufe0f'
86
87// The columns a character takes on a terminal on its own: 0 for a combining
88// mark, joiner or variation selector, 2 for a wide one, 1 otherwise.
89const charWidth = (char: string) => {
90 if (/^[\p{Mn}\p{Me}\u200b-\u200d\ufe00-\ufe0f]$/u.test(char)) {
91 return 0
92 }
93 const c = char.codePointAt(0) ?? 0
94 return WIDE.some(([from, to]) => c >= from && c <= to) ? 2 : 1
95}
96
97// Each character of `text` with the columns it adds on a terminal: a
98// character joined on by a ZWJ adds none (a family emoji is one picture), and
99// an emoji selector widens a narrow character it follows to 2 (a heart
100// followed by U+FE0F).
101const charWidths = function* (text: string): Generator<[string, number]> {
102 let previous = ''
103 let clusterWidth = 0
104 for (const char of text) {
105 const width =
106 previous === ZWJ ? 0 : char === EMOJI_STYLE ? (clusterWidth === 1 ? 1 : 0) : charWidth(char)
107 clusterWidth = previous === ZWJ || char === EMOJI_STYLE || width === 0 ? clusterWidth + width : width
108 previous = char
109 yield [char, width]
110 }
111}
112
113// The columns a string takes on a terminal.
114export const textWidth = (text: string) => {
115 let width = 0
116 for (const [, charColumns] of charWidths(text)) {
117 width += charColumns
118 }
119 return width
120}
121
122// The longest start of `text` that fits in `room` columns.
123export const fitWidth = (text: string, room: number) => {
124 let width = 0
125 let fit = ''
126 for (const [char, charColumns] of charWidths(text)) {
127 width += charColumns
128 if (width > room) {
129 break
130 }
131 fit += char
132 }
133 return fit
134}
135
136// The longest end of `text` that fits in `room` columns, never opening on a
137// combining mark, an emoji selector or a character joined on by a ZWJ.
138export const fitEnd = (text: string, room: number) => {
139 const clusters: { text: string; width: number }[] = []
140 for (const [char, charColumns] of charWidths(text)) {
141 const last = clusters.at(-1)
142 if (last && (charColumns === 0 || char === EMOJI_STYLE)) {
143 last.text += char
144 last.width += charColumns
145 } else {
146 clusters.push({ text: char, width: charColumns })
147 }
148 }
149 let width = 0
150 let fit = ''
151 for (const cluster of clusters.reverse()) {
152 width += cluster.width
153 if (width > room) {
154 break
155 }
156 fit = cluster.text + fit
157 }
158 return fit
159}
160
161// `text` without control characters (C0, DEL, C1, and the bidi overrides and
162// isolates that would make it read in another order), but for those in `keep`.
163export const stripControl = (text: string, keep = '') =>
164 text.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, char => (keep.includes(char) ? char : ''))
165
166// Relative to `root`, so the terminal (started there) can still resolve it
167// and open it on ctrl+click; a file outside it keeps its full path. Under a
168// Windows root either separator matches and case is ignored.
169export const shortPath = (path: string, root: string) => {
170 const dir = /[\\/]$/.test(root) ? root : `${root}/`
171 const head = path.slice(0, dir.length)
172 const isWindows = /^([A-Za-z]:[\\/]|\\\\)/.test(root)
173 const fold = (s: string) => s.replace(/\\/g, '/').toLowerCase()
174 const isInside = isWindows ? fold(head) === fold(dir) : head === dir
175 return isInside ? path.slice(dir.length) : path
176}
177
178// A file:// URL to `path`, which a terminal opens on ctrl+click wherever it
179// was started: a Windows path with forward slashes, a UNC one as its host,
180// either without the `\\?\` long-path prefix. Undefined for a path no URL
181// can hold: one with a lone surrogate, as a Windows file name can have.
182export const fileUrl = (path: string) => {
183 const slashed = path.replace(/\\/g, '/').replace(/^\/\/\?\/(UNC\/)?/i, (_, unc) => (unc ? '//' : ''))
184 const url = slashed.startsWith('//') ? `file:${slashed}` : `file://${slashed.startsWith('/') ? '' : '/'}${slashed}`
185 try {
186 return encodeURI(url).replace(/[?#]/g, encodeURIComponent)
187 } catch {
188 return undefined
189 }
190}
191
192// The render event with some of the tool call's input replaced, for the
193// engine to draw the row with.
194export const withInput = <E extends { props: { input?: unknown } }>(e: E, input: object): E => ({
195 ...e,
196 props: { ...e.props, input: { ...(e.props.input as object), ...input } },
197})
198types/index.d.ts 23 lines1// Whether a Bash row's fold is open.
2export type IsOutputOpen = boolean
3
4// A Bash call's command as one row draws it: the columns it takes, and
5// whether joining its lines changed it; null for a call this session did not
6// see made.
7export type BashCommandShape = { width: number; isReshaped: boolean } | null
8
9// The project root when the session started; null until it is recorded.
10export type StartRoot = string | null
11
12declare module 'claude-code' {
13 interface PluginState {
14 'better-tool-rows': {
15 // One per Bash row, by its tool_use_id.
16 isOutputOpen: StateFamily<IsOutputOpen>
17 // One per Bash call, by its tool_use_id.
18 commandShape: StateFamily<BashCommandShape>
19 startRoot: StartRoot
20 }
21 }
22}
23