SLOPSHOPPER

desktop-look

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…

newbandrowsguardprocess
★ 1v0.16.0MITupdated 2026-10-07KyongSik-Yoon/claude-mods/desktop-look
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desktop-look
fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ▎ Editing auth.ts… › ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 2 files +14 -3 $0.42 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
2 files +14 -3 $0.42 ⟨Claude Code's own drawing⟩
Your message
fix the failing auth test and add an audit log call
Tool row
▎ Editing auth.ts… ›
README

desktop-look

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

Default Claude Code, the mobile layout, and a line opened by a click

  • Tool calls as one line, as the mobile app lists them: 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 ›)
  • A run of calls reads as one line: calls made one after another with no text between join their marks through the blank row between them, in the lower line's colour, so a run that failed partway turns red where it broke
  • A click opens a line: the engine's own row appears beneath it (the command, the diff, the output), and a second click folds it again. The todo list, the questions you answered and plans start open
  • Lists as the apps set them: bullets • ◦ ▪ 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 -
  • Bold that reads as bold in Korean: CommonMark leaves "인용"을 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
  • The text between tool calls sits beside a grey bar, so the narration of the work reads apart from the turn's opening line and its answer, which stay plain; the reply's ● bullet is dropped
  • Your prompts beside an orange bar: your own messages sit at the left edge beside one cell of the theme's Claude orange, so they stand out from the reply and the row Claude Code draws under a prompt with a pasted image (⎿ [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 pasted
  • Info band above the prompt: the repository's uncommitted changes (2 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
  • Works alongside smooth-stream: a reply streams as the engine draws it, and its lists take their bullets once it is done. A reply without a list is still the engine's own drawing, so a mod that rewrites its text composes
  • Terminal only; the desktop app, VS Code and mobile keep their own look. Clicks need fullscreen mode (/config → fullscreen); on the main screen tool calls keep the engine's rows

Install

claude 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).

Options

No setup needed. To change one, run /plugin configure desktop-look@kyongsik-mods (or open /config):

OptionDefault
Prompt width75%How wide your own messages may grow: 60%, 75% or 90% of the terminal
Your promptsonYour own messages beside the orange bar
RepliesonLists with bullets, Korean bold, the narration bar, no reply bullet
Tool linesonTool calls as one line that opens on a click
Info bandonThe band above the prompt
Band: modeloffModel and effort, for a status line that does not show them
Band: contextoffContext 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.

How it works

  • 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' }).
  • The prompt's bar is a one-cell Box with a 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.
  • The band is an 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.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 1098 lines
1import { 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}
1098
types/index.d.ts 31 lines
1// 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