Make the terminal transcript look like the Claude Code apps: your prompts beside an orange bar, tool calls as one line with a state mark, lists set in with…

A Claude Code mod that lays the terminal transcript out as the Claude Code mobile app does.

Ran <what the command is for> ›, Read register.tsx ›, Edited math.ts +1 -0 ›. A thin mark opens each line in its state's colour: blue while the call runs or waits at the permission dialog (the label too), red when it failed (with ✗) or was cut (Interrupted ·), green once it succeeded, as the stock transcript's dot turns. A run of reads and searches folds to one count line (Read 2 files, ran 1 command ›)• ◦ ▪ by depth, numbers lined up on their dot, the list set in two cells from the edge and each item's wrapped lines hung under its text, where the stock transcript draws - and 1. flush left. A loose list (blank lines between items) keeps a blank row between them. A task item's [ ] or [x] becomes a box, ☐ or ☑, in place of its bullet. A list inside a quote is set the same way, with the engine's dim ▎ down the quote's side, and a rule (---), which the engine leaves as its dashes, is a dim line across. Prose, code, tables and headings stay the engine's markdown, so what the engine does not draw stays as written there: strike and HTML tags. An approved plan's lists are set the same way under the engine's User approved Claude's plan line; the approval dialog itself reaches no hook, so it keeps the stock -"인용"을 or `file를` as asterisks, since the run closes after punctuation and runs into a particle. A zero-width space on that side lets it close; the screen drops the character, so text copied off it is as written● bullet is dropped⎿ [Image #1]), which no mod can move, hangs right under them; wrapping at a share of the terminal width. Pasted text reads as the composer's [Pasted text +N lines], and a click on it opens what was pasted2 files +7 -0, as git diff HEAD with the new files counts them, the same figure a diff viewer shows) and what the session spent ($0.05) on the left, the tools running (↻ Read ×3, Grep, or ↻ 4 running when narrow) on the right; hidden when there is nothing to show/config → fullscreen); on the main screen tool calls keep the engine's rowsclaude plugin marketplace add KyongSik-Yoon/claude-mods
claude plugin install desktop-look@kyongsik-mods
Then run /reload-plugins or start a new session. Requires a Claude Code build with mods support (tested on 2.1.291, fullscreen and light/dark themes).
No setup needed. To change one, run /plugin configure desktop-look@kyongsik-mods (or open /config):
| Option | Default | |
|---|---|---|
| Prompt width | 75% | How wide your own messages may grow: 60%, 75% or 90% of the terminal |
| Your prompts | on | Your own messages beside the orange bar |
| Replies | on | Lists with bullets, Korean bold, the narration bar, no reply bullet |
| Tool lines | on | Tool calls as one line that opens on a click |
| Info band | on | The band above the prompt |
| Band: model | off | Model and effort, for a status line that does not show them |
| Band: context | off | Context fill, for a status line that does not show it: blue while calm, the theme's warning amber from 300k tokens or 70%, its error red from 500k tokens or 90%, whichever comes first. After a compaction it drops to the engine's estimate at once, and the next response measures it |
Each of the three drawing switches, when off, leaves that part to the engine or to a mod beneath, so only the parts you want are drawn here: with only Your prompts on, a mod such as prismantis can draw the replies and tool calls (turn its own prompt style off).
The cost is what Claude Code computes at API prices; on a subscription it is not what you are billed.
ToolUse and ToolGroup draw their own line from the call's props (tool, input, isRunning, isErrored, isInterrupted, output); the line is a plain Button whose press flips a $.state family member keyed by the row's requestId (its tool_use_id). Open, the row adds the engine's own drawing (await next(e)), and ToolResult, which shares that id, draws again. A group opens by passing isExpanded: true down; a group of one call opens that call's row instead.AssistantMessage passes isFirstOfReply: false down, which drops the bullet and keeps the engine's markdown. A reply with a list is cut where its lists are, as CommonMark reads them (code fences, lazy lines, which lists may break into a paragraph): each item is a row of its mark and a column holding its own pieces, and the prose between is drawn by the Markdown element, so links, code and tables look as the engine draws them. A text block is narration when, within its turn, a tool call came before it and another after it: read off each turn.step's streamed blocks, and for a resumed session off $.session.messages({ as: 'api' }).claude background that the row's height stretches, the prompt kept to a share of the row by a growing spacer on its right. A pasted block reaches the hook in the engine's framing for the model (<pasted_content id="…">), which is folded back to the placeholder, counted as the engine counts; the engine drops a paste's last line break before framing, so a paste that ended in one reads one line short. The narration bar is a one-cell Box with a subtle background that the row's height stretches, and a tool line's mark is one glyph on its one row, so nothing can run past its row.AbovePrompt hook reading values in $.state, written by tool.call (tools running), $.process.run (git diff HEAD --numstat and git ls-files --others from the repository's top, counted as the session starts, after each step and after each Edit, Write or Bash call), session.measure (cost, context), session.compact (the context left, estimated by $.session.usage({ breakdown: 'summary' }) until the next response measures it) and turn.step (model). An ExitPlanMode result is drawn by the engine with its plan swapped for an invisible character, so its header and saved-path line stay, and the plan's pieces follow beneath.claude plugin validate .
claude plugin test .hooks/register.tsx 1098 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ClientElements, CoreEngineInterface, PromptOrigin, Register, RenderNode, StateDollar } from 'claude-code'
3
4import type { Context, Edits, Model } from '../types'
5
6// Origins whose text is the person's own prompt; everything else (task
7// notifications, peers, channels) keeps the engine's row.
8const OWN: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge'])
9
10const WIDTHS = ['60%', '75%', '90%'] as const
11
12// The theme's own blue, which reads in either: rgb(87,105,247) in light,
13// rgb(177,185,249) in dark.
14const CALM_COLOR = 'suggestion'
15
16
17// The bar beside the person's prompt: a full cell in the theme's Claude
18// orange, so it reads apart from a tool line's thin state mark and from the
19// grey bar beside the text between tool calls.
20const PROMPT_COLOR = 'claude'
21
22// The mobile app's soft grey bar beside the text between tool calls; the
23// theme's own shade, so it reads in light and dark.
24const NARRATION_COLOR = 'subtle'
25
26// Tools whose row is the point (the plan, the todo list, the answers given):
27// they start open, and a press folds them.
28const OPEN_BY_DEFAULT: ReadonlySet<string> = new Set(['TodoWrite', 'AskUserQuestion', 'ExitPlanMode'])
29
30// A tool line opens with a thin mark in its state's colour: blue while it
31// runs, red when it failed or was cut, green once it succeeded, as the stock
32// transcript's dot turns. A quarter cell, so it reads apart from the
33// narration's bar.
34type Tone = 'running' | 'failed' | 'done'
35const TONES = { running: { color: 'blue' }, failed: { color: 'red' }, done: { color: 'green' } } as const
36const STATE_MARK = '▎ '
37// The blank row above a line that sits right under another call's: the mark
38// runs through it in this line's colour, so a run of calls reads as one line.
39const JOIN_MARK = '▎'
40
41const MODEL = atom({ plugin: 'desktop-look', key: 'model' } as const, null)
42const CONTEXT = atom({ plugin: 'desktop-look', key: 'context' } as const, null)
43const TOOLS = atom({ plugin: 'desktop-look', key: 'tools' } as const, [] as string[])
44const EDITS = atom({ plugin: 'desktop-look', key: 'edits' } as const, null)
45const COST = atom({ plugin: 'desktop-look', key: 'cost' } as const, null)
46const NARRATION = atom({ plugin: 'desktop-look', key: 'narration' } as const, [] as string[])
47const JOINED = atom({ plugin: 'desktop-look', key: 'joined' } as const, [] as string[])
48const AFTER_CALL = atom({ plugin: 'desktop-look', key: 'afterCall' } as const, false)
49const OPEN = atom({ plugin: 'desktop-look', key: 'open' } as const, false)
50
51// Enough for a long session's worth of steps; older ones have scrolled away.
52const NARRATION_KEPT = 300
53const JOINED_KEPT = 1000
54
55type Patch = { lines: string[] }
56
57// The lines an Edit or Write result added and removed, from its patch; a new
58// file's patch is empty, so its content counts as added.
59export const diffStat = (tool: string, result: unknown): { file: string; added: number; removed: number } | null => {
60 if (tool !== 'Edit' && tool !== 'Write') return null
61 const r = result as { filePath?: string; structuredPatch?: Patch[]; type?: string; content?: string }
62 if (typeof r?.filePath !== 'string') return null
63 let added = 0
64 let removed = 0
65 for (const hunk of r.structuredPatch ?? []) {
66 for (const line of hunk.lines) {
67 if (line.startsWith('+')) added += 1
68 else if (line.startsWith('-')) removed += 1
69 }
70 }
71 if (r.type === 'create' && added === 0 && typeof r.content === 'string' && r.content !== '') {
72 added = r.content.replace(/\n$/, '').split('\n').length
73 }
74 return { file: r.filePath, added, removed }
75}
76
77// `mcp__slack__send_message` reads as `send_message`; built-in names as given.
78export const toolLabel = (tool: string): string => tool.split('__').pop() || tool
79
80// The tools running, in the order they started, each once with its count:
81// `Read ×3, Grep`. Wider than `room`, it falls back to `4 running`.
82export const runningLabel = (tools: readonly string[], room: number): string => {
83 const counts = new Map<string, number>()
84 for (const t of tools) counts.set(t, (counts.get(t) ?? 0) + 1)
85 const named = [...counts].map(([t, n]) => (n > 1 ? `${t} ×${n}` : t)).join(', ')
86 return named.length <= room ? named : `${tools.length} running`
87}
88
89export const dollars = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
90
91// `claude-opus-5-5[1m]` reads as `Opus 5.5 · 1M`, `claude-haiku-4-5-20251001`
92// as `Haiku 4.5`; an id it does not know is shown as given.
93export const modelLabel = (id: string): string => {
94 const m = /(opus|sonnet|haiku|fable)(?:-(\d+)(?:-(\d{1,2})(?!\d))?)?/i.exec(id)
95 if (!m?.[1]) return id
96 const family = m[1][0]!.toUpperCase() + m[1].slice(1).toLowerCase()
97 const version = m[2] ? ` ${m[2]}${m[3] ? `.${m[3]}` : ''}` : ''
98 return `${family}${version}${/\[1m\]/i.test(id) ? ' · 1M' : ''}`
99}
100
101// How full the context is, by whichever bites first: the tokens held (a long
102// context dulls the model well before a 1M window fills), or the window's
103// share (a 200k window nears compaction).
104export const pressure = (context: Context): 'calm' | 'warn' | 'alert' => {
105 const tokens = context.tokens ?? 0
106 const percent = context.percent ?? 0
107 if (tokens >= 500_000 || percent >= 90) return 'alert'
108 if (tokens >= 300_000 || percent >= 70) return 'warn'
109 return 'calm'
110}
111
112// Calm in the theme's blue; then the theme's warning and
113// error colours, which a light theme darkens (a named yellow glares there).
114const SHADES = { calm: { color: CALM_COLOR }, warn: { color: 'warning' }, alert: { color: 'error' } } as const
115
116const short = (n: number): string =>
117 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
118
119// Ten cells, as the desktop app's context ring reads at a glance: the filled
120// run and the empty one. Box-drawing glyphs, which every terminal draws one
121// cell wide.
122export const fill = (percent: number): [string, string] => {
123 const cells = Math.min(10, Math.max(0, Math.round(percent / 10)))
124 return ['━'.repeat(cells), '─'.repeat(10 - cells)]
125}
126
127// The model's text that sits between two tool calls of one turn: what the
128// mobile app draws beside a grey bar. The turn's opening line and its answer
129// stay plain. `pieces` are one turn's, or one step's, in order: a text block,
130// or null for a tool call; `toolBefore` when a call came earlier in the turn.
131export const between = (pieces: readonly (string | null)[], toolBefore = false): string[] => {
132 const last = pieces.lastIndexOf(null)
133 let before = toolBefore
134 const found: string[] = []
135 pieces.forEach((piece, at) => {
136 if (piece === null) before = true
137 else if (before && at < last && piece.trim()) found.push(piece.trim())
138 })
139 return found
140}
141
142// The calls of one turn whose line sits right under another call's: a call
143// with no text since the last one. `pieces` are a text, or a call's id;
144// `before` whether the turn's last block so far was a call. Answers the ids
145// and whether the last block here is a call.
146export const joinedCalls = (pieces: readonly (string | { id: string })[], before = false): [string[], boolean] => {
147 const found: string[] = []
148 let after = before
149 for (const piece of pieces) {
150 if (typeof piece === 'string') {
151 if (piece.trim()) after = false
152 } else {
153 if (after) found.push(piece.id)
154 after = true
155 }
156 }
157 return [found, after]
158}
159
160type Block = { readonly type: string; readonly [field: string]: unknown }
161
162// The same, read off a transcript in Messages API form, turn by turn.
163export const joinedIn = (messages: readonly { role: 'user' | 'assistant'; content: readonly Block[] }[]): string[] => {
164 const found: string[] = []
165 let after = false
166 for (const m of messages) {
167 if (m.role === 'user') {
168 if (!m.content.some(b => b.type === 'tool_result')) after = false
169 continue
170 }
171 const pieces = m.content.flatMap((b): (string | { id: string })[] =>
172 b.type === 'tool_use' && typeof b.id === 'string' ? [{ id: b.id }] : b.type === 'text' && typeof b.text === 'string' ? [b.text] : [],
173 )
174 const [ids, last] = joinedCalls(pieces, after)
175 found.push(...ids)
176 after = last
177 }
178 return found.slice(-JOINED_KEPT)
179}
180
181// The same, read off a transcript in Messages API form (blocks intact), turn
182// by turn: a person's prompt opens one; tool results carry it on.
183export const narrations = (messages: readonly { role: 'user' | 'assistant'; content: readonly Block[] }[]): string[] => {
184 const found: string[] = []
185 let turn: (string | null)[] = []
186 for (const m of messages) {
187 if (m.role === 'user') {
188 if (!m.content.some(b => b.type === 'tool_result')) {
189 found.push(...between(turn))
190 turn = []
191 }
192 continue
193 }
194 for (const b of m.content) {
195 if (b.type === 'tool_use') turn.push(null)
196 else if (b.type === 'text' && typeof b.text === 'string') turn.push(b.text)
197 }
198 }
199 found.push(...between(turn))
200 return found.slice(-NARRATION_KEPT)
201}
202
203// What a call did, as the mobile app's one line says it: `Ran <what the
204// command is for>`, `Read register.tsx`; a running call in the present tense.
205const VERBS: Readonly<Record<string, readonly [running: string, done: string]>> = {
206 Bash: ['Running', 'Ran'],
207 Read: ['Reading', 'Read'],
208 Edit: ['Editing', 'Edited'],
209 MultiEdit: ['Editing', 'Edited'],
210 NotebookEdit: ['Editing', 'Edited'],
211 Write: ['Writing', 'Wrote'],
212 Grep: ['Searching for', 'Searched for'],
213 Glob: ['Finding', 'Found'],
214 WebFetch: ['Fetching', 'Fetched'],
215 WebSearch: ['Searching the web for', 'Searched the web for'],
216 Agent: ['Running agent', 'Ran agent'],
217 Task: ['Running agent', 'Ran agent'],
218 Skill: ['Loading skill', 'Loaded skill'],
219 TodoWrite: ['Updating todos', 'Updated todos'],
220}
221
222const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
223
224// The call's object: a command's description, a file's name, a pattern.
225const subject = (tool: string, input: unknown): string => {
226 const args = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
227 const arg = (key: string): string => (typeof args[key] === 'string' ? (args[key] as string) : '')
228 switch (tool) {
229 case 'Bash':
230 return arg('description') || (arg('command').split('\n')[0] ?? '')
231 case 'Read':
232 case 'Edit':
233 case 'MultiEdit':
234 case 'Write':
235 return basename(arg('file_path'))
236 case 'NotebookEdit':
237 return basename(arg('notebook_path'))
238 case 'Grep':
239 case 'Glob':
240 return arg('pattern')
241 case 'WebFetch':
242 return /^[a-z]+:\/\/([^/?#]+)/i.exec(arg('url'))?.[1] ?? arg('url')
243 case 'WebSearch':
244 return arg('query')
245 case 'Skill':
246 return arg('skill')
247 case 'TodoWrite':
248 return ''
249 default:
250 return arg('description')
251 }
252}
253
254export const callLabel = (tool: string, input: unknown, running: boolean): string => {
255 const verbs = VERBS[tool]
256 const head = verbs ? verbs[running ? 0 : 1] : toolLabel(tool)
257 const what = subject(tool, input).trim()
258 return what ? `${head} ${what}` : head
259}
260
261// A folded run of calls, counted as the engine's own line counts them:
262// `Read 2 files, ran 1 command`, in the order the kinds first came.
263const KINDS: Readonly<Record<string, readonly [running: string, done: string, one: string, many: string]>> = {
264 Read: ['reading', 'read', 'file', 'files'],
265 Bash: ['running', 'ran', 'command', 'commands'],
266 Grep: ['searching', 'searched', 'pattern', 'patterns'],
267 Glob: ['searching', 'searched', 'pattern', 'patterns'],
268}
269
270export const groupLabel = (calls: readonly { tool: string; input: unknown; isRunning: boolean }[]): string => {
271 const running = calls.some(c => c.isRunning)
272 // One call reads as the call itself: `Ran <what for>`, not `Ran 1 command`.
273 if (calls.length === 1) return callLabel(calls[0]!.tool, calls[0]!.input, running)
274 const counts = new Map<string, { n: number; tool: string }>()
275 for (const c of calls) {
276 const kind = KINDS[c.tool] ? KINDS[c.tool]![1] : c.tool
277 const seen = counts.get(kind)
278 counts.set(kind, { n: (seen?.n ?? 0) + 1, tool: seen?.tool ?? c.tool })
279 }
280 const said = [...counts.values()]
281 .map(({ n, tool }) => {
282 const kind = KINDS[tool]
283 if (!kind) return n > 1 ? `${toolLabel(tool)} ×${n}` : toolLabel(tool)
284 return `${kind[running ? 0 : 1]} ${n} ${n === 1 ? kind[2] : kind[3]}`
285 })
286 .join(', ')
287 return said.charAt(0).toUpperCase() + said.slice(1)
288}
289
290// Cells a string takes: two for Hangul, CJK and full-width forms, one otherwise.
291const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
292const cells = (s: string): number => [...s].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
293
294// One row's worth: cut to `room` cells with an ellipsis.
295export const clip = (s: string, room: number): string => {
296 if (cells(s) <= room) return s
297 let out = ''
298 let used = 0
299 for (const ch of s) {
300 const w = WIDE.test(ch) ? 2 : 1
301 if (used + w > room - 1) break
302 out += ch
303 used += w
304 }
305 return `${out}…`
306}
307
308// A reply cut where its lists are: the lists this mod draws, as the apps do
309// (a bullet, an indent, the wrapped lines hung under the text), and the rest
310// as the engine's markdown. `gap` when a blank line came before the piece.
311export type Piece =
312 | { kind: 'text'; text: string; gap: boolean }
313 | { kind: 'list'; marker: string; start: number; loose: boolean; items: Piece[][]; tasks: (boolean | null)[]; gap: boolean }
314 | { kind: 'quote'; pieces: Piece[]; gap: boolean }
315 | { kind: 'rule'; gap: boolean }
316
317type Item = { marker: string; start: number; col: number; empty: boolean }
318type Fence = { mark: string; length: number } | null
319
320const ITEM = /^( {0,3})([-+*]|\d{1,9}[.)])( +|$)(.*)$/
321const RULE = /^ {0,3}([-*_])(?: *\1){2,} *$/
322const HEADING = /^ {0,3}#{1,6}(?: |$)/
323const QUOTE = /^ {0,3}>/
324const FENCE = /^ {0,3}(`{3,}|~{3,})/
325// A task item's box, `[ ]` or `[x]`, and the text after it.
326const TASK = /^\[([ xX])\] +(\S.*)$/
327
328// A list item's first line, as CommonMark reads it: its marker and the column
329// its text starts at (past one to four spaces; five or more begin indented code).
330const itemAt = (line: string): Item | null => {
331 const m = ITEM.exec(line)
332 if (!m || RULE.test(line)) return null
333 const [, indent = '', marker = '', spaces = '', rest = ''] = m
334 const pad = !rest || spaces.length > 4 ? 1 : spaces.length
335 const start = /\d/.test(marker) ? parseInt(marker, 10) : 1
336 return { marker: /\d/.test(marker) ? marker.slice(-1) : marker, start, col: indent.length + marker.length + pad, empty: !rest.trim() }
337}
338
339const indentOf = (line: string): number => line.length - line.trimStart().length
340
341// A numbered item's marker is its delimiter, `.` or `)`; a bullet's, its char.
342const numbered = (marker: string): boolean => marker === '.' || marker === ')'
343
344// The fence open after `line`: opened, closed by a run as long, or as it was.
345const fenceAfter = (fence: Fence, line: string): Fence => {
346 const run = FENCE.exec(line)?.[1] ?? ''
347 if (!fence) return run ? { mark: run.charAt(0), length: run.length } : null
348 return run.charAt(0) === fence.mark && run.length >= fence.length && !line.trim().slice(run.length).trim() ? null : fence
349}
350
351const isParagraph = (line: string): boolean => !!line.trim() && !HEADING.test(line) && !RULE.test(line)
352
353type List = Piece & { kind: 'list' }
354
355// One list from `at`, opened by `head`: its items while siblings follow, each
356// item's lines those indented to its text, plus lazy lines carrying on its
357// paragraph. Blank lines after the last item stay the caller's.
358const listAt = (lines: readonly string[], at: number, head: Item): [List, number] => {
359 const list: List = { kind: 'list', marker: head.marker, start: head.start, loose: false, items: [], tasks: [], gap: false }
360 let i = at
361 for (let item = head; ; ) {
362 let last = (lines[i] ?? '').slice(item.col)
363 const task = TASK.exec(last)
364 list.tasks.push(task ? task[1] !== ' ' : null)
365 if (task) last = task[2] ?? ''
366 const body = [last]
367 let fence = fenceAfter(null, last)
368 for (i++; i < lines.length; i++) {
369 const line = lines[i] ?? ''
370 if (!line.trim() || indentOf(line) >= item.col) last = line.slice(Math.min(item.col, indentOf(line)))
371 else if (!fence && isParagraph(last) && isParagraph(line) && !QUOTE.test(line) && !FENCE.test(line) && !itemAt(line))
372 last = line.trimStart()
373 else break
374 body.push(last)
375 fence = fenceAfter(fence, last)
376 }
377 let blank = 0
378 while (body.length - blank > 1 && !body[body.length - 1 - blank]?.trim()) blank++
379 list.items.push(cut(body.slice(0, body.length - blank)))
380 const next = i < lines.length ? itemAt(lines[i] ?? '') : null
381 if (!next || next.marker !== head.marker) return [list, i - blank]
382 list.loose ||= blank > 0
383 item = next
384 }
385}
386
387type Quote = Piece & { kind: 'quote' }
388
389// One block quote from `at`: its `>` lines, and lazy lines carrying on its
390// paragraph; its own lines, markers off, cut into pieces as any text is.
391const quoteAt = (lines: readonly string[], at: number): [Quote, number] => {
392 const inner: string[] = []
393 let i = at
394 let fence: Fence = null
395 for (; i < lines.length; i++) {
396 const line = lines[i] ?? ''
397 const last = inner[inner.length - 1] ?? ''
398 if (QUOTE.test(line)) inner.push(line.replace(/^ {0,3}> ?/, ''))
399 else if (!fence && isParagraph(last) && isParagraph(line) && !FENCE.test(line) && !itemAt(line)) inner.push(line.trimStart())
400 else break
401 fence = fenceAfter(fence, inner[inner.length - 1] ?? '')
402 }
403 return [{ kind: 'quote', pieces: cut(inner), gap: false }, i]
404}
405
406// Lines into pieces. A list may break into a paragraph only as CommonMark
407// lets it: with text in its first item and, numbered, starting at 1.
408export const cut = (lines: readonly string[]): Piece[] => {
409 const pieces: Piece[] = []
410 let text: string[] = []
411 let gap = false
412 const flush = () => {
413 const a = text.findIndex(line => line.trim())
414 if (a < 0) gap ||= text.length > 0
415 else {
416 let b = text.length
417 while (!text[b - 1]?.trim()) b--
418 pieces.push({ kind: 'text', text: text.slice(a, b).join('\n'), gap: gap || a > 0 })
419 gap = b < text.length
420 }
421 text = []
422 }
423 let fence: Fence = null
424 let paragraph = false
425 for (let i = 0; i < lines.length; ) {
426 const line = lines[i] ?? ''
427 // A quote holding something drawn here is a piece of its own; one that
428 // holds only text stays in the text, as the engine draws it.
429 if (!fence && QUOTE.test(line)) {
430 const [quote, next] = quoteAt(lines, i)
431 if (quote.pieces.some(p => p.kind !== 'text')) {
432 flush()
433 pieces.push({ ...quote, gap })
434 gap = false
435 paragraph = false
436 i = next
437 continue
438 }
439 }
440 // A rule, which the engine draws as its dashes; `---` under a paragraph
441 // underlines a heading instead.
442 if (!fence && RULE.test(line) && !(paragraph && line.trim().startsWith('-'))) {
443 flush()
444 pieces.push({ kind: 'rule', gap })
445 gap = false
446 paragraph = false
447 i++
448 continue
449 }
450 const item = fence ? null : itemAt(line)
451 if (item && (!paragraph || (!item.empty && (!numbered(item.marker) || item.start === 1)))) {
452 flush()
453 const [list, next] = listAt(lines, i, item)
454 pieces.push({ ...list, gap })
455 gap = false
456 paragraph = false
457 i = next
458 continue
459 }
460 const was = fence
461 fence = fenceAfter(fence, line)
462 paragraph = !was && !fence && isParagraph(line)
463 text.push(line)
464 i++
465 }
466 flush()
467 return pieces
468}
469
470// The pieces of a reply worth drawing here: null when it has no list, rule
471// or quote holding one, or holds what only the engine's own drawing hides or
472// can take.
473export const listed = (text: string): Piece[] | null => {
474 if (text.length > 50_000 || text.includes('<context>')) return null
475 const pieces = cut(text.split('\n'))
476 return pieces.some(p => p.kind !== 'text') ? pieces : null
477}
478
479const BULLETS = '•◦▪'
480// A task's box, open or ticked, as the apps draw it.
481const BOXES = { open: '☐', done: '☑' } as const
482
483// Each item's mark: its number, as the list counts from its start, or the
484// bullet for its depth; a task's box in place of its bullet, or after its
485// number; numbers right-aligned to the widest.
486export const marks = (list: List, depth: number): string[] => {
487 const said = list.items.map((_, i) => {
488 const task = list.tasks[i]
489 const box = task === undefined || task === null ? null : task ? BOXES.done : BOXES.open
490 if (numbered(list.marker)) return `${list.start + i}${list.marker}${box ? ` ${box}` : ''}`
491 return box ?? BULLETS.charAt(Math.min(depth, BULLETS.length - 1))
492 })
493 const wide = Math.max(...said.map(m => m.length))
494 return said.map(m => `${m.padStart(wide)} `)
495}
496
497// CommonMark leaves a `**` as written when it closes right after a quote, a
498// bracket or a `?` and runs into a letter, which Korean particles do all the
499// time (`**"인용"**을`), or opens in the mirror of that. A zero-width space
500// on that side lets the run open or close, and the screen drops it, so text
501// copied off it is as written; code is left as written.
502const JOINER = '\u200B'
503const PUNCTUATION = /[\p{P}\p{S}]/u
504const side = (ch: string | undefined): 'space' | 'mark' | 'letter' =>
505 ch === undefined || /\s/u.test(ch) ? 'space' : PUNCTUATION.test(ch) ? 'mark' : 'letter'
506
507const mendLine = (line: string): string => {
508 const chars = [...line]
509 let out = ''
510 for (let i = 0; i < chars.length; ) {
511 const ch = chars[i] ?? ''
512 let n = 0
513 while (chars[i + n] === ch) n++
514 if (ch === '\\') n = Math.min(2, chars.length - i)
515 else if (ch === '`') {
516 // A code span ends at the next run of as many backticks.
517 for (let j = i + n; j < chars.length; ) {
518 let m = 0
519 while (chars[j + m] === '`') m++
520 if (m === n) {
521 n = j + m - i
522 break
523 }
524 j += m || 1
525 }
526 } else if (ch === '*' && n >= 2) {
527 const [before, after] = [side(chars[i - 1]), side(chars[i + n])]
528 const run = '*'.repeat(n)
529 out += before === 'mark' && after === 'letter' ? JOINER + run : after === 'mark' && before === 'letter' ? run + JOINER : run
530 i += n
531 continue
532 }
533 out += chars.slice(i, i + n).join('')
534 i += n
535 }
536 return out
537}
538
539// The text as the engine should read it: bold that CommonMark would leave as
540// asterisks mended, fences left alone; the same string when nothing needs it.
541export const mended = (text: string): string => {
542 let fence: Fence = null
543 const lines = text.split('\n').map(line => {
544 const was = fence
545 fence = fenceAfter(fence, line)
546 return was || fence || !line.includes('**') ? line : mendLine(line)
547 })
548 const out = lines.join('\n')
549 return out === text ? text : out
550}
551
552// `git diff --numstat` read as one stat: a binary file (`-`) counts as a file
553// with no lines.
554export const numstat = (out: string): Edits => {
555 const rows = out.split('\n').filter(row => row.includes('\t'))
556 const count = (n: string | undefined) => (n === undefined || n === '-' ? 0 : Number(n) || 0)
557 return rows.reduce<Edits>(
558 (sum, row) => {
559 const [added, removed] = row.split('\t')
560 return { files: sum.files + 1, added: sum.added + count(added), removed: sum.removed + count(removed) }
561 },
562 { files: 0, added: 0, removed: 0 },
563 )
564}
565
566// A text file's lines as git counts them added; null for a binary one.
567export const linesOf = (text: string): number | null =>
568 text.includes('\0') ? null : text === '' ? 0 : text.replace(/\n$/, '').split('\n').length
569
570// What counting the working copy needs of `$`.
571type Git = Pick<CoreEngineInterface, 'process' | 'fs'>
572
573// Git's empty tree, what a repository with no commit yet diffs against.
574const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
575// Past this many new files, the rest count as files without their lines.
576const UNTRACKED_READ = 200
577
578// The working copy's changes not yet committed, as the reviewer and the
579// desktop app's diff count them: tracked files against HEAD, staged or not,
580// and each new file not ignored, all its lines added. Null outside a repository.
581const uncommitted = async ($: Git): Promise<Edits | null> => {
582 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
583 if (top.exitCode !== 0) return null
584 // From the top, so a session in a subfolder counts the whole repository.
585 const root = top.stdout.trim()
586 const git = (...args: string[]) => $.process.run(['git', ...args], { cwd: root })
587 let diff = await git('diff', 'HEAD', '--numstat')
588 if (diff.exitCode !== 0) diff = await git('diff', EMPTY_TREE, '--numstat')
589 if (diff.exitCode !== 0) return null
590 const stat = numstat(diff.stdout)
591 const others = await git('ls-files', '--others', '--exclude-standard', '-z')
592 const fresh = others.exitCode === 0 ? others.stdout.split('\0').filter(Boolean) : []
593 let added = 0
594 for (const file of fresh.slice(0, UNTRACKED_READ)) {
595 try {
596 added += linesOf(await $.fs.read(`${root}/${file}`)) ?? 0
597 } catch {
598 // Over the read limit or gone since: a file without its lines.
599 }
600 }
601 return { files: stat.files + fresh.length, added: stat.added + added, removed: stat.removed }
602}
603
604// One count of the working copy at a time; a call while one runs counts
605// again once it is done, so the last change is in the figure.
606let counting: Promise<void> | null = null
607let stale = false
608const recount = ($: Git & StateDollar): Promise<void> => {
609 if (counting) {
610 stale = true
611 return counting
612 }
613 counting = (async () => {
614 do {
615 stale = false
616 const edits = await uncommitted($).catch(() => null)
617 await update($, EDITS, () => edits)
618 } while (stale)
619 })().finally(() => {
620 counting = null
621 })
622 return counting
623}
624
625// Whether a call's line runs its mark up into the row above: the call came
626// right after another, with no text between.
627const joinsAbove = async ($: StateDollar, id: string): Promise<boolean> => (await read($, JOINED)).includes(id)
628
629// Tools that may change the working copy: an edit, or a command (a commit, a
630// checkout, a script).
631const CHANGES_FILES: ReadonlySet<string> = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
632
633// The lists of an approved plan's text, when it has any.
634export const planPieces = (output: unknown): Piece[] | null => {
635 if (typeof output !== 'object' || output === null || !('plan' in output)) return null
636 return typeof output.plan === 'string' ? listed(mended(output.plan)) : null
637}
638
639// Taller and wider than any reply's quote or rule; the box around clips them.
640const QUOTE_BAR = Array.from({ length: 1000 }, () => '▎').join('\n')
641const RULE_LINE = '─'.repeat(500)
642
643// Text with lists, drawn: each item a row, its mark, then its text in a
644// column, so wrapped lines hang under the text; a list at the left edge set
645// in by two. The rest goes to the engine's Markdown, styling and all.
646const setIn = (ui: Pick<ClientElements, 'Box' | 'Text' | 'Markdown'>, pieces: readonly Piece[]): RenderNode[] => {
647 const { Box, Text, Markdown } = ui
648 const draw = (pieces: readonly Piece[], depth: number): RenderNode[] =>
649 pieces.map((piece, at) => (
650 <Box flexDirection="column" marginTop={at > 0 && piece.gap ? 1 : 0}>
651 {piece.kind === 'text'
652 ? <Markdown text={piece.text} />
653 : piece.kind === 'list'
654 ? drawList(piece, depth)
655 : piece.kind === 'quote'
656 ? drawQuote(piece, depth)
657 : drawRule()}
658 </Box>
659 ))
660 // The engine's dim bar down the quote's left, as tall as what it holds.
661 const drawQuote = (quote: Quote, depth: number): RenderNode => (
662 <Box flexDirection="column" paddingLeft={2} position="relative">
663 <Box position="absolute" top={0} left={0} width={1} height="100%" overflow="hidden">
664 <Text dimColor>{QUOTE_BAR}</Text>
665 </Box>
666 {draw(quote.pieces, depth)}
667 </Box>
668 )
669 // A dim line across the width, where the engine leaves the dashes.
670 const drawRule = (): RenderNode => (
671 <Box height={1} overflow="hidden">
672 <Text dimColor>{RULE_LINE}</Text>
673 </Box>
674 )
675 const drawList = (list: List, depth: number): RenderNode => {
676 const shown = marks(list, depth)
677 return (
678 <Box flexDirection="column" paddingLeft={depth === 0 ? 2 : 0}>
679 {list.items.map((item, i) => (
680 <Box flexDirection="row" marginTop={i > 0 && list.loose ? 1 : 0}>
681 <Box flexShrink={0}>
682 <Text>{shown[i]}</Text>
683 </Box>
684 <Box flexDirection="column" flexShrink={1} flexGrow={1}>
685 {draw(item, depth + 1)}
686 </Box>
687 </Box>
688 ))}
689 </Box>
690 )
691 }
692 return draw(pieces, 0)
693}
694
695// Pasted text reaches the hook in the engine's framing for the model: each
696// block as `\n\n<pasted_content id="…">\n` + what was pasted +
697// `\n</pasted_content id="…">\n`, in place of the composer's placeholder. Each
698// folds back to that placeholder, the framing's newlines with it, counted as
699// the engine counts (the pasted text's line breaks). The placeholder's number
700// runs across the session and the hook is not told it, so it is left out.
701const PASTED = /(?:\n\n)?<pasted_content id="([^"]*)">\n?([\s\S]*?)\n?<\/pasted_content id="\1">\n?/g
702
703// A prompt cut where its pasted blocks are; each block with the line breaks
704// the engine counts (it drops a paste's last one before framing, so a paste
705// that ended in one reads one short of the composer's).
706export type Said = { kind: 'text'; text: string } | { kind: 'paste'; body: string; lines: number }
707
708export const said = (text: string): Said[] => {
709 const out: Said[] = []
710 let at = 0
711 for (const m of text.matchAll(PASTED)) {
712 if (m.index > at) out.push({ kind: 'text', text: text.slice(at, m.index) })
713 const body = m[2] ?? ''
714 out.push({ kind: 'paste', body, lines: body.split('\n').length - 1 })
715 at = m.index + m[0].length
716 }
717 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
718 return out
719}
720
721export const placeholder = (lines: number): string => `[Pasted text +${lines} lines]`
722
723export const folded = (text: string): string =>
724 said(text)
725 .map(piece => (piece.kind === 'text' ? piece.text : placeholder(piece.lines)))
726 .join('')
727
728export const register: Register = (on, options) => {
729 const width = WIDTHS.find(w => w === options.bubbleWidth) ?? '75%'
730 const spacer = `${100 - parseInt(width, 10)}%`
731 const showBand = options.showBand !== false
732 const bandModel = options.bandModel === true
733 const bandContext = options.bandContext === true
734 const showPrompt = options.showPrompt !== false
735 const showReplies = options.showReplies !== false
736 const showTools = options.showTools !== false
737
738 // The band's figures: seeded when the session starts, then pushed by the
739 // engine (each main-loop step, each measurement, each tool call); the
740 // working copy's changes counted again after each step and each call that
741 // may change it.
742 on('session.start', async ($, e, next) => {
743 const started = await next(e)
744 const [id, usage, messages] = await Promise.all([
745 $.session.model(),
746 $.session.usage(),
747 $.session.messages({ as: 'api' }),
748 ])
749 await update($, MODEL, model => model ?? { id })
750 await update($, CONTEXT, () => usage.context)
751 await update($, COST, () => usage.cost?.usd ?? null)
752 await update($, TOOLS, () => [])
753 // A resumed transcript's text between tool calls, read off the messages.
754 await update($, NARRATION, () => narrations(messages))
755 await update($, JOINED, () => joinedIn(messages))
756 void recount($)
757 return started
758 })
759
760 on('turn.step', async function* ($, e, next) {
761 if (e.agentId) return yield* next(e)
762 const model: Model = e.effort === undefined ? { id: e.model } : { id: e.model, effort: String(e.effort) }
763 await update($, MODEL, () => model)
764 // The response's blocks as they stream: each text, and where calls sit.
765 const blocks = new Map<number, string | null>()
766 const ids = new Map<number, string>()
767 const stream = next(e)
768 let item = await stream.next()
769 while (!item.done) {
770 const chunk = item.value
771 if (chunk.kind === 'text') blocks.set(chunk.index, (blocks.get(chunk.index) ?? '') + chunk.text)
772 else if (chunk.kind === 'tool') {
773 blocks.set(chunk.index, null)
774 ids.set(chunk.index, chunk.id)
775 }
776 yield chunk
777 item = await stream.next()
778 }
779 const step = item.value
780 const streamed = [...blocks].sort(([a], [b]) => a - b).map(([, piece]) => piece)
781 // A response a hook beneath answered whole streams no text: read it off
782 // the result, its text before its calls.
783 const pieces =
784 streamed.some(p => p !== null) || !step.answer.trim() ? streamed : [step.answer, ...step.toolUses.map(() => null)]
785 const said = between(pieces, e.index > 0)
786 if (said.length > 0) await update($, NARRATION, kept => [...kept, ...said].slice(-NARRATION_KEPT))
787 // Calls right under a call: a new turn starts after the person's prompt.
788 const order = [...blocks].sort(([a], [b]) => a - b).map(([at, piece]) => piece ?? { id: ids.get(at) ?? '' })
789 const [joined, after] = joinedCalls(order, e.index > 0 && (await read($, AFTER_CALL)))
790 await update($, AFTER_CALL, () => after)
791 if (joined.length > 0) await update($, JOINED, kept => [...kept, ...joined].slice(-JOINED_KEPT))
792 // What the person changed or committed in the meantime.
793 void recount($)
794 return step
795 })
796
797 on('session.measure', async ($, e, next) => {
798 if (e.changed.includes('context')) {
799 const { tokens, window, percent } = e.context
800 const context: Context = { window, ...(tokens === undefined ? {} : { tokens }), ...(percent === undefined ? {} : { percent }) }
801 await update($, CONTEXT, () => context)
802 }
803 if (e.changed.includes('cost') && e.cost) {
804 const usd = e.cost.usd
805 await update($, COST, () => usd)
806 }
807 return next(e)
808 })
809
810 // No response reports the context a compaction left until the next one is
811 // answered: count it now, so the band drops when the conversation does.
812 on('session.compact', async ($, e, next) => {
813 const done = await next(e)
814 if (e.agentId !== undefined || e.trigger === 'precompute' || !done.messages) return done
815 const { context } = await $.session.usage({ breakdown: 'summary' })
816 const tokens = context.breakdown?.totalTokens ?? done.tokensAfter
817 if (tokens !== undefined) {
818 const percent = Math.min(100, Math.round((tokens / context.window) * 100))
819 await update($, CONTEXT, () => ({ window: context.window, tokens, percent }))
820 }
821 return done
822 })
823
824 on('tool.call', async ($, e, next) => {
825 const label = toolLabel(e.tool)
826 await update($, TOOLS, tools => [...tools, label])
827 try {
828 const done = await next(e)
829 if (CHANGES_FILES.has(e.tool)) void recount($)
830 return done
831 } finally {
832 await update($, TOOLS, tools => {
833 const at = tools.indexOf(label)
834 return at < 0 ? tools : [...tools.slice(0, at), ...tools.slice(at + 1)]
835 })
836 }
837 })
838
839 // One row above the prompt, as the desktop app's composer bar: on the left
840 // what this session edited and spent, on the right the tools running; the
841 // model and the context fill when turned on.
842 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
843 if (e.surface !== 'terminal' || !showBand || e.props.hasSurvey) return next(e)
844 const [stored, measured, tools, edits, cost] = await Promise.all([
845 read($, MODEL),
846 read($, CONTEXT),
847 read($, TOOLS),
848 read($, EDITS),
849 read($, COST),
850 ])
851 const model = bandModel ? stored : null
852 const context = bandContext ? measured : null
853 const edited = edits && edits.files > 0 ? edits : null
854 const spent = cost !== null && cost > 0 ? cost : null
855 if (!model && context?.percent === undefined && tools.length === 0 && !edited && spent === null) return next(e)
856 const { Box, Text } = $.ui.resolve(e)
857
858 // The left: what the working copy holds uncommitted and the session spent, then the model when on.
859 const left: RenderNode[] = []
860 if (edited) {
861 left.push(
862 <Text>
863 <Text dimColor>
864 {String(edited.files)} {edited.files === 1 ? 'file' : 'files'}{' '}
865 </Text>
866 <Text color="green">+{String(edited.added)}</Text>
867 <Text dimColor> </Text>
868 <Text color="red">-{String(edited.removed)}</Text>
869 </Text>,
870 )
871 }
872 if (spent !== null) left.push(<Text dimColor>{dollars(spent)}</Text>)
873 if (model) left.push(<Text dimColor>◆ {modelLabel(model.id)}{model.effort ? ` · ${model.effort}` : ''}</Text>)
874
875 const right: RenderNode[] = []
876 if (tools.length > 0) {
877 // What the left half and the gaps leave; the context fill takes its own.
878 const used =
879 (edited ? `${edited.files} files +${edited.added} -${edited.removed}`.length + 2 : 0) +
880 (spent !== null ? dollars(spent).length + 2 : 0) +
881 (model ? modelLabel(model.id).length + (model.effort?.length ?? 0) + 7 : 0) +
882 (context?.percent !== undefined ? 30 : 0)
883 const room = Math.max(0, e.props.bodyColumns - used - 4)
884 right.push(<Text color="blue">↻ {runningLabel(tools, room)}</Text>)
885 }
886 if (context?.percent !== undefined) {
887 const shade = SHADES[pressure(context)]
888 const [filled, empty] = fill(context.percent)
889 right.push(
890 <Text>
891 <Text {...shade}>{filled}</Text>
892 <Text dimColor>{empty}</Text>
893 <Text {...shade}>
894 {' '}
895 {String(context.percent)}%
896 {context.tokens === undefined ? '' : ` · ${short(context.tokens)}/${short(context.window)}`}
897 </Text>
898 </Text>,
899 )
900 }
901
902 return (
903 <Box flexDirection="column">
904 <Box width={e.props.bodyColumns} justifyContent="space-between">
905 <Box gap={2}>{...left}</Box>
906 <Box gap={2}>{...right}</Box>
907 </Box>
908 {await next(e)}
909 </Box>
910 )
911 })
912
913 // The person's prompt at the left edge beside an orange bar, where the
914 // engine's row for a pasted image (`⎿ [Image #1]`), which no hook reaches,
915 // sits under it; at most `width` of the terminal wide. Pasted text reads as
916 // the composer's placeholder, which a press opens.
917 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
918 if (e.surface !== 'terminal' || !showPrompt || !OWN.has(e.props.origin.kind) || e.props.from || e.props.task) return next(e)
919 const { Box, Text, Button } = $.ui.resolve(e)
920 const pieces = said(e.props.text)
921 // A pasted block is its placeholder, a press away from what was pasted:
922 // each its own row, the text around it on rows of its own.
923 const rows: RenderNode[] = []
924 for (const [i, piece] of pieces.entries()) {
925 if (piece.kind === 'text') {
926 const text = piece.text.replace(/^\n/, '').replace(/\s+$/, '')
927 if (text) rows.push(<Text wrap="wrap">{text}</Text>)
928 continue
929 }
930 const member = memberOf(OPEN, { requestId: `${e.requestId}:paste:${i}` })
931 const open = e.props.isExpanded || (await read($, member))
932 const toggle = () => void update($, member, v => !v)
933 rows.push(
934 <Box flexDirection="column">
935 <Box flexDirection="row">
936 <Button plain dimColor key={`paste-${i}`} label={placeholder(piece.lines)} onPress={toggle} />
937 <Text> </Text>
938 <Button plain dimColor key={`mark-${i}`} label={open ? '⌄' : '›'} onPress={toggle} />
939 </Box>
940 {open ? <Text wrap="wrap">{piece.body}</Text> : null}
941 </Box>,
942 )
943 }
944 return (
945 <Box flexDirection="row" marginTop={1}>
946 <Box flexShrink={1} flexDirection="row">
947 <Box width={1} flexShrink={0} backgroundColor={PROMPT_COLOR} />
948 <Box flexShrink={1} flexDirection="column" paddingLeft={1}>
949 {...rows}
950 </Box>
951 </Box>
952 <Box flexGrow={1} minWidth={spacer} />
953 </Box>
954 )
955 })
956
957 // The reply's text without the bullet, flush left as the mobile app sets it;
958 // the text between two tool calls beside a grey bar. A reply with a list is
959 // drawn here, its lists set in with bullets and its prose by the Markdown
960 // element; any other reply is the engine's, so a mod beneath that rewrites
961 // the text composes with this.
962 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
963 if (e.surface !== 'terminal' || !showReplies) return next(e)
964 const plain = { ...e, props: { ...e.props, text: mended(e.props.text), isFirstOfReply: false } }
965 if (e.props.isSummary) return next(plain)
966 const { Box } = $.ui.resolve(e)
967 // Opens with a blank row as the engine's drawing does.
968 const pieces = listed(plain.props.text)
969 const reply = pieces ? (
970 <Box flexDirection="column" marginTop={1}>
971 {setIn($.ui.resolve(e), pieces)}
972 </Box>
973 ) : (
974 await next(plain)
975 )
976 if (!(await read($, NARRATION)).includes(e.props.text.trim())) return reply
977 // The bar skips that blank row.
978 return (
979 <Box flexDirection="row">
980 <Box width={1} flexShrink={0} marginTop={1} backgroundColor={NARRATION_COLOR} />
981 <Box flexShrink={1} flexGrow={1} paddingLeft={1}>
982 {reply}
983 </Box>
984 </Box>
985 )
986 })
987
988 // A tool call as one dim line, as the mobile app lists it: `Ran <what for> ›`;
989 // blue while it runs, a red mark when it failed. A click on the line opens
990 // the engine's own row (the command, the diff, the output) beneath it. On
991 // the main screen nothing takes a click, so the engine's rows stay.
992 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
993 if (e.surface !== 'terminal' || !showTools || e.viewport?.isFullscreen === false) return next(e)
994 const { Box, Text, Button } = $.ui.resolve(e)
995 const { tool, input, isErrored, isInterrupted, output } = e.props
996 // A call waiting at the permission dialog has no result yet either.
997 const isRunning = e.props.isRunning || (output === undefined && !isErrored && !isInterrupted)
998 const member = memberOf(OPEN, e)
999 const open = (await read($, member)) !== OPEN_BY_DEFAULT.has(tool)
1000 const toggle = () => void update($, member, v => !v)
1001 const room = Math.max(10, (e.viewport?.columns ?? 80) - 16)
1002 const label = clip(callLabel(tool, input, isRunning), room)
1003 const stat = isRunning || isErrored ? null : diffStat(tool, output)
1004 const mark = open ? '⌄' : '›'
1005 const tone: Tone = isErrored || isInterrupted ? 'failed' : isRunning ? 'running' : 'done'
1006 const joined = await joinsAbove($, e.requestId)
1007 return (
1008 <Box flexDirection="column" marginTop={joined ? 0 : 1}>
1009 {joined ? <Text {...TONES[tone]}>{JOIN_MARK}</Text> : null}
1010 <Box flexDirection="row">
1011 <Text {...TONES[tone]}>{STATE_MARK}</Text>
1012 {isInterrupted ? (
1013 <Text dimColor>Interrupted · </Text>
1014 ) : isErrored ? (
1015 <Text color="red">✗ </Text>
1016 ) : null}
1017 {isRunning ? (
1018 <Text color="blue">{label}…</Text>
1019 ) : (
1020 <Button plain dimColor key="line" label={label} onPress={toggle} />
1021 )}
1022 {stat ? (
1023 <Text>
1024 {' '}
1025 <Text color="green">+{String(stat.added)}</Text> <Text color="red">-{String(stat.removed)}</Text>
1026 </Text>
1027 ) : null}
1028 <Text> </Text>
1029 <Button plain dimColor key="mark" label={mark} onPress={toggle} />
1030 </Box>
1031 {open ? await next(e) : null}
1032 </Box>
1033 )
1034 })
1035
1036 // The result under a standalone row: drawn while its row is open.
1037 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
1038 if (e.surface !== 'terminal' || !showTools || e.viewport?.isFullscreen === false) return next(e)
1039 const open = (await read($, memberOf(OPEN, e))) !== OPEN_BY_DEFAULT.has(e.props.tool)
1040 const { Box } = $.ui.resolve(e)
1041 if (!open) return <Box />
1042 // An approved plan: the engine's lines above it, its lists drawn as a
1043 // reply's are, under the engine's `⎿` gutter.
1044 const pieces = e.props.tool === 'ExitPlanMode' && !e.props.isErrored ? planPieces(e.props.output) : null
1045 if (!pieces) return next(e)
1046 const output = e.props.output as Record<string, unknown>
1047 return (
1048 <Box flexDirection="column">
1049 {await next({ ...e, props: { ...e.props, output: { ...output, plan: JOINER } } })}
1050 <Box flexDirection="column" paddingLeft={5}>
1051 {setIn($.ui.resolve(e), pieces)}
1052 </Box>
1053 </Box>
1054 )
1055 })
1056
1057 // A folded run of reads and searches: one dim count line; a press unfolds it
1058 // into a line per call, each of which opens on its own. A run of one call
1059 // unfolds into that call's row, opened, so its line is not drawn twice.
1060 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1061 if (e.surface !== 'terminal' || !showTools || e.viewport?.isFullscreen === false) return next(e)
1062 const { Box, Text, Button } = $.ui.resolve(e)
1063 const { calls } = e.props
1064 const member = memberOf(OPEN, e)
1065 const open = e.props.isExpanded || (await read($, member))
1066 const only = calls.length === 1 ? calls[0]!.tool_use_id : undefined
1067 if (open && only !== undefined) return next({ ...e, props: { ...e.props, isExpanded: true } })
1068 const toggle = async () => {
1069 const now = await update($, member, v => !v)
1070 if (only !== undefined) await update($, memberOf(OPEN, { requestId: only }), () => now)
1071 }
1072 const running = calls.some(c => c.isRunning)
1073 const failed = calls.some(c => c.isErrored || c.isInterrupted)
1074 const tone: Tone = failed ? 'failed' : running ? 'running' : 'done'
1075 const room = Math.max(10, (e.viewport?.columns ?? 80) - 10)
1076 const label = clip(groupLabel(calls), room)
1077 const first = calls[0]
1078 const joined = first?.tool_use_id !== undefined && (await joinsAbove($, first.tool_use_id))
1079 return (
1080 <Box flexDirection="column" marginTop={joined ? 0 : 1}>
1081 {joined ? <Text {...TONES[tone]}>{JOIN_MARK}</Text> : null}
1082 <Box flexDirection="row">
1083 <Text {...TONES[tone]}>{STATE_MARK}</Text>
1084 {failed ? <Text color="red">✗ </Text> : null}
1085 {running ? (
1086 <Text color="blue">{label}…</Text>
1087 ) : (
1088 <Button plain dimColor key="line" label={label} onPress={() => void toggle()} />
1089 )}
1090 <Text> </Text>
1091 <Button plain dimColor key="mark" label={open ? '⌄' : '›'} onPress={() => void toggle()} />
1092 </Box>
1093 {open ? await next({ ...e, props: { ...e.props, isExpanded: true } }) : null}
1094 </Box>
1095 )
1096 })
1097}
1098types/index.d.ts 31 lines1// The main loop's model, as the last turn ran it.
2export type Model = { id: string; effort?: string }
3
4// The live context window, as the engine last measured it.
5export type Context = { tokens?: number; window: number; percent?: number }
6
7// The working copy's uncommitted changes, as the desktop app's diff stat reads.
8export type Edits = { files: number; added: number; removed: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'desktop-look': {
13 model: Model | null
14 context: Context | null
15 tools: string[]
16 edits: Edits | null
17 cost: number | null
18 // The text the model wrote between two tool calls, which the transcript
19 // sets apart as the mobile app does.
20 narration: string[]
21 // Tool calls whose line sits right under another call's, no text
22 // between: their mark runs up into the row above, so a run reads as one
23 // line. And whether the main loop's last block was a call.
24 joined: string[]
25 afterCall: boolean
26 // Whether a tool row shows what the engine draws under its one line.
27 open: StateFamily<boolean>
28 }
29 }
30}
31