Restyles the Claude Code terminal like an ACP chat: prompt and tool cards, inline diffs, a status band, footer stats, a /dash pane and a done toast

A Claude Code mod that restyles the terminal like an ACP chat (the look of spec-ade's agent panel), using only what a terminal can draw: colored cells, block characters, one monospace font.
›.╭─╮ │ ╰─╯) with no filled block. Consecutive calls share one frame, a row each: status (✓ ◌ ✗ ■), the tool, its target and a right-aligned summary (142 dòng, 4 file, 24 passed, a shell command's output as N dòng) with how long it took. The top border says N lệnh · time. The border takes the state's colour (blue while a call runs, red after a failure, grey-blue otherwise); a running row is filled blue, and a failed shell row adds a └ line with the last error line. In fullscreen a finished run of 4 or more clean calls folds behind ▸ mở rộng; text from Claude between calls starts a new frame. On the main screen rows cannot redraw, so each call is its own frame. A tool from another plugin or an MCP server (mcp__…) keeps its own header and result but draws inside the same frame, one per call, never grouped.−N +N at the edge. In fullscreen a diff over 4 lines folds behind ▸ diff./dash for its first changed lines.◌ ◆ Explore find the viewport code 6 tool · 12s, its last three steps under it while it runs, then ✓ with the files it read and edited and the first line of its answer. In fullscreen, hover it for every step and the head of the answer. A toast says when each subagent finishes.◆ 2 agent: Explore ◌ · Plan ◌), model, context bar./dash pane (type /dash again to close it): totals, a timeline of what Claude did (think, read, search, edit, run), tool calls per minute, per-tool bars, changed files with −/+ counts, current commit./enhance <prompt>: rewrites a rough prompt into a clear one using the conversation so far (same model, cached context), keeps your language, and puts it in the prompt box so you can edit before pressing Enter. In a new session it falls back to the session model without context. To rewrite what is already in the prompt box with a key, bind one to app:toggleDiffPreSession in ~/.claude/keybindings.json (the ✨ in the band presses on it), e.g. { "context": "Global", "bindings": { "ctrl+shift+l": "app:toggleDiffPreSession" } }.Đang pha cà phê…, Đang soi bug… (or Đang vắt óc… while thinking) instead of the sampled English word, one phrase per turn; the closing line reads ✻ Pha xong cà phê trong 1m 12s./acp-theme lists them, /acp-theme <name> switches and remembers the choice across sessions: spec-ade (default), tokyo-night, dracula, catppuccin, github-light (for light terminals).Đang đọc viewport.ts, Explore: đang tìm "devtools"). /acp-scene turns it off or on./play): a jump-over-the-bugs game in the same band while you wait. Focus the band with Ctrl+X then Tab and press j to jump (q quits); in fullscreen, click the scene and use Space. The round ends with the turn, and the best score is kept.The prompt and subagent cards get half a row of padding and corners cut by half a cell from quadrant blocks (▗▄▖, ▝▀▘); bars fill in eighths of a cell.
As a Claude Code plugin, from this repository's marketplace. Inside Claude Code:
/plugin marketplace add huydepzai121/claude-acp-ui
/plugin install acp-ui@claude-acp-ui
Or from a shell:
claude plugin marketplace add huydepzai121/claude-acp-ui
claude plugin install acp-ui@claude-acp-ui
Start a new Claude Code session to see it. Update with claude plugin marketplace update claude-acp-ui; disable or remove it from /plugin.
npx claude-acp-ui # copies the mod to ~/.claude/mods/acp-ui and lists it in CLAUDE_CODE_PLUGIN_DIRS
npx claude-acp-ui uninstall # undoes both
claude --plugin-dir "$(npx claude-acp-ui path)" # one session only, nothing installed
Use one way or the other, not both: two copies load as two plugins of the same name.
/acp-theme github-light on a light one.❯) is drawn by Claude Code; a mod cannot restyle it./dash pane docks beside the transcript only in fullscreen mode (/tui fullscreen) at 110+ columns; otherwise it opens above the prompt.test, vitest, jest, pytest or playwright counts as a test run, pass or fail by its exit status.│ sized to the block, so a result block taller than 400 rows loses them below that.npm run validate # claude plugin validate plugin
npm test # claude plugin test plugin
The mod lives in plugin/: hooks/register.tsx is the hooks module, types/index.d.ts its state contract.
MIT
hooks/register.tsx 1557 lines1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, Register, RenderElement, TextProps, Timer } from 'claude-code'
3
4import type { AgentRun, AgentStep, Band, Beat, Enhanced, FileChange, Footer, Git, Scene, Stats, Timing, ToolRuns } from '../types'
5import type { SceneProps } from './scene'
6
7type TextEl = ElementConstructor<TextProps>
8type BoxEl = ElementConstructor<BoxProps>
9
10// ---------------------------------------------------------------------------
11// Themes. Every drawing reads the live palette `C` and syntax colors `SYN`;
12// switching a theme copies another palette into them and redraws.
13
14type Palette = {
15 widget: string; snippet: string; running: string; pill: string; popup: string; track: string; frame: string; prompt: string; redDim: string
16 text: string; soft: string; dim: string; faint: string; rule: string
17 accent: string; green: string; red: string; blue: string; yellow: string; orange: string; violet: string; cyan: string
18 addBg: string; delBg: string; tagBlueBg: string; tagBlueFg: string; tagYellowBg: string; tagYellowFg: string
19}
20type Syntax = { keyword: string; fn: string; string: string; number: string; comment: string; punct: string }
21type Theme = { label: string; c: Palette; syn: Syntax }
22
23const THEMES: Record<string, Theme> = {
24 // spec-ade's dark tokens (UI.md), resolved to opaque hex for the terminal.
25 'spec-ade': {
26 label: 'spec-ade (mặc định, nền tối)',
27 c: {
28 widget: '#303135', snippet: '#27282B', running: '#1B2A40', pill: '#3A3B3F', popup: '#3A3B3F', track: '#55565A', frame: '#5B6B8C', prompt: '#C77DBA', redDim: '#B8626D',
29 text: '#E0E1E4', soft: '#C3C5C9', dim: '#909192', faint: '#696A6B', rule: '#3A3A3A',
30 accent: '#4B8DEC', green: '#69B090', red: '#F87C88', blue: '#71A3EF', yellow: '#E5BF8C', orange: '#E09B70', violet: '#AF9CFF', cyan: '#82D2CE',
31 addBg: '#2C3B36', delBg: '#493135', tagBlueBg: '#10447F', tagBlueFg: '#D6E3F9', tagYellowBg: '#5C4014', tagYellowFg: '#F5DEC2',
32 },
33 syn: { keyword: '#AF9CFF', fn: '#87C3FF', string: '#E5BF8C', number: '#82D2CE', comment: '#696A6B', punct: '#9EA3A8' },
34 },
35 'tokyo-night': {
36 label: 'Tokyo Night (nền tối xanh)',
37 c: {
38 widget: '#292E42', snippet: '#1F2335', running: '#23345C', pill: '#2F3549', popup: '#2F3549', track: '#3B4261', frame: '#4A5578', prompt: '#E0719F', redDim: '#B5586C',
39 text: '#C0CAF5', soft: '#A9B1D6', dim: '#8189B0', faint: '#565F89', rule: '#2F3549',
40 accent: '#7AA2F7', green: '#9ECE6A', red: '#F7768E', blue: '#7AA2F7', yellow: '#E0AF68', orange: '#FF9E64', violet: '#BB9AF7', cyan: '#7DCFFF',
41 addBg: '#243B35', delBg: '#45283A', tagBlueBg: '#2A4B8D', tagBlueFg: '#D5E2FF', tagYellowBg: '#5A4320', tagYellowFg: '#F4DDB8',
42 },
43 syn: { keyword: '#BB9AF7', fn: '#7AA2F7', string: '#9ECE6A', number: '#FF9E64', comment: '#565F89', punct: '#89DDFF' },
44 },
45 dracula: {
46 label: 'Dracula (nền tối tím)',
47 c: {
48 widget: '#44475A', snippet: '#343746', running: '#2E3A5C', pill: '#4A4D60', popup: '#4A4D60', track: '#5B5F78', frame: '#6272A4', prompt: '#FFB86C', redDim: '#C25B63',
49 text: '#F8F8F2', soft: '#E2E2DC', dim: '#A6ACCD', faint: '#7A80A8', rule: '#44475A',
50 accent: '#BD93F9', green: '#50FA7B', red: '#FF5555', blue: '#8BE9FD', yellow: '#F1FA8C', orange: '#FFB86C', violet: '#FF79C6', cyan: '#8BE9FD',
51 addBg: '#2F4A3A', delBg: '#5A2E38', tagBlueBg: '#5A4A8A', tagBlueFg: '#F0E8FF', tagYellowBg: '#6A5A2A', tagYellowFg: '#FFF6C8',
52 },
53 syn: { keyword: '#FF79C6', fn: '#50FA7B', string: '#F1FA8C', number: '#BD93F9', comment: '#7A80A8', punct: '#E2E2DC' },
54 },
55 catppuccin: {
56 label: 'Catppuccin Mocha (nền tối dịu)',
57 c: {
58 widget: '#313244', snippet: '#26273A', running: '#283457', pill: '#3B3D52', popup: '#3B3D52', track: '#585B70', frame: '#6B7396', prompt: '#F5C2E7', redDim: '#B96C82',
59 text: '#CDD6F4', soft: '#BAC2DE', dim: '#9399B2', faint: '#6C7086', rule: '#313244',
60 accent: '#89B4FA', green: '#A6E3A1', red: '#F38BA8', blue: '#89B4FA', yellow: '#F9E2AF', orange: '#FAB387', violet: '#CBA6F7', cyan: '#94E2D5',
61 addBg: '#2E3F3A', delBg: '#4A2F3D', tagBlueBg: '#34507F', tagBlueFg: '#DCE7FF', tagYellowBg: '#5E5032', tagYellowFg: '#FBEFD2',
62 },
63 syn: { keyword: '#CBA6F7', fn: '#89B4FA', string: '#A6E3A1', number: '#FAB387', comment: '#6C7086', punct: '#9399B2' },
64 },
65 'github-light': {
66 label: 'GitHub Light (cho terminal nền sáng)',
67 c: {
68 widget: '#EAEEF2', snippet: '#F3F4F6', running: '#DDF4FF', pill: '#E6EAEF', popup: '#E6EAEF', track: '#C9D1D9', frame: '#6E7B91', prompt: '#BF3989', redDim: '#A5343C',
69 text: '#1F2328', soft: '#424A53', dim: '#59636E', faint: '#818B98', rule: '#D0D7DE',
70 accent: '#0969DA', green: '#1A7F37', red: '#CF222E', blue: '#0969DA', yellow: '#9A6700', orange: '#BC4C00', violet: '#8250DF', cyan: '#1B7C83',
71 addBg: '#DAFBE1', delBg: '#FFEBE9', tagBlueBg: '#DDF4FF', tagBlueFg: '#0550AE', tagYellowBg: '#FFF8C5', tagYellowFg: '#7D4E00',
72 },
73 syn: { keyword: '#CF222E', fn: '#8250DF', string: '#0A3069', number: '#0550AE', comment: '#6E7781', punct: '#424A53' },
74 },
75}
76const DEFAULT_THEME = 'spec-ade'
77const THEME_KEY = 'theme'
78
79const C: Palette = { ...(THEMES[DEFAULT_THEME] as Theme).c }
80const SYN: Syntax = { ...(THEMES[DEFAULT_THEME] as Theme).syn }
81
82const applyTheme = (name: string): boolean => {
83 const theme = THEMES[name]
84 if (!theme) return false
85 Object.assign(C, theme.c)
86 Object.assign(SYN, theme.syn)
87 return true
88}
89
90const DASH = 'acp-dash'
91const DIFF_LINES = 10
92const FOLD_OVER = 4
93const FOLD_RUN = 4
94const THINK_GAP_MS = 3000
95const TEST_COMMAND = /\b(test|vitest|jest|pytest|playwright)\b/
96const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
97const RUN_TOOLS = new Set(['Bash', 'PowerShell'])
98const DRAWN_TOOLS = new Set([
99 'Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit',
100 'Bash', 'PowerShell', 'Grep', 'Glob', 'WebFetch', 'WebSearch',
101])
102const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
103const LEVELS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
104
105const KIND: Record<string, Beat> = {
106 Read: 'read', NotebookRead: 'read', WebFetch: 'read',
107 Grep: 'search', Glob: 'search', WebSearch: 'search',
108 Edit: 'edit', Write: 'edit', MultiEdit: 'edit', NotebookEdit: 'edit',
109 Bash: 'run', PowerShell: 'run',
110}
111const kindOf = (tool: string): Beat => KIND[tool] ?? 'other'
112
113// Read per drawing, so a theme switch recolors them.
114const ICON_GLYPH: Record<Beat, [string, keyof Palette]> = {
115 read: ['◇', 'blue'], search: ['⌕', 'yellow'], edit: ['✎', 'orange'], run: ['❯', 'violet'], think: ['·', 'violet'], other: ['•', 'dim'],
116}
117const iconOf = (beat: Beat): { text: string; color: string } => {
118 const [text, key] = ICON_GLYPH[beat]
119 return { text, color: C[key] }
120}
121const BEAT_KEY: Record<Beat, keyof Palette> = {
122 think: 'violet', read: 'blue', search: 'yellow', edit: 'orange', run: 'cyan', other: 'dim',
123}
124const beatColor = (beat: Beat): string => C[BEAT_KEY[beat]]
125const BEAT_LABEL: Array<[Beat, string]> = [['think', 'nghĩ'], ['read', 'đọc'], ['search', 'tìm'], ['edit', 'sửa'], ['run', 'chạy']]
126
127const git = atom({ plugin: 'acp-ui', key: 'git' } as const, null)
128const band = atom({ plugin: 'acp-ui', key: 'band' } as const, null)
129const footer = atom({ plugin: 'acp-ui', key: 'footer' } as const, null)
130const stats = atom({ plugin: 'acp-ui', key: 'stats' } as const, { total: 0, tools: {} })
131const timings = atom({ plugin: 'acp-ui', key: 'timings' } as const, {})
132const expanded = atom({ plugin: 'acp-ui', key: 'expanded' } as const, {})
133const toolRuns = atom({ plugin: 'acp-ui', key: 'toolRuns' } as const, { current: '', byId: {}, order: {} })
134const beats = atom({ plugin: 'acp-ui', key: 'beats' } as const, [])
135const activity = atom({ plugin: 'acp-ui', key: 'activity' } as const, {})
136const agents = atom({ plugin: 'acp-ui', key: 'agents' } as const, {})
137const agentByToolUse = atom({ plugin: 'acp-ui', key: 'agentByToolUse' } as const, {})
138const enhanced = atom({ plugin: 'acp-ui', key: 'enhanced' } as const, { busy: false, text: null } as Enhanced)
139
140const scene = atom({ plugin: 'acp-ui', key: 'scene' } as const, {
141 enabled: true, bubble: '', turnTools: 0, playing: false, jumpSeq: 0, score: 0, best: 0,
142} as Scene)
143const SCENE_KEY = 'scene'
144const BEST_KEY = 'nebug-best'
145// Rows the scene Client draws: the bubble row plus the pixel grid's text rows
146// (scene.tsx PX_ROWS / 2), so the whole cat fits in either mode.
147const SCENE_ROWS = { scene: 6, game: 8 } as const
148
149const RUN_KEEP = 200
150
151// Calls whose row an expanded group draws with the result inline: no separate
152// result block follows them, so their frame closes in the row itself.
153const inlineRows = new Set<string>()
154
155// The next call joins the open run, or starts one named after its own id.
156// The oldest runs go once more than RUN_KEEP calls are kept.
157function joinRun(runs: ToolRuns, id: string): ToolRuns {
158 const current = runs.current !== '' ? runs.current : `run:${id}`
159 let order = { ...runs.order, [current]: [...(runs.order[current] ?? []), id] }
160 let kept = Object.values(order).reduce((n, ids) => n + ids.length, 0)
161 for (const key of Object.keys(order)) {
162 if (kept <= RUN_KEEP || key === current) break
163 kept -= order[key]?.length ?? 0
164 order = Object.fromEntries(Object.entries(order).filter(([k]) => k !== key))
165 }
166 const byId = Object.fromEntries(Object.entries(order).flatMap(([run, ids]) => ids.map(one => [one, run] as const)))
167
168 return { current, byId, order }
169}
170
171const endRun = (runs: ToolRuns): ToolRuns => (runs.current === '' ? runs : { ...runs, current: '' })
172
173const AGENT_TOOLS = new Set(['Agent', 'Task'])
174// A tool another plugin or an MCP server provides: its result block closes the
175// frame its call opens. The built-in ones this mod does not draw (a todo list,
176// a plan) have no result block, so their row closes its own frame.
177const isForeign = (tool: string): boolean => tool.startsWith('mcp__')
178const isRunning = (run: AgentRun): boolean => run.endedAt === null
179const firstLine = (text: string): string => lines(text.trim()).find(l => l.trim() !== '')?.trim() ?? ''
180
181type Fields = Record<string, unknown>
182
183const fields = (value: unknown): Fields =>
184 typeof value === 'object' && value !== null ? (value as Fields) : {}
185
186const str = (value: unknown): string => (typeof value === 'string' ? value : '')
187
188const num = (value: unknown): number | null => (typeof value === 'number' ? value : null)
189
190const lines = (text: string): string[] => (text === '' ? [] : text.replace(/\r\n/g, '\n').split('\n'))
191
192const fileName = (path: string): string => path.split(/[\\/]/).slice(-3).join('/')
193
194const seconds = (ms: number): string => (ms < 10000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`)
195
196// The lines that differ once the shared head and tail are stripped.
197function changedLines(before: string, after: string): { removed: string[]; added: string[] } {
198 const a = lines(before)
199 const b = lines(after)
200 let head = 0
201 while (head < a.length && head < b.length && a[head] === b[head]) head += 1
202 let tail = 0
203 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
204
205 return { removed: a.slice(head, a.length - tail), added: b.slice(head, b.length - tail) }
206}
207
208function editDiff(tool: string, input: Fields): { removed: string[]; added: string[] } {
209 if (tool === 'Write') return { removed: [], added: lines(str(input.content)) }
210 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
211 return input.edits.map(fields).reduce<{ removed: string[]; added: string[] }>(
212 (sum, one) => {
213 const diff = changedLines(str(one.old_string), str(one.new_string))
214 return { removed: [...sum.removed, ...diff.removed], added: [...sum.added, ...diff.added] }
215 },
216 { removed: [], added: [] },
217 )
218 }
219
220 return changedLines(str(input.old_string), str(input.new_string))
221}
222
223// ---------------------------------------------------------------------------
224// Drawing on a cell grid. A terminal Box paints no background, so every card
225// row is one Text whose background runs the full width; the card's top and
226// bottom edges are quadrant blocks, giving half a row of padding and corners
227// cut by half a cell.
228
229type Seg = { text: string; color?: string; bold?: boolean; bg?: string }
230
231const cells = (text: string): number => [...text].length
232
233const segCells = (segs: Seg[]): number => segs.reduce((n, s) => n + cells(s.text), 0)
234
235const fit = (text: string, max: number): string =>
236 cells(text) <= max ? text : `${[...text].slice(0, Math.max(0, max - 1)).join('')}…`
237
238// Cuts a run of segments to `max` cells, ending in an ellipsis.
239function fitSegs(segs: Seg[], max: number): Seg[] {
240 if (segCells(segs) <= max) return segs
241 const out: Seg[] = []
242 let room = max - 1
243 for (const s of segs) {
244 if (room <= 0) break
245 const taken = [...s.text].slice(0, room).join('')
246 out.push({ ...s, text: taken })
247 room -= cells(taken)
248 }
249 out.push({ text: '…', color: C.dim })
250
251 return out
252}
253
254// A tool frame spans the terminal, less a cell each side.
255const frameWidth = (columns: number | undefined): number => Math.max(40, (columns ?? 100) - 2)
256
257function hardWrap(text: string, max: number): string[] {
258 const chars = [...text]
259 if (chars.length <= max) return [text]
260 const rows: string[] = []
261 for (let i = 0; i < chars.length; i += max) rows.push(chars.slice(i, i + max).join(''))
262 return rows
263}
264
265// A bar `width` cells long, filled to `ratio` in eighths of a cell.
266function smoothBar(ratio: number, width: number): { filled: string; empty: string } {
267 const eighths = Math.round(Math.max(0, Math.min(1, ratio)) * width * 8)
268 const full = Math.floor(eighths / 8)
269 const part = EIGHTHS[eighths % 8] ?? ''
270 const filled = '█'.repeat(full) + part
271
272 return { filled, empty: '▁'.repeat(Math.max(0, width - cells(filled))) }
273}
274
275// ---------------------------------------------------------------------------
276// Syntax highlighting: a small tokenizer good enough for one diff line of the
277// common languages; no grammar, no state across lines.
278
279const KEYWORDS = new Set((
280 'const let var function return if else for while do switch case break continue new class extends ' +
281 'import export from default async await try catch finally throw typeof instanceof in of this super ' +
282 'null undefined true false void yield interface type enum implements public private protected static ' +
283 'readonly as def elif pass lambda None True False self fn pub mut impl struct use mod match package ' +
284 'func go defer chan select where with not and or is'
285).split(' '))
286const HASH_COMMENTS = /\.(py|sh|bash|zsh|ps1|ya?ml|toml|rb|r|conf|ini|env|dockerfile)$/i
287
288function highlight(code: string, path: string): Seg[] {
289 const rules: Array<[RegExp, string | null]> = [
290 [HASH_COMMENTS.test(path) ? /#.*/y : /\/\/.*|\/\*.*?(?:\*\/|$)/y, SYN.comment],
291 [/"(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|`(?:[^`\\]|\\.)*`?/y, SYN.string],
292 [/\d[\w.]*/y, SYN.number],
293 [/[A-Za-z_$][\w$]*(?=\s*\()/y, SYN.fn],
294 [/[A-Za-z_$][\w$]*/y, null],
295 [/\s+/y, C.text],
296 [/[^\sA-Za-z_$\d"'`#/]+|[#/]/y, SYN.punct],
297 ]
298 const out: Seg[] = []
299 let i = 0
300 while (i < code.length) {
301 let matched = false
302 for (const [re, color] of rules) {
303 re.lastIndex = i
304 const m = re.exec(code)
305 if (m && m[0].length > 0) {
306 const text = m[0]
307 const c = color ?? (KEYWORDS.has(text) ? SYN.keyword : C.text)
308 const last = out.at(-1)
309 if (last && last.color === c) last.text += text
310 else out.push({ text, color: c })
311 i += text.length
312 matched = true
313 break
314 }
315 }
316 if (!matched) {
317 out.push({ text: code.charAt(i), color: C.text })
318 i += 1
319 }
320 }
321
322 return out
323}
324
325// ---------------------------------------------------------------------------
326
327function paint(Text: TextEl, bg: string | undefined, width: number, segs: Seg[]): RenderElement {
328 const used = 1 + segCells(segs)
329
330 return (
331 <Text backgroundColor={bg}>
332 {' '}
333 {segs.map(s => (
334 <Text color={s.color} bold={s.bold} backgroundColor={s.bg ?? bg}>{s.text}</Text>
335 ))}
336 {' '.repeat(Math.max(0, width - used))}
337 </Text>
338 )
339}
340
341const edgeTop = (Text: TextEl, bg: string, width: number): RenderElement => (
342 <Text color={bg}>{`▗${'▄'.repeat(Math.max(0, width - 2))}▖`}</Text>
343)
344
345const edgeBottom = (Text: TextEl, bg: string, width: number): RenderElement => (
346 <Text color={bg}>{`▝${'▀'.repeat(Math.max(0, width - 2))}▘`}</Text>
347)
348
349function card(Box: BoxEl, Text: TextEl, bg: string, width: number, rows: RenderElement[], marginTop = 1): RenderElement {
350 return (
351 <Box flexDirection="column" marginTop={marginTop}>
352 {edgeTop(Text, bg, width)}
353 {rows}
354 {edgeBottom(Text, bg, width)}
355 </Box>
356 )
357}
358
359function pill(Text: TextEl, bg: string, segs: Seg[]): RenderElement {
360 return (
361 <Text>
362 <Text color={bg}>▐</Text>
363 {segs.map(s => (
364 <Text backgroundColor={bg} color={s.color} bold={s.bold}>{s.text}</Text>
365 ))}
366 <Text color={bg}>▌</Text>
367 </Text>
368 )
369}
370
371type Status = { isRunning: boolean; isErrored: boolean; isInterrupted?: boolean }
372
373const statusOf = (s: Status): Seg =>
374 s.isInterrupted ? { text: '■', color: C.faint }
375 : s.isErrored ? { text: '✗', color: C.red }
376 : s.isRunning ? { text: '◌', color: C.accent }
377 : { text: '✓', color: C.green }
378
379function target(tool: string, input: Fields): Seg[] {
380 if (tool === 'Grep') {
381 const where = str(input.path) ? [{ text: ` trong ${fileName(str(input.path))}`, color: C.text }] : []
382 return [{ text: `"${str(input.pattern)}"`, color: C.yellow }, ...where]
383 }
384 if (RUN_TOOLS.has(tool)) return [{ text: str(input.command).split('\n')[0] ?? '', color: C.text }]
385 const path = str(input.file_path) || str(input.notebook_path) || str(input.path)
386 const text = path ? fileName(path) : str(input.pattern) || str(input.url) || str(input.query)
387
388 return [{ text, color: C.text }]
389}
390
391function outputSummary(tool: string, output: unknown): Seg[] {
392 const out = fields(output)
393 if (tool === 'Read') {
394 const count = num(fields(out.file).numLines)
395 return count === null ? [] : [{ text: `${count} dòng`, color: C.dim }]
396 }
397 if (tool === 'Grep' || tool === 'Glob') {
398 const count = num(out.numFiles) ?? (Array.isArray(out.filenames) ? out.filenames.length : null)
399 return count === null ? [] : [{ text: `${count} file`, color: C.dim }]
400 }
401 if (RUN_TOOLS.has(tool)) {
402 const all = `${str(out.stdout)}\n${str(out.stderr)}`
403 const failed = /(\d+) failed/.exec(all)
404 if (failed) return [{ text: `${failed[1]} failed`, color: C.red }]
405 const passed = /(\d+) passed/.exec(all)
406 if (passed) return [{ text: `${passed[1]} passed`, color: C.green }]
407 const count = lines(str(out.stdout)).filter(l => l.trim() !== '').length
408 return count === 0 ? [] : [{ text: `${count} dòng`, color: C.dim }]
409 }
410
411 return []
412}
413
414function summaryOf(tool: string, s: Status & { output?: unknown }): Seg[] {
415 if (s.isInterrupted) return [{ text: 'Interrupted', color: C.faint }]
416 if (s.isRunning) return [{ text: 'đang chạy', color: C.blue }]
417
418 return outputSummary(tool, s.output)
419}
420
421// The right side of a row: the summary, then how long the call took.
422function rowRight(tool: string, s: Status & { output?: unknown }, ms: number | null): Seg[] {
423 const summary = summaryOf(tool, s)
424 const took: Seg[] = !s.isRunning && ms !== null ? [{ text: seconds(ms), color: C.faint }] : []
425
426 return summary.length > 0 && took.length > 0 ? [...summary, { text: ' · ', color: C.faint }, ...took] : [...summary, ...took]
427}
428
429// The last non-empty line a failed call printed: stderr, else stdout; a
430// refusal or error text arrives as a plain string.
431function errorLine(output: unknown): string {
432 const last = (text: string): string =>
433 lines(text).map(l => l.trim()).filter(l => l !== '').at(-1) ?? ''
434 if (typeof output === 'string') return last(output)
435 const out = fields(output)
436
437 return last(str(out.stderr)) || last(str(out.stdout))
438}
439
440// ---------------------------------------------------------------------------
441// Tool frames: a rounded border in the colour of the calls' state, no fill. A
442// run of calls shares one frame; each row is `│` + content + `│`.
443
444const NAME_COL = 10
445// Cells before a row's target: space, status, two gaps, the name column, a gap.
446const TARGET_COL = 1 + 1 + 2 + NAME_COL + 2
447
448// One status for several calls: running if any runs, failed if any failed,
449// interrupted only when all were.
450const mergeStatus = (list: Status[]): Status => ({
451 isRunning: list.some(s => s.isRunning),
452 isErrored: list.some(s => s.isErrored),
453 isInterrupted: list.length > 0 && list.every(s => s.isInterrupted),
454})
455
456const frameColor = (s: Status): string =>
457 s.isRunning ? C.accent : s.isErrored ? C.red : s.isInterrupted ? C.faint : C.frame
458
459// What the top border says: the state mark and, for several calls, how many;
460// then the time the finished ones took. A lone call with no time known has none.
461function frameLabel(list: Status[], finished: number[]): Seg[] {
462 const mark = statusOf(mergeStatus(list))
463 const time: Seg[] = finished.length > 0 ? [{ text: seconds(finished.reduce((a, b) => a + b, 0)), color: C.faint }] : []
464 if (list.length > 1) {
465 return [mark, { text: ` ${list.length} lệnh`, color: C.soft }, ...(time.length > 0 ? [{ text: ' · ', color: C.faint }, ...time] : [])]
466 }
467
468 return time.length > 0 ? [mark, { text: ' ' }, ...time] : []
469}
470
471type Tail = { cells: number; node: RenderElement }
472
473function borderTop(Box: BoxEl, Text: TextEl, color: string, width: number, label: Seg[], tail?: Tail): RenderElement {
474 if (label.length === 0) return <Text color={color}>{`╭${'─'.repeat(Math.max(0, width - 2))}╮`}</Text>
475 const head: Seg[] = [{ text: '╭─ ', color }, ...label, { text: ' ' }]
476 const fill = '─'.repeat(Math.max(1, width - segCells(head) - (tail ? tail.cells + 3 : 0) - 1))
477 const lead = head.map(s => (
478 <Text color={s.color} bold={s.bold}>{s.text}</Text>
479 ))
480 if (!tail) {
481 return (
482 <Text>
483 {lead}
484 <Text color={color}>{`${fill}╮`}</Text>
485 </Text>
486 )
487 }
488
489 return (
490 <Box>
491 <Text>
492 {lead}
493 <Text color={color}>{`${fill} `}</Text>
494 </Text>
495 {tail.node}
496 <Text color={color}>{' ─╮'}</Text>
497 </Box>
498 )
499}
500
501const borderBottom = (Text: TextEl, color: string, width: number): RenderElement => (
502 <Text color={color}>{`╰${'─'.repeat(Math.max(0, width - 2))}╯`}</Text>
503)
504
505// A content row between the side borders; `parts` fill the width inside them.
506const framed = (Box: BoxEl, Text: TextEl, color: string, parts: RenderElement[]): RenderElement => (
507 <Box>
508 <Text color={color}>│</Text>
509 {parts}
510 <Text color={color}>│</Text>
511 </Box>
512)
513
514// Side bars of a tall block: a column of `│` drawn absolute, spanning the block
515// whatever its height, clipped to it. Taller than any result block there is.
516const BAR_ROWS = 400
517const BAR_COLUMN = Array.from({ length: BAR_ROWS }, () => '│').join('\n')
518
519// Another plugin's (or the engine's) tree between side bars of unknown height,
520// with the top border and the bottom border each drawn or left to the other
521// half of the frame: a call's header opens it, its result closes it.
522function enclosed(Box: BoxEl, Text: TextEl, color: string, width: number, tree: RenderElement, edges: { top: boolean; bottom: boolean }): RenderElement {
523 const bar = (side: 'left' | 'right'): RenderElement => (
524 <Box position="absolute" top={0} bottom={0} width={1} overflow="hidden" {...(side === 'left' ? { left: 0 } : { right: 0 })}>
525 <Text color={color}>{BAR_COLUMN}</Text>
526 </Box>
527 )
528
529 return (
530 <Box flexDirection="column" marginTop={edges.top ? 1 : 0}>
531 {edges.top ? borderTop(Box, Text, color, width, []) : null}
532 <Box flexDirection="column" width={width} paddingX={1} overflow="hidden">
533 {bar('left')}
534 {bar('right')}
535 {tree}
536 </Box>
537 {edges.bottom ? borderBottom(Text, color, width) : null}
538 </Box>
539 )
540}
541
542// One call: status, tool name, target, right-aligned summary. A running call
543// stands out by its frame colour and its blue name, never by a fill.
544function callRow(Text: TextEl, width: number, status: Status, tool: string, goal: Seg[], right: Seg[]): RenderElement {
545 const rightCells = segCells(right)
546 const room = width - TARGET_COL - 1
547 const shown = fitSegs(goal, Math.max(8, room - (rightCells > 0 ? rightCells + 2 : 0)))
548 const gap = Math.max(2, room - segCells(shown) - rightCells)
549
550 const name = fit(tool, NAME_COL)
551
552 return paint(Text, undefined, width, [
553 statusOf(status),
554 { text: ' ' },
555 { text: name, color: status.isRunning ? C.blue : C.dim },
556 { text: ' '.repeat(NAME_COL - cells(name) + 2) },
557 ...shown,
558 ...(rightCells > 0 ? [{ text: ' '.repeat(gap) }, ...right] : []),
559 ])
560}
561
562const noteRow = (Text: TextEl, width: number, segs: Seg[]): RenderElement =>
563 paint(Text, undefined, width, [{ text: ' '.repeat(TARGET_COL - 1) }, ...segs])
564
565function diffRow(Text: TextEl, width: number, mark: '+' | '−', code: string, path: string): RenderElement {
566 const lineBg = mark === '+' ? C.addBg : C.delBg
567 const codeWidth = width - TARGET_COL - 4
568 const segs = fitSegs(highlight(code, path), codeWidth).map(s => ({ ...s, bg: lineBg }))
569
570 return paint(Text, undefined, width, [
571 { text: ' '.repeat(TARGET_COL - 1) },
572 { text: ` ${mark} `, color: mark === '+' ? C.green : C.red, bg: lineBg },
573 ...segs,
574 { text: ' '.repeat(Math.max(0, codeWidth - segCells(segs))), bg: lineBg },
575 ])
576}
577
578function prettyModel(id: string): string {
579 const name = id.replace(/^claude-/, '').replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
580 const [family = name, ...version] = name.split('-')
581
582 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim()
583}
584
585async function refreshGit($: EngineInterface): Promise<void> {
586 const cwd = await $.session.cwd()
587 const run = (argv: string[]) => $.process.run(['git', ...argv], { cwd, timeoutMs: 5000 }).catch(() => null)
588 const [branch, head, status, numstat] = await Promise.all([
589 run(['rev-parse', '--abbrev-ref', 'HEAD']),
590 run(['rev-parse', '--short', 'HEAD']),
591 run(['status', '--porcelain']),
592 run(['diff', 'HEAD', '--numstat']),
593 ])
594 if (branch === null || branch.exitCode !== 0) {
595 await update($, git, () => null)
596 return
597 }
598
599 const counts = new Map<string, { added: number; removed: number }>()
600 for (const row of lines(numstat?.stdout.trim() ?? '')) {
601 const [added = '0', removed = '0', path = ''] = row.split('\t')
602 counts.set(path, { added: Number(added) || 0, removed: Number(removed) || 0 })
603 }
604 const listed = lines(status?.stdout.trimEnd() ?? '').map(row => {
605 const code = row.slice(0, 2)
606 const path = row.slice(3).split(' -> ').at(-1) ?? ''
607 const letter = code === '??' ? 'A' : (code.trim().charAt(0) || 'M')
608 return { status: letter, path, isTracked: code !== '??' }
609 })
610 // The first changed lines of each tracked file, for the pane's hover card.
611 const files: FileChange[] = await Promise.all(
612 listed.map(async (f, i) => {
613 let preview: string[] = []
614 if (f.isTracked && i < 8) {
615 const diff = await run(['diff', 'HEAD', '--unified=0', '--no-color', '--', f.path])
616 preview = lines(diff?.stdout ?? '')
617 .filter(l => (l.startsWith('+') || l.startsWith('-')) && !l.startsWith('+++') && !l.startsWith('---'))
618 .slice(0, 6)
619 }
620 return { status: f.status, path: f.path, preview, ...(counts.get(f.path) ?? { added: 0, removed: 0 }) }
621 }),
622 )
623
624 await update($, git, () => ({ branch: branch.stdout.trim(), head: head?.stdout.trim() ?? '', files }))
625}
626
627async function refreshSession($: EngineInterface): Promise<void> {
628 const [model, usage, now] = await Promise.all([$.session.model(), $.session.usage(), $.clock.now()])
629 const next: Band = { model: prettyModel(model), ctxPercent: usage.context.percent ?? null }
630 await update($, band, () => next)
631 await update($, footer, previous => ({
632 test: previous?.test ?? null,
633 minutes: Math.max(0, Math.round((now - usage.startedAt) / 60000)),
634 usd: usage.cost?.usd ?? null,
635 }))
636}
637
638// ---------------------------------------------------------------------------
639// /enhance: rewrite a rough prompt into a clear one, using the conversation so
640// far, and put it in the prompt box for the person to review before sending.
641
642const enhanceRequest = (draft: string): string =>
643 [
644 "Rewrite the user's draft prompt below into a clear, specific prompt for the coding agent in this session.",
645 'Use the conversation so far to resolve vague references (files, functions, errors, earlier decisions).',
646 "Keep the draft's language: if it is Vietnamese, write Vietnamese.",
647 'Keep the intent; do not add requirements the user did not ask for or imply.',
648 'Shape: one line stating the goal, then the relevant context, then numbered steps or acceptance criteria only when they help.',
649 'Output ONLY the rewritten prompt: no preamble, no quotes, no code fences.',
650 '',
651 'Draft:',
652 '<<<',
653 draft,
654 '>>>',
655 ].join('\n')
656
657// The band's ✨ button presses on this engine action's chord, so binding a key
658// to it in ~/.claude/keybindings.json (ctrl+shift+l) enhances the prompt box.
659// The action itself only acts while the diff panel is open.
660const ENHANCE_ACTION = 'app:toggleDiffPreSession'
661
662// Rewrites the draft and puts it in the prompt box; resolves to the rewrite
663// when the box could not take it, so a command can show it instead.
664async function enhanceIntoPrompt($: EngineInterface, draft: string): Promise<string | null> {
665 $.ui.status('✨ đang viết lại câu lệnh…')
666 await update($, enhanced, () => ({ busy: true, text: null }))
667 try {
668 const better = await enhance($, draft)
669 if (better === null || better === '') {
670 $.ui.toast('Không viết lại được lúc này; câu gốc đã được đặt lại vào ô nhập.')
671 await $.prompt.fill({ text: draft, mode: 'replace' })
672 return null
673 }
674 const filled = await $.prompt.fill({ text: better, mode: 'replace' })
675 if (!filled.isFilled) {
676 await update($, enhanced, en => ({ ...en, text: better }))
677 return better
678 }
679 $.ui.toast('✨ Đã viết lại — xem trong ô nhập, Enter để gửi.')
680
681 return null
682 } finally {
683 $.ui.status(undefined)
684 await update($, enhanced, en => ({ ...en, busy: false }))
685 }
686}
687
688async function enhancePromptBox($: EngineInterface): Promise<void> {
689 const { text } = await $.prompt.read()
690 if (text.trim() === '') {
691 $.ui.toast('Gõ câu lệnh vào ô nhập trước, hoặc dùng /enhance <câu lệnh>.')
692 return
693 }
694 await enhanceIntoPrompt($, text.trim())
695}
696
697// Drops a wrapping code fence or quotes a model may add despite the request.
698const unwrap = (text: string): string =>
699 text
700 .trim()
701 .replace(/^```[\w-]*\n([\s\S]*?)\n```$/, '$1')
702 .replace(/^"([\s\S]*)"$/, '$1')
703 .trim()
704
705async function enhance($: EngineInterface, draft: string): Promise<string | null> {
706 const request = enhanceRequest(draft)
707 const forked = await $.model.fork({ prompt: request })
708 if (forked.isAnswered) return unwrap(forked.text)
709 if (forked.reason !== 'nothing-to-fork') return null
710 // A new session has nothing to fork: ask the session's model without context.
711 const completed = await $.model.complete({ model: await $.session.model(), prompt: request })
712
713 return completed.isAnswered ? unwrap(completed.text) : null
714}
715
716// ---------------------------------------------------------------------------
717// Vietnamese spinner words. The engine samples one English word per turn; the
718// mod maps it through a hash, so a turn keeps one phrase for its whole run.
719
720const WORKING = [
721 'Đang pha cà phê', 'Đang gõ phím lạch cạch', 'Đang hỏi Stack Overflow', 'Đang đọc tài liệu',
722 'Đang nấu mì tôm', 'Đang lục lọi code', 'Đang xếp hàng mua trà sữa', 'Đang gỡ rối dây điện',
723 'Đang soi bug', 'Đang đếm dấu chấm phẩy', 'Đang tra Google', 'Đang rửa bát code',
724 'Đang uống trà đá', 'Đang nhắn hỏi đồng nghiệp', 'Đang chạy deadline', 'Đang vẽ sơ đồ',
725]
726const THINKING = ['Đang suy nghĩ', 'Đang vắt óc', 'Đang ngẫm nghĩ', 'Đang cân não', 'Đang thiền', 'Đang nghĩ kế']
727const DONE = [
728 'Pha xong cà phê', 'Nấu xong mì', 'Gỡ xong rối', 'Vắt óc xong', 'Uống xong trà đá', 'Soi xong bug',
729 'Chạy kịp deadline', 'Xong việc',
730]
731
732const hash = (text: string): number => [...text].reduce((h, ch) => (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0, 7)
733
734const pick = (pool: readonly string[], seed: string): string => pool[hash(seed) % pool.length] ?? pool[0] ?? ''
735
736// As the engine writes a turn's length: `3s`, `1m 4s`, `1h 2m`.
737function duration(ms: number): string {
738 const total = Math.max(0, Math.round(ms / 1000))
739 const h = Math.floor(total / 3600)
740 const m = Math.floor((total % 3600) / 60)
741 const s = total % 60
742 if (h > 0) return `${h}h ${m}m`
743 if (m > 0) return `${m}m ${s}s`
744
745 return `${s}s`
746}
747
748function bubbleFor(tool: string, input: Fields, agentType?: string): string {
749 const goal = target(tool, input).map(seg => seg.text).join('')
750 const what =
751 AGENT_TOOLS.has(tool) ? `Đang giao việc cho ${str(input.subagent_type) || 'agent'}`
752 : kindOf(tool) === 'read' ? `Đang đọc ${goal}`
753 : kindOf(tool) === 'search' ? `Đang tìm ${goal}`
754 : kindOf(tool) === 'edit' ? `Đang sửa ${goal}`
755 : kindOf(tool) === 'run' ? `Đang chạy ${goal}`
756 : `Đang dùng ${tool}`
757
758 return fit(agentType ? `${agentType}: ${what.charAt(0).toLowerCase()}${what.slice(1)}` : what, 60)
759}
760
761const statusColor = (letter: string): string =>
762 letter === 'A' ? C.green : letter === 'D' ? C.red : letter === 'U' ? C.yellow : C.blue
763
764const section = (Text: TextEl, title: string, width: number, note = ''): RenderElement => (
765 <Text color={C.dim}>
766 {title} <Text color={C.rule}>{'─'.repeat(Math.max(3, width - cells(title) - cells(note) - 2))}</Text>
767 {note === '' ? '' : ` ${note}`}
768 </Text>
769)
770
771// Redraws once a second while a subagent runs, so its elapsed time moves.
772// A reload starts the module over, and a later spawn starts the timer again.
773let ticker: Timer | null = null
774
775function startTicker($: EngineInterface): void {
776 if (ticker) return
777 ticker = $.clock.every(1000, () => {
778 void read($, agents).then(all => {
779 if (Object.values(all).some(isRunning)) {
780 $.ui.invalidate('ui.render')
781 } else {
782 ticker?.cancel()
783 ticker = null
784 }
785 })
786 })
787}
788
789export const register: Register = on => {
790 let turnStartedAt = 0
791 let lastActivityAt = 0
792
793 on('session.start', async ($, e, next) => {
794 // The chosen theme is kept across sessions; an unknown one falls back.
795 const saved = await $.store.get(THEME_KEY)
796 if (typeof saved === 'string' && applyTheme(saved)) $.ui.invalidate('ui.render')
797 await $.command.register({
798 name: 'acp-theme',
799 description: 'Đổi bộ màu của acp-ui; gõ không kèm tên để xem danh sách',
800 argumentHint: `<${Object.keys(THEMES).join('|')}>`,
801 })
802 const [sceneSetting, best] = await Promise.all([$.store.get(SCENE_KEY), $.store.get(BEST_KEY)])
803 await update($, scene, sc => ({ ...sc, enabled: sceneSetting !== 'off', best: typeof best === 'number' ? best : sc.best }))
804 await $.command.register({ name: 'play', description: 'Bật hoặc tắt trò Né bug trên dải phía trên ô nhập' })
805 await $.command.register({ name: 'acp-scene', description: 'Bật hoặc tắt cảnh chú mèo khi Claude đang làm việc' })
806 await $.command.register({ name: 'dash', description: 'Mở hoặc đóng pane tổng quan phiên làm việc' })
807 await $.command.register({
808 name: 'enhance',
809 description: 'Viết lại câu lệnh cho rõ ràng theo ngữ cảnh phiên, đặt vào ô nhập để xem trước khi gửi',
810 argumentHint: '<câu lệnh>',
811 })
812 await Promise.all([refreshGit($), refreshSession($)])
813
814 return next(e)
815 })
816
817 // /dash toggles the pane: a second run closes it.
818 on('command.run', { command: 'dash' }, async $ => {
819 if ((await $.ui.panes()).some(pane => pane.id === DASH)) {
820 await $.ui.close({ id: DASH })
821 return { text: 'Đã đóng pane /dash.' }
822 }
823 await $.ui.open({ id: DASH, title: 'Phiên làm việc' })
824
825 return { text: 'Đã mở pane /dash. Gõ /dash lần nữa để đóng.' }
826 })
827
828 on('command.run', { command: 'play' }, async $ => {
829 const now = (await read($, scene)).playing
830 await update($, scene, sc => ({ ...sc, playing: !now, enabled: true }))
831 return now
832 ? { text: 'Đã tắt Né bug.' }
833 : { text: 'Bật Né bug! Bấm Ctrl+X rồi Tab để chọn dải phía trên ô nhập, rồi bấm j để nhảy (toàn màn hình: bấm vào cảnh rồi Space). q để thoát.' }
834 })
835
836 on('command.run', { command: 'acp-scene' }, async $ => {
837 const isOn = !(await read($, scene)).enabled
838 await update($, scene, sc => ({ ...sc, enabled: isOn, playing: isOn ? sc.playing : false }))
839 await $.store.set(SCENE_KEY, isOn ? 'on' : 'off')
840 return { text: isOn ? 'Đã bật cảnh chú mèo.' : 'Đã tắt cảnh chú mèo.' }
841 })
842
843 // The scene posts the score as it changes; the best one is kept.
844 on('ui.message', async ($, e, next) => {
845 const data = fields(e.data)
846 const score = num(data.score)
847 if (e.module.endsWith('scene.tsx') && score !== null) {
848 const best = Math.max(num(data.best) ?? 0, (await read($, scene)).best)
849 await update($, scene, sc => ({ ...sc, score, best }))
850 if (data.over === true) await $.store.set(BEST_KEY, best)
851 }
852
853 return next(e)
854 })
855
856 // /acp-theme lists the themes; /acp-theme <name> switches, redraws and saves.
857 on('command.run', { command: 'acp-theme' }, async ($, e) => {
858 const name = e.args.trim().toLowerCase()
859 const current = (await $.store.get(THEME_KEY)) ?? DEFAULT_THEME
860 if (name === '') {
861 const rows = Object.entries(THEMES).map(([key, t]) => `- \`${key}\`: ${t.label}${key === current ? ' ← đang dùng' : ''}`)
862 return { text: ['Bộ màu của acp-ui (gõ `/acp-theme <tên>` để đổi):', ...rows].join('\n') }
863 }
864 if (!applyTheme(name)) {
865 return { text: `Không có bộ màu \`${name}\`. Có: ${Object.keys(THEMES).map(k => `\`${k}\``).join(', ')}.` }
866 }
867 await $.store.set(THEME_KEY, name)
868 $.ui.invalidate('ui.render')
869
870 return { text: `Đã đổi bộ màu sang ${(THEMES[name] as Theme).label}.` }
871 })
872
873 // The rewrite lands in the prompt box, not the transcript, so the model reads
874 // nothing until the person sends it.
875 on('command.run', { command: 'enhance' }, async ($, e) => {
876 const draft = e.args.trim()
877 if (draft === '') {
878 $.ui.toast('Gõ /enhance <câu lệnh> để viết lại câu lệnh đó.')
879 return {}
880 }
881 const unfilled = await enhanceIntoPrompt($, draft)
882
883 return unfilled === null ? {} : { text: unfilled }
884 })
885
886 on('prompt.submit', async ($, e, next) => {
887 await update($, scene, sc => ({ ...sc, bubble: 'Đang đọc yêu cầu…', turnTools: 0 }))
888 await update($, toolRuns, endRun)
889 turnStartedAt = await $.clock.now()
890 lastActivityAt = turnStartedAt
891
892 return next(e)
893 })
894
895 on('tool.call', async ($, e, next) => {
896 const tool = String(e.tool)
897 const id = e.tool_use_id
898 const startedAt = await $.clock.now()
899 const minute = String(Math.floor(startedAt / 60000))
900 const gap = lastActivityAt > 0 ? startedAt - lastActivityAt : 0
901
902 await update($, stats, s => ({ total: s.total + 1, tools: { ...s.tools, [tool]: (s.tools[tool] ?? 0) + 1 } }))
903 await update($, beats, list => [...list, ...(gap > THINK_GAP_MS ? (['think'] as Beat[]) : []), kindOf(tool)].slice(-80))
904 await update($, activity, counts => {
905 const oldest = Number(minute) - 60
906 const kept = Object.fromEntries(Object.entries(counts).filter(([m]) => Number(m) >= oldest))
907 return { ...kept, [minute]: (kept[minute] ?? 0) + 1 }
908 })
909 if (id) {
910 await update($, timings, all => {
911 const entries = Object.entries({ ...all, [id]: { startedAt, ms: null } as Timing })
912 return Object.fromEntries(entries.sort((a, b) => a[1].startedAt - b[1].startedAt).slice(-200))
913 })
914 }
915
916 // A subagent's call is also a step on its card.
917 const agentId = e.agentId
918 // Consecutive drawn calls of the main loop share a frame; any other call
919 // (an Agent card, another plugin's tool) breaks the run around it.
920 const isDrawn = DRAWN_TOOLS.has(tool)
921 if (!agentId) {
922 if (isDrawn && id) await update($, toolRuns, runs => joinRun(runs, id))
923 else if (!isDrawn) await update($, toolRuns, endRun)
924 }
925 const agentType = agentId ? (await read($, agents))[agentId]?.type : undefined
926 await update($, scene, sc => ({
927 ...sc,
928 bubble: bubbleFor(tool, fields(e), agentType),
929 turnTools: agentId ? sc.turnTools : sc.turnTools + 1,
930 }))
931 const goal = target(tool, fields(e)).map(seg => seg.text).join('')
932 if (agentId && id) {
933 const step: AgentStep = { id, tool, target: goal, isDone: false, isErrored: false }
934 await update($, agents, all => {
935 const run = all[agentId]
936 return run ? { ...all, [agentId]: { ...run, tools: run.tools + 1, steps: [...run.steps, step].slice(-40) } } : all
937 })
938 }
939
940 const ran = await next(e)
941 const endedAt = await $.clock.now()
942 if (!agentId) lastActivityAt = endedAt
943 if (!agentId && !isDrawn) await update($, toolRuns, endRun)
944 const isErrored = ran.deny !== undefined || ran.isError === true
945 if (agentId && id) {
946 const path = str(fields(e).file_path) || str(fields(e).notebook_path)
947 await update($, agents, all => {
948 const run = all[agentId]
949 if (!run) return all
950 const steps = run.steps.map(st => (st.id === id ? { ...st, isDone: true, isErrored } : st))
951 const filesRead = tool === 'Read' && path && !run.filesRead.includes(path) ? [...run.filesRead, path] : run.filesRead
952 const filesEdited = EDIT_TOOLS.has(tool) && path && !run.filesEdited.includes(path) ? [...run.filesEdited, path] : run.filesEdited
953 return { ...all, [agentId]: { ...run, steps, filesRead, filesEdited } }
954 })
955 }
956 if (id) await update($, timings, all => ({ ...all, [id]: { startedAt, ms: endedAt - startedAt, isErrored } }))
957
958 if (RUN_TOOLS.has(tool)) {
959 const command = str(fields(e).command)
960 if (TEST_COMMAND.test(command) && ran.deny === undefined) {
961 const test: Footer['test'] = ran.isError === true ? 'fail' : 'pass'
962 await update($, footer, previous => ({ minutes: 0, usd: null, ...previous, test }))
963 }
964 }
965 if (EDIT_TOOLS.has(tool) || RUN_TOOLS.has(tool)) await refreshGit($)
966
967 return ran
968 })
969
970 // Text the model writes between tool calls ends the run before it: the calls
971 // after it draw in a new frame. A subagent's steps are not the main loop's.
972 on('turn.step', async function* ($, e, next) {
973 const result = yield* next(e)
974 if (!e.agentId && result.answer.trim() !== '') await update($, toolRuns, endRun)
975
976 return result
977 })
978
979 // A subagent started by the Agent tool: tracked from spawn to its last turn.
980 on('agent.spawn', async ($, e, next) => {
981 const started = await next(e)
982 const agentId = started.agentId
983 if (agentId) {
984 const run: AgentRun = {
985 agentId,
986 toolUseId: e.tool_use_id,
987 type: e.subagentType,
988 description: e.description,
989 startedAt: await $.clock.now(),
990 endedAt: null,
991 tools: 0,
992 steps: [],
993 filesRead: [],
994 filesEdited: [],
995 answer: '',
996 }
997 await update($, agents, all => {
998 const kept = Object.values(all).sort((a, b) => a.startedAt - b.startedAt).slice(-30)
999 return { ...Object.fromEntries(kept.map(r => [r.agentId, r])), [agentId]: run }
1000 })
1001 await update($, agentByToolUse, all => ({ ...all, [e.tool_use_id]: agentId }))
1002 startTicker($)
1003 }
1004
1005 return started
1006 })
1007
1008 on('turn.complete', async ($, e, next) => {
1009 // A subagent's last turn closes its card; the main turn's work below is not its.
1010 const agentId = e.agentId
1011 if (agentId) {
1012 const endedAt = await $.clock.now()
1013 const run = (await read($, agents))[agentId]
1014 if (run) {
1015 await update($, agents, all => ({ ...all, [agentId]: { ...run, endedAt, answer: e.answer } }))
1016 const summary = firstLine(e.answer)
1017 $.ui.toast(`✓ ${run.type} xong (${duration(endedAt - run.startedAt)})${summary ? `: ${fit(summary, 70)}` : ''}`, { timeoutMs: 6000 })
1018 }
1019 return next(e)
1020 }
1021
1022 await Promise.all([refreshGit($), refreshSession($)])
1023 const played = await read($, scene)
1024 await update($, scene, sc => ({ ...sc, bubble: '✓ Xong!', playing: false }))
1025 if (played.playing) {
1026 await $.store.set(BEST_KEY, played.best)
1027 $.ui.toast(`Né bug: ${played.score} điểm (kỷ lục ${played.best})`, { timeoutMs: 6000 })
1028 }
1029 const took = Math.round(((await $.clock.now()) - turnStartedAt) / 1000)
1030 if (turnStartedAt > 0 && took >= 15) {
1031 const changed = (await read($, git))?.files.length ?? 0
1032 const label = took >= 60 ? `${Math.round(took / 60)}m` : `${took}s`
1033 $.ui.toast(`✓ Claude đã xong việc · ${changed} file đổi · ${label}`, { timeoutMs: 6000 })
1034 }
1035
1036 return next(e)
1037 })
1038
1039 // The person's prompt: a rounded box in its own border colour, as wide as the
1040 // tool frames, with no fill.
1041 on('ui.render', { component: 'UserMessage', surface: 'terminal' }, ($, e, next) => {
1042 if (e.props.origin.kind !== 'composer') return next(e)
1043 const { Box, Text } = $.ui.resolve(e)
1044 const width = frameWidth(e.viewport?.columns)
1045 const rows = lines(e.props.text).flatMap(row => hardWrap(row, width - 7))
1046
1047 return (
1048 <Box flexDirection="column" marginTop={1}>
1049 {borderTop(Box, Text, C.prompt, width, [])}
1050 {rows.map((row, i) => framed(Box, Text, C.prompt, [
1051 paint(Text, undefined, width - 2, [{ text: ` ${i === 0 ? '› ' : ' '}`, color: C.accent }, { text: row, color: C.text }]),
1052 ]))}
1053 {borderBottom(Text, C.prompt, width)}
1054 </Box>
1055 )
1056 })
1057
1058 // Assistant prose: plain markdown, no card and no bullet.
1059 on('ui.render', { component: 'AssistantMessage', surface: 'terminal' }, ($, e) => {
1060 const { Box, Markdown } = $.ui.resolve(e)
1061
1062 return (
1063 <Box marginTop={e.props.isFirstOfReply ? 1 : 0}>
1064 <Markdown text={e.props.text} />
1065 </Box>
1066 )
1067 })
1068
1069 // A folded run of reads and searches: one frame, a row per call. A group
1070 // holding a tool this frame cannot draw faithfully (another plugin's, an agent
1071 // call) goes on down the chain, where that plugin or the engine draws it.
1072 on('ui.render', { component: 'ToolGroup', surface: 'terminal' }, async ($, e, next) => {
1073 for (const call of e.props.calls) {
1074 if (!call.tool_use_id) continue
1075 if (e.props.isExpanded) inlineRows.add(call.tool_use_id)
1076 else inlineRows.delete(call.tool_use_id)
1077 }
1078 if (e.props.isExpanded) return next(e)
1079 const { Box, Text } = $.ui.resolve(e)
1080 if (e.props.calls.some(call => !DRAWN_TOOLS.has(call.tool))) {
1081 // The engine's folded row for the group, inside one frame of the group's state.
1082 const state = mergeStatus(e.props.calls)
1083
1084 return enclosed(Box, Text, frameColor(state), frameWidth(e.viewport?.columns), await next(e), { top: true, bottom: true })
1085 }
1086 const width = frameWidth(e.viewport?.columns)
1087 const inner = width - 2
1088 const calls = e.props.calls
1089 const timed = await read($, timings)
1090 const msOf = (id?: string): number | null => (id ? (timed[id]?.ms ?? null) : null)
1091 const status = mergeStatus(calls)
1092 const color = frameColor(status)
1093 const finished = calls.flatMap(call => msOf(call.tool_use_id) ?? [])
1094
1095 return (
1096 <Box flexDirection="column" marginTop={1}>
1097 {borderTop(Box, Text, color, width, frameLabel(calls, finished))}
1098 {calls.map(call =>
1099 framed(Box, Text, color, [
1100 callRow(Text, inner, call, call.tool, target(call.tool, fields(call.input)), rowRight(call.tool, call, msOf(call.tool_use_id))),
1101 ]),
1102 )}
1103 {borderBottom(Text, color, width)}
1104 </Box>
1105 )
1106 })
1107
1108 // An Agent call as a live card: what the subagent is doing, then a summary.
1109 // Its status comes from the agent's own run: a background agent keeps
1110 // working after the Agent tool call itself returned.
1111 on('ui.render', { component: 'ToolUse', surface: 'terminal' }, async ($, e, next) => {
1112 if (!AGENT_TOOLS.has(e.props.tool)) return next(e)
1113 const { Box, Text } = $.ui.resolve(e)
1114 const width = frameWidth(e.viewport?.columns)
1115 const inner = width - 2
1116 const input = fields(e.props.input)
1117 const [byTool, runs, now] = await Promise.all([read($, agentByToolUse), read($, agents), $.clock.now()])
1118 const runId = byTool[e.props.tool_use_id]
1119 const run = runId ? runs[runId] : undefined
1120 const live = run ? isRunning(run) : e.props.isRunning
1121 const state: Status = { isRunning: live, isErrored: e.props.isErrored, isInterrupted: e.props.isInterrupted }
1122 const color = frameColor(state)
1123 const type = run?.type ?? (str(input.subagent_type) || 'agent')
1124 const description = run?.description || str(input.description) || firstLine(str(input.prompt))
1125 const right: Seg[] = run
1126 ? [{ text: `${run.tools} tool · ${duration((run.endedAt ?? now) - run.startedAt)}`, color: live ? C.blue : C.dim }]
1127 : []
1128 const rightCells = segCells(right)
1129 const fixed = 1 + 2 + 2 + cells(type) + 2 + (rightCells > 0 ? rightCells + 2 : 0) + 1
1130 const shown = fitSegs([{ text: description, color: C.text }], Math.max(8, inner - fixed))
1131 const header = paint(Text, undefined, inner, [
1132 statusOf(state),
1133 { text: ' ' },
1134 { text: '◆', color: C.violet },
1135 { text: ' ' },
1136 { text: type, color: C.dim },
1137 { text: ' ' },
1138 ...shown,
1139 ...(rightCells > 0 ? [{ text: ' '.repeat(Math.max(2, inner - fixed - segCells(shown) + 2)) }, ...right] : []),
1140 ])
1141 const stepRow = (st: AgentStep): RenderElement => {
1142 const mark: Seg = !st.isDone ? { text: '◌', color: C.accent } : st.isErrored ? { text: '✗', color: C.red } : { text: ' ' }
1143 return paint(Text, undefined, inner, [
1144 { text: ' ' },
1145 iconOf(kindOf(st.tool)),
1146 { text: ' ' },
1147 { text: fit(st.tool, 6).padEnd(7), color: C.dim },
1148 { text: fit(st.target, inner - 18), color: st.isDone ? C.soft : C.text },
1149 { text: ' ' },
1150 mark,
1151 ])
1152 }
1153 const body: RenderElement[] = [header]
1154 if (run && live) body.push(...run.steps.slice(-3).map(stepRow))
1155 if (run && !live) {
1156 const parts = [
1157 run.filesRead.length > 0 ? `đọc ${run.filesRead.length} file` : '',
1158 run.filesEdited.length > 0 ? `sửa ${run.filesEdited.length} file` : '',
1159 ].filter(Boolean)
1160 const answer = firstLine(run.answer)
1161 if (parts.length > 0) body.push(paint(Text, undefined, inner, [{ text: ` ${parts.join(' · ')}`, color: C.dim }]))
1162 if (answer) body.push(paint(Text, undefined, inner, [{ text: ' ↳ ', color: C.faint }, { text: fit(answer, inner - 7), color: C.soft }]))
1163 }
1164
1165 return (
1166 <Box flexDirection="column" marginTop={1}>
1167 {borderTop(Box, Text, color, width, [])}
1168 {body.map(row => framed(Box, Text, color, [row]))}
1169 {borderBottom(Text, color, width)}
1170 </Box>
1171 )
1172 })
1173
1174 // One tool call as a row of a frame. Fullscreen rows redraw as a run grows, so
1175 // the calls of one run join: the first row draws the top border, the last the
1176 // bottom one. The main screen cannot redraw a row once printed, so each call
1177 // there is a whole frame. Edits carry their changed lines, highlighted; in
1178 // fullscreen a long diff folds behind a toggle, a finished run of several
1179 // calls folds into its border.
1180 on('ui.render', { component: 'ToolUse', surface: 'terminal' }, async ($, e, next) => {
1181 const tool = e.props.tool
1182 const width = frameWidth(e.viewport?.columns)
1183 if (!DRAWN_TOOLS.has(tool)) {
1184 // Any tool the mod does not draw itself: the engine's or another plugin's
1185 // row, inside a frame. An MCP tool's result block closes it; a built-in
1186 // one has no result block, and neither has a call still running or one
1187 // drawn with its result inline, so their frame closes here.
1188 const { Box, Text } = $.ui.resolve(e)
1189 const isOpen = isForeign(tool) && !e.props.isRunning && e.props.output !== undefined && !inlineRows.has(e.props.tool_use_id)
1190 const status: Status = { isRunning: e.props.isRunning, isErrored: e.props.isErrored, isInterrupted: e.props.isInterrupted }
1191
1192 return enclosed(Box, Text, frameColor(status), width, await next(e), { top: true, bottom: !isOpen })
1193 }
1194 const { Box, Text, Button } = $.ui.resolve(e)
1195 const id = e.props.tool_use_id
1196 const isFullscreen = e.viewport?.isFullscreen === true
1197 const inner = width - 2
1198 const [runs, timed, folds] = await Promise.all([read($, toolRuns), read($, timings), read($, expanded)])
1199 const input = fields(e.props.input)
1200 const path = str(input.file_path) || str(input.notebook_path)hooks/scene.tsx 323 lines1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2
3// The band's pixel scene while Claude works, and the "Né bug" game.
4// Drawn on a grid of half-block cells: each text row holds two pixel rows,
5// the top one as the glyph's color and the bottom one as its background.
6// Everything moves every frame: the cat walks toward the turn's progress and
7// paces there, bobbing, blinking and wagging; the ground scrolls under it,
8// stars twinkle and drift, and the bubble types its words out.
9
10export type SceneProps = {
11 mode: 'scene' | 'game'
12 bubble: string
13 progress: number
14 jumpSeq: number
15 width: number
16 colors: {
17 cat: string; eye: string; nose: string; track: string; trackDone: string
18 star: string; bug: string; house: string; roof: string; bubbleBg: string; bubbleFg: string; dim: string
19 }
20}
21
22type Bug = { x: number }
23type SceneState = {
24 // The latest props and width, read by the frame timer, which outlives a call.
25 live: { props: SceneProps; width: number }
26 frame: number
27 // The cat's position in pixels, where it heads, and which way it faces.
28 catX: number
29 facing: 1 | -1
30 // Stars as [x, row]; they drift left a pixel every few frames.
31 stars: Array<[number, number]>
32 // The bubble's text and how much of it is typed out so far.
33 said: string
34 typed: number
35 lastJumpSeq: number
36 jumpT: number
37 bugs: Bug[]
38 spawnIn: number
39 score: number
40 best: number
41 over: boolean
42}
43
44const FRAME_MS = 70
45const JUMP_FRAMES = 10
46const JUMP_HEIGHT = 4
47const CAT_X_GAME = 4
48
49// Pixel rows of the grid, an even number so text rows hold two each. Scene: the
50// ground, the cat and its bob. Game: the ground, the cat and its highest jump.
51// The Client's height in register.tsx is one bubble row plus half of this.
52const PX_ROWS = { scene: 10, game: 14 } as const
53// The ground fills one whole text row; everything else stands on top of it.
54const GROUND_ROWS = 2
55const CAT_W = 11
56const PACE = 5 // how far the cat paces either side of where it stands
57const WALK_PX = 0.5 // pixels per frame
58const TYPE_CHARS = 2 // bubble characters per frame
59
60// 11 × 7 pixels facing right; '#' fur, 'o' eye, 'p' nose, 't' tail.
61const CAT_TOP = [
62 '..#......#.',
63 '..##....##.',
64 '..########.',
65 't.#o####o#.',
66 't.###pp###.',
67]
68const TAIL_UP = [
69 't.#......#.',
70 't.##....##.',
71 '..########.',
72 '..#o####o#.',
73 '..###pp###.',
74]
75const CAT_LEGS = [
76 ['...######..', '...#.##.#..'],
77 ['...######..', '..#..##..#.'],
78]
79const BUG_FRAMES = [
80 ['#.#', '###'],
81 ['.#.', '###'],
82]
83const HOUSE = ['..#..', '.###.', '#####', '#.#.#', '#.#.#']
84
85type Px = (string | null)[][]
86
87const blank = (w: number, h: number): Px => Array.from({ length: h }, () => Array<string | null>(w).fill(null))
88
89const mirror = (sprite: readonly string[]): string[] => sprite.map(row => [...row].reverse().join(''))
90
91function stamp(px: Px, sprite: readonly string[], x: number, y: number, colors: Record<string, string>): void {
92 sprite.forEach((row, dy) => {
93 ;[...row].forEach((ch, dx) => {
94 const color = colors[ch]
95 const yy = y + dy
96 const xx = x + dx
97 if (color && yy >= 0 && yy < px.length && xx >= 0 && xx < (px[0]?.length ?? 0)) {
98 const line = px[yy]
99 if (line) line[xx] = color
100 }
101 })
102 })
103}
104
105// Packs two pixel rows per text row, joining runs of one look into one Text.
106function rows(px: Px, Text: ClientSurface['elements']['Text']): RenderElement[] {
107 const out: RenderElement[] = []
108 for (let r = 0; r < px.length; r += 2) {
109 const top = px[r] ?? []
110 const bottom = px[r + 1] ?? []
111 const runs: Array<{ glyph: string; fg?: string; bg?: string; n: number }> = []
112 for (let x = 0; x < top.length; x += 1) {
113 const t = top[x] ?? null
114 const b = bottom[x] ?? null
115 const cell = t && b ? { glyph: '▀', fg: t, bg: b }
116 : t ? { glyph: '▀', fg: t }
117 : b ? { glyph: '▄', fg: b }
118 : { glyph: ' ' }
119 const last = runs.at(-1)
120 if (last && last.glyph === cell.glyph && last.fg === cell.fg && last.bg === cell.bg) last.n += 1
121 else runs.push({ ...cell, n: 1 })
122 }
123 out.push(
124 <Text>
125 {runs.map(run => (
126 <Text color={run.fg} backgroundColor={run.bg}>{run.glyph.repeat(run.n)}</Text>
127 ))}
128 </Text>,
129 )
130 }
131
132 return out
133}
134
135// The cat for this frame: legs alternate while it walks, the tail wags, the
136// eyes close for two frames every few seconds, and it faces where it goes.
137function catSprite(frame: number, facing: 1 | -1, isWalking: boolean): string[] {
138 const top = (frame % 16 < 8 ? CAT_TOP : TAIL_UP).map(row =>
139 frame % 48 < 2 ? row.replace(/o/g, '#') : row,
140 )
141 const legs = CAT_LEGS[isWalking ? Math.floor(frame / 3) % 2 : 0] ?? CAT_LEGS[0] ?? []
142 const sprite = [...top, ...legs]
143
144 return facing === 1 ? sprite : mirror(sprite)
145}
146
147const homeX = (props: SceneProps, width: number): number =>
148 Math.max(0, Math.min(width - CAT_W - 8, Math.round(Math.max(0, Math.min(1, props.progress)) * (width - CAT_W - 8))))
149
150const fresh = (props: SceneProps, width: number, best: number): SceneState => ({
151 live: { props, width },
152 frame: 0,
153 catX: homeX(props, width),
154 facing: 1,
155 stars: Array.from({ length: Math.max(4, Math.floor(width / 10)) }, (_, i) => [(i * 37 + 11) % Math.max(1, width), i % 3] as [number, number]),
156 said: props.bubble,
157 typed: 0,
158 lastJumpSeq: props.jumpSeq,
159 jumpT: 0,
160 bugs: [],
161 spawnIn: 12,
162 score: 0,
163 best,
164 over: false,
165})
166
167const Scene: ClientModule<SceneProps, SceneState> = (props, surface) => {
168 const { Box, Text } = surface.elements
169 const width = Math.max(30, props.width)
170 const state = surface.state ?? fresh(props, width, 0)
171 state.live.props = props
172 state.live.width = width
173
174 if (surface.state === undefined) {
175 surface.setState(state)
176 surface.every(FRAME_MS, () => {
177 const s = surface.state
178 if (!s) return
179 surface.setState(step(s, surface))
180 })
181 surface.onKey(event => {
182 const s = surface.state
183 if (!s) return
184 if (event.key === ' ' || event.key === 'up' || event.key === 'w') surface.setState(jump(s))
185 })
186 }
187
188 // A jump pressed from the band's hotkey arrives as a new jumpSeq.
189 if (props.jumpSeq !== state.lastJumpSeq) {
190 surface.setState({ ...jump(state), lastJumpSeq: props.jumpSeq })
191 }
192
193 const c = props.colors
194 const height = PX_ROWS[props.mode]
195 // The pixel row of the ground's top edge, which sprites rest just above.
196 const ground = height - GROUND_ROWS
197 const px = blank(width, height)
198 const catColors = { '#': c.cat, o: c.eye, p: c.nose, t: c.cat }
199
200 // Stars twinkle in the top rows and drift.
201 state.stars.forEach(([x, row], i) => {
202 if ((state.frame + i * 5) % 14 < 9) {
203 const line = px[row]
204 if (line && x >= 0 && x < width) line[x] = c.star
205 }
206 })
207
208 // The ground: crenellated, scrolling under the cat; the done part lit.
209 const scroll = props.mode === 'game' ? state.frame : Math.floor(state.frame / 2)
210 const doneTo = Math.round(Math.max(0, Math.min(1, props.progress)) * (width - 1))
211 for (let y = ground; y < height; y += 1) {
212 const track = px[y]
213 if (!track) continue
214 for (let x = 0; x < width; x += 1) {
215 if ((x + scroll) % 3 !== 2) track[x] = props.mode === 'scene' && x <= doneTo ? c.trackDone : c.track
216 }
217 }
218
219 let bubbleX = 0
220 if (props.mode === 'scene') {
221 stamp(px, HOUSE, width - 6, ground - HOUSE.length, { '#': c.house })
222 const roof = px[ground - HOUSE.length]
223 if (roof) roof[width - 4] = c.roof
224 const x = Math.round(state.catX)
225 const bob = Math.floor(state.frame / 3) % 2
226 const cat = catSprite(state.frame, state.facing, true)
227 stamp(px, cat, x, ground - cat.length - bob, catColors)
228 bubbleX = x + CAT_W + 1
229 } else {
230 const lift = state.jumpT > 0 ? Math.round(Math.sin((Math.PI * state.jumpT) / JUMP_FRAMES) * JUMP_HEIGHT) : 0
231 const cat = catSprite(state.frame, 1, state.jumpT === 0 && !state.over)
232 stamp(px, cat, CAT_X_GAME, ground - cat.length - lift, catColors)
233 const bug = BUG_FRAMES[Math.floor(state.frame / 4) % 2] ?? BUG_FRAMES[0] ?? []
234 state.bugs.forEach(b => stamp(px, bug, b.x, ground - bug.length, { '#': c.bug }))
235 }
236
237 const full = props.mode === 'scene'
238 ? state.said
239 : state.over
240 ? `Trúng bug! ${state.score} điểm · kỷ lục ${state.best} · Space/j chơi lại`
241 : `Né bug · ${state.score} điểm · kỷ lục ${state.best}`
242 const shown = props.mode === 'scene' ? [...full].slice(0, state.typed).join('') : full
243 const cursor = props.mode === 'scene' && state.typed < [...full].length ? '▏' : ''
244 const text = `${shown}${cursor}`.slice(0, width - 4)
245 const pad = Math.max(0, Math.min(bubbleX, width - [...full].length - 4))
246
247 return (
248 <Box flexDirection="column">
249 <Text>
250 {' '.repeat(pad)}
251 <Text color={c.bubbleBg}>▐</Text>
252 <Text backgroundColor={c.bubbleBg} color={c.bubbleFg}>{text}</Text>
253 <Text color={c.bubbleBg}>▌</Text>
254 </Text>
255 {rows(px, Text)}
256 </Box>
257 )
258}
259
260function jump(s: SceneState): SceneState {
261 if (s.over) return { ...fresh(s.live.props, s.live.width, s.best), lastJumpSeq: s.lastJumpSeq, frame: s.frame }
262 return s.jumpT > 0 ? s : { ...s, jumpT: 1 }
263}
264
265// One frame of the scene: the cat walks home or paces around it, the stars
266// drift, and the bubble types on.
267function sceneStep(s: SceneState, frame: number): SceneState {
268 const { props, width } = s.live
269 const home = homeX(props, width)
270 const lo = Math.max(0, home - PACE)
271 const hi = Math.min(width - CAT_W - 8, home + PACE)
272 // Far from home it heads there; near it, it turns at the pacing bounds.
273 let facing = s.facing
274 if (s.catX < lo) facing = 1
275 else if (s.catX > hi) facing = -1
276 else if (s.catX + WALK_PX * facing > hi || s.catX + WALK_PX * facing < lo) facing = facing === 1 ? -1 : 1
277 const catX = s.catX + WALK_PX * facing
278
279 const stars = frame % 6 === 0
280 ? s.stars.map(([x, row]) => [x - 1 < 0 ? width - 1 : x - 1, row] as [number, number])
281 : s.stars
282 const isNew = props.bubble !== s.said
283 const said = isNew ? props.bubble : s.said
284 const typed = isNew ? 0 : Math.min([...said].length, s.typed + TYPE_CHARS)
285
286 return { ...s, frame, catX, facing, stars, said, typed }
287}
288
289// One frame: walk the legs, move the bugs, land the jump, score and collide.
290function step(s: SceneState, surface: ClientSurface<SceneState>): SceneState {
291 const { props, width } = s.live
292 const frame = s.frame + 1
293 if (props.mode !== 'game') return sceneStep(s, frame)
294 if (s.over) return { ...s, frame }
295
296 const speed = 1 + Math.floor(s.score / 8)
297 let score = s.score
298 const bugs = s.bugs
299 .map(b => ({ x: b.x - speed }))
300 .filter(b => {
301 if (b.x + 3 < CAT_X_GAME) {
302 score += 1
303 return false
304 }
305 return true
306 })
307 let spawnIn = s.spawnIn - 1
308 if (spawnIn <= 0) {
309 bugs.push({ x: width - 1 })
310 spawnIn = 14 + ((frame * 7) % 16) - Math.min(8, Math.floor(s.score / 5))
311 }
312 const jumpT = s.jumpT > 0 ? (s.jumpT + 1 > JUMP_FRAMES ? 0 : s.jumpT + 1) : 0
313 const lift = jumpT > 0 ? Math.round(Math.sin((Math.PI * jumpT) / JUMP_FRAMES) * JUMP_HEIGHT) : 0
314 const hit = lift < 2 && bugs.some(b => b.x <= CAT_X_GAME + 9 && b.x + 2 >= CAT_X_GAME + 3)
315 const best = Math.max(s.best, score)
316 if (score !== s.score || hit) surface.post({ score, best, over: hit })
317 const stars = frame % 3 === 0 ? s.stars.map(([x, row]) => [x - 1 < 0 ? width - 1 : x - 1, row] as [number, number]) : s.stars
318
319 return { ...s, frame, bugs, spawnIn, jumpT, score, best, over: hit, stars }
320}
321
322export default Scene
323types/index.d.ts 87 lines1export type FileChange = {
2 status: string
3 path: string
4 added: number
5 removed: number
6 // The first changed lines, each led by `+` or `-`; empty for a new file.
7 preview: string[]
8}
9
10export type Git = { branch: string; head: string; files: FileChange[] }
11
12export type Band = { model: string; ctxPercent: number | null }
13
14export type Footer = { test: 'pass' | 'fail' | null; minutes: number; usd: number | null }
15
16export type Stats = { total: number; tools: Record<string, number> }
17
18// When a tool call started (clock ms) and how long it ran, by tool_use_id;
19// `isErrored` is set once the call ends in an error or a refusal.
20export type Timing = { startedAt: number; ms: number | null; isErrored?: boolean }
21
22// The main loop's tool calls grouped into runs, so consecutive rows share one
23// frame: the open run's id (empty when the next call starts a new one), each
24// call's run, and each run's calls in order.
25export type ToolRuns = { current: string; byId: Record<string, string>; order: Record<string, string[]> }
26
27// What Claude did, in order: one entry per tool call, `think` for a quiet gap.
28export type Beat = 'think' | 'read' | 'search' | 'edit' | 'run' | 'other'
29
30// One step a subagent took: a tool call, as its card lists it.
31export type AgentStep = { id: string; tool: string; target: string; isDone: boolean; isErrored: boolean }
32
33// A subagent the session started, from spawn to its last turn.
34export type AgentRun = {
35 agentId: string
36 toolUseId: string
37 type: string
38 description: string
39 startedAt: number
40 endedAt: number | null
41 tools: number
42 steps: AgentStep[]
43 filesRead: string[]
44 filesEdited: string[]
45 answer: string
46}
47
48// The band's scene: what the cat says, how far the turn got, and the game.
49export type Scene = {
50 enabled: boolean
51 bubble: string
52 turnTools: number
53 playing: boolean
54 jumpSeq: number
55 score: number
56 best: number
57}
58
59// /enhance in flight, and a rewrite the prompt box would not take.
60export interface Enhanced {
61 busy: boolean
62 text: string | null
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 'acp-ui': {
68 git: Git | null
69 band: Band | null
70 footer: Footer | null
71 stats: Stats
72 timings: Record<string, Timing>
73 // Folded/unfolded state by tool_use_id (a diff) or run id (a whole run).
74 expanded: Record<string, boolean>
75 toolRuns: ToolRuns
76 beats: Beat[]
77 // Tool calls per clock minute (minute index → count).
78 activity: Record<string, number>
79 // Subagents by agentId, and the Agent tool call that started each.
80 agents: Record<string, AgentRun>
81 agentByToolUse: Record<string, string>
82 scene: Scene
83 enhanced: Enhanced
84 }
85 }
86}
87