Declutters Claude Code's conversation: long commands, diffs and tool chatter step aside into a tidy pane with your changes, commits and commands.

<h1 align="center">sideline</h1>
Keeps Claude Code's conversation clean, and puts the details to the side
<a href="https://code.claude.com"><img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.308-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.308 or newer"></a> <img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fartberri%2Fsideline%2Fmain%2Fplugin%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <a href="https://github.com/artberri/sideline/actions/workflows/check.yml"><img src="https://github.com/artberri/sideline/actions/workflows/check.yml/badge.svg" alt="Checks"></a> <a href="https://github.com/artberri/sideline/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT licence"></a>
<img src="https://raw.githubusercontent.com/artberri/sideline/main/demo/sideline.gif" alt="sideline beside a Claude Code session: the edited file shimmers in the changes tree, the test command runs in the commands box, and the transcript stays tidy" width="900">
[!NOTE] sideline is a Claude Code mod and needs Claude Code 2.1.308 or newer. In the terminal its pane docks beside the conversation in the fullscreen layout (
/tui fullscreen); it opens by itself on a terminal at least 144 columns wide, and/sidelinetoggles it at any width. Claude Code Desktop docks it on its own. VS Code and the mobile app aren't supported yet: the pane doesn't open there by itself, and/sidelineopens a version that isn't laid out for them and can't be clicked. The transcript tidying doesn't depend on the pane.
Claude's conversation fills up with things you rarely read: whole shell scripts, every edit's diff, "Ran 1 shell command" lines. sideline clears them out, so the conversation reads as what Claude said and did, and keeps the details a glance away, in a pane at its side:
Update(src/greet.js) (+4 -0), not the diff;ctrl+o still shows everything, and each of these can be turned off.
The pane stacks a SIDELINE · IDLE / WORKING / WAITING title and the key hints over three boxes:
+N -N counts, a shimmer on the file Claude is editing, and each file's diff a click (or l) away. With nothing changed, it lists the recent commits, and each opens as its own tree and diffs. It scrolls with the wheel and the keys.● running, ✓ done, ✗ failed; click one to read it whole.sideline is in the artberri marketplace. Add it once, then install:
claude plugin marketplace add artberri/claude-plugins
claude plugin install sideline@artberri
or, inside a Claude Code session, /plugin marketplace add artberri/claude-plugins and /plugin install sideline@artberri.
The pane opens by itself on a wide enough terminal and in Claude Code Desktop. /sideline toggles it (/sideline open and /sideline close do one or the other); once you close it, it stays closed for the session until you open it again. The transcript stays tidy either way. Focus it with ctrl+x tab (the abovePrompt:focus keybinding), then use the keys in its hint line: j/k to move, l to open, h to go back, c for the commands box.
Set them in /config, or under pluginConfigs → sideline → options in your settings.
| Option | Default | What it does |
|---|---|---|
glyphs | auto | auto uses Nerd Font icons when a Nerd Font is installed; nerd or plain forces one set. |
background | (empty) | A #hex colour to paint the pane with, such as your terminal's background. |
transcriptDiffs | hide | hide folds an edit's diff into its counts; show keeps Claude Code's diffs. |
transcriptCommands | hide | hide shows a described command's description and drops finished group lines; show keeps them. |
sessionBox | show | hide drops the SESSION box and puts the branch line back in CHANGES. |
commandRows | 5 | How many commands the COMMANDS box shows, 0 to 20; 0 hides it. |
The file icons need Nerd Fonts 3.0 or later; with an older one, or none, set glyphs to plain.
sideline only watches and reads, and nothing it sees leaves your machine. Every hook passes its event on unchanged: it never approves, refuses or edits a tool call or a prompt, and adds nothing to what Claude sees.
| It sees | Through |
|---|---|
| each tool call's name and input, and whether it failed | tool.call |
| when a main turn starts and ends, and its model and effort | turn.start, turn.step, turn.complete |
| context and rate-limit readings | session.measure, $.session.usage() |
| the repo's status, diffs and recent commits | read-only git (status, diff, diff-tree, show, log, rev-parse, with --no-optional-locks) |
| untracked files' sizes and times, to count their lines | stat |
| your OS and installed fonts, to choose its icons | uname -s, fc-list or the macOS Fonts folders |
It never writes a file, commits, stages or pushes, makes no network requests, calls no model and has no dependencies; everything it keeps is session state in Claude Code. What it hides in the transcript (long commands, diffs, finished count lines) is display only: the full command is in the COMMANDS box, permission prompts still show it whole, ctrl+o shows everything, and the transcriptCommands and transcriptDiffs options turn the hiding off. claude plugin validate on the plugin's folder prints exactly what it hooks and calls.
Nothing, to anywhere. Its only way out of Claude Code is $.process.run, and it runs just these fixed, local commands, none of which reaches the network:
git, read-only, to draw the changes, diffs and commits (the full list is below);uname -s, once per session, to draw Claude Code's own row dot (⏺ on macOS, ● elsewhere);fc-list :charset=e0a0 file, once per session with glyphs on auto, to find a Nerd Font. Without fc-list, as on a stock macOS, it reads the file names in $HOME/Library/Fonts and /Library/Fonts instead ($.fs.list).It runs no shell.
Each git command starts git --no-optional-locks -C <folder>, runs with GIT_OPTIONAL_LOCKS=0 and a 10-second timeout, and is one of these:
status --porcelain=v2 --branch -z --untracked-files=all # the changed files and the branch
rev-parse --show-toplevel # the repo's root
rev-parse --verify --quiet refs/stash # whether there is a stash
diff HEAD --numstat -z # tracked files' line counts
diff --no-index --numstat -z -- /dev/null <untracked file> # an untracked file's line counts
log -n50 --format=%h%x00%s%x00%ct%x00 # the recent commits, with nothing changed
diff HEAD -- <file> # a changed file's diff
diff --no-index -- /dev/null <untracked file> # an untracked file's diff
diff-tree -r --root --no-commit-id -m --first-parent --numstat -z <commit> # a commit's line counts
diff-tree -r --root --no-commit-id -m --first-parent --name-status -z <commit> # a commit's files
show --format= -m --first-parent <commit> -- <file> # a file's diff in a commit
<folder> is the session's working folder or its repo's root; <file> and <untracked file> are paths git status (or, in a commit, diff-tree) listed; <commit> is a hash git log listed. Nothing you or Claude type goes into a command.
From the conversation it reads only what the table above lists: turn.step gives it the model and effort of each main request, never the messages, and tool.call gives it each tool call's name, input and outcome, which it draws in the pane (a shell command in COMMANDS, an edited file's shimmer in CHANGES). None of it goes into a command or leaves Claude Code.
It reads two things from your disk besides git's output, and neither goes into a command or leaves Claude Code:
$.fs.stat: an untracked file's size and modification time, which only decide whether git counts that file's lines again; they're kept only for the session.$.fs.list: the file names in the two macOS Fonts folders, once per session with glyphs on auto, checked for one containing Nerd. Only the answer, Nerd Font icons or plain ones, is kept; the names aren't.Everything these commands print is drawn in the pane and nowhere else.
The pane doesn't appear. Check claude --version is 2.1.308 or later, then run /reload-plugins and /sideline. Below 144 columns Claude Code doesn't open a pane nobody asked for, so /sideline is needed there. Look in the transcript for a dim line starting sideline:: it names the hook that failed; please open an issue with it.
The pane doesn't open in VS Code or the mobile app. That's on purpose: sideline isn't laid out for them yet, so it only opens on its own in the terminal and in Claude Code Desktop.
Icons draw as boxes or blanks. The file icons need Nerd Fonts 3.0 or later: update your Nerd Font, or set glyphs to plain.
The pane is grey. Claude Code draws docked panes on its own grey; set background to your terminal's background colour, for example #1e1e3f.
I want to see the full command or diff in the conversation. ctrl+o shows everything; set transcriptCommands or transcriptDiffs to show to keep them there.
See CONTRIBUTING.md. sideline learned from claude-code-filetree and claude-flightdeck; see ACKNOWLEDGEMENTS.md.
hooks/register.tsx 1187 lines1import { atom, read, update } from 'claude-code'
2import type { ClientElements, Color, EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type {
5 ActiveEdit,
6 ChangedFile,
7 Commit,
8 CommitView,
9 Cursor,
10 FileDiff,
11 FileStatus,
12 Glyphs,
13 LoggedCommand,
14 SessionInfo,
15 Snapshot,
16 TurnState,
17} from '../types'
18import { LOG_SIZE, commandStep, hiddenCommandInput, logLine, runningDescription } from './commands'
19import { readCommitDiff, readCommitFiles, readDiff, readSnapshot } from './git'
20import type { Git } from './git'
21import { USAGE_LABEL, USAGE_SEPARATOR, bar, barWidth, level, modelName, percentLabel, readings } from './meter'
22import { rowIcon } from './icons'
23import { cutDiff, drawDiff, drawRows } from './rows'
24import type { RowSpec } from './rows'
25import { fitSubject, shortAge } from './commits'
26import { bashChangedFiles, countsLabel, fileRow, summarize } from './summary'
27import {
28 BRANCH_ICON,
29 backgroundOption,
30 hasNerdFont,
31 isNerdFontFile,
32 lighten,
33 rowDotFor,
34 statusSymbols,
35} from './symbols'
36import { resolveCursor, treeRows } from './tree'
37import {
38 SESSION_ROWS,
39 changesRows,
40 clampOffset,
41 commandRowsOption,
42 commandsBoxRows,
43 hiddenAround,
44 hiddenLabel,
45 isOverCommands,
46 revealOffset,
47 wrapLines,
48} from './window'
49import type { TreeRow } from './tree'
50
51const PANE = 'sideline'
52const TITLE = 'Changes'
53const NAME = 'SIDELINE'
54const CHANGES_COLOR = 'merged'
55const SESSION_COLOR = 'yellow'
56const COMMAND_COLOR = 'inactive'
57// Each log line starts with its command's state.
58const LOG_GLYPH: Record<LoggedCommand['state'], string> = { running: '●', done: '✓', failed: '✗' }
59const LOG_GLYPH_COLOR: Record<LoggedCommand['state'], Color> = { running: 'claude', done: 'success', failed: 'error' }
60// What a titled box takes from the width inside the pane: its border and its padding, each side.
61const BOX_FRAME = 4
62// The surfaces that draw a Client. VS Code's element table answers `'Client' in` at run time, though it draws none.
63const HAS_CLIENT = new Set(['terminal', 'desktop'])
64// The surfaces the pane opens on at session start.
65const OPENS_ON = new Set(['terminal', 'desktop'])
66const POLL_MS = 2_000
67const REFRESH_AFTER = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
68const FLASH_MS = 100
69// Most edits take milliseconds; the shimmer plays at least this long so it can be seen, without delaying the tool.
70const MIN_SHIMMER_MS = 1_200
71// Claude Code's own result gutter, which a row drawn in place of a ToolResult does not get.
72const RESULT_GUTTER = ' ⎿ '
73const ANSWERS_HEADER = "User answered Claude's questions"
74const DOWN = new Set(['j', 'down'])
75const UP = new Set(['k', 'up'])
76const OPEN = new Set(['l', 'right', 'return'])
77const BACK = new Set(['h', 'left', 'backspace'])
78// The key that puts the focus ring on the newest command in the log.
79const COMMANDS_KEY = 'c'
80const COMMAND_KEY_PREFIX = 'command-'
81// The tools whose call waits on the person: a question, a plan to approve.
82const DIALOG_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
83
84const snapshot = atom({ plugin: 'sideline', key: 'snapshot' } as const, { kind: 'loading' } as Snapshot)
85const glyphs = atom({ plugin: 'sideline', key: 'glyphs' } as const, 'plain')
86const edits = atom({ plugin: 'sideline', key: 'edits' } as const, [] as ActiveEdit[])
87const selected = atom({ plugin: 'sideline', key: 'selected' } as const, null as string | null)
88const diff = atom({ plugin: 'sideline', key: 'diff' } as const, null as FileDiff | null)
89const cursor = atom({ plugin: 'sideline', key: 'cursor' } as const, null as Cursor | null)
90const commands = atom({ plugin: 'sideline', key: 'commands' } as const, [] as LoggedCommand[])
91// The log line unfolded to its whole command, by tool_use_id.
92const openCommand = atom({ plugin: 'sideline', key: 'openCommand' } as const, null as string | null)
93// The first line the changes box shows of its tree, diff or command.
94const changesOffset = atom({ plugin: 'sideline', key: 'changesOffset' } as const, 0)
95// The logged command whose line holds the pane's focus ring, by tool_use_id: while set, j/k move between commands.
96const session = atom(
97 { plugin: 'sideline', key: 'session' } as const,
98 {
99 model: null,
100 effort: null,
101 context: null,
102 fiveHour: null,
103 sevenDay: null,
104 } as SessionInfo,
105)
106// The commit opened in the changes box from the recent commits list, or null for the working changes.
107const isPaneClosed = atom({ plugin: 'sideline', key: 'isPaneClosed' } as const, false)
108const isPaneOpen = atom({ plugin: 'sideline', key: 'isPaneOpen' } as const, false)
109const viewing = atom({ plugin: 'sideline', key: 'viewing' } as const, null as CommitView | null)
110const focusedCommand = atom({ plugin: 'sideline', key: 'focusedCommand' } as const, null as string | null)
111const turn = atom({ plugin: 'sideline', key: 'turn' } as const, 'idle' as TurnState)
112
113const STATUS_COLOR: Record<FileStatus, Color> = {
114 added: 'success',
115 modified: 'warning',
116 deleted: 'error',
117 renamed: 'blue',
118 conflicted: 'error',
119}
120
121let refreshing: Promise<void> | null = null
122let isRefreshQueued = false
123// The last description a live group's line showed, for the moment the next call's input is still being written.
124let lastDescription: string | undefined
125// The changes box's last drawn window, which a scroll clamps to: its content's lines and the rows it shows.
126let changesWindow = { lines: 0, rows: 0 }
127// The commands box's lines, from the `commandRows` option; set by register, read by the key handlers.
128let commandLines = 5
129// How many recent commits the changes box last drew, which j/k move through.
130let commitsShown = 0
131// Claude Code's row dot on this machine, read at session start.
132let rowDot = rowDotFor('')
133
134// The engine calls the git readers make, written here: the directory reads a mod's engine calls only in the file that
135// declares its hooks.
136function gitFor($: EngineInterface): Git {
137 return {
138 run: (cwd, args) =>
139 $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], {
140 timeoutMs: 10_000,
141 env: { GIT_OPTIONAL_LOCKS: '0' },
142 }),
143 stat: async (path) => {
144 try {
145 return await $.fs.stat(path)
146 } catch {
147 return null
148 }
149 },
150 }
151}
152
153async function resolveGlyphs($: EngineInterface, option: unknown): Promise<Glyphs> {
154 if (option === 'nerd' || option === 'plain') return option
155 try {
156 const run = await $.process.run(['fc-list', ':charset=e0a0', 'file'], { timeoutMs: 5_000 })
157 if (hasNerdFont(run.stdout)) return 'nerd'
158 } catch {
159 // No fc-list, as on a stock macOS: the Fonts folders decide.
160 }
161 const home = await $.env.get('HOME')
162 for (const folder of [`${home}/Library/Fonts`, '/Library/Fonts']) {
163 try {
164 const entries = await $.fs.list(folder)
165 if (entries.some((entry) => isNerdFontFile(entry.name))) return 'nerd'
166 } catch {
167 // A folder that isn't there (not macOS, or no fonts installed for the user).
168 }
169 }
170 return 'plain'
171}
172
173async function resolveRowDot($: EngineInterface): Promise<string> {
174 try {
175 const run = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 })
176 return rowDotFor(run.stdout)
177 } catch {
178 return rowDotFor('')
179 }
180}
181
182async function readAndStore($: EngineInterface): Promise<void> {
183 const fresh = await readSnapshot(gitFor($), await $.session.cwd())
184 const current = await read($, snapshot)
185 if (JSON.stringify(current) !== JSON.stringify(fresh)) await update($, snapshot, () => fresh)
186 await readSelectedDiff($, fresh)
187}
188
189async function closeDiff($: EngineInterface): Promise<void> {
190 await update($, changesOffset, () => 0)
191 await update($, selected, () => null)
192 await update($, diff, () => null)
193}
194
195// The open diff follows its file, and closes once the file is no longer changed.
196async function readSelectedDiff($: EngineInterface, shown: Snapshot): Promise<void> {
197 const path = await read($, selected)
198 if (path === null) return
199 const commit = await read($, viewing)
200 const files = commit?.files ?? (shown.kind === 'repo' ? shown.files : [])
201 const file = files.find((one) => one.path === path)
202 if (!file || shown.kind !== 'repo') {
203 await closeDiff($)
204 return
205 }
206 const hunks = commit
207 ? await readCommitDiff(gitFor($), shown.root, commit.sha, path)
208 : await readDiff(gitFor($), shown.root, file)
209 const fresh: FileDiff = { path, hunks }
210 const current = await read($, diff)
211 if (JSON.stringify(current) !== JSON.stringify(fresh)) await update($, diff, () => fresh)
212}
213
214// The poll and tool calls overlap; one read runs at a time, and a request during it runs once more afterwards.
215function refresh($: EngineInterface): Promise<void> {
216 if (refreshing) {
217 isRefreshQueued = true
218 return refreshing
219 }
220 refreshing = readAndStore($)
221 .catch(() => undefined)
222 .finally(() => {
223 refreshing = null
224 if (isRefreshQueued) {
225 isRefreshQueued = false
226 void refresh($)
227 }
228 })
229 return refreshing
230}
231
232function editedPath(call: { tool: string } & Record<string, unknown>): string | undefined {
233 if (call.tool === 'NotebookEdit' && typeof call.notebook_path === 'string') return call.notebook_path
234 if ((call.tool === 'Edit' || call.tool === 'Write') && typeof call.file_path === 'string') return call.file_path
235 return undefined
236}
237
238// Files Claude is editing that git does not list yet (a new file, or one that was clean) still get a row.
239function withEditedFiles(files: ChangedFile[], edited: Map<string, ActiveEdit>): ChangedFile[] {
240 const listed = new Set(files.map((file) => file.path))
241 const extra = [...edited]
242 .filter(([path]) => !listed.has(path))
243 .map(([path, edit]): ChangedFile => ({
244 path,
245 status: edit.isNewFile ? 'added' : 'modified',
246 isUntracked: edit.isNewFile,
247 added: 0,
248 deleted: 0,
249 isBinary: false,
250 }))
251 return [...files, ...extra]
252}
253
254// The tree as the pane draws it, with the files Claude is editing; null outside a repo.
255async function currentTree($: EngineInterface): Promise<{ rows: TreeRow[]; edited: Map<string, ActiveEdit> } | null> {
256 const shown = await read($, snapshot)
257 if (shown.kind !== 'repo') return null
258 // An opened commit shows its own files, with nothing being edited in them.
259 const commit = await read($, viewing)
260 if (commit) return { rows: treeRows(commit.files), edited: new Map() }
261 const root = `${shown.root}/`
262 const edited = new Map(
263 (await read($, edits))
264 .filter((edit) => edit.path.startsWith(root))
265 .map((edit) => [edit.path.slice(root.length), edit]),
266 )
267 return { rows: treeRows(withEditedFiles(shown.files, edited)), edited }
268}
269
270async function openFile($: EngineInterface, path: string): Promise<void> {
271 await update($, openCommand, () => null)
272 await update($, changesOffset, () => 0)
273 await update($, selected, () => path)
274 await refresh($)
275}
276
277// Opens a logged command in the changes box in place of the tree or a diff, or closes it when it is the one open.
278async function toggleCommand($: EngineInterface, id: string): Promise<void> {
279 const isOpen = (await read($, openCommand)) === id
280 if (!isOpen) await closeDiff($)
281 await update($, changesOffset, () => 0)
282 await update($, openCommand, () => (isOpen ? null : id))
283}
284
285async function select($: EngineInterface, id: string, index: number): Promise<void> {
286 await update($, focusedCommand, () => null)
287 await update($, cursor, () => ({ id, index }))
288 // The pane never scrolls; the changes box's own window follows the selection.
289 const { lines, rows } = changesWindow
290 await update($, changesOffset, (offset) => revealOffset(clampOffset(offset, lines, rows), index, rows))
291}
292
293// In a diff, j/k open the next or previous changed file's diff, and the selection follows it.
294async function stepDiff($: EngineInterface, rows: TreeRow[], open: string, by: number): Promise<void> {
295 if (by === 0) return
296 const files = rows.flatMap((row, index) => (row.kind === 'file' ? [{ id: row.id, index }] : []))
297 const target = files[files.findIndex((file) => file.id === open) + by]
298 if (!target) return
299 await select($, target.id, target.index)
300 await openFile($, target.id)
301}
302
303// One key, from the rows Client or a legend hotkey: moves the selection, opens its file, or closes the diff.
304// Puts the pane's focus ring on a logged command's line, and j/k on the commands box until h or the person moves the
305// ring elsewhere.
306async function focusCommand($: EngineInterface, id: string): Promise<void> {
307 await update($, focusedCommand, () => id)
308 await $.ui.focus({ requestId: PANE, key: `${COMMAND_KEY_PREFIX}${id}` }).catch(() => undefined)
309}
310
311// In the commands box j/k step through the log, l opens the command, h leaves for the tree.
312async function navigateCommands($: EngineInterface, id: string, key: string): Promise<void> {
313 const ids = (await read($, commands)).slice(-commandLines).map((one) => one.id)
314 const step = commandStep(
315 ids,
316 id,
317 DOWN.has(key) ? 'down' : UP.has(key) ? 'up' : OPEN.has(key) ? 'open' : BACK.has(key) ? 'leave' : null,
318 )
319 if (step?.kind === 'focus') await focusCommand($, step.id)
320 else if (step?.kind === 'open') await toggleCommand($, id)
321 else if (step?.kind === 'leave') {
322 await update($, focusedCommand, () => null)
323 await $.ui.focus({ requestId: PANE, key: 'nav-l' }).catch(() => undefined)
324 }
325}
326
327// Opens a recent commit in the changes box: its files become the tree, from the top, with nothing selected.
328async function openCommit($: EngineInterface, commit: Commit): Promise<void> {
329 const shown = await read($, snapshot)
330 if (shown.kind !== 'repo') return
331 const files = await readCommitFiles(gitFor($), shown.root, commit.sha)
332 await closeDiff($)
333 await update($, cursor, () => null)
334 await update($, viewing, () => ({ sha: commit.sha, subject: commit.subject, files }))
335}
336
337// Back from an opened commit to the working changes (and the list, while there are none).
338async function closeCommit($: EngineInterface): Promise<void> {
339 await closeDiff($)
340 await update($, cursor, () => null)
341 await update($, viewing, () => null)
342}
343
344// The selection on the recent commits list, by `commit:<sha>` in the shared cursor: j/k move it through the commits
345// drawn, and l opens the selected one.
346async function navigateCommits($: EngineInterface, key: string): Promise<void> {
347 const shown = await read($, snapshot)
348 if (shown.kind !== 'repo') return
349 const listed = shown.commits.slice(0, commitsShown)
350 const ids = listed.map((commit) => `commit:${commit.sha}`)
351 const at = resolveCursor(
352 ids.map((id) => ({ id })),
353 await read($, cursor),
354 )
355 if (OPEN.has(key)) {
356 const commit = listed[at]
357 if (commit) await openCommit($, commit)
358 return
359 }
360 const by = DOWN.has(key) ? 1 : UP.has(key) ? -1 : 0
361 if (by === 0 || ids.length === 0) return
362 const index = at < 0 ? 0 : Math.min(Math.max(at + by, 0), ids.length - 1)
363 await update($, cursor, () => ({ id: ids[index] ?? '', index }))
364}
365
366async function navigate($: EngineInterface, key: string): Promise<void> {
367 const focused = await read($, focusedCommand)
368 // c toggles: into the commands box on the newest command, and from there back to the changes box, as h does.
369 if (key === COMMANDS_KEY) {
370 if (commandLines === 0) return
371 const newest = (await read($, commands)).at(-1)
372 if (focused !== null) await navigateCommands($, focused, 'h')
373 else if (newest) await focusCommand($, newest.id)
374 return
375 }
376 if (focused !== null && (await read($, openCommand)) === null) {
377 await navigateCommands($, focused, key)
378 return
379 }
380 if ((await read($, openCommand)) !== null) {
381 if (BACK.has(key)) {
382 await update($, openCommand, () => null)
383 await update($, changesOffset, () => 0)
384 }
385 return
386 }
387 const open = await read($, selected)
388 if (open !== null && BACK.has(key)) {
389 await closeDiff($)
390 return
391 }
392 // Back steps out one level at a time: a file's diff above, then the opened commit.
393 if (BACK.has(key) && (await read($, viewing)) !== null) {
394 await closeCommit($)
395 return
396 }
397 const tree = await currentTree($)
398 if (tree && tree.rows.length === 0 && (await read($, viewing)) === null) {
399 await navigateCommits($, key)
400 return
401 }
402 if (!tree || tree.rows.length === 0) return
403 if (open !== null) {
404 await stepDiff($, tree.rows, open, DOWN.has(key) ? 1 : UP.has(key) ? -1 : 0)
405 return
406 }
407 const at = resolveCursor(tree.rows, await read($, cursor))
408 const last = tree.rows.length - 1
409 if (OPEN.has(key)) {
410 const row = tree.rows[at]
411 if (row?.kind === 'file') await openFile($, row.id)
412 return
413 }
414 const index = DOWN.has(key)
415 ? at < 0
416 ? 0
417 : Math.min(at + 1, last)
418 : UP.has(key)
419 ? at < 0
420 ? 0
421 : Math.max(at - 1, 0)
422 : -1
423 const row = tree.rows[index]
424 if (row) await select($, row.id, index)
425}
426
427// Turns a finished edit into the flash once it has shimmered for MIN_SHIMMER_MS, then clears it.
428async function finishEdit($: EngineInterface, id: string, startedAt: number): Promise<void> {
429 const flash = () => {
430 void update($, edits, (list) => list.map((one) => (one.id === id ? { ...one, state: 'done' } : one)))
431 $.clock.after(FLASH_MS, () => void update($, edits, (list) => list.filter((one) => one.id !== id)))
432 }
433 const wait = MIN_SHIMMER_MS - ((await $.clock.now()) - startedAt)
434 if (wait > 0) $.clock.after(wait, flash)
435 else flash()
436}
437
438type TitledBox = {
439 name: string
440 color: Color
441 key: string
442 rows?: number
443 label?: string
444 footer?: string
445 background?: string
446}
447
448// A rounded box with its caps title laid over the top border, at the left, `label` over it at the right, and `footer`
449// over the bottom border, at the right: each a sibling drawn after the box, so it paints over the border rather than inside the box's clip. `rows`
450// fixes the box's content rows; without it the box takes every row left.
451function titled(
452 { Box, Text }: Pick<ClientElements, 'Box' | 'Text'>,
453 { name, color, key, rows, label = '', footer = '', background }: TitledBox,
454 body: JSX.Element,
455) {
456 return (
457 <Box key={key} flexDirection="column" flexGrow={rows === undefined ? 1 : 0} flexShrink={0}>
458 <Box
459 key={`${key}-box`}
460 flexDirection="column"
461 borderStyle="round"
462 borderColor={color}
463 paddingX={1}
464 overflow="hidden"
465 flexGrow={rows === undefined ? 1 : 0}
466 height={rows === undefined ? undefined : rows + 2}
467 >
468 {body}
469 </Box>
470 <Box position="absolute" top={0} left={2}>
471 <Text color={color} bold backgroundColor={background}>{` ${name} `}</Text>
472 </Box>
473 {label !== '' && (
474 <Box position="absolute" top={0} right={2}>
475 <Text color={color} backgroundColor={background}>{` ${label} `}</Text>
476 </Box>
477 )}
478 {footer !== '' && (
479 <Box position="absolute" bottom={0} right={2}>
480 <Text color={color} backgroundColor={background}>{` ${footer} `}</Text>
481 </Box>
482 )}
483 </Box>
484 )
485}
486
487// Runs the mod's own bookkeeping around a tool call or a site's event, and swallows its errors: it must never stop or
488// change what it watches. `undefined` when it failed.
489async function quietly<T>(work: () => Promise<T>): Promise<T | undefined> {
490 try {
491 return await work()
492 } catch {
493 return undefined
494 }
495}
496
497// What a call started, for its end: a dialog (question, plan) waiting on the person, a logged command, a file edit.
498type NotedCall =
499 | { kind: 'dialog' }
500 | { kind: 'command' }
501 | { kind: 'edit'; edit: ActiveEdit; startedAt: number }
502 | { kind: 'other' }
503
504async function noteCallStart($: EngineInterface, call: ToolCallInput): Promise<NotedCall> {
505 // A question or a plan to approve keeps its call open exactly as long as its dialog waits for the person.
506 if (DIALOG_TOOLS.has(String(call.tool)) && call.agentId === undefined) {
507 await update($, turn, () => 'waiting')
508 return { kind: 'dialog' }
509 }
510 const path = editedPath(call)
511 if (path) {
512 const startedAt = await $.clock.now()
513 const edit: ActiveEdit = { id: call.tool_use_id, path, state: 'running', isNewFile: call.tool === 'Write' }
514 await update($, edits, (list) => [...list.filter((one) => one.path !== path), edit])
515 return { kind: 'edit', edit, startedAt }
516 }
517 if (call.tool === 'Bash') {
518 const id = call.tool_use_id
519 await update($, commands, (list) =>
520 [...list, { id, command: call.command, state: 'running' as const }].slice(-LOG_SIZE),
521 )
522 const kept = new Set((await read($, commands)).map((one) => one.id))
523 await update($, openCommand, (open) => (open !== null && kept.has(open) ? open : null))
524 return { kind: 'command' }
525 }
526 return { kind: 'other' }
527}
528
529// `result` is null when the call threw.
530async function noteCallEnd(
531 $: EngineInterface,
532 call: ToolCallInput,
533 noted: NotedCall | undefined,
534 result: ToolCallResult | null,
535): Promise<void> {
536 if (noted?.kind === 'dialog') {
537 await update($, turn, (state) => (state === 'waiting' ? 'working' : state))
538 return
539 }
540 const isFailed = result === null || Boolean(result.deny) || Boolean(result.isError)
541 if (noted?.kind === 'edit') {
542 if (isFailed) await update($, edits, (list) => list.filter((one) => one.id !== noted.edit.id))
543 else await finishEdit($, noted.edit.id, noted.startedAt)
544 void refresh($)
545 return
546 }
547 if (noted?.kind === 'command') {
548 const output: unknown = result?.result
549 const isInterrupted =
550 typeof output === 'object' && output !== null && 'interrupted' in output && output.interrupted === true
551 const state: LoggedCommand['state'] = isFailed || isInterrupted ? 'failed' : 'done'
552 await update($, commands, (list) => list.map((one) => (one.id === call.tool_use_id ? { ...one, state } : one)))
553 }
554 if (result && call.tool === 'Bash' && !isFailed) {
555 const startedAt = await $.clock.now()
556 for (const file of bashChangedFiles(result.result)) {
557 const edit: ActiveEdit = { id: `${call.tool_use_id}:${file.path}`, state: 'running', ...file }
558 await update($, edits, (list) => [...list.filter((one) => one.path !== file.path), edit])
559 await finishEdit($, edit.id, startedAt)
560 }
561 }
562 if (REFRESH_AFTER.has(String(call.tool))) void refresh($)
563}
564
565export const register: Register = (on, options) => {
566 const background = backgroundOption(options.background)
567 const hoverBackground = background ? lighten(background) : null
568 const isHidingDiffs = options.transcriptDiffs !== 'show'
569 const isHidingCommands = options.transcriptCommands !== 'show'
570 commandLines = commandRowsOption(options.commandRows)
571 const commandsBox = commandsBoxRows(commandLines)
572 const isShowingSession = options.sessionBox !== 'hide'
573
574 on('session.start', async ($, e, next) => {
575 await $.command.register({
576 name: 'sideline',
577 description: 'Toggle the sideline pane',
578 argumentHint: '[open|close]',
579 })
580 const resolved = await resolveGlyphs($, options.glyphs)
581 await update($, glyphs, () => resolved)
582 rowDot = await resolveRowDot($)
583 // A host without a bound session (headless, an SDK host) has no model yet; the first request brings it.
584 const model = await $.session.model().catch(() => null)
585 if (model) await update($, session, (info) => ({ ...info, model }))
586 const usage = await $.session.usage().catch(() => null)
587 if (usage) await update($, session, (info) => ({ ...info, ...readings(usage) }))
588 await refresh($)
589 // The pane opens by itself where it is laid out for: the terminal and Claude Code Desktop. In VS Code or the
590 // mobile app alone it waits for /sideline.
591 const isWanted = !(await read($, isPaneClosed))
592 if (isWanted && (await $.session.surfaces()).some((surface) => OPENS_ON.has(surface)))
593 void $.ui.open({ id: PANE, title: TITLE }).then(() => update($, isPaneOpen, () => true))
594 $.clock.every(POLL_MS, () => void refresh($))
595 return next(e)
596 })
597
598 // /sideline toggles the pane; /sideline open and /sideline close do one or the other.
599 on('command.run', { command: 'sideline' }, async ($, e) => {
600 const arg = e.args.trim().toLowerCase()
601 // The engine's list, or the mod's own note when that list doesn't show the pane yet.
602 const isListed = (await $.ui.panes().catch(() => [])).some((pane) => pane.id === PANE)
603 const isOpen = isListed || (await read($, isPaneOpen))
604 const isClosing = arg === 'close' || (arg !== 'open' && isOpen)
605 if (isClosing) {
606 await update($, isPaneClosed, () => true)
607 await $.ui.close({ id: PANE })
608 return { text: 'sideline pane closed.' }
609 }
610 await update($, isPaneClosed, () => false)
611 await refresh($)
612 await $.ui.open({ id: PANE, title: TITLE })
613 await update($, isPaneOpen, () => true)
614 return { text: 'sideline pane opened.' }
615 })
616
617 // The person closing the pane themselves (its mark, ctrl+x x) keeps it closed through reloads, until they open it.
618 on('ui.close', { id: PANE }, async ($, e, next) => {
619 await quietly(async () => {
620 await update($, isPaneOpen, () => false)
621 if (e.origin.kind === 'person') await update($, isPaneClosed, () => true)
622 })
623 return next(e)
624 })
625
626 // Only the main loop's turns: a subagent's start and end (`agentId`) come and go inside them.
627 on('turn.start', async ($, e, next) => {
628 if (e.agentId === undefined) await update($, turn, () => 'working')
629 return next(e)
630 })
631
632 // /compact resets the context bar to `—`: the engine keeps the old reading until the next response.
633 on('command.run', { command: 'compact' }, async ($, e, next) => {
634 await update($, session, (info) => (info.context === null ? info : { ...info, context: null }))
635 return next(e)
636 })
637
638 // The main loop's model and effort as each of its requests goes out: /model and /effort show from the next one.
639 // Context and rate-limit readings arrive pushed, after each main turn and as a window moves a point.
640 on('session.measure', async ($, e, next) => {
641 await update($, session, (info) => ({ ...info, ...readings(e) }))
642 return next(e)
643 })
644
645 on('turn.step', async function* ($, e, next) {
646 if (e.agentId === undefined) {
647 const effort = e.effort === undefined ? null : String(e.effort)
648 await update($, session, (info) =>
649 info.model === e.model && info.effort === effort ? info : { ...info, model: e.model, effort },
650 )
651 }
652 return yield* next(e)
653 })
654
655 on('turn.complete', async ($, e, next) => {
656 if (e.agentId !== undefined) return next(e)
657 return next(e).finally(() => update($, turn, () => 'idle'))
658 })
659
660 // The call itself goes through untouched: the mod's own bookkeeping before and after it is fail-safe, so an error
661 // in it can never stop or change a tool call.
662 on('tool.call', async ($, e, next) => {
663 const noted = await quietly(() => noteCallStart($, e))
664 const result = await next(e).catch(async (error: unknown) => {
665 await quietly(() => noteCallEnd($, e, noted, null))
666 throw error
667 })
668 await quietly(() => noteCallEnd($, e, noted, result))
669 return result
670 })
671
672 // The person's own Tab or click onto a log line puts j/k on the commands box, and onto anything else but the hint
673 // buttons (which j/k/l press) takes them back to the tree. The mod's own moves set it where they happen.
674 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
675 const element = e.element ?? ''
676 const isCommand = element.startsWith(COMMAND_KEY_PREFIX) && element !== 'command-back'
677 if (e.origin.kind === 'person' && !element.startsWith('nav-'))
678 await quietly(() =>
679 update($, focusedCommand, () => (isCommand ? element.slice(COMMAND_KEY_PREFIX.length) : null)),
680 )
681 return next(e)
682 })
683
684 // The pane always fits its rows, so the engine never scrolls it: a wheel tick over the changes box moves that box's
685 // own window, and the scroll keys (no pointer) move it too. A tick over the commands box moves nothing.
686 on('ui.scroll', { requestId: PANE }, async ($, e) => {
687 if (!isOverCommands(e.pointer?.row, e.bodyRows, commandsBox)) {
688 const { lines, rows } = changesWindow
689 await quietly(() =>
690 update($, changesOffset, (offset) => clampOffset(clampOffset(offset, lines, rows) + e.by, lines, rows)),
691 )
692 }
693 return {}
694 })
695
696 on('ui.message', { requestId: PANE, element: 'rows' }, async ($, e) => {
697 const data = e.data
698 if (typeof data !== 'object' || data === null) return {}
699 if ('back' in data) await closeDiff($)
700 else if ('key' in data && typeof data.key === 'string') await navigate($, data.key)
701 else if ('press' in data && typeof data.press === 'string') {
702 await select($, data.press, 'index' in data && typeof data.index === 'number' ? data.index : 0)
703 if ('kind' in data && data.kind === 'file') await openFile($, data.press)
704 }
705 return {}
706 })
707
708 // A finished Edit or Write row carries its counts on its title line, and its result line (the same path again) is
709 // drawn as nothing below.
710 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
711 const { tool, output, isRunning, isErrored, isInterrupted, isExpanded } = e.props
712 const isFileTool = tool === 'Edit' || tool === 'Write'
713 if (!isHidingDiffs || !isFileTool || isRunning || isErrored || isInterrupted || isExpanded === true)
714 return next(e)
715 const row = fileRow(tool, output, await $.session.cwd())
716 if (!row) return next(e)
717 const { Box, Text } = $.ui.resolve(e)
718 // Engine rows keep a blank line above them; a row drawn in their place doesn't get it.
719 return (
720 <Box marginTop={1}>
721 <Text>
722 <Text color="success">{`${rowDot} `}</Text>
723 <Text bold>{row.name}</Text>
724 {`(${row.summary.path}) `}
725 <Text dimColor>{countsLabel(row.summary)}</Text>
726 </Text>
727 </Box>
728 )
729 })
730
731 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
732 const { tool, input, isErrored, isInterrupted, isExpanded } = e.props
733 if (!isHidingCommands || tool !== 'Bash' || isErrored || isInterrupted || isExpanded === true) return next(e)
734 const hidden = hiddenCommandInput(input)
735 return hidden ? next({ ...e, props: { ...e.props, input: hidden } }) : next(e)
736 })
737
738 // The live line of a folded group draws a command under the description, and that text is no prop a rewrite
739 // reaches, so the line is drawn here. Not only for a calls-only group: a nested CLAUDE.md the group loaded or a hook
740 // row turns that off, and the engine still prints the command. Those rows come back once the group folds.
741 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
742 const { calls, isActive, isExpanded, isCallsOnly } = e.props
743 if (!isHidingCommands || isExpanded) return next(e)
744 // A finished group's count line (`Ran 1 shell command`) says nothing the rows around it don't; one that also
745 // loaded a CLAUDE.md or ran a hook keeps it. A failed or interrupted call also makes a group not calls-only, and
746 // the commands box already marks it ✗, so a group with one is taken to be calls-only otherwise.
747 const isFailed = calls.some((call) => call.isErrored || call.isInterrupted)
748 if (!isActive && isCallsOnly !== true && !isFailed) return next(e)
749 const { Box, Text } = $.ui.resolve(e)
750 if (!isActive) return <Box />
751 const description = runningDescription(calls, lastDescription)
752 if (!description) return next(e)
753 lastDescription = description
754 // Engine rows keep a blank line above them; a row drawn in their place doesn't get it.
755 return (
756 <Box marginTop={1}>
757 <Text>
758 <Text dimColor>{`${rowDot} `}</Text>
759 {description}
760 </Text>
761 </Box>
762 )
763 })
764
765 // The person just picked the answers, so the row keeps its header and drops the question → answer lines. It is drawn
766 // here: handed empty questions, the engine draws no row at all.
767 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
768 const { tool, isErrored, isExpanded } = e.props
769 if (!isHidingCommands || tool !== 'AskUserQuestion' || isErrored || isExpanded === true) return next(e)
770 const { Box, Text } = $.ui.resolve(e)
771 return (
772 <Box marginTop={1}>
773 <Text>{`${rowDot} ${ANSWERS_HEADER}`}</Text>
774 </Box>
775 )
776 })
777
778 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
779 if (!isHidingDiffs || e.props.isExpanded === true || e.props.isErrored) return next(e)
780 const cwd = await $.session.cwd()
781 const summaries = summarize(e.props.tool, e.props.output, cwd)
782 if (!summaries) return next(e)
783 const { Box, Text } = $.ui.resolve(e)
784 if (fileRow(e.props.tool, e.props.output, cwd)) return <Box />
785 return (
786 <Box flexDirection="column">
787 {summaries.map((summary) => (
788 <Box key={summary.path}>
789 <Text>
790 <Text dimColor>{RESULT_GUTTER}</Text>
791 {`${summary.verb} `}
792 <Text bold>{summary.path}</Text>
793 <Text dimColor>{` ${countsLabel(summary)}`}</Text>
794 </Text>
795 </Box>
796 ))}
797 </Box>
798 )
799 })
800
801 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
802 const elements = $.ui.resolve(e)
803 const { Box, Text } = elements
804 const shown = await read($, snapshot)
805 const glyphSet = await read($, glyphs)
806 const icon = BRANCH_ICON[glyphSet]
807 const fill = (body: JSX.Element) => (
808 <Box
809 flexDirection="column"
810 backgroundColor={background}
811 width={e.props.bodyColumns}
812 height={e.props.scroll.bodyRows}
813 >
814 {body}
815 </Box>
816 )
817 const state = await read($, turn)
818 const title = (
819 <Box justifyContent="center" flexShrink={0}>
820 <Text bold wrap="truncate">
821 {NAME}
822 <Text dimColor>{' · '}</Text>
823 {state === 'waiting' ? (
824 <Text color="warning">WAITING</Text>
825 ) : state === 'working' ? (
826 <Text color="claude">WORKING</Text>
827 ) : (
828 <Text dimColor>IDLE</Text>
829 )}
830 </Text>
831 </Box>
832 )
833 const changes = (body: JSX.Element, footer = '', label = '') =>
834 titled(elements, { name: 'CHANGES', color: CHANGES_COLOR, key: 'changes', label, footer, background }, body)
835 const info = await read($, session)
836 // The session box: the branch line, then the model and effort, on its first line.
837 const sessionBox = (branch: JSX.Element) =>
838 isShowingSession &&
839 titled(
840 elements,
841 { name: 'SESSION', color: SESSION_COLOR, key: 'session', rows: 2, background },
842 <Box flexDirection="column">
843 <Box justifyContent="space-between">
844 {branch}
845 <Text wrap="truncate">
846 <Text color={SESSION_COLOR}>{` ${info.model ? modelName(info.model) : '—'}`}</Text>
847 <Text dimColor>{' · '}</Text>
848 <Text color={SESSION_COLOR}>{info.effort ?? '—'}</Text>
849 </Text>
850 </Box>
851 <Text wrap="truncate">
852 <Text dimColor>{USAGE_LABEL}</Text>
853 <Text
854 color={level(info.context)}
855 >{`${bar(info.context ?? 0, barWidth(e.props.bodyColumns - BOX_FRAME, [info.context, info.fiveHour, info.sevenDay].map(percentLabel)))} ${percentLabel(info.context)}`}</Text>
856 <Text dimColor>{`${USAGE_SEPARATOR}5h `}</Text>
857 <Text color={level(info.fiveHour)}>{percentLabel(info.fiveHour)}</Text>
858 <Text dimColor>{`${USAGE_SEPARATOR}7d `}</Text>
859 <Text color={level(info.sevenDay)}>{percentLabel(info.sevenDay)}</Text>
860 </Text>
861 </Box>,
862 )
863 const logged = await read($, commands)
864 const isInCommands = (await read($, focusedCommand)) !== null
865 const open = await read($, openCommand)
866 const logColumns = e.props.bodyColumns - BOX_FRAME
867 // The commands box `rows` lines tall, with the newest commands that fit.
868 const logBox = (rows: number) =>
869 titled(
870 elements,
871 { name: 'COMMANDS', color: COMMAND_COLOR, key: 'commands', rows, background },
872 <Box flexDirection="column">
873 {logged.length === 0 && <Text dimColor>no commands yet</Text>}
874 {logged.slice(-rows).map((entry) => (
875 <Box key={`log-${entry.id}`} flexDirection="row">
876 <Text color={LOG_GLYPH_COLOR[entry.state]}>{`${LOG_GLYPH[entry.state]} `}</Text>
877 <elements.Button
878 key={`command-${entry.id}`}
879 plain
880 dimColor={entry.state !== 'running' || undefined}
881 label={logLine(entry.command, logColumns - 2)}
882 onPress={() => void toggleCommand($, entry.id)}
883 />
884 </Box>
885 ))}
886 </Box>,
887 )
888 // No box at all with `commandRows` at 0.
889 const log = commandLines > 0 && logBox(commandLines)
890 if (shown.kind === 'loading')
891 return fill(
892 <Box flexDirection="column" flexGrow={1}>
893 {title}
894 {sessionBox(<Text> </Text>)}
895 {changes(<Text> </Text>)}
896 {log}
897 </Box>,
898 )
899 // Outside a repo `CHANGES` has one line to say so, and the commands box takes every row left; with no commands box,
900 // `CHANGES` keeps them.
901 if (shown.kind === 'none') {
902 const notRepo = <Text dimColor>Not a git repository</Text>
903 // The title, the session box, `CHANGES` (one line and its border) and the commands box's own border.
904 const taken = 1 + (isShowingSession ? SESSION_ROWS : 0) + 3 + 2
905 const rowsLeft = Math.max(1, e.props.scroll.bodyRows - taken)
906 return fill(
907 <Box flexDirection="column" flexGrow={1}>
908 {title}
909 {sessionBox(<Text dimColor>not a git repo</Text>)}
910 {commandLines > 0
911 ? titled(
912 elements,
913 { name: 'CHANGES', color: CHANGES_COLOR, key: 'changes', rows: 1, background },
914 notRepo,
915 )
916 : changes(notRepo)}
917 {commandLines > 0 && logBox(rowsLeft)}
918 </Box>,
919 )
920 }
921 const symbols = statusSymbols(shown.status)
922 const tree = await currentTree($)
923 const edited = tree?.edited ?? new Map<string, ActiveEdit>()
924 const treeRowsShown = tree?.rows ?? []
925 const openPath = await read($, selected)
926 const openDiff = await read($, diff)
927 const opened = openPath !== null && openDiff?.path === openPath ? openDiff : null
928 const diffView = opened ? { path: opened.path, ...cutDiff(opened.hunks) } : null
929 const at = resolveCursor(treeRowsShown, await read($, cursor))
930 const commandView = logged.find((entry) => entry.id === open) ?? null
931 const cursorId = treeRowsShown[at]?.id ?? null
932 const fileCount = treeRowsShown.filter((row) => row.kind === 'file').length
933 const rows = treeRowsShown.map((row): RowSpec => ({
934 id: row.id,
935 kind: row.kind,
936 indent: ' '.repeat(row.depth),
937 icon: rowIcon(row.kind, row.name, glyphSet),
938 iconColor: row.status ? STATUS_COLOR[row.status] : null,
939 name: row.name,
940 isStruck: row.status === 'deleted',
941 lit: (row.kind === 'file' && edited.get(row.id)?.state) || null,
942 added: row.added,
943 deleted: row.deleted,
944 isBinary: row.isBinary,
945 }))
946 const commit = await read($, viewing)
947 // The keys are always listed in a repo, so the way around (the tree, the commits, the commands) is never hidden.
948 const hints = (
949 <Box flexDirection="column" alignItems="center" marginTop={1} flexShrink={0}>
950 <Box flexDirection="row">
951 {commandView ? (
952 <elements.Button
953 key="nav-h"
954 plain
955 dimColor
956 autoFocus
957 hotkey="h"
958 label="back"
959 onPress={() => navigate($, 'h')}
960 />
961 ) : diffView ? (
962 <Box flexDirection="row">
963 <elements.Button
964 key="nav-h"
965 plain
966 dimColor
967 autoFocus
968 hotkey="h"
969 label="back"
970 onPress={() => navigate($, 'h')}
971 />
972 <Text> </Text>
973 <elements.Button
974 key="nav-j"
975 plain
976 dimColor
977 hotkey="j"
978 label="next file"
979 onPress={() => navigate($, 'j')}
980 />
981 <Text> </Text>
982 <elements.Button
983 key="nav-k"
984 plain
985 dimColor
986 hotkey="k"
987 label="previous file"
988 onPress={() => navigate($, 'k')}
989 />
990 </Box>
991 ) : (
992 <Box flexDirection="row">
993 {commit && (
994 <elements.Button
995 key="nav-h"
996 plain
997 dimColor
998 hotkey="h"
999 label="back"
1000 onPress={() => navigate($, 'h')}
1001 />
1002 )}
1003 {commit && <Text> </Text>}
1004 <elements.Button
1005 key="nav-j"
1006 plain
1007 dimColor
1008 hotkey="j"
1009 label="down"
1010 onPress={() => navigate($, 'j')}
1011 />
1012 <Text> </Text>
1013 <elements.Button
1014 key="nav-k"
1015 plain
1016 dimColor
1017 hotkey="k"
1018 label="up"
1019 onPress={() => navigate($, 'k')}
1020 />
1021 <Text> </Text>
1022 <elements.Button
1023 key="nav-l"
1024 plain
1025 dimColor
1026 autoFocus
1027 hotkey="l"
1028 label="open"
1029 onPress={() => navigate($, 'l')}
1030 />
1031 {logged.length > 0 && commandLines > 0 && <Text> </Text>}
1032 {logged.length > 0 && commandLines > 0 && (
1033 <elements.Button
1034 key="nav-c"
1035 plain
1036 dimColor
1037 hotkey={COMMANDS_KEY}
1038 label={isInCommands ? 'changes' : 'commands'}
1039 onPress={() => navigate($, COMMANDS_KEY)}
1040 />
1041 )}
1042 </Box>
1043 )}
1044 </Box>
1045 </Box>
1046 )
1047 const branchLine = (
1048 <Text wrap="truncate">
1049 <Text color="magenta" bold>{`${icon} ${shown.branch}`}</Text>
1050 {symbols && <Text color="red" bold>{` [${symbols}]`}</Text>}
1051 </Text>
1052 )
1053 const filesLabel = `${fileCount} file${fileCount === 1 ? '' : 's'}`
1054 const windowRows = changesRows(e.props.scroll.bodyRows, true, isShowingSession, commandsBox)
1055 const innerColumns = e.props.bodyColumns - BOX_FRAME
1056 const commandRows = commandView ? wrapLines(commandView.command, innerColumns) : []
1057 const diffLines = diffView && diffView.hunks !== '' ? diffView.hunks.replace(/\n$/, '').split('\n') : []
1058 // A diff or a command keeps its header row and the blank row under it above the window.
1059 // An opened commit keeps its header row, and a blank row under it, above everything else in the box.
1060 const contentRows = commit ? Math.max(1, windowRows - 2) : windowRows
1061 const shownRows = commandView || diffView ? Math.max(1, contentRows - 2) : contentRows
1062 const lineCount = commandView ? commandRows.length : diffView ? diffLines.length : rows.length
1063 changesWindow = { lines: lineCount, rows: shownRows }
1064 const offset = clampOffset(await read($, changesOffset), lineCount, shownRows)
1065 const footer = hiddenLabel(hiddenAround(offset, lineCount, shownRows))
1066 const windowDiff = diffView && {
1067 ...diffView,
1068 hunks: diffView.hunks === '' ? '' : diffLines.slice(offset, offset + shownRows).join('\n') + '\n',
1069 }
1070 const windowTree = rows.slice(offset, offset + shownRows)
1071 const now = await $.clock.now()
1072 // Under the status line, a blank row and the heading.
1073 const listedCommits = shown.commits.slice(0, Math.max(0, shownRows - 3))
1074 commitsShown = listedCommits.length
1075 const selectedId = (await read($, cursor))?.id ?? ''
1076 const selectedCommit = selectedId.startsWith('commit:') ? selectedId.slice('commit:'.length) : null
1077 return fill(
1078 <Box flexDirection="column" flexGrow={1}>
1079 {title}
1080 {hints}
1081 {sessionBox(branchLine)}
1082 {changes(
1083 <Box flexDirection="column">
1084 {!isShowingSession && (
1085 <Box justifyContent="space-between">
1086 {branchLine}
1087 {fileCount > 0 && <Text dimColor>{` ${filesLabel}`}</Text>}
1088 </Box>
1089 )}
1090 {commit && (
1091 <Box flexDirection="row" marginTop={isShowingSession ? 0 : 1} marginBottom={1}>
1092 <elements.Button
1093 key="commit-back"
1094 plain
1095 label="← back"
1096 onPress={() => void closeCommit($)}
1097 />
1098 <Text wrap="truncate-end">
1099 <Text dimColor>{` ${commit.sha} `}</Text>
1100 <Text bold>{commit.subject}</Text>
1101 </Text>
1102 </Box>
1103 )}
1104 <Box flexDirection="column" marginTop={isShowingSession || commit ? 0 : 1}>
1105 {commandView ? (
1106 <Box flexDirection="column">
1107 <elements.Button
1108 key="command-back"
1109 plain
1110 label="← back"
1111 onPress={() => void toggleCommand($, commandView.id)}
1112 />
1113 <Box flexDirection="column" marginTop={1}>
1114 {commandRows.slice(offset, offset + shownRows).map((line, index) => (
1115 <Text key={`command-line-${offset + index}`}>{line}</Text>
1116 ))}
1117 </Box>
1118 </Box>
1119 ) : commit && rows.length === 0 && !diffView ? (
1120 <Text dimColor>no files changed</Text>
1121 ) : rows.length === 0 && !diffView ? (
1122 <Box flexDirection="column">
1123 <Text dimColor>No uncommitted changes</Text>
1124 <Box flexDirection="column" marginTop={1}>
1125 <Text bold>Recent commits</Text>
1126 {shown.commits.length === 0 && <Text dimColor>no commits yet</Text>}
1127 {listedCommits.map((recent) => (
1128 <Box
1129 key={`commit-line-${recent.sha}`}
1130 justifyContent="space-between"
1131 backgroundColor={
1132 recent.sha === selectedCommit && hoverBackground
1133 ? hoverBackground
1134 : undefined
1135 }
1136 >
1137 <elements.Button
1138 key={`commit-${recent.sha}`}
1139 plain
1140 label={recent.subject}
1141 onPress={() => void openCommit($, recent)}
1142 >
1143 <Text dimColor>{`${recent.sha} `}</Text>
1144 <Text inverse={recent.sha === selectedCommit && !hoverBackground}>
1145 {fitSubject(
1146 recent.subject,
1147 innerColumns -
1148 recent.sha.length -
1149 1 -
1150 shortAge(now, recent.time).length -
1151 1,
1152 )}
1153 </Text>
1154 </elements.Button>
1155 <Text dimColor>{` ${shortAge(now, recent.time)}`}</Text>
1156 </Box>
1157 ))}
1158 </Box>
1159 </Box>
1160 ) : HAS_CLIENT.has(e.surface) && 'Client' in elements ? (
1161 <elements.Client
1162 key="rows"
1163 module="./rows.tsx"
1164 props={{
1165 rows: windowTree,
1166 hoverBackground,
1167 diff: windowDiff,
1168 cursor: cursorId,
1169 offset,
1170 }}
1171 />
1172 ) : windowDiff ? (
1173 drawDiff(windowDiff, elements, () => void closeDiff($))
1174 ) : (
1175 drawRows({ rows: windowTree, hoverBackground }, elements, 0, -1, cursorId)
1176 )}
1177 </Box>
1178 </Box>,
1179 footer,
1180 commit ? commit.sha : isShowingSession && fileCount > 0 ? filesLabel : '',
1181 )}
1182 {log}
1183 </Box>,
1184 )
1185 })
1186}
1187hooks/commands.ts 70 lines1// A command longer than this, or on more than one line, is hidden behind its description.
2export const LONG_COMMAND = 80
3
4export type DescribedCommand = { command: string; description: string }
5
6const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
7
8export const isLongCommand = (command: string) => command.includes('\n') || command.length > LONG_COMMAND
9
10// A Bash input's command and the model's own description of it; `undefined` without a description, since sideline never
11// makes one up.
12export function describedCommand(input: unknown): DescribedCommand | undefined {
13 if (!isRecord(input) || typeof input.command !== 'string' || typeof input.description !== 'string') return undefined
14 const description = input.description.trim()
15 return description === '' ? undefined : { command: input.command, description }
16}
17
18// The input a Bash row is drawn from with its command hidden: the description in the command's place, so the engine
19// draws `Bash(<description>)` and no `$ command` line. `undefined` when the row keeps its command.
20export function hiddenCommandInput(input: unknown): Record<string, unknown> | undefined {
21 const described = describedCommand(input)
22 if (!isRecord(input) || !described || !isLongCommand(described.command)) return undefined
23 const { description: _, ...rest } = input
24 return { ...rest, command: described.description }
25}
26
27type GroupCall = { tool: string; input: unknown; isRunning: boolean }
28
29// The description a live group's line shows in place of its current Bash call's command; `undefined` for another tool
30// or an undescribed command. Any length: under its line the engine prints the turn's latest command, even one that
31// isn't in the group. The current call is the running one, else the last: a call is not running yet as the group
32// appears, nor any more just before the group folds. While the model is still writing the call its input is empty,
33// and the engine shows the previous description with the previous command, so `previous` stands in.
34export function runningDescription(calls: readonly GroupCall[], previous?: string): string | undefined {
35 const running = calls.toReversed().find((call) => call.isRunning) ?? calls.at(-1)
36 if (!running || running.tool !== 'Bash') return undefined
37 const isStreaming = isRecord(running.input) && running.input.command === undefined
38 return isStreaming ? previous : describedCommand(running.input)?.description
39}
40
41// The pane's log keeps this many commands, the newest last: as many as the commands box can show (the `commandRows`
42// option's most); the box draws the newest that fit.
43export const LOG_SIZE = 20
44
45// A command as one line of the log: its first non-blank line with whitespace squeezed, after `$ `, cut to `columns`
46// with an ellipsis; a script with more lines ends in one too.
47export function logLine(command: string, columns: number): string {
48 const lines = command
49 .split('\n')
50 .map((line) => line.replace(/\s+/g, ' ').trim())
51 .filter((line) => line !== '')
52 const line = `$ ${lines[0] ?? ''}${lines.length > 1 ? ' …' : ''}`
53 return line.length > columns ? `${line.slice(0, Math.max(columns - 1, 0))}…` : line
54}
55
56export type CommandMove = 'down' | 'up' | 'open' | 'leave'
57
58// What a key does while the focus ring is on the log line `current`: j/k move it to the next or previous command,
59// stopping at the ends; l opens the command; h leaves the box. `ids` are oldest first.
60export function commandStep(
61 ids: readonly string[],
62 current: string,
63 move: CommandMove | null,
64): { kind: 'focus'; id: string } | { kind: 'open' } | { kind: 'leave' } | null {
65 if (move === 'open' || move === 'leave') return { kind: move }
66 if (move === null) return null
67 const target = ids[ids.indexOf(current) + (move === 'down' ? 1 : -1)]
68 return target === undefined ? null : { kind: 'focus', id: target }
69}
70hooks/git.ts 228 lines1import type { ProcessRunResult } from 'claude-code'
2
3import type { BranchStatus, ChangedFile, Commit, FileStatus, LineCounts, Snapshot } from '../types'
4
5type RepoSnapshot = Extract<Snapshot, { kind: 'repo' }>
6
7const UNTRACKED_COUNT_LIMIT = 60
8
9// What the readers need from the engine. register.tsx builds it: the directory reads a mod's engine calls only in the
10// file that declares its hooks.
11export type Git = {
12 // Runs git with these arguments in a folder.
13 run: (cwd: string, args: string[]) => Promise<ProcessRunResult>
14 // A file's size and modification time, or null for a file gone or a host without one.
15 stat: (path: string) => Promise<{ size: number; mtimeMs: number } | null>
16}
17
18const emptyStatus = (): BranchStatus => ({
19 conflicted: 0,
20 stashed: false,
21 deleted: 0,
22 renamed: 0,
23 modified: 0,
24 staged: 0,
25 untracked: 0,
26 ahead: 0,
27 behind: 0,
28})
29
30const changed = (path: string, status: FileStatus, isUntracked = false): ChangedFile => ({
31 path,
32 status,
33 isUntracked,
34 added: 0,
35 deleted: 0,
36 isBinary: false,
37})
38
39// A record's path follows a fixed number of space-separated fields, and may itself contain spaces.
40const pathAfter = (record: string, fields: number) => record.split(' ').slice(fields).join(' ')
41
42// `git status --porcelain=v2 --branch -z`: NUL-separated records; a rename record (`2`) is followed by one more
43// NUL-terminated field, the path it came from.
44export function parseStatus(stdout: string): RepoSnapshot {
45 const status = emptyStatus()
46 const files: ChangedFile[] = []
47 let oid = ''
48 let head = ''
49 const records = stdout.split('\0')
50 for (let i = 0; i < records.length; i++) {
51 const record = records[i] ?? ''
52 if (record.startsWith('# branch.oid ')) oid = record.slice('# branch.oid '.length)
53 else if (record.startsWith('# branch.head ')) head = record.slice('# branch.head '.length)
54 else if (record.startsWith('# branch.ab ')) {
55 const [ahead = '', behind = ''] = record.slice('# branch.ab '.length).split(' ')
56 status.ahead = Number(ahead.replace('+', '')) || 0
57 status.behind = Number(behind.replace('-', '')) || 0
58 } else if (record.startsWith('1 ') || record.startsWith('2 ')) {
59 const isRename = record.startsWith('2 ')
60 const index = record[2] ?? '.'
61 const worktree = record[3] ?? '.'
62 if (isRename) {
63 status.renamed++
64 i++
65 }
66 const isDeleted = index === 'D' || worktree === 'D'
67 if (isDeleted) status.deleted++
68 if (index !== '.' && index !== 'D' && !isRename) status.staged++
69 if (worktree === 'M' || worktree === 'T') status.modified++
70 const fileStatus: FileStatus = isRename
71 ? 'renamed'
72 : isDeleted
73 ? 'deleted'
74 : index === 'A'
75 ? 'added'
76 : 'modified'
77 files.push(changed(pathAfter(record, isRename ? 9 : 8), fileStatus))
78 } else if (record.startsWith('u ')) {
79 status.conflicted++
80 files.push(changed(pathAfter(record, 10), 'conflicted'))
81 } else if (record.startsWith('? ')) {
82 status.untracked++
83 files.push(changed(record.slice(2), 'added', true))
84 }
85 }
86 const isDetached = head === '(detached)'
87 return {
88 kind: 'repo',
89 root: '',
90 branch: isDetached ? oid.slice(0, 7) : head,
91 isDetached,
92 status,
93 files,
94 commits: [],
95 }
96}
97
98// `git diff --numstat -z`: `added\tdeleted\tpath\0`, or `added\tdeleted\t\0from\0to\0` for a rename (and for
99// `--no-index` against /dev/null). A binary file counts `-\t-`.
100export function parseNumstat(stdout: string): Map<string, LineCounts> {
101 const counts = new Map<string, LineCounts>()
102 const tokens = stdout.split('\0')
103 for (let i = 0; i < tokens.length; i++) {
104 const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(tokens[i] ?? '')
105 if (!match) continue
106 const [, added = '', deleted = '', inline = ''] = match
107 let path = inline
108 if (inline === '') {
109 path = tokens[i + 2] ?? ''
110 i += 2
111 }
112 const isBinary = added === '-'
113 counts.set(path, { added: isBinary ? 0 : Number(added), deleted: isBinary ? 0 : Number(deleted), isBinary })
114 }
115 return counts
116}
117
118// An untracked file's counts with the size and modification time they were read at, by absolute path: the poll runs
119// `git diff --no-index` again only for a file that changed. It starts empty on a reload.
120const untrackedCache = new Map<string, { size: number; mtimeMs: number; counts: LineCounts | undefined }>()
121
122async function untrackedCounts(git: Git, cwd: string, files: ChangedFile[]) {
123 const counts = new Map<string, LineCounts>()
124 const seen = new Set<string>()
125 for (const file of files.filter((f) => f.isUntracked).slice(0, UNTRACKED_COUNT_LIMIT)) {
126 const path = `${cwd}/${file.path}`
127 seen.add(path)
128 // Without a stat (a file gone between status and now, a host without one) the file is read every time.
129 const stat = await git.stat(path)
130 const cached = untrackedCache.get(path)
131 let one = cached?.counts
132 if (!stat || !cached || cached.size !== stat.size || cached.mtimeMs !== stat.mtimeMs) {
133 const run = await git.run(cwd, ['diff', '--no-index', '--numstat', '-z', '--', '/dev/null', file.path])
134 one = parseNumstat(run.stdout).get(file.path)
135 if (stat) untrackedCache.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, counts: one })
136 }
137 if (one) counts.set(file.path, one)
138 }
139 // A file that is no longer untracked (added, deleted, ignored) leaves the cache.
140 for (const path of untrackedCache.keys()) if (!seen.has(path)) untrackedCache.delete(path)
141 return counts
142}
143
144// How many recent commits are read; the pane draws as many as its changes box fits.
145const LOG_LIMIT = 50
146
147// `git log --format=%h%x00%s%x00%ct%x00`: one commit per line, its short hash, subject and commit time in seconds.
148export function parseLog(stdout: string): Commit[] {
149 return stdout.split('\n').flatMap((line) => {
150 const [sha, subject, seconds] = line.split('\0')
151 if (!sha || subject === undefined || !seconds) return []
152 return [{ sha, subject, time: Number(seconds) * 1000 }]
153 })
154}
155
156// `--name-status -z`: a status letter (`M`, `A`, `D`, `R100`, ...) and its path, NUL-separated; a rename or copy has two
157// paths, the old then the new.
158export function parseNameStatus(stdout: string): Map<string, FileStatus> {
159 const statuses = new Map<string, FileStatus>()
160 const fields = stdout.split('\0')
161 for (let i = 0; i < fields.length - 1;) {
162 const code = fields[i] ?? ''
163 const isMove = code.startsWith('R') || code.startsWith('C')
164 const path = fields[i + (isMove ? 2 : 1)]
165 if (path) statuses.set(path, NAME_STATUS[code.charAt(0)] ?? 'modified')
166 i += isMove ? 3 : 2
167 }
168 return statuses
169}
170
171const NAME_STATUS: Record<string, FileStatus> = { A: 'added', D: 'deleted', R: 'renamed', C: 'added', U: 'conflicted' }
172
173// What a commit changed, against its first parent (`-m --first-parent` for a merge; `--root` for the first commit).
174const COMMIT_DIFF = ['-r', '--root', '--no-commit-id', '-m', '--first-parent']
175
176// The files a commit changed, with their status and line counts, as the tree draws them.
177export async function readCommitFiles(git: Git, root: string, sha: string): Promise<ChangedFile[]> {
178 const counts = parseNumstat((await git.run(root, ['diff-tree', ...COMMIT_DIFF, '--numstat', '-z', sha])).stdout)
179 const statuses = parseNameStatus(
180 (await git.run(root, ['diff-tree', ...COMMIT_DIFF, '--name-status', '-z', sha])).stdout,
181 )
182 return [...statuses].map(([path, status]) => ({
183 path,
184 status,
185 isUntracked: false,
186 ...(counts.get(path) ?? { added: 0, deleted: 0, isBinary: false }),
187 }))
188}
189
190// One file's diff in a commit, against its first parent.
191export async function readCommitDiff(git: Git, root: string, sha: string, path: string): Promise<string> {
192 return hunksOnly((await git.run(root, ['show', '--format=', '-m', '--first-parent', sha, '--', path])).stdout)
193}
194
195export async function readSnapshot(git: Git, cwd: string): Promise<Snapshot> {
196 const run = await git.run(cwd, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'])
197 if (run.exitCode !== 0) return { kind: 'none' }
198 const snapshot = parseStatus(run.stdout)
199 const top = await git.run(cwd, ['rev-parse', '--show-toplevel'])
200 snapshot.root = top.stdout.trim()
201 const stash = await git.run(cwd, ['rev-parse', '--verify', '--quiet', 'refs/stash'])
202 snapshot.status.stashed = stash.exitCode === 0
203 const tracked = await git.run(cwd, ['diff', 'HEAD', '--numstat', '-z'])
204 const counts = tracked.exitCode === 0 ? parseNumstat(tracked.stdout) : new Map<string, LineCounts>()
205 for (const [path, one] of await untrackedCounts(git, cwd, snapshot.files)) counts.set(path, one)
206 snapshot.files = snapshot.files.map((file) => ({ ...file, ...counts.get(file.path) }))
207 if (snapshot.files.length === 0) {
208 // A repo with no commits yet fails `git log`; that is an empty list too.
209 const log = await git.run(cwd, ['log', `-n${LOG_LIMIT}`, '--format=%h%x00%s%x00%ct%x00'])
210 snapshot.commits = log.exitCode === 0 ? parseLog(log.stdout) : []
211 }
212 return snapshot
213}
214
215// Drops git's file header (`diff --git`, `index`, `---`, `+++`), keeping the hunks from the first `@@`.
216export function hunksOnly(diff: string): string {
217 const start = diff.startsWith('@@') ? 0 : diff.indexOf('\n@@') + 1
218 return start > 0 || diff.startsWith('@@') ? diff.slice(start) : ''
219}
220
221export async function readDiff(git: Git, root: string, file: ChangedFile): Promise<string> {
222 const args = file.isUntracked
223 ? ['diff', '--no-index', '--', '/dev/null', file.path]
224 : ['diff', 'HEAD', '--', file.path]
225 const run = await git.run(root, args)
226 return hunksOnly(run.stdout)
227}
228hooks/meter.ts 54 lines1const capital = (family: string) => family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
2
3// A model id as the session box shows it: `claude-opus-5-5` is `Opus 5.5`, `claude-3-5-sonnet-20241022` is
4// `Sonnet 3.5`, and a 1M-context id (`[1m]`, `-1m`) gains ` 1M`; any other id stays as it is.
5export function modelName(id: string): string {
6 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
7 const current = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?!\d)/i.exec(id)
8 if (current?.[1] && current[2])
9 return `${capital(current[1])} ${current[2]}${current[3] ? `.${current[3]}` : ''}${big}`
10 const older = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
11 if (older?.[1] && older[3]) return `${capital(older[3])} ${older[1]}${older[2] ? `.${older[2]}` : ''}${big}`
12 return id
13}
14
15// A percentage as a bar of `width` cells, `▰` used and `▱` left, clamped to 0..100.
16export function bar(percent: number, width: number): string {
17 const used = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
18 return '▰'.repeat(used) + '▱'.repeat(width - used)
19}
20
21// The colour a usage percentage reads in: plain up to 75%, orange past it, red past 90%.
22export function level(percent: number | null): 'warning' | 'error' | undefined {
23 if (percent === null || percent <= 75) return undefined
24 return percent > 90 ? 'error' : 'warning'
25}
26
27type Readings = { context: { percent?: number }; rateLimits: readonly { kind: string; percentUsed: number }[] }
28
29// The context used and the 5-hour and 7-day usage out of a usage reading, each null when it doesn't say.
30export function readings({ context, rateLimits }: Readings): {
31 context: number | null
32 fiveHour: number | null
33 sevenDay: number | null
34} {
35 const limit = (kind: string) => rateLimits.find((one) => one.kind === kind)?.percentUsed ?? null
36 return { context: context.percent ?? null, fiveHour: limit('five_hour'), sevenDay: limit('seven_day') }
37}
38
39// A percentage as the session box prints it: whole, or `—` before anything reported it.
40export const percentLabel = (percent: number | null) => (percent === null ? '—' : `${Math.round(percent)}%`)
41
42// The usage line's parts: `context`, then the 5h and 7d windows after a middle dot.
43export const USAGE_LABEL = 'context '
44export const USAGE_SEPARATOR = ' · '
45
46// What the usage line spends besides the bar and its numbers: `context `, a space, then ` · 5h ` and ` · 7d `.
47const USAGE_TEXT = USAGE_LABEL.length + 1 + 2 * (USAGE_SEPARATOR.length + '5h '.length)
48
49// The context bar's width on a line `columns` wide holding `numbers` as printed: whatever the text leaves, so the line
50// ends at the box's right edge, never under 3 cells.
51export function barWidth(columns: number, numbers: readonly string[]): number {
52 return Math.max(3, columns - USAGE_TEXT - numbers.reduce((sum, number) => sum + number.length, 0))
53}
54hooks/icons.ts 168 lines1import type { Glyphs } from '../types'
2
3// Nerd Font code points, named as in nerd-fonts' glyphnames.json. Nerd Fonts v3 or later: 33 were added in 3.0, and
4// U+E76F was another glyph in 2.x.
5const GLYPH = {
6 folderOpen: 0xe5fe, // custom-folder_open
7 file: 0xe64e, // seti-default
8 typescript: 0xe628, // seti-typescript
9 tsconfig: 0xe69d, // seti-tsconfig
10 javascript: 0xe60c, // seti-javascript
11 react: 0xe625, // seti-react
12 json: 0xe60b, // seti-json
13 markdown: 0xe609, // seti-markdown
14 yaml: 0xe6a8, // seti-yml
15 toml: 0xe6b2, // custom-toml
16 shell: 0xe691, // seti-shell
17 powershell: 0xe683, // seti-powershell
18 html: 0xe60e, // seti-html
19 css: 0xe614, // seti-css
20 sass: 0xe603, // seti-sass
21 go: 0xe627, // seti-go
22 rust: 0xe68b, // seti-rust
23 python: 0xe606, // seti-python
24 ruby: 0xe605, // seti-ruby
25 php: 0xe608, // seti-php
26 java: 0xe66d, // seti-java
27 kotlin: 0xe634, // seti-kotlin
28 swift: 0xe699, // seti-swift
29 c: 0xe649, // seti-c
30 cpp: 0xe646, // seti-cpp
31 lua: 0xe620, // seti-lua
32 elixir: 0xe62d, // seti-elixir
33 vue: 0xe6a0, // seti-vue
34 svelte: 0xe697, // seti-svelte
35 graphql: 0xe662, // seti-graphql
36 sql: 0xe706, // dev-database
37 prisma: 0xe684, // seti-prisma
38 terraform: 0xe69a, // seti-terraform
39 xml: 0xe619, // seti-xml
40 csv: 0xe64a, // seti-csv
41 image: 0xe60d, // seti-image
42 svg: 0xe698, // seti-svg
43 pdf: 0xe67d, // seti-pdf
44 video: 0xe69f, // seti-video
45 audio: 0xe638, // seti-audio
46 font: 0xe659, // seti-font
47 archive: 0xe6aa, // seti-zip
48 config: 0xe615, // seti-config
49 lock: 0xe672, // seti-lock
50 git: 0xe65d, // seti-git
51 npm: 0xe616, // seti-npm
52 yarn: 0xe6a7, // seti-yarn
53 bun: 0xe76f, // dev-bun
54 docker: 0xe650, // seti-docker
55 makefile: 0xe673, // seti-makefile
56 license: 0xe60a, // seti-license
57} as const
58
59type GlyphName = keyof typeof GLYPH
60
61const BY_NAME: Record<string, GlyphName> = {
62 'package.json': 'npm',
63 'package-lock.json': 'npm',
64 'tsconfig.json': 'tsconfig',
65 'yarn.lock': 'yarn',
66 'bun.lock': 'bun',
67 'bun.lockb': 'bun',
68 dockerfile: 'docker',
69 'docker-compose.yml': 'docker',
70 makefile: 'makefile',
71 license: 'license',
72 'license.md': 'license',
73 '.gitignore': 'git',
74 '.gitattributes': 'git',
75 '.gitmodules': 'git',
76 '.prototools': 'config',
77 '.editorconfig': 'config',
78}
79
80const BY_EXTENSION: Record<string, GlyphName> = {
81 ts: 'typescript',
82 mts: 'typescript',
83 cts: 'typescript',
84 tsx: 'react',
85 jsx: 'react',
86 js: 'javascript',
87 mjs: 'javascript',
88 cjs: 'javascript',
89 json: 'json',
90 jsonc: 'json',
91 md: 'markdown',
92 mdx: 'markdown',
93 yaml: 'yaml',
94 yml: 'yaml',
95 toml: 'toml',
96 sh: 'shell',
97 bash: 'shell',
98 zsh: 'shell',
99 fish: 'shell',
100 ps1: 'powershell',
101 html: 'html',
102 htm: 'html',
103 css: 'css',
104 scss: 'sass',
105 sass: 'sass',
106 go: 'go',
107 rs: 'rust',
108 py: 'python',
109 rb: 'ruby',
110 php: 'php',
111 java: 'java',
112 kt: 'kotlin',
113 swift: 'swift',
114 c: 'c',
115 h: 'c',
116 cpp: 'cpp',
117 cc: 'cpp',
118 hpp: 'cpp',
119 lua: 'lua',
120 ex: 'elixir',
121 exs: 'elixir',
122 vue: 'vue',
123 svelte: 'svelte',
124 graphql: 'graphql',
125 gql: 'graphql',
126 sql: 'sql',
127 prisma: 'prisma',
128 tf: 'terraform',
129 tfvars: 'terraform',
130 xml: 'xml',
131 csv: 'csv',
132 png: 'image',
133 jpg: 'image',
134 jpeg: 'image',
135 gif: 'image',
136 webp: 'image',
137 ico: 'image',
138 svg: 'svg',
139 pdf: 'pdf',
140 mp4: 'video',
141 mov: 'video',
142 webm: 'video',
143 mp3: 'audio',
144 wav: 'audio',
145 ttf: 'font',
146 otf: 'font',
147 woff: 'font',
148 woff2: 'font',
149 zip: 'archive',
150 gz: 'archive',
151 tar: 'archive',
152 lock: 'lock',
153 env: 'config',
154 ini: 'config',
155 conf: 'config',
156}
157
158const PLAIN = { dir: '▾', file: '·' } as const
159
160export function rowIcon(kind: 'dir' | 'file', name: string, glyphs: Glyphs): string {
161 if (glyphs === 'plain') return PLAIN[kind]
162 if (kind === 'dir') return String.fromCodePoint(GLYPH.folderOpen)
163 const lower = name.toLowerCase()
164 const dot = lower.lastIndexOf('.')
165 const named = BY_NAME[lower] ?? (dot > 0 ? BY_EXTENSION[lower.slice(dot + 1)] : undefined)
166 return String.fromCodePoint(GLYPH[named ?? 'file'])
167}
168hooks/rows.tsx 179 lines1import type { ClientElements, ClientModule, Color } from 'claude-code'
2
3export type RowSpec = {
4 id: string
5 kind: 'dir' | 'file'
6 indent: string
7 icon: string
8 iconColor: Color | null
9 name: string
10 isStruck: boolean
11 lit: 'running' | 'done' | null
12 added: number
13 deleted: number
14 isBinary: boolean
15}
16
17// The open file's diff as the rows Client draws it, cut to fit a Client's props.
18export type DiffView = { path: string; hunks: string; isTruncated: boolean }
19
20export type RowsProps = {
21 rows: RowSpec[]
22 hoverBackground: string | null
23 diff: DiffView | null
24 cursor: string | null
25 // The tree's index of the first row in `rows`: the changes box draws a window of the tree.
26 offset: number
27}
28
29type Local = { phase: number; hovered: number; stop?: () => void }
30
31const TICK_MS = 50
32const BAND = 3
33
34// A band of bright characters sweeps across the name; the rest stays dim, both in Claude's own accent color.
35function isInBand(index: number, phase: number, length: number): boolean {
36 const position = (phase % (length + BAND * 2)) - BAND
37 return Math.abs(index - position) < BAND / 2 + 0.5
38}
39
40// A Client's props are capped at 100,000 characters; a diff past this is cut at the last hunk that fits.
41export const DIFF_LIMIT = 60_000
42
43export function cutDiff(hunks: string, limit = DIFF_LIMIT): { hunks: string; isTruncated: boolean } {
44 if (hunks.length <= limit) return { hunks, isTruncated: false }
45 const lastHunk = hunks.lastIndexOf('\n@@', limit)
46 const end = lastHunk > 0 ? lastHunk + 1 : hunks.lastIndexOf('\n', limit) + 1
47 return { hunks: hunks.slice(0, end), isTruncated: true }
48}
49
50export function drawDiff(
51 diff: DiffView,
52 { Box, Text, Button, Code }: Pick<ClientElements, 'Box' | 'Text' | 'Button' | 'Code'>,
53 onBack: () => void,
54) {
55 return (
56 <Box flexDirection="column">
57 <Box flexDirection="row">
58 <Button key="back" plain label="← files" onPress={onBack} />
59 <Text bold>{` ${diff.path}`}</Text>
60 </Box>
61 <Box flexDirection="column" marginTop={1}>
62 {diff.hunks === '' ? (
63 <Text dimColor>no text diff (binary file)</Text>
64 ) : (
65 <Code source={diff.hunks} path={diff.path} format="diff" wrap="truncate-end" />
66 )}
67 {diff.isTruncated && <Text dimColor>… diff truncated</Text>}
68 </Box>
69 </Box>
70 )
71}
72
73export function drawRows(
74 { rows, hoverBackground }: Pick<RowsProps, 'rows' | 'hoverBackground'>,
75 { Box, Text }: Pick<ClientElements, 'Box' | 'Text'>,
76 phase: number,
77 hovered = -1,
78 cursor: string | null = null,
79) {
80 const isHovered = (row: RowSpec) => row.kind === 'file' && rows[hovered]?.id === row.id
81 const isSelected = (row: RowSpec) => row.id === cursor
82 const name = (row: RowSpec) => {
83 if (row.lit === 'done') {
84 return (
85 <Text color="claude" bold>
86 {row.name}
87 </Text>
88 )
89 }
90 if (row.lit === 'running') {
91 const chars = [...row.name]
92 return (
93 <Text>
94 {chars.map((char, i) =>
95 isInBand(i, phase, chars.length) ? (
96 <Text color="claude" bold>
97 {char}
98 </Text>
99 ) : (
100 <Text color="claude" dimColor>
101 {char}
102 </Text>
103 ),
104 )}
105 </Text>
106 )
107 }
108 return (
109 <Text strikethrough={row.isStruck} underline={isHovered(row)} inverse={isSelected(row) && !hoverBackground}>
110 {row.name}
111 </Text>
112 )
113 }
114
115 return (
116 <Box flexDirection="column">
117 {rows.map((row) => (
118 <Box
119 key={row.id}
120 flexDirection="row"
121 backgroundColor={
122 (isHovered(row) || isSelected(row)) && hoverBackground ? hoverBackground : undefined
123 }
124 >
125 <Box flexGrow={1} flexShrink={1} overflow="hidden">
126 <Text wrap="truncate-end">
127 {row.indent}
128 <Text
129 color={row.lit === 'running' ? 'claude' : (row.iconColor ?? undefined)}
130 >{`${row.icon} `}</Text>
131 {name(row)}
132 </Text>
133 </Box>
134 {row.isBinary ? (
135 <Text dimColor> bin</Text>
136 ) : (
137 <Text>
138 {row.added > 0 && <Text color="success">{` +${row.added}`}</Text>}
139 {row.deleted > 0 && <Text color="error">{` -${row.deleted}`}</Text>}
140 </Text>
141 )}
142 </Box>
143 ))}
144 </Box>
145 )
146}
147
148// The animated rows: a Client owns the frame timer, so the shimmer never makes the pane's hook redraw.
149// The animated rows: a Client owns the frame timer, so the shimmer never makes the pane's hook redraw.
150// Keys and clicks go to the hooks module, which owns the selection, so the legend's hotkeys move the same one.
151export const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
152 const state = surface.state ?? { phase: 0, hovered: -1 }
153 const isRunning = props.rows.some((row) => row.lit === 'running')
154 if (isRunning && !state.stop) {
155 const stop = surface.every(TICK_MS, () => {
156 const current = surface.state
157 if (current) surface.setState({ ...current, phase: current.phase + 1 })
158 })
159 surface.setState({ ...state, stop })
160 } else if (!isRunning && state.stop) {
161 state.stop()
162 surface.setState({ ...state, phase: 0, stop: undefined })
163 }
164 surface.onKey((event) => surface.post({ key: event.key }))
165 if (props.diff) {
166 surface.onPointer(() => undefined)
167 return drawDiff(props.diff, surface.elements, () => surface.post({ back: true }))
168 }
169 surface.onPointer((event) => {
170 const current = surface.state ?? state
171 const hovered = event.type === 'leave' || !props.rows[event.y] ? -1 : event.y
172 if (hovered !== current.hovered) surface.setState({ ...current, hovered })
173 if (event.type !== 'down' || (event.button ?? 'left') !== 'left') return
174 const row = props.rows[event.y]
175 if (row) surface.post({ press: row.id, kind: row.kind, index: props.offset + event.y })
176 })
177 return drawRows(props, surface.elements, state.phase, state.hovered, props.cursor)
178}
179hooks/commits.ts 22 lines1const UNITS: [number, string][] = [
2 [365 * 24 * 3600, 'y'],
3 [30 * 24 * 3600, 'mo'],
4 [7 * 24 * 3600, 'w'],
5 [24 * 3600, 'd'],
6 [3600, 'h'],
7 [60, 'm'],
8]
9
10// How long ago `time` was, as a commit line shows it: `now` under a minute, else the largest whole unit (`2h`, `3d`).
11export function shortAge(now: number, time: number): string {
12 const seconds = Math.max(0, Math.floor((now - time) / 1000))
13 for (const [size, unit] of UNITS) if (seconds >= size) return `${Math.floor(seconds / size)}${unit}`
14 return 'now'
15}
16
17// A commit's subject cut to the cells its line leaves: the line is a Button, whose children wrap instead of truncating.
18export function fitSubject(subject: string, columns: number): string {
19 if (columns <= 0) return ''
20 return subject.length > columns ? `${subject.slice(0, Math.max(columns - 1, 0))}…` : subject
21}
22hooks/summary.ts 77 lines1export type FileSummary = { verb: 'Updated' | 'Created' | 'Deleted'; path: string; added: number; deleted?: number }
2
3type Hunk = { lines: readonly string[] }
4
5const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
6
7const hunksOf = (value: unknown): Hunk[] =>
8 Array.isArray(value) ? value.filter((hunk): hunk is Hunk => isRecord(hunk) && Array.isArray(hunk.lines)) : []
9
10export function countPatch(hunks: Hunk[]): { added: number; deleted: number } {
11 let added = 0
12 let deleted = 0
13 for (const hunk of hunks)
14 for (const line of hunk.lines) {
15 if (line.startsWith('+')) added++
16 else if (line.startsWith('-')) deleted++
17 }
18 return { added, deleted }
19}
20
21const lineCount = (content: string) => (content === '' ? 0 : content.replace(/\n$/, '').split('\n').length)
22
23const relativeTo = (cwd: string, path: string) => (path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
24
25// The one-line summaries that stand in for an edit's inline diff; `undefined` when the result has nothing to summarize,
26// so the engine draws its own row.
27export function summarize(tool: string, output: unknown, cwd: string): FileSummary[] | undefined {
28 if (!isRecord(output)) return undefined
29 if ((tool === 'Edit' || tool === 'Write') && typeof output.filePath === 'string') {
30 const path = relativeTo(cwd, output.filePath)
31 if (tool === 'Write' && output.type === 'create' && typeof output.content === 'string')
32 return [{ verb: 'Created', path, added: lineCount(output.content) }]
33 return [{ verb: 'Updated', path, ...countPatch(hunksOf(output.structuredPatch)) }]
34 }
35 if (tool === 'Bash' && isRecord(output.bashEditDiff) && Array.isArray(output.bashEditDiff.files)) {
36 const files = output.bashEditDiff.files.filter(isRecord).flatMap((file): FileSummary[] => {
37 if (typeof file.filePath !== 'string') return []
38 const path = relativeTo(cwd, file.filePath)
39 const counts = countPatch(hunksOf(file.hunks))
40 if (file.created === true) return [{ verb: 'Created', path, added: counts.added }]
41 if (file.deleted === true) return [{ verb: 'Deleted', path, added: 0, deleted: counts.deleted }]
42 return [{ verb: 'Updated', path, ...counts }]
43 })
44 return files.length > 0 ? files : undefined
45 }
46 return undefined
47}
48
49export function countsLabel(summary: FileSummary): string {
50 if (summary.verb === 'Created') return `(+${summary.added})`
51 if (summary.verb === 'Deleted') return `(-${summary.deleted ?? 0})`
52 return `(+${summary.added} -${summary.deleted ?? 0})`
53}
54
55// The tools whose row names one file, as Claude Code titles them; the counts join that title, so their result line,
56// which repeats the path, is dropped.
57const FILE_TOOL_NAMES: Record<string, string> = { Edit: 'Update', Write: 'Write' }
58
59export type FileRow = { name: string; summary: FileSummary }
60
61// The title a finished Edit or Write row is drawn with; `undefined` for any other tool or an output with no summary.
62export function fileRow(tool: string, output: unknown, cwd: string): FileRow | undefined {
63 const name = FILE_TOOL_NAMES[tool]
64 const [summary, ...rest] = (name && summarize(tool, output, cwd)) || []
65 return name && summary && rest.length === 0 ? { name, summary } : undefined
66}
67
68// The files a Bash command changed, as Claude Code reports them under `bashEditDiff`, by absolute path.
69export function bashChangedFiles(output: unknown): { path: string; isNewFile: boolean }[] {
70 if (!isRecord(output) || !isRecord(output.bashEditDiff) || !Array.isArray(output.bashEditDiff.files)) return []
71 return output.bashEditDiff.files
72 .filter(isRecord)
73 .flatMap((file) =>
74 typeof file.filePath === 'string' ? [{ path: file.filePath, isNewFile: file.created === true }] : [],
75 )
76}
77hooks/symbols.ts 57 lines1import type { BranchStatus, Glyphs } from '../types'
2
3export const BRANCH_ICON: Record<Glyphs, string> = { nerd: '\u{e0a0}', plain: '⎇' }
4
5// Starship's git_status defaults, in its order (every status symbol, then ahead and behind), except that diverged also
6// shows both counts, `⇕⇡2⇣1` (a common override), instead of a bare `⇕`.
7export function statusSymbols(s: BranchStatus): string {
8 const all = [
9 s.conflicted ? '=' : '',
10 s.stashed ? '$' : '',
11 s.deleted ? '✘' : '',
12 s.renamed ? '»' : '',
13 s.modified ? '!' : '',
14 s.staged ? '+' : '',
15 s.untracked ? '?' : '',
16 ].join('')
17 const aheadBehind =
18 s.ahead && s.behind ? `⇕⇡${s.ahead}⇣${s.behind}` : s.ahead ? `⇡${s.ahead}` : s.behind ? `⇣${s.behind}` : ''
19 return all + aheadBehind
20}
21
22// `fc-list :charset=e0a0 file` lists the font files with the branch glyph, which every Nerd Font has; macOS's
23// LastResort font claims every code point, so it doesn't count.
24export const hasNerdFont = (fcList: string) =>
25 fcList.split('\n').some((line) => line.trim() !== '' && !/lastresort/i.test(line))
26
27// A Nerd Font's file name says so: `JetBrainsMonoNerdFont-Regular.ttf`.
28export const isNerdFontFile = (name: string) => name.includes('Nerd')
29
30// The dot Claude Code puts before a row: ⏺ on macOS, ● elsewhere. A row sideline draws in place of the engine's uses it.
31export const rowDotFor = (system: string) => (system.trim() === 'Darwin' ? '⏺' : '●')
32
33const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
34
35export function backgroundOption(option: unknown): string | undefined {
36 return typeof option === 'string' && HEX_COLOR.test(option.trim()) ? option.trim() : undefined
37}
38
39// A hovered row's background: the pane's own background with a little white mixed in.
40export function lighten(hex: string, amount = 0.12): string {
41 const full =
42 hex.length === 4
43 ? `#${hex
44 .slice(1)
45 .split('')
46 .map((c) => c + c)
47 .join('')}`
48 : hex
49 const channel = (i: number) => {
50 const value = Number.parseInt(full.slice(i, i + 2), 16)
51 return Math.round(value + (255 - value) * amount)
52 .toString(16)
53 .padStart(2, '0')
54 }
55 return `#${channel(1)}${channel(3)}${channel(5)}`
56}
57hooks/tree.ts 91 lines1import type { ChangedFile, Cursor, FileStatus } from '../types'
2
3export type TreeRow = {
4 id: string
5 depth: number
6 name: string
7 kind: 'dir' | 'file'
8 status?: FileStatus
9 added: number
10 deleted: number
11 isBinary: boolean
12}
13
14type Dir = { dirs: Map<string, Dir>; files: ChangedFile[] }
15
16const newDir = (): Dir => ({ dirs: new Map(), files: [] })
17
18function insert(root: Dir, file: ChangedFile): void {
19 const parts = file.path.split('/')
20 let dir = root
21 for (const part of parts.slice(0, -1)) {
22 let next = dir.dirs.get(part)
23 if (!next) {
24 next = newDir()
25 dir.dirs.set(part, next)
26 }
27 dir = next
28 }
29 dir.files.push(file)
30}
31
32function totals(dir: Dir): { added: number; deleted: number } {
33 let added = 0
34 let deleted = 0
35 for (const file of dir.files) {
36 added += file.added
37 deleted += file.deleted
38 }
39 for (const child of dir.dirs.values()) {
40 const sub = totals(child)
41 added += sub.added
42 deleted += sub.deleted
43 }
44 return { added, deleted }
45}
46
47const byName = (a: string, b: string) => a.localeCompare(b)
48
49function emit(dir: Dir, prefix: string, depth: number, rows: TreeRow[]): void {
50 for (const [name, child] of [...dir.dirs].toSorted(([a], [b]) => byName(a, b))) {
51 let label = name
52 let path = `${prefix}${name}/`
53 let shown = child
54 while (shown.files.length === 0 && shown.dirs.size === 1) {
55 const [[onlyName, only]] = [...shown.dirs] as [[string, Dir]]
56 label = `${label}/${onlyName}`
57 path = `${path}${onlyName}/`
58 shown = only
59 }
60 rows.push({ id: path, depth, name: `${label}/`, kind: 'dir', isBinary: false, ...totals(shown) })
61 emit(shown, path, depth + 1, rows)
62 }
63 for (const file of dir.files.toSorted((a, b) => byName(a.path, b.path))) {
64 rows.push({
65 id: file.path,
66 depth,
67 name: file.path.slice(file.path.lastIndexOf('/') + 1),
68 kind: 'file',
69 status: file.status,
70 added: file.added,
71 deleted: file.deleted,
72 isBinary: file.isBinary,
73 })
74 }
75}
76
77export function treeRows(files: ChangedFile[]): TreeRow[] {
78 const root = newDir()
79 for (const file of files) insert(root, file)
80 const rows: TreeRow[] = []
81 emit(root, '', 0, rows)
82 return rows
83}
84
85// The selected row's index: its id's row, or the row now at its old index when it left; -1 with nothing selected.
86export function resolveCursor(rows: readonly { id: string }[], cursor: Cursor | null): number {
87 if (cursor === null || rows.length === 0) return -1
88 const index = rows.findIndex((row) => row.id === cursor.id)
89 return index >= 0 ? index : Math.min(cursor.index, rows.length - 1)
90}
91hooks/window.ts 76 lines1import { LOG_SIZE } from './commands'
2
3// The rows the pane spends outside the changes box's content: the title and the box's border.
4const CHANGES_CHROME = 1 + 2
5// The branch line and the blank row under it, in the changes box when there is no session box.
6const BRANCH_ROWS = 2
7// The session box: its two lines and its border.
8export const SESSION_ROWS = 2 + 2
9// The hint line under the title, with the blank row above it.
10const HINT_ROWS = 2
11// The commands box's lines when the `commandRows` option says nothing usable.
12export const COMMAND_ROWS_DEFAULT = 5
13const COMMAND_ROWS_MAX = LOG_SIZE
14
15// The `commandRows` option as a number of lines: a whole number from 0 to 20, else the default.
16export function commandRowsOption(value: unknown): number {
17 return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= COMMAND_ROWS_MAX
18 ? value
19 : COMMAND_ROWS_DEFAULT
20}
21
22// The rows the commands box takes for `lines` lines: those and its border, or none at all when it has no lines.
23export function commandsBoxRows(lines: number): number {
24 return lines === 0 ? 0 : lines + 2
25}
26
27// Whether a wheel tick at body row `row` is over the commands box, the last `boxRows` rows of the pane; a scroll key
28// (no row) is not.
29export function isOverCommands(row: number | undefined, bodyRows: number, boxRows: number): boolean {
30 return row !== undefined && boxRows > 0 && row >= bodyRows - boxRows
31}
32
33// How many content rows the changes box shows in a pane `bodyRows` tall; never below one. The session box above it
34// takes the branch line out of it.
35export function changesRows(bodyRows: number, hasHints: boolean, hasSession: boolean, commandsRows: number): number {
36 const above = (hasHints ? HINT_ROWS : 0) + (hasSession ? SESSION_ROWS : BRANCH_ROWS)
37 return Math.max(1, bodyRows - CHANGES_CHROME - above - commandsRows)
38}
39
40// An offset kept inside `[0, lines - rows]`, so the window never runs past either end.
41export function clampOffset(offset: number, lines: number, rows: number): number {
42 return Math.min(Math.max(0, offset), Math.max(0, lines - rows))
43}
44
45// The offset that brings line `index` into a window of `rows`: unchanged when it already shows, else just enough to
46// put it at the window's nearer edge.
47export function revealOffset(offset: number, index: number, rows: number): number {
48 if (index < offset) return index
49 if (index >= offset + rows) return index - rows + 1
50 return offset
51}
52
53// The lines a window of `rows` at `offset` hides above and below.
54export function hiddenAround(offset: number, lines: number, rows: number): { above: number; below: number } {
55 const at = clampOffset(offset, lines, rows)
56 return { above: at, below: Math.max(0, lines - at - rows) }
57}
58
59// `↑ N ↓ M` for the lines out of view, only the sides that have some; empty when it all fits.
60export function hiddenLabel({ above, below }: { above: number; below: number }): string {
61 return [above > 0 ? `↑ ${above}` : '', below > 0 ? `↓ ${below}` : ''].filter(Boolean).join(' ')
62}
63
64// A command's text as rows `columns` wide, each line wrapped at the width, so one row is one line of the window; the
65// first line after `$ `.
66export function wrapLines(command: string, columns: number): string[] {
67 const width = Math.max(1, columns)
68 return command.split('\n').flatMap((line, index) => {
69 const text = index === 0 ? `$ ${line}` : line
70 if (text === '') return [' ']
71 const rows: string[] = []
72 for (let at = 0; at < text.length; at += width) rows.push(text.slice(at, at + width))
73 return rows
74 })
75}
76types/index.d.ts 92 lines1export type Glyphs = 'nerd' | 'plain'
2
3export type BranchStatus = {
4 conflicted: number
5 stashed: boolean
6 deleted: number
7 renamed: number
8 modified: number
9 staged: number
10 untracked: number
11 ahead: number
12 behind: number
13}
14
15export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'conflicted'
16
17export type LineCounts = { added: number; deleted: number; isBinary: boolean }
18
19export type ChangedFile = LineCounts & { path: string; status: FileStatus; isUntracked: boolean }
20
21// A recent commit, listed while nothing is changed: its short hash, subject, and commit time in ms.
22export type Commit = { sha: string; subject: string; time: number }
23
24// A commit opened in the changes box: the files it changed, shown as the tree in place of the working changes.
25export type CommitView = { sha: string; subject: string; files: ChangedFile[] }
26
27export type Snapshot =
28 | { kind: 'loading' }
29 | { kind: 'none' }
30 | {
31 kind: 'repo'
32 root: string
33 branch: string
34 isDetached: boolean
35 status: BranchStatus
36 files: ChangedFile[]
37 // The most recent commits, read only while `files` is empty.
38 commits: Commit[]
39 }
40
41// A file Claude is editing (`running`) or just finished editing (`done`, shown as a flash until it expires), by
42// absolute path.
43export type ActiveEdit = { id: string; path: string; state: 'running' | 'done'; isNewFile: boolean }
44
45// The open file's diff against HEAD: unified hunks, or empty for a binary file.
46export type FileDiff = { path: string; hunks: string }
47
48// A Bash command in the pane's log of the last few, by its tool_use_id: still running, finished, or failed (an error,
49// a refusal or an interruption).
50export type LoggedCommand = { id: string; command: string; state: 'running' | 'done' | 'failed' }
51
52// What Claude is doing, as the pane's title shows it: working through a turn, waiting on the person's answer to a
53// question or a plan, or waiting for a prompt.
54export type TurnState = 'idle' | 'working' | 'waiting'
55
56// What the session box shows, each null until Claude Code has reported it: the main loop's model id and effort, the
57// context used and the 5-hour and 7-day usage, in percent.
58export type SessionInfo = {
59 model: string | null
60 effort: string | null
61 context: number | null
62 fiveHour: number | null
63 sevenDay: number | null
64}
65
66// The selected row: its id, and its index so the selection can fall to the nearest row when it leaves the tree.
67export type Cursor = { id: string; index: number }
68
69declare module 'claude-code' {
70 interface PluginState {
71 sideline: {
72 snapshot: Snapshot
73 glyphs: Glyphs
74 edits: ActiveEdit[]
75 selected: string | null
76 diff: FileDiff | null
77 cursor: Cursor | null
78 commands: LoggedCommand[]
79 openCommand: string | null
80 changesOffset: number
81 focusedCommand: string | null
82 viewing: CommitView | null
83 // The person closed the pane (its mark, ctrl+x x, /sideline); it stays closed through a reload of the mod.
84 isPaneClosed: boolean
85 // The mod opened the pane and nobody has closed it since.
86 isPaneOpen: boolean
87 session: SessionInfo
88 turn: TurnState
89 }
90 }
91}
92