SLOPSHOPPER

sideline

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

newpanerowsguardcommandprocess
v0.1.0MITupdated 2026-10-23artberri/sideline/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sideline
│ ┃ sideline ✕ › fix the failing auth test and add an audit log call │ ┃ SIDELINE · IDLE │ ┃ ⏺ Read(src/auth.ts) │ ┃ j: down k: up l: open c: commands ⎿ Read 6 lines │ ┃ ╭─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ │ ⎇ [$] Op ⎿ Added 2 lines, removed 1 line │ ┃ │ context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% · 5h ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ SESSION │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ▣ client module ./rows.tsx │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ CHANGES │ ┃ 3 files › /sideline │ ┃ ╭─────────────────────────────────────────── ⎿ sideline: sideline pane closed. │ ┃ │ ✗ $ bun test │ ┃ │ ✓ $ git status --porcelain │ ┃ │ ✓ $ rm -rf build && git push --force origi │ ┃ │ ✓ $ cat .env │ ┃ │ │ ┃ ╰─────────────────────────────────────────── │ ┃ COMMANDS │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sideline
SIDELINE · IDLE j: down k: up l: open c: commands ╭──────────────────────────────────────────────────────╮ │ ⎇ [$] Opus 5.5 · — │ │ context ▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 49% · 5h 31% · 7d — │ ╰──────────────────────────────────────────────────────╯ SESSION ╭──────────────────────────────────────────────────────╮ │ ▣ client module ./rows.tsx │ ╰──────────────────────────────────────────────────────╯ CHANGES 3 files ╭──────────────────────────────────────────────────────╮ │ ✗ $ bun test │ │ ✓ $ git status --porcelain │ │ ✓ $ rm -rf build && git push --force origin main │ │ ✓ $ cat .env │ │ │ ╰──────────────────────────────────────────────────────╯ COMMANDS
README

<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 /sideline toggles 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 /sideline opens 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:

  • a described shell command shows its description, not the whole script;
  • an edit is one line with its counts, Update(src/greet.js) (+4 -0), not the diff;
  • finished "Ran 1 shell command" and "Read 2 files" lines go.

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:

  • SESSION: the branch with Starship-style git status symbols, the model and effort, a context bar, and your 5-hour and 7-day usage.
  • CHANGES: a tree of the changed files with +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.
  • COMMANDS: the latest shell commands Claude ran, ● running, ✓ done, ✗ failed; click one to read it whole.

Install

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.

Options

Set them in /config, or under pluginConfigs → sideline → options in your settings.

OptionDefaultWhat it does
glyphsautoauto 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.
transcriptDiffshidehide folds an edit's diff into its counts; show keeps Claude Code's diffs.
transcriptCommandshidehide shows a described command's description and drops finished group lines; show keeps them.
sessionBoxshowhide drops the SESSION box and puts the branch line back in CHANGES.
commandRows5How 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.

What it can see and do

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 seesThrough
each tool call's name and input, and whether it failedtool.call
when a main turn starts and ends, and its model and effortturn.start, turn.step, turn.complete
context and rate-limit readingssession.measure, $.session.usage()
the repo's status, diffs and recent commitsread-only git (status, diff, diff-tree, show, log, rev-parse, with --no-optional-locks)
untracked files' sizes and times, to count their linesstat
your OS and installed fonts, to choose its iconsuname -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.

What it sends

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.

Troubleshooting

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.

Contributing

See CONTRIBUTING.md. sideline learned from claude-code-filetree and claude-flightdeck; see ACKNOWLEDGEMENTS.md.

Licence

MIT

Source 12 files
hooks/register.tsx 1187 lines
1import { 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}
1187
hooks/commands.ts 70 lines
1// 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}
70
hooks/git.ts 228 lines
1import 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}
228
hooks/meter.ts 54 lines
1const 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}
54
hooks/icons.ts 168 lines
1import 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}
168
hooks/rows.tsx 179 lines
1import 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}
179
hooks/commits.ts 22 lines
1const 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}
22
hooks/summary.ts 77 lines
1export 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}
77
hooks/symbols.ts 57 lines
1import 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}
57
hooks/tree.ts 91 lines
1import 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}
91
hooks/window.ts 76 lines
1import { 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}
76
types/index.d.ts 92 lines
1export 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