SLOPSHOPPER

better-tool-rows

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…

newrowsguard
★ 1v0.1.14MITupdated 2026-10-08akilin/claude-plugins/plugins/better-tool-rows
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-tool-rows
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⟨Claude Code's own drawing⟩file:///work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
⟨Claude Code's own drawing⟩file:///work/app/src/auth.ts
README

claude-plugins

Claude Code plugins that change how the terminal UI looks. They only change rendering: what the model sees and does is untouched.

Install

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.

Update

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.

Screenshots

With both plugins:

A session with better-tool-rows and prompt-bubbles

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

The same session without the plugins

Plugins

better-tool-rows

Shorter, quieter tool rows.

  • Read / Edit / Write show the file path relative to the folder Claude Code was started in (the link behind it still holds the full path, so ctrl+click opens it from anywhere), even after Claude 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.
  • Edit / Write hide the diff and put the line counts on the row instead: 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.
  • A tool row straight after a one-line Edit / Write row drops the blank line above it, so a run of edits reads as a list. After text, or after a row with lines beneath it (Bash output, Read's Read 7 lines), the blank line stays.
  • Bash commands are put on one line (a multi-line command's lines are joined) and cut with … so Bash(command) always fits on one line, wide characters included.
  • Bash commands that create, delete or edit files list each one beneath the row with its line counts (Created notes.md +10, Deleted notes.md -10, Updated notes.md +1 -1) instead of a diff.
  • Bash output longer than 5 lines is folded behind a clickable ▸ 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).

prompt-bubbles

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.

Development

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.

License

MIT

Source 7 files
hooks/register.ts 12 lines
1import 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}
12
hooks/bash.tsx 242 lines
1import { 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}
242
hooks/files.tsx 216 lines
1import { 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}
216
hooks/spacing.tsx 117 lines
1import 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}
117
hooks/shell.ts 88 lines
1import { 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]$/, '')
88
hooks/utils.tsx 198 lines
1import 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})
198
types/index.d.ts 23 lines
1// 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