SLOPSHOPPER

acp-ui

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

newpanebandspinnerrowsguard
v0.8.5MITupdated 2026-10-05huydepzai121/claude-acp-ui/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · acp-ui
│ ┃ Phiên làm việc ✕ ╭────────────────────────────────────────────╮ │ ┃ ▗▄▄▄▄▄▄▄▄▄▄▖ ▗▄▄▄▄▄▄▄▄▄▄▖ ▗▄▄▄▄▄▄▄▄▄▄▖ ╭───────────────────│ acp-ui │ │ ┃ 9 +14 −3 ────────────────────│ Gõ /enhance <câu lệnh> để viết lại câu │ │ ┃ tool call dòng thêm dòng xoá ────────────────────│ lệnh đó. │ │ ┃ ▝▀▀▀▀▀▀▀▀▀▀▘ ▝▀▀▀▀▀▀▀▀▀▀▘ ▝▀▀▀▀▀▀▀▀▀▀▘ │ › fix the failing╰────────────────────────────────────────────╯ │ ┃ │ ┃ TIMELINE │ ┃ ────────────────────────────────────── ╰────────────────────────────────────────────────────────────────── │ ┃ ▆▆▆▆▆▆▆▆▆ ─────────────────────────────────────────────────────────────────── │ ┃ ■ nghĩ ■ đọc ■ tìm ■ sửa ■ chạy ───────────────────────╯ │ ┃ │ ┃ HOẠT ĐỘNG ────────────────────────────── 24 ⏺ Read(src/auth.ts) │ ┃ phút ⎿ Read 6 lines │ ┃ │ ┃ █ ╭──────────────────────────────────────────────────────────────────── │ ┃ ───────────────────────────────────────────────────────────────────── │ ┃ █ ───────────────────╮ │ ┃ │ ✓ Edit app/src/auth.ts │ ┃ █ │ ┃ −0 +0 │ ┃ █ ╰──────────────────────────────────────────────────────────────────── │ ┃ 01:30 bây ───────────────────────────────────────────────────────────────────── │ ┃ giờ ───────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ TOOL ⎿ 3 pass, 1 fail │ ┃ ────────────────────────────────────────── ✻ Đang vắt óc… ▐⎇ feat/auth-refresh▌▐2 file đổi▌▐Opus 5.5▌▐ctx ███▉▁▁▁▁ 49%▌✨ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▐⎇ feat/auth-refresh▌▐2 file đổi▌▐Opus 5.5▌▐ctx ███▉▁▁▁▁ 49%▌✨
Pane · Phiên làm việc
▗▄▄▄▄▄▄▄▄▄▄▖ ▗▄▄▄▄▄▄▄▄▄▄▖ ▗▄▄▄▄▄▄▄▄▄▄▖ 9 +14 −3 tool call dòng thêm dòng xoá ▝▀▀▀▀▀▀▀▀▀▀▘ ▝▀▀▀▀▀▀▀▀▀▀▘ ▝▀▀▀▀▀▀▀▀▀▀▘ TIMELINE ────────────────────────────────────── ▆▆▆▆▆▆▆▆▆ ■ nghĩ ■ đọc ■ tìm ■ sửa ■ chạy HOẠT ĐỘNG ────────────────────────────── 24 phút █ █ █ █ 01:30 bây giờ TOOL ────────────────────────────────────────── ❯ Bash ████████████████████████████████ 4 ✎ Write ████████████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 2 ◇ Read ████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 1 ⌕ Grep ████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 1 ✎ Edit ████████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 1 CHANGES ─────────────────────────────────────── M src/auth.ts −3 +11 A src/auth.test.ts −0 +3 ▐⎇ feat/auth-refresh▌ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Turn line
✻ Xong việc trong 42s
Your message
╭─────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────╮ │ › fix the failing auth test and add an audit log call ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
╭─────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────╮ │ ✓ Edit app/src/auth.ts −0 +0 ╰─────────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────╯
README

claude-acp-ui

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.

  • Prompt card: your message sits in a filled card with ›.
  • Tool frames: tool calls draw in a full-width rounded frame (╭─╮ │ ╰─╯) 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.
  • Inline diffs: Edit / MultiEdit rows show the changed lines on red and green rows, syntax-highlighted, −N +N at the edge. In fullscreen a diff over 4 lines folds behind ▸ diff.
  • Hover cards (fullscreen): hover a tool row for the full command, how long it ran and its last output lines; hover a changed file in /dash for its first changed lines.
  • Subagent cards: an Agent call draws as a live card: ◌ ◆ 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.
  • Status band above the prompt: branch, changed-file count, running subagents (◆ 2 agent: Explore ◌ · Plan ◌), model, context bar.
  • Footer: test result, session minutes and cost after the hint line.
  • /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" } }.
  • Vietnamese spinner: the working line reads Đ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.
  • Themes: /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).
  • Cat scene above the prompt while Claude works: a pixel cat walks a track toward its house as the turn goes on, with a speech bubble saying what is happening (Đang đọc viewport.ts, Explore: đang tìm "devtools"). /acp-scene turns it off or on.
  • Né bug (/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.
  • Toast when a turn of 15 s or more finishes.

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.

Install

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.

Without the marketplace

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.

Requirements

  • Claude Code 2.1.287 or newer. Function-hook mods are early access and the API may change between releases.
  • A dark terminal theme, or /acp-theme github-light on a light one.
  • A font that draws Unicode block elements. Windows Terminal, iTerm2, kitty, WezTerm and Ghostty all do.

Limits

  • The prompt input box (its rules and ❯) is drawn by Claude Code; a mod cannot restyle it.
  • The /dash pane docks beside the transcript only in fullscreen mode (/tui fullscreen) at 110+ columns; otherwise it opens above the prompt.
  • Text added to the hint line is drawn dim.
  • The test result is a guess: a Bash command naming test, vitest, jest, pytest or playwright counts as a test run, pass or fail by its exit status.
  • Edit diffs show the changed lines without file line numbers. Highlighting is a small per-line tokenizer, not a full grammar.
  • Hover and the fold toggle need the mouse, so they work in fullscreen only; on the main screen diffs show open, up to 10 lines.
  • A folded group that holds a tool this plugin does not draw (another plugin's tool, an agent call) is left to the next hook or the engine instead of getting the group card.
  • A framed MCP tool's result block closes its frame; a call shown with no separate result block (an expanded group) closes it in the row, and an interrupted call's result bar takes the normal frame colour. Side bars are a column of │ sized to the block, so a result block taller than 400 rows loses them below that.
  • "think" on the timeline is a guess: a quiet gap of 3 s or more between tool calls.

Develop

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.

License

MIT

Source 3 files
hooks/register.tsx 1557 lines
1import { 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 lines
1import 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
323
types/index.d.ts 87 lines
1export 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