Claude Context First — a context-first, spec-driven, wave-parallel development workflow. Keeps CLAUDE.md/.claude specs continuously fresh and grounds every…

The Claude Context First plugin. See the root README for installation and an overview.
plugins/ccf/
├─ .claude-plugin/plugin.json # manifest
├─ .mcp.json # microsoft-learn + context7 (HTTP, key-less)
├─ commands/ # 4 slash commands; /ccf:plan is a skill (see skills/)
├─ agents/ # 6 subagents, ALL read-only (see below)
├─ skills/ # plan/ (the /ccf:plan workflow), grill-me/ (internal interview engine)
├─ hooks/ # 7 .mjs hooks + hooks.json + lib/ helpers; register.tsx + ui/ = the mod (UI layer)
├─ types/ # $.state contract of the mod (plugin.json "types")
├─ tests/ # mod tests, run by `claude plugin test`
├─ scripts/ # 11 human-run CLIs (nothing invokes them automatically)
└─ templates/ # {{...}} files that /ccf:init instantiates
All 6 CCF agents are read-only. You implement directly in the main session; /ccf:cook runs one built-in general-purpose agent per task in isolated worktrees. A spawned coding agent was measured slower than writing the code yourself with the plan already loaded.
Every agent inherits the host project's tools, MCP servers and skills (no per-agent allowlist to maintain), and carries disallowedTools: Write, Edit, NotebookEdit, Agent, Task, so no file writes and no nested spawning.
| Agent | Role |
|---|---|
ccf-codebase-analyzer | Reads one slice of the codebase, reports what exists. Fanned out 5x by /ccf:init and /ccf:plan. |
ccf-best-practice-researcher | Fetches cited best practices from Context7 / MS Learn. |
ccf-spec-writer | Drafts spec text from a decisions summary; the main thread writes the files. |
ccf-spec-checker | Fresh-context reviewer used by /ccf:check. |
ccf-scope-checker | Second reviewer in /ccf:check: does the diff stay inside the task's files and criteria, and cover all of them. |
ccf-finding-refuter | Runs in /ccf:check step 6c when a FAIL: exists: tries to disprove each one with quoted evidence and returns REFUTED or STANDS. A refuted finding stays FAIL: with a note. |
Run directly with node (no build, no dependency, Node ≥ 18). The .mjs extension keeps Claude Code on Windows from prepending bash. Hooks auto-load from hooks/hooks.json; do not add a "hooks" field to plugin.json pointing at that path: it loads the file twice and fails with Duplicate hooks file detected.
| Hook | Event | Behavior |
|---|---|---|
plan-mode-guard | UserPromptSubmit | Blocks /ccf:plan outside plan mode (exit 2). |
plan-skill-inject | UserPromptSubmit | In plan mode, nudges the model toward the ccf:plan skill, once per session. Never blocks. |
session-start | SessionStart | Re-injects the context-first reminder; re-loads the in-progress task after compact/clear; adds a freshness signal when code is newer than the spec. |
updatespec-nudge | Stop | Advisory only. Five nudges: verify your work, run check then updatespec, mark done tasks, archive closed iterations, prune old archived task files. |
auto-verify | Stop | Opt-in (--auto-verify). The only blocking Stop hook: drives one verify step (/ccf:check, then /ccf:updatespec) via decision: "block". |
completion-evidence | Stop | Opt-in (--completion-evidence + TYPESAFE_API_KEY). Advisory: asks Jev whether the diff meets the task's criteria. The diff leaves your machine. |
explore-guide-inject | SubagentStart (Explore) | Injects an LSP/Grep/Glob exploration directive into the built-in Explore agent. |
Manual test:
echo '{"prompt":"/ccf:plan","permission_mode":"default"}' | node hooks/plan-mode-guard.mjs
# exit 2 + stderr saying plan mode is required
hooks/hooks.json also names "modules": ["./register.tsx"], a Claude Code function-hooks module. It adds the Rem mascot, who docks beside the prompt on her own in a fullscreen terminal, whose line follows the session (the typed /ccf:* command, the running test, CCF script or git command, the cook task agents, and the last pass/fail result, built in hooks/lib/rem-lines.mjs), and a CCF UI layer that only reads what CCF already keeps: PLAN.md, PENDING.md, scripts/plan-waves.mjs, scripts/spec-budget.mjs and the freshness check in hooks/lib/freshness.mjs. The mod sandbox has no Node, so it runs node hooks/lib/ui-snapshot.mjs and those scripts through $.process.run and draws their JSON.
Tested against Claude Code 2.1.289. The function-hooks API is early access and can change between releases; re-run claude plugin validate plugins/ccf and claude plugin test plugins/ccf before trusting it on a newer build.
Every CCF UI piece is off by default. Turn one on in /config (the plugin's userConfig rows):
| Option | What it shows |
|---|---|
uiBand | A line above the prompt: the active task, its lifecycle step and the next command, e.g. 070 ●━━●━━◉━━○ in-review · next: /ccf:check. |
uiBoard | The top of Rem's 40-column dock pane shows a progress bar, the closed/total count with open risks from PENDING.md, and one count per column (todo / doing / review / done). |
uiWaves | On /ccf:cook, the wave split read from the output of its own plan-waves.mjs call and each worktree agent as it spawns and finishes; a task PLAN.md already shows in-review or done reads as done. The map sits in Rem's dock pane and scrolls on its own (.. +N marks hidden rows); its rows are buttons, and pressing one opens a Waves tab beside Rem with the full map (summary, per-wave heading, every task with its state); outside a fullscreen terminal there is no dock, so no map. While it is off, /ccf:cook or its plan-waves.mjs call shows a one-time toast saying so. |
uiStatusLine | Whether the spec is older than the code, plus the CLAUDE.md size from spec-budget.mjs. |
Progress bars use Raster on the terminal and Svg on desktop, which has no Raster. Nothing in the UI layer writes a file or decides a gate: a failing mod hook is skipped and a refused tree is replaced by the engine's own drawing, so the .mjs hooks above behave the same with the UI on or off.
Human-run CLIs. File-mutating actions belong here, never in a hook.
| Script | What it does |
|---|---|
archive-plan.mjs | Retire a fully-closed iteration: PLAN.md → ARCHIVE.md (--apply to perform; default previews). |
jev-slice-check.mjs | Advisory: ask Jev which open tasks depend on each other (needs TYPESAFE_API_KEY). |
jev-backtest.mjs | Backtest Jev's dependency recall against the archived task corpus (needs TYPESAFE_API_KEY). |
jev-verify-findings.mjs | Advisory: ask Jev whether each FAIL: in a /ccf:check report is really in the diff. Annotates only. |
plan-waves.mjs | Print the wave split for the backlog; --inline lets Jev mark small tasks to run in the main session. |
worktree-preflight.mjs | Read-only pre-merge check of a parallel wave (scope, overlap, merge conflicts). |
integrate-wave.mjs | Merge a wave: preflight, --no-ff merge per branch, tests after every merge, reset to last green on red. |
prune-archive.mjs | Keep the task files of the newest 10 archived iterations; --apply stages git rm of older ones (default previews; never commits). ARCHIVE.md and git history stay the record. |
spec-budget.mjs | Read-only: measure CLAUDE.md plus every recursive @import (bytes, lines, depth, total); /ccf:updatespec prints it before → after. |
eval-changelog.mjs | Write the current version's CHANGELOG.md entry from the newest eval results, or --not-run "<reason>" (--apply to write; default previews; never commits). |
memory-audit.mjs | Read-only: measure a project memory dir and open the consolidation gate for /ccf:updatespec step 5b. Opt-in --jev + TYPESAFE_API_KEY adds an advisory keep/merge/drop label; memory text leaves your machine. |
/ccf:init reads templates/, replaces {{...}} placeholders, writes real CLAUDE.md + .claude/. Target: each CLAUDE.md under 200 lines by @import-ing rule files (under 50 lines each).
hooks/register.tsx 461 lines1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, Elements, PluginOptions, Register, RenderElement, StateDollar, ToolCallResult } from 'claude-code'
3
4import type { Feeling, Mood, Walk } from '../types'
5import { SPRITE_COLUMNS, SPRITE_ROWS, STANDING, encode, pixelsOf } from './sprite'
6import type { Pose } from './sprite'
7import { NO_TASK_LINE, claudeMdBudget, dockIsEmpty, dockLayout, isPlanWrite, liveAgents, planWavesRequest, scrollWindow, statusText, walkStep, waveScroll, wavesFromOutput } from './lib/ui-model.mjs'
8import {
9 BUDGET_SCRIPT,
10 COOK,
11 SNAPSHOT_SCRIPT,
12 WAVES_OFF_NOTICE,
13 bandTree,
14 dockBoardRows,
15 dockDivider,
16 dockWaveLines,
17 dockWaveRows,
18 NO_WAVES_LINE,
19 wavesTabLines,
20 wavesTabRows,
21 isWavesOffRun,
22 isWavesRun,
23 shellOutput,
24 registerCcfUi,
25 wantsSnapshot,
26 wantsWaves,
27} from './ui/ccf-ui.mjs'
28import { GREETING, VOICE_DEBOUNCE_MS, agentDoneLine, commandActivity, commandResult, doneLine, isVoiceOn, promptActivity, remVoice, startLine, voiceReply, voiceRequest } from './lib/rem-lines.mjs'
29
30const PANE = 'rem'
31const PANE_WAVES = 'waves'
32const SLEEP_AFTER_MS = 10 * 60_000
33const LONG_TURN_MS = 30_000
34const HAIR = '#7fb2f0'
35const CCF_SCRIPT_LIMIT_MS = 15_000
36const CCF_NOTICE_MS = 8000
37const DOCK_COLUMNS = 40
38const WALK_TICK_MS = 500
39
40const KAOMOJI: Readonly<Record<Mood, string>> = {
41 idle: '(・ω・)',
42 thinking: '(・_・?)',
43 happy: '(^▽^)',
44 worried: '(;・△・)',
45 sleepy: '(-ω-)',
46 surprised: '(゜o゜)',
47}
48
49const cellsByLook = new Map<string, string>()
50
51function cellsOf(mood: Mood, pose: Pose): string {
52 const key = `${mood}:${pose.stride}:${pose.isFlipped}`
53 const cached = cellsByLook.get(key)
54 if (cached !== undefined) return cached
55 const cells = encode(pixelsOf(mood, pose))
56 cellsByLook.set(key, cells)
57 return cells
58}
59
60function poseOf(mood: Mood, { x, dir }: Walk, room: number): Pose {
61 if (room <= 0 || mood === 'sleepy') return STANDING
62 const stride = x % 4 === 1 ? 'left' : x % 4 === 3 ? 'right' : 'stand'
63 return { stride, isFlipped: dir === 1 }
64}
65
66const feeling = atom({ plugin: 'ccf', key: 'feeling' } as const, GREETING as Feeling)
67const isDismissed = atom({ plugin: 'ccf', key: 'isDismissed' } as const, false)
68const isDocked = atom({ plugin: 'ccf', key: 'isDocked' } as const, false)
69const ccfSnapshot = atom({ plugin: 'ccf', key: 'snapshot' } as const, null)
70const ccfBudget = atom({ plugin: 'ccf', key: 'budget' } as const, null)
71const ccfStatusLine = atom({ plugin: 'ccf', key: 'statusLine' } as const, null)
72const ccfWaves = atom({ plugin: 'ccf', key: 'waves' } as const, [])
73const ccfAgents = atom({ plugin: 'ccf', key: 'agents' } as const, [])
74const ccfWavesSource = atom({ plugin: 'ccf', key: 'wavesSource' } as const, null)
75const hasNoticedWavesOff = atom({ plugin: 'ccf', key: 'hasNoticedWavesOff' } as const, false)
76const ccfWavesOffset = atom({ plugin: 'ccf', key: 'wavesOffset' } as const, 0)
77const remWalk = atom({ plugin: 'ccf', key: 'walk' } as const, { x: 0, dir: 1 } as Walk)
78
79type Engine = Pick<CoreEngineInterface, 'ui' | 'state'>
80type TerminalElements = Elements['terminal']
81type WaveRegion = { top: number; rows: number; total: number }
82type Activity = NonNullable<ReturnType<typeof commandActivity>>
83type CookAgent = { agentId: string; taskId: string; isDone: boolean }
84
85const feel = ($: StateDollar, mood: Mood, line: string) => update($, feeling, () => ({ mood, line }))
86
87async function speak($: CoreEngineInterface, options: PluginOptions, mood: Mood, line: string) {
88 await feel($, mood, line)
89 if (!isVoiceOn(options)) return
90 const turn = remVoice.begin()
91 const known = remVoice.cached(mood, line)
92 if (known !== null) {
93 await feel($, mood, known)
94 return
95 }
96 $.clock.after(VOICE_DEBOUNCE_MS, async () => {
97 try {
98 if (!remVoice.isCurrent(turn)) return
99 const reply = await $.model.complete(voiceRequest(mood, line))
100 const said = reply.isAnswered ? voiceReply(reply.text, line) : null
101 if (said === null) return
102 remVoice.remember(mood, line, said)
103 await update($, feeling, now => (now.mood === mood && now.line === line ? { mood, line: said } : now))
104 } catch {}
105 })
106}
107
108async function dock($: Engine, { isAsked, columns }: { isAsked: boolean; columns: number }): Promise<boolean> {
109 const opened = await $.ui.open({ id: PANE, title: 'Rem', columns, rows: SPRITE_ROWS })
110 await update($, isDocked, () => opened.isPlaced)
111 await showStatus($)
112 if (!opened.isPlaced && isAsked) $.ui.toast(`Rem chưa đứng cạnh ô chat được: ${opened.reason}`, { timeoutMs: 8000 })
113 return opened.isPlaced
114}
115
116async function showStatus($: Engine) {
117 const line = await read($, ccfStatusLine)
118 if (line === null) return
119 $.ui.status((await read($, isDocked)) ? undefined : line)
120}
121
122async function canDock($: StateDollar, isLayoutReady: boolean): Promise<boolean> {
123 return isLayoutReady && !(await read($, isDocked)) && !(await read($, isDismissed))
124}
125
126async function reactToRun($: CoreEngineInterface, activity: Activity, ran: ToolCallResult, options: PluginOptions) {
127 if (ran.deny !== undefined) return null
128
129 const result = commandResult(activity, ran.isError === true, shellOutput(ran) || (ran.text ?? ''))
130 if (result.mood === 'thinking') await feel($, 'thinking', result.line)
131 else await speak($, options, result.mood as Mood, result.line)
132 return result.mood === 'thinking' ? null : result
133}
134
135const Bubble = ({ Box, Text }: TerminalElements, line: string) => (
136 <Box borderStyle="round" borderColor={HAIR} paddingX={1} flexShrink={1}>
137 <Text>{line}</Text>
138 </Box>
139)
140
141const Rem = ({ Raster, Box }: TerminalElements, mood: Mood, x = 0, pose: Pose = STANDING) => (
142 <Box key="rem-walk" marginLeft={x}>
143 <Raster key="rem" columns={SPRITE_COLUMNS} rows={SPRITE_ROWS} cells={cellsOf(mood, pose)} />
144 </Box>
145)
146
147const StatusRow = ({ Text }: TerminalElements, status: string | null) =>
148 status !== null ? <Text key="status" dimColor wrap="truncate-end">{status}</Text> : null
149
150const EmptyRow = ({ Text }: TerminalElements, isEmpty: boolean) =>
151 isEmpty ? <Text key="no-task" dimColor wrap="truncate-end">{NO_TASK_LINE}</Text> : null
152
153const inColumn = (elements: TerminalElements, { mood, line }: Feeling, minHeight: number, band: RenderElement | null, status: string | null, x: number, pose: Pose, isEmpty = false) => (
154 <elements.Box flexDirection="column" justifyContent="flex-end" minHeight={minHeight}>
155 {Bubble(elements, line)}
156 {Rem(elements, mood, x, pose)}
157 {EmptyRow(elements, isEmpty)}
158 {band}
159 {StatusRow(elements, status)}
160 </elements.Box>
161)
162
163const inRow = (elements: TerminalElements, { mood, line }: Feeling) => (
164 <elements.Box flexDirection="row" justifyContent="flex-end" alignItems="flex-end" gap={1}>
165 {Bubble(elements, line)}
166 {Rem(elements, mood)}
167 </elements.Box>
168)
169
170async function runCcfScript($: CoreEngineInterface, script: string) {
171 try {
172 const cwd = (await read($, ccfWavesSource))?.dir ?? (await $.session.cwd())
173 const ran = await $.process.run(['node', `${$.plugin.root}/${script}`, '--dir', cwd], { cwd, timeoutMs: CCF_SCRIPT_LIMIT_MS })
174 return JSON.parse(ran.stdout)
175 } catch {
176 return null
177 }
178}
179
180async function refreshCcf($: CoreEngineInterface, options: PluginOptions) {
181 const found = await runCcfScript($, SNAPSHOT_SCRIPT)
182 const now = found?.ok === true ? found : null
183 await update($, ccfSnapshot, () => now)
184 if (options.uiStatusLine !== true) return
185 const size = now ? claudeMdBudget(await runCcfScript($, BUDGET_SCRIPT)) : null
186 await update($, ccfBudget, () => size)
187 const line = now ? statusText(now.specStale, size ?? undefined) ?? null : null
188 await update($, ccfStatusLine, () => line)
189 if (line === null) $.ui.status(undefined)
190 else await showStatus($)
191}
192
193async function noticeWavesOff($: CoreEngineInterface, options: PluginOptions) {
194 try {
195 if (wantsWaves(options) || (await read($, hasNoticedWavesOff))) return
196 await update($, hasNoticedWavesOff, () => true)
197 $.ui.toast(WAVES_OFF_NOTICE, { timeoutMs: CCF_NOTICE_MS })
198 } catch {}
199}
200
201async function loadWavesFromRun($: CoreEngineInterface, command: string, ran: ToolCallResult, options: PluginOptions) {
202 try {
203 const found = wavesFromOutput(shellOutput(ran))
204 if (!found) return
205 const { dir, tasks } = planWavesRequest(command)
206 const source = { dir: dir ?? (await $.session.cwd()), tasks: tasks ?? null }
207 await update($, ccfWavesSource, () => source)
208 await update($, ccfAgents, liveAgents)
209 await update($, ccfWavesOffset, () => 0)
210 await update($, ccfWaves, () => found)
211 $.clock.after(1, () => refreshCcf($, options))
212 } catch {}
213}
214
215async function ccfSessionStart($: CoreEngineInterface, options: PluginOptions) {
216 try {
217 if (wantsSnapshot(options)) $.clock.after(1, () => refreshCcf($, options))
218 } catch {}
219}
220
221async function ccfTurnComplete($: CoreEngineInterface, agentId: string | undefined, options: PluginOptions) {
222 try {
223 if (agentId !== undefined) {
224 await update($, ccfAgents, list => list.map(agent => (agent.agentId === agentId ? { ...agent, isDone: true } : agent)))
225 if (!wantsWaves(options)) return
226 $.clock.after(1, () => refreshCcf($, options))
227 } else if (wantsSnapshot(options)) {
228 $.clock.after(1, () => refreshCcf($, options))
229 }
230 } catch {}
231}
232
233async function ccfBand($: CoreEngineInterface, e: { surface: string; props: { hasSurvey: boolean } }, elements: TerminalElements, options: PluginOptions) {
234 if (options.uiBand !== true || e.props.hasSurvey) return null
235 try {
236 return (bandTree(elements, await read($, ccfSnapshot), await read($, ccfWaves), await read($, ccfAgents), options) ?? null) as RenderElement | null
237 } catch {
238 return null
239 }
240}
241
242async function dockTree(
243 $: StateDollar,
244 elements: TerminalElements,
245 now: Feeling,
246 { bodyRows, bodyColumns }: { bodyRows: number; bodyColumns: number },
247 options: PluginOptions,
248): Promise<{ tree: RenderElement; region: WaveRegion | null }> {
249 const snapshot = options.uiBoard === true || options.uiBand === true || wantsWaves(options) ? await read($, ccfSnapshot) : null
250 const board = options.uiBoard === true ? snapshot : null
251 const lines = wantsWaves(options) ? dockWaveLines(await read($, ccfWaves), await read($, ccfAgents), snapshot) : []
252 const band = options.uiBand === true ? ((bandTree(elements, snapshot, await read($, ccfWaves), await read($, ccfAgents), options) ?? null) as RenderElement | null) : null
253 const status = options.uiStatusLine === true ? await read($, ccfStatusLine) : null
254 const room = Math.max(0, bodyColumns - SPRITE_COLUMNS)
255 const walk = await read($, remWalk)
256 const x = Math.min(walk.x, room)
257 const pose = poseOf(now.mood, walk, room)
258 const isEmpty = (options.uiBoard === true || wantsWaves(options)) && dockIsEmpty(snapshot, lines.length)
259 if (isEmpty || (board === null && lines.length === 0)) return { tree: inColumn(elements, now, bodyRows, band, status, x, pose, isEmpty), region: null }
260
261 const layout = dockLayout({ bodyRows, bodyColumns, line: now.line, hasBoard: board !== null, waveLineCount: lines.length, spriteRows: SPRITE_ROWS + (band !== null ? 1 : 0) + (status !== null ? 1 : 0) })
262 const window = scrollWindow(lines.length, lines.length > 0 ? await read($, ccfWavesOffset) : 0, layout.waveRows)
263 const { Box } = elements
264 const tree = (
265 <Box flexDirection="column" minHeight={bodyRows}>
266 {board !== null ? [...dockBoardRows(elements, board, layout.boardRows), dockDivider(elements, bodyColumns, 'board-divider')] : null}
267 {lines.length > 0 ? [...dockWaveRows(elements, lines, window, () => { void $.ui.open({ id: PANE_WAVES, title: 'Waves' }) }), dockDivider(elements, bodyColumns, 'wave-divider')] : null}
268 <Box flexGrow={1} />
269 {Bubble(elements, now.line)}
270 {Rem(elements, now.mood, x, pose)}
271 {band}
272 {StatusRow(elements, status)}
273 </Box>
274 )
275 return { tree, region: lines.length > 0 ? { top: layout.waveTop, rows: window.end - window.start, total: lines.length } : null }
276}
277
278export const register: Register = (on, options) => {
279 registerCcfUi(on, options)
280
281 let lastActiveAt = 0
282 let isTurnRunning = false
283 let isFullscreenLayout = false
284 let hasTriedDock = false
285 let hasAutoDocked = false
286 let waveRegion: WaveRegion | null = null
287 let walkRoom = 0
288 let lastPrompt: ReturnType<typeof promptActivity> = null
289 let lastResult: Awaited<ReturnType<typeof reactToRun>> = null
290 const dockColumns = options.uiBoard === true || wantsWaves(options) ? DOCK_COLUMNS : SPRITE_COLUMNS
291
292 on('session.start', async ($, e, next) => {
293 await ccfSessionStart($, options)
294 lastActiveAt = await $.clock.now()
295
296 hasTriedDock = false
297 hasAutoDocked = false
298 for (const delayMs of [2000, 5000]) {
299 $.clock.after(delayMs, async () => {
300 if (await canDock($, isFullscreenLayout && !hasTriedDock)) await dock($, { isAsked: false, columns: dockColumns })
301 })
302 }
303
304 $.clock.every(60_000, async () => {
305 if (isTurnRunning) return
306 if ((await $.clock.now()) - lastActiveAt < SLEEP_AFTER_MS) return
307 if ((await read($, feeling)).mood === 'sleepy') return
308 await speak($, options, 'sleepy', 'Lâu quá không thấy ngài. Rem chợp mắt một chút nhé.')
309 })
310
311 $.clock.every(WALK_TICK_MS, async () => {
312 if (walkRoom <= 0 || !(await read($, isDocked))) return
313 if ((await read($, feeling)).mood === 'sleepy') return
314 await update($, remWalk, now => walkStep(now, walkRoom))
315 })
316
317 return next(e)
318 })
319
320 on('prompt.submit', async ($, e, next) => {
321 lastPrompt = promptActivity(e.text)
322 lastResult = null
323 if (COOK.test(e.text)) await noticeWavesOff($, options)
324 if (await canDock($, isFullscreenLayout && !hasTriedDock)) {
325 hasTriedDock = true
326 await dock($, { isAsked: true, columns: dockColumns })
327 }
328 return next(e)
329 })
330
331 on('ui.close', async ($, e, next) => {
332 if (e.id === PANE) {
333 await update($, isDocked, () => false)
334 await showStatus($)
335 if (e.origin.kind === 'person') await update($, isDismissed, () => true)
336 }
337 return next(e)
338 })
339
340 on('turn.start', async ($, e, next) => {
341 isTurnRunning = true
342 if (lastPrompt !== null) await speak($, options, 'thinking', startLine(lastPrompt))
343 else await feel($, 'thinking', startLine(lastPrompt))
344 return next(e)
345 })
346
347 on('tool.call', async ($, e, next) => {
348 const activity = e.tool === 'PowerShell' || e.tool === 'Bash' ? commandActivity(e.command) : null
349 if (activity !== null) await feel($, 'thinking', activity.line)
350 if (isWavesRun(e, options)) {
351 const ran = await next(e)
352 await loadWavesFromRun($, e.command, ran, options)
353 if (activity !== null) lastResult = (await reactToRun($, activity, ran, options)) ?? lastResult
354 return ran
355 }
356 if (isWavesOffRun(e, options)) await noticeWavesOff($, options)
357 if (wantsSnapshot(options) && e.agentId === undefined && isPlanWrite(e.tool, String((e as { file_path?: unknown }).file_path ?? ''))) {
358 const ran = await next(e)
359 $.clock.after(1, () => refreshCcf($, options))
360 return ran
361 }
362 if (activity === null) return next(e)
363
364 const ran = await next(e)
365 lastResult = (await reactToRun($, activity, ran, options)) ?? lastResult
366 return ran
367 })
368
369 on('turn.complete', async ($, e, next) => {
370 await ccfTurnComplete($, e.agentId, options)
371 if (e.agentId !== undefined) {
372 const agents: CookAgent[] = await read($, ccfAgents)
373 const finished = agents.find(agent => agent.agentId === e.agentId)
374 const line = finished ? agentDoneLine(await read($, ccfWaves), agents, finished.taskId) : null
375 if (line !== null) await speak($, options, 'thinking', line)
376 return next(e)
377 }
378
379 isTurnRunning = false
380 lastActiveAt = await $.clock.now()
381
382 if (e.reason === 'aborted') {
383 await speak($, options, 'surprised', 'Ơ, ngài dừng Rem lại à?')
384 } else if (e.reason !== 'answer') {
385 await speak($, options, 'worried', 'Hình như có trục trặc rồi, ngài thử lại giúp Rem nhé.')
386 } else if (['thinking', 'happy', 'worried'].includes((await read($, feeling)).mood)) {
387 await speak($, options, lastResult?.failed ? 'worried' : 'happy', doneLine(lastPrompt ?? undefined, lastResult?.outcome ?? ''))
388 }
389
390 if (!e.isAborted && e.durationMs > LONG_TURN_MS) {
391 $.ui.toast(`Rem làm xong rồi (${Math.round(e.durationMs / 1000)}s)`)
392 }
393
394 return next(e)
395 })
396
397 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
398 const now = await read($, feeling)
399
400 if (e.surface === 'terminal') {
401 const elements = $.ui.resolve(e)
402 walkRoom = e.props.placement === 'dock' ? Math.max(0, e.props.bodyColumns - SPRITE_COLUMNS) : 0
403 if (e.props.placement === 'dock' && !(await read($, isDocked))) {
404 await update($, isDocked, () => true)
405 await showStatus($)
406 }
407 if (e.props.placement !== 'dock') {
408 waveRegion = null
409 return inRow(elements, now)
410 }
411 const drawn = await dockTree($, elements, now, { bodyRows: e.props.scroll.bodyRows, bodyColumns: e.props.bodyColumns }, options)
412 waveRegion = drawn.region
413 return drawn.tree
414 }
415
416 const { Text } = $.ui.resolve(e)
417 return <Text color={HAIR}>{KAOMOJI[now.mood]} Rem: {now.line}</Text>
418 })
419
420 on('ui.render', { component: 'Pane', requestId: PANE_WAVES }, async ($, e) => {
421 const rows = wavesTabLines(await read($, ccfWaves), await read($, ccfAgents), await read($, ccfSnapshot))
422 if (e.surface === 'terminal') {
423 const elements = $.ui.resolve(e)
424 const { Box } = elements
425 return <Box flexDirection="column">{wavesTabRows(elements, rows, e.props.bodyColumns)}</Box>
426 }
427 const { Text } = $.ui.resolve(e)
428 return <Text>{rows.length === 0 ? NO_WAVES_LINE : `${rows.length} wave`}</Text>
429 })
430
431 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
432 const region = waveRegion
433 const offset = await read($, ccfWavesOffset)
434 const moved = region === null ? null : waveScroll(region, offset, e.by, e.pointer !== undefined, e.pointer?.row ?? 0)
435 if (moved === null) return next(e)
436 if (moved !== offset) await update($, ccfWavesOffset, () => moved)
437 return {}
438 })
439
440 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
441 if (e.surface === 'terminal' && e.viewport !== undefined) isFullscreenLayout = e.viewport.isFullscreen === true
442
443 const docked = await read($, isDocked)
444 if (isFullscreenLayout && !hasAutoDocked && !docked && !(await read($, isDismissed))) {
445 hasAutoDocked = true
446 $.clock.after(0, async () => {
447 if (await canDock($, true)) await dock($, { isAsked: false, columns: dockColumns })
448 })
449 }
450 const band = e.surface === 'terminal' && docked ? null : await ccfBand($, e, $.ui.resolve(e) as TerminalElements, options)
451 const now = await read($, feeling)
452 const isHidden = e.props.hasSurvey || docked || (await read($, isDismissed))
453 if (isHidden && band == null) return next(e)
454
455 const { Box, Text } = $.ui.resolve(e)
456 const rem = isHidden ? await next(e) : e.surface === 'terminal' ? inRow($.ui.resolve(e), now) : <Text color={HAIR}>{KAOMOJI[now.mood]} Rem: {now.line}</Text>
457 if (band == null) return rem
458 return rem ? <Box flexDirection="column">{band}{rem}</Box> : band
459 })
460}
461hooks/sprite.ts 284 lines1import type { Mood } from '../types'
2
3export const PALETTE: Readonly<Record<string, number>> = {
4 a: 0x7787af,
5 b: 0xf9f9fc,
6 c: 0x483b4e,
7 d: 0xe78ed9,
8 e: 0xa4d1fa,
9 f: 0xb89698,
10 g: 0x7c6477,
11 h: 0xe9d5e1,
12 i: 0xc968b9,
13 j: 0x95add3,
14 k: 0xbcfafd,
15 l: 0x85b1e6,
16 m: 0xd296cc,
17 n: 0x6d698c,
18 o: 0x6f88c7,
19 p: 0xbec6fa,
20 q: 0xe0bfba,
21 r: 0xaeceec,
22 s: 0xfbe7e0,
23 t: 0xb29fb9,
24}
25
26export const BODY: readonly string[] = [
27 '............aaa......................',
28 '...........abbbj.....aaa.............',
29 '...........abbbracccabbba............',
30 '.....aaa.oajbbbbbgbbbbbbj............',
31 '....abbbjobbjjjngggnjbbbrg...........',
32 '....lbbbbnjreellllllljjbbgg.aoa......',
33 '....ojbrrreeeeellllleeeejnggbbba.....',
34 '....abreeeeeeeeeeeeeeeeeelbbbbbo.....',
35 '...abjeeeeeeeeeeeeeeeeeeeejbbbba.....',
36 '.ajngeereeeeeeeeeeeeeeeeeeelbja......',
37 'abbnjekkkeeeeeeeeeeeeeeeeeeebll......',
38 'obbjekkkkkeeeeeeeeeeeepeerrellpii....',
39 'abaeekkkkkekkeeeeeeeeeiipmdelpiddi...',
40 '.aoeeekkkkrkkeeeekeeeeiimddreidjbii..',
41 '..leeeeeeeeeeeeeeeeeeedddiipeddiiddi.',
42 '.aeeeeeeeeeeeeeeeeerbrmmpiieedpiibdi.',
43 '.oeeeeeeeeeeeeeeeeersbppeppepidddiii.',
44 '.leeeeeeeeeeeeeeeeeessreeeeeeeimjdi..',
45 '.leeeeeeeeeeeeeeeeebjjbreeeelepiddi..',
46 'aeeeeeeeeeeeeeeeeersssssreeeleeiiiii.',
47 'oeeeeeeeeeeeeeeeeersssssreeelleii.ii.',
48 'oeeeeeeeeeeeeeeeeebbaobsreeellpii.md.',
49 'leeleeeeeeeeeeeeeesaooaseeeellrii..dm',
50 'leeleeeeeeeeeeeeeesboooseeerjlejdm.dd',
51 'aeeeleeeeeeleeeeersbllrseerbfleldd.dm',
52 '.jeelleeeeellererrsbjlbserbsfleodd.m.',
53 '.aeelllrrrrjbsssssssssssrqbfjel.dd...',
54 '..leeoofsshssssssssssssssfoaleo.dm...',
55 '..aeeoofsssssssssssssssstaolea..m....',
56 '...oeooafsssssqffssssssfoolro........',
57 '....aoooogtsssssssssqfgaa.oa.........',
58 '.....oa.aaagggfffffggtt..............',
59 '..........thhthhhhhtthbt.............',
60 '..........tbbbgggggbbbbt.............',
61 '......ff..tbbbggbggbbbf..............',
62 '....fffsf.cthbbbbbbbhtsf.............',
63 '....fssqsfcctthtthttgqsgc............',
64 '.....fsssfcctbbbbbbhcgggc............',
65 '......fffgcccthhhhtgcccccc...........',
66 '.......ggggccccccccccbcccgc..........',
67 '.......cggcbcccttccccbbccggqf........',
68 '........ccbcgggggggggcbcgggssf.......',
69 '..........tcgggbbggggcbcggsqf........',
70 '.........cbtggbbbbggtbc..fff.........',
71 '.........cbtbbbbbbbbtbgc.............',
72 '........cgbbtbbbbbbtbbggc............',
73 '.......tgggbbhhhhhhbbggggt...........',
74 '.......tbgggbbbbbbbbggggbt...........',
75 '........tbbgggggggggggbbt............',
76 '.........ttbbbbbbbbbbbtt.............',
77 '...........thhhttthhht...............',
78 '............thht..thht...............',
79 '............tbbt..tbbt...............',
80 '............tbbt..tbbt...............',
81 '.............thg...thg...............',
82 '.............ggg...ggg...............',
83]
84
85type Stamp = { x: number; y: number; rows: readonly string[] }
86
87const EYE = { x: 19, y: 21 }
88const MOUTH = { x: 13, y: 26 }
89const OPEN_EYE: Stamp = { ...EYE, y: 18, rows: ['ssss', 'ssss', 'ssss', 'nnnn', 'booo', 'booo', 'ooll', 'ooll'] }
90
91const FACES: Readonly<Record<Mood, readonly Stamp[]>> = {
92 idle: [OPEN_EYE],
93 thinking: [
94 { ...EYE, rows: ['ssss', 'ssss', 'ssss', 'oooo', 'oooo'] },
95 { ...MOUTH, rows: ['ssssss', 'ssssss', 'ssssss', 'ssssff'] },
96 ],
97 happy: [
98 { ...EYE, rows: ['ssss', 'snns', 'snns', 'nssn', 'nssn'] },
99 { x: 21, y: 26, rows: ['qq', 'qq'] },
100 { ...MOUTH, rows: ['ssssss', 'ssssss', 'sgssgs', 'ssggss'] },
101 ],
102 worried: [
103 OPEN_EYE,
104 { x: 22, y: 26, rows: ['l', 'l'] },
105 { ...MOUTH, rows: ['ssssss', 'ssssss', 'ssggss', 'ssggss'] },
106 ],
107 sleepy: [
108 { ...EYE, rows: ['ssss', 'ssss', 'ssss', 'ssss', 'nnnn'] },
109 { ...MOUTH, rows: ['ssssss', 'ssssss', 'ssssss', 'ssffss'] },
110 ],
111 surprised: [
112 { ...EYE, rows: ['ssss', 'snns', 'snns', 'ssss', 'ssss'] },
113 { ...MOUTH, rows: ['ssssss', 'ssggss', 'ssggss', 'ssggss'] },
114 ],
115}
116
117const CELL_WIDTH = 2
118const CELL_HEIGHT = 4
119const PADDING_TO_ALIGN_FACE_WITH_CELLS = { left: 1, top: 2 }
120const PIXEL_COLUMNS = BODY[0]?.length ?? 0
121
122export const SPRITE_COLUMNS = Math.ceil((PIXEL_COLUMNS + PADDING_TO_ALIGN_FACE_WITH_CELLS.left) / CELL_WIDTH)
123export const SPRITE_ROWS = Math.ceil((BODY.length + PADDING_TO_ALIGN_FACE_WITH_CELLS.top) / CELL_HEIGHT)
124
125const TRANSPARENT = '.'
126
127export type Stride = 'stand' | 'left' | 'right'
128export type Pose = { stride: Stride; isFlipped: boolean }
129
130export const STANDING: Pose = { stride: 'stand', isFlipped: false }
131
132const LEG_TOP = BODY.findIndex(row => row.includes('thht..thht'))
133const LEG_BLOCKS = { left: { from: 11, to: 17, dx: -1 }, right: { from: 18, to: 24, dx: 1 } } as const
134
135const SKIN = 'fsq'
136const HANDS = {
137 left: { top: 34, bottom: 38, from: 4, to: 9 },
138 right: { top: 40, bottom: 43, from: 26, to: 30 },
139} as const
140
141function swingHand(rows: string[][], hand: 'left' | 'right', dy: number) {
142 const { top, bottom, from, to } = HANDS[hand]
143 const moved: { y: number; x: number; pixel: string }[] = []
144 for (let y = top; y <= bottom; y++) {
145 for (let x = from; x < to; x++) {
146 const pixel = rows[y]?.[x]
147 if (pixel === undefined || !SKIN.includes(pixel)) continue
148 moved.push({ y: y + dy, x, pixel })
149 rows[y][x] = TRANSPARENT
150 }
151 }
152 for (const { y, x, pixel } of moved) if (rows[y] !== undefined) rows[y][x] = pixel
153}
154
155function swingArms(rows: string[][], stride: 'left' | 'right') {
156 swingHand(rows, 'left', stride === 'left' ? 2 : -2)
157 swingHand(rows, 'right', stride === 'left' ? -2 : 2)
158}
159
160function liftLeg(rows: string[][], stride: 'left' | 'right') {
161 const { from, to, dx } = LEG_BLOCKS[stride]
162 const block = rows.slice(LEG_TOP).map(row => row.slice(from, to))
163 for (const row of rows.slice(LEG_TOP)) row.fill(TRANSPARENT, from, to)
164 block.forEach((line, dy) => {
165 const target = rows[LEG_TOP + dy - 1]
166 if (target === undefined || LEG_TOP + dy - 1 < LEG_TOP) return
167 line.forEach((pixel, i) => {
168 if (pixel !== TRANSPARENT) target[from + i + dx] = pixel
169 })
170 })
171}
172
173export function pixelsOf(mood: Mood, pose: Pose = STANDING): readonly string[] {
174 const rows = BODY.map(row => [...row])
175 for (const { x, y, rows: stamp } of FACES[mood]) {
176 stamp.forEach((line, dy) => {
177 const row = rows[y + dy]
178 if (row === undefined || x + line.length > row.length) {
179 throw new Error(`rem-mascot: mặt '${mood}' tại (${x},${y + dy}) nằm ngoài hình`)
180 }
181 row.splice(x, line.length, ...line)
182 })
183 }
184 if (pose.stride !== 'stand') {
185 liftLeg(rows, pose.stride)
186 swingArms(rows, pose.stride)
187 }
188 return rows.map(row => (pose.isFlipped ? row.reverse() : row).join(''))
189}
190
191export const BLOCK_GLYPHS: readonly (readonly [glyph: string, shape: string])[] = [
192 [' ', '.. .. .. ..'],
193 ['▘', '#. #. .. ..'],
194 ['▝', '.# .# .. ..'],
195 ['▀', '## ## .. ..'],
196 ['▖', '.. .. #. #.'],
197 ['▌', '#. #. #. #.'],
198 ['▞', '.# .# #. #.'],
199 ['▛', '## ## #. #.'],
200 ['▗', '.. .. .# .#'],
201 ['▚', '#. #. .# .#'],
202 ['▐', '.# .# .# .#'],
203 ['▜', '## ## .# .#'],
204 ['▂', '.. .. .. ##'],
205 ['▄', '.. .. ## ##'],
206 ['▙', '#. #. ## ##'],
207 ['▟', '.# .# ## ##'],
208 ['▆', '.. ## ## ##'],
209 ['█', '## ## ## ##'],
210]
211
212type Glyph = { codePoint: number; covers: readonly boolean[] }
213type Fit = { glyph: Glyph; foreground: string; background: string; cost: number }
214
215const GLYPHS: readonly Glyph[] = BLOCK_GLYPHS.map(([glyph, shape]) => ({
216 codePoint: glyph.codePointAt(0) ?? 0x20,
217 covers: [...shape.replaceAll(' ', '')].map(dot => dot === '#'),
218}))
219
220const DEFAULT_COLOR = 0x01000000
221const MISMATCHED_SHAPE_COST = 300 ** 2
222
223function colorOf(pixel: string): number {
224 if (pixel === TRANSPARENT) return DEFAULT_COLOR
225 const color = PALETTE[pixel]
226 if (color === undefined) throw new Error(`rem-mascot: không có màu cho pixel '${pixel}'`)
227 return color
228}
229
230function distance(a: string, b: string): number {
231 if (a === b) return 0
232 if (a === TRANSPARENT || b === TRANSPARENT) return MISMATCHED_SHAPE_COST
233 const [p, q] = [colorOf(a), colorOf(b)]
234 return [16, 8, 0].reduce((sum, shift) => sum + (((p >> shift) & 255) - ((q >> shift) & 255)) ** 2, 0)
235}
236
237function fitCell(block: readonly string[]): Fit {
238 const kinds = [...new Set(block)]
239 const costs = new Map(kinds.map(kind => [kind, block.map(pixel => distance(pixel, kind))]))
240 let best: Fit = { glyph: GLYPHS[0] ?? { codePoint: 0x20, covers: [] }, foreground: TRANSPARENT, background: TRANSPARENT, cost: Infinity }
241
242 for (const background of kinds) {
243 const behind = costs.get(background) ?? []
244 for (const foreground of kinds) {
245 if (foreground === TRANSPARENT) continue
246 const ahead = costs.get(foreground) ?? []
247 for (const glyph of GLYPHS) {
248 const cost = glyph.covers.reduce((sum, covered, i) => sum + ((covered ? ahead[i] : behind[i]) ?? 0), 0)
249 if (cost < best.cost) best = { glyph, foreground, background, cost }
250 }
251 }
252 }
253 return best
254}
255
256export function encode(pixels: readonly string[]): string {
257 if (pixels.length !== BODY.length || pixels.some(row => row.length !== PIXEL_COLUMNS)) {
258 throw new Error(`rem-mascot: hình phải đúng ${PIXEL_COLUMNS}x${BODY.length} pixel`)
259 }
260
261 const { left, top } = PADDING_TO_ALIGN_FACE_WITH_CELLS
262 const at = (x: number, y: number) => pixels[y - top]?.[x - left] ?? TRANSPARENT
263 const view = new DataView(new ArrayBuffer(SPRITE_COLUMNS * SPRITE_ROWS * 12))
264 let offset = 0
265
266 for (let row = 0; row < SPRITE_ROWS; row++) {
267 for (let column = 0; column < SPRITE_COLUMNS; column++) {
268 const block = Array.from({ length: CELL_WIDTH * CELL_HEIGHT }, (_, i) =>
269 at(column * CELL_WIDTH + (i % CELL_WIDTH), row * CELL_HEIGHT + Math.floor(i / CELL_WIDTH)),
270 )
271 const { glyph, foreground, background } = fitCell(block)
272 for (const word of [glyph.codePoint, colorOf(foreground), colorOf(background)]) {
273 view.setUint32(offset, word, true)
274 offset += 4
275 }
276 }
277 }
278
279 const bytes = new Uint8Array(view.buffer)
280 let binary = ''
281 for (const byte of bytes) binary += String.fromCharCode(byte)
282 return btoa(binary)
283}
284hooks/lib/ui-model.mjs 250 lines1export const LIFECYCLE = ["todo", "in-progress", "in-review", "done"];
2
3const NEXT_COMMAND = new Map([["todo", "/ccf:cook"], ["in-progress", "/ccf:cook"], ["in-review", "/ccf:check"], ["done", "/ccf:plan"]]);
4const NO_TASK = { id: "", title: "", status: "", column: "" };
5const NO_SNAPSHOT = { ok: false, active: NO_TASK, tasks: [NO_TASK], openRisks: 0, specStale: false };
6const NO_WAVE_TASK = { id: "", title: "", taskFile: "", worktree: "", branch: "" };
7const NO_AGENT = { agentId: "", taskId: "", isDone: false };
8
9export function columnOf(status = "", isClosed = false) {
10 if (isClosed) return "done";
11 if (/^in[-\s]?review$/i.test(status)) return "in-review";
12 if (/^in[-\s]?progress$/i.test(status)) return "in-progress";
13 return "todo";
14}
15
16export function lifecycleTrack(column = "") {
17 const at = LIFECYCLE.indexOf(column);
18 return LIFECYCLE.map((_, i) => (i < at ? "●" : i === at ? "◉" : "○")).join("━━");
19}
20
21export function nextCommand(column = "") {
22 return NEXT_COMMAND.get(column) ?? "/ccf:plan";
23}
24
25export function bandLine(snapshot = NO_SNAPSHOT) {
26 if (!snapshot || snapshot.ok !== true) return null;
27 const active = snapshot.active;
28 if (active && active.id) {
29 const command = nextCommand(active.column);
30 return { text: `${active.id} ${lifecycleTrack(active.column)} ${active.status} · next: ${command}`, command };
31 }
32 const todo = (snapshot.tasks ?? []).filter((task) => task.column === "todo").length;
33 if (todo > 0) return { text: `CCF · ${todo} todo · next: /ccf:cook`, command: "/ccf:cook" };
34 return { text: "CCF · no open task · next: /ccf:plan", command: "/ccf:plan" };
35}
36
37export function boardColumns(tasks = [NO_TASK]) {
38 return LIFECYCLE.map((column) => ({ column, tasks: tasks.filter((task) => task.column === column) }));
39}
40
41export function claudeMdBudget(report = { files: [{ path: "", bytes: 0, lines: 0, depth: 0 }], total: 0, claudeMdOver: false, limits: { lines: 0, bytes: 0 } }) {
42 const root = (report?.files ?? []).find((file) => file.depth === 0);
43 if (!root) return null;
44 return {
45 bytes: root.bytes,
46 lines: root.lines,
47 total: report.total,
48 isOver: report.claudeMdOver === true,
49 maxBytes: report.limits?.bytes ?? 0,
50 maxLines: report.limits?.lines ?? 0,
51 };
52}
53
54function kilobytes(bytes = 0) {
55 return (bytes / 1024).toFixed(1).replace(/\.0$/, "");
56}
57
58export function statusText(specStale = false, budget = { bytes: 0, lines: 0, total: 0, isOver: false, maxBytes: 0, maxLines: 0 }) {
59 if (specStale !== true && specStale !== false) return undefined;
60 const parts = ["CCF", specStale ? "spec older than code: /ccf:updatespec" : "spec fresh"];
61 if (budget) {
62 parts.push(`CLAUDE.md ${kilobytes(budget.bytes)}/${kilobytes(budget.maxBytes)}KB, ${budget.lines} lines${budget.isOver ? " (over limit)" : ""}`);
63 parts.push(`paid ${kilobytes(budget.total)}KB`);
64 }
65 return parts.join(" · ");
66}
67
68export function matchWaveTask(waves = [[NO_WAVE_TASK]], text = "") {
69 for (const wave of waves) {
70 for (const task of wave) {
71 const marks = [task.taskFile, task.worktree, task.branch, `task-${task.id}-`].filter(Boolean);
72 if (marks.some((mark) => text.includes(mark))) return task.id;
73 }
74 }
75 return null;
76}
77
78const PLAN_WAVES_RUN = /plan-waves\.mjs(["'\s]|$)/;
79
80export function isPlanWavesRun(command = "") {
81 return PLAN_WAVES_RUN.test(String(command ?? ""));
82}
83
84function flagValue(command = "", flag = "") {
85 const found = command.match(new RegExp(`${flag}\\s+(?:"([^"]*)"|'([^']*)'|([^\\s"';|&>]+))`));
86 if (!found) return undefined;
87 return found[1] ?? found[2] ?? found[3];
88}
89
90export function planWavesRequest(command = "") {
91 const text = String(command ?? "");
92 return { dir: flagValue(text, "--dir"), tasks: flagValue(text, "--tasks") };
93}
94
95export function wavesFromOutput(output = "") {
96 const text = String(output ?? "");
97 const start = text.indexOf("{");
98 const end = text.lastIndexOf("}");
99 if (start < 0 || end < start) return null;
100 try {
101 const plan = JSON.parse(text.slice(start, end + 1));
102 return plan?.ok === true && Array.isArray(plan.waves) ? plan.waves : null;
103 } catch {
104 return null;
105 }
106}
107
108export function waveRows(waves = [[NO_WAVE_TASK]], agents = [NO_AGENT], closedIds = [""]) {
109 return waves.map((wave) =>
110 wave.map((task) => {
111 const mine = agents.filter((agent) => agent.taskId === task.id);
112 const state = closedIds.includes(task.id) ? "done" : mine.length === 0 ? "waiting" : mine.every((agent) => agent.isDone) ? "done" : "running";
113 return { id: task.id, title: task.title, state };
114 }),
115 );
116}
117
118export function closedTaskIds(snapshot = NO_SNAPSHOT) {
119 return (snapshot?.tasks ?? []).filter((task) => task.column === "in-review" || task.column === "done").map((task) => task.id);
120}
121
122export function liveAgents(agents = [NO_AGENT]) {
123 return agents.filter((agent) => !agent.isDone);
124}
125
126const PLAN_WRITE_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
127
128export function isPlanWrite(tool = "", filePath = "") {
129 return PLAN_WRITE_TOOLS.has(tool) && /(^|[\\/])\.claude[\\/]plan[\\/]PLAN\.md$/.test(String(filePath ?? ""));
130}
131
132export function isCookTaskBrief(text = "") {
133 return /\bworktree-ccf-/.test(String(text ?? ""));
134}
135
136export function waveLines(rows = [[{ id: "", title: "", state: "" }]]) {
137 return rows.flatMap((wave, index) => [
138 { isHeading: true, text: `wave ${index + 1}`, state: "" },
139 ...wave.map((task) => ({ isHeading: false, text: `${task.id} ${task.title}`, state: task.state })),
140 ]);
141}
142
143export function waveSummary(rows = [[{ id: "", title: "", state: "" }]]) {
144 const tasks = rows.flat();
145 const count = (state = "") => tasks.filter((task) => task.state === state).length;
146 return {
147 waves: rows.map((wave) => ({ title: wave.map((task) => task.id).join(" "), total: wave.length, done: wave.filter((task) => task.state === "done").length })),
148 counts: { done: count("done"), running: count("running"), waiting: count("waiting") },
149 };
150}
151
152export function scrollWindow(total = 0, offset = 0, visible = 1) {
153 const rows = Math.max(1, visible);
154 const start = Math.min(Math.max(0, offset), Math.max(0, total - rows));
155 const end = Math.min(total, start + rows);
156 return { start, end, hiddenAbove: start, hiddenBelow: total - end };
157}
158
159export function waveScroll(region = { top: 0, rows: 0, total: 0 }, offset = 0, by = 0, hasPointer = false, pointerRow = 0) {
160 if (region.total <= region.rows) return null;
161 if (hasPointer && (pointerRow < region.top || pointerRow >= region.top + region.rows)) return null;
162 return scrollWindow(region.total, offset + by, region.rows).start;
163}
164
165export function hiddenMark(count = 0) {
166 return count > 0 ? `.. +${count}` : "";
167}
168
169export function wrappedRows(text = "", width = 1) {
170 const room = Math.max(1, width);
171 let rows = 1;
172 let used = 0;
173 for (const word of String(text ?? "").split(/\s+/).filter(Boolean)) {
174 const size = [...word].length;
175 if (used > 0 && used + 1 + size <= room) {
176 used += 1 + size;
177 continue;
178 }
179 if (used > 0) rows += 1;
180 const extra = Math.floor((size - 1) / room);
181 rows += extra;
182 used = size - extra * room;
183 }
184 return rows;
185}
186
187const BUBBLE_FRAME_ROWS = 2;
188const BUBBLE_FRAME_COLUMNS = 4;
189const MIN_WAVE_ROWS = 3;
190
191export function dockLayout({ bodyRows = 0, bodyColumns = 0, line = "", hasBoard = false, waveLineCount = 0, spriteRows = 0 } = {}) {
192 const bubbleRows = BUBBLE_FRAME_ROWS + wrappedRows(line, bodyColumns - BUBBLE_FRAME_COLUMNS);
193 const hasWaves = waveLineCount > 0;
194 const room = (boardRows = 0) => bodyRows - boardRows - (hasBoard ? 1 : 0) - (hasWaves ? 1 : 0) - bubbleRows - spriteRows;
195 const boardRows = !hasBoard ? 0 : hasWaves && room(2) < MIN_WAVE_ROWS ? 1 : 2;
196 const waveRows = hasWaves ? Math.max(1, Math.min(waveLineCount, room(boardRows))) : 0;
197 return { boardRows, waveTop: boardRows + (hasBoard ? 1 : 0), waveRows };
198}
199
200export function boardSummary(snapshot = NO_SNAPSHOT) {
201 const tasks = snapshot?.tasks ?? [];
202 const [todo, doing, review, done] = boardColumns(tasks).map((column) => column.tasks.length);
203 const risks = snapshot?.openRisks ?? 0;
204 return {
205 closed: done,
206 total: tasks.length,
207 headline: `${done}/${tasks.length} closed · ${risks} risk${risks === 1 ? "" : "s"}`,
208 counts: `todo ${todo} · doing ${doing} · review ${review} · done ${done}`,
209 };
210}
211
212const FULL_BLOCK = 0x2588;
213const LIGHT_SHADE = 0x2591;
214const DONE_COLOR = 0x5fb36b;
215const OPEN_COLOR = 0x6b6b6b;
216const DEFAULT_COLOR = 0x01000000;
217
218function filledCount(done = 0, total = 0, width = 0) {
219 return total > 0 ? Math.round((done / total) * width) : 0;
220}
221
222export function progressCells(done = 0, total = 0, width = 1) {
223 const filled = filledCount(done, total, width);
224 const view = new DataView(new ArrayBuffer(width * 12));
225 for (let i = 0; i < width; i++) {
226 const isFilled = i < filled;
227 view.setUint32(i * 12, isFilled ? FULL_BLOCK : LIGHT_SHADE, true);
228 view.setUint32(i * 12 + 4, isFilled ? DONE_COLOR : OPEN_COLOR, true);
229 view.setUint32(i * 12 + 8, DEFAULT_COLOR, true);
230 }
231 let binary = "";
232 for (const byte of new Uint8Array(view.buffer)) binary += String.fromCharCode(byte);
233 return btoa(binary);
234}
235
236export const NO_TASK_LINE = "Chưa có task nào. Gõ /ccf:plan nhé.";
237
238export function dockIsEmpty(snapshot = NO_SNAPSHOT, waveLineCount = 0) {
239 return waveLineCount === 0 && !((snapshot?.tasks?.length ?? 0) > 0);
240}
241
242export function walkStep({ x = 0, dir = 1 } = {}, room = 0) {
243 if (!(room > 0)) return { x: 0, dir: 1 };
244 const clamped = Math.min(Math.max(0, x), room);
245 const next = clamped + dir;
246 if (next > room) return { x: room - 1, dir: -1 };
247 if (next < 0) return { x: Math.min(1, room), dir: 1 };
248 return { x: next, dir };
249}
250hooks/ui/ccf-ui.mjs 183 lines1import { atom, read, update } from 'claude-code'
2
3import { bandLine, boardSummary, closedTaskIds, hiddenMark, isCookTaskBrief, isPlanWavesRun, progressCells, waveLines, waveRows, waveSummary, wavesFromOutput } from '../lib/ui-model.mjs'
4import { GREETING, VOICE_DEBOUNCE_MS, isVoiceOn, remVoice, runningLine, spawnedTaskId, voiceReply, voiceRequest } from '../lib/rem-lines.mjs'
5
6export const COOK = /(^|\s)\/ccf:cook(\s|$)/
7export const SNAPSHOT_SCRIPT = 'hooks/lib/ui-snapshot.mjs'
8export const BUDGET_SCRIPT = 'scripts/spec-budget.mjs'
9export const WAVES_SCRIPT = 'scripts/plan-waves.mjs'
10export const WAVES_OFF_NOTICE = 'CCF wave map is off, so the Rem dock shows no wave map during /ccf:cook. Turn on uiWaves for the ccf plugin in /config.'
11
12const ACCENT = '#7fb2f0'
13const BAR_COLUMNS = 20
14const STATE_GLYPH = { waiting: '○', running: '◉', done: '●' }
15const WAVES_SCRIPT_LIMIT_MS = 15_000
16
17const snapshot = atom({ plugin: 'ccf', key: 'snapshot' }, null)
18const waves = atom({ plugin: 'ccf', key: 'waves' }, [])
19const agents = atom({ plugin: 'ccf', key: 'agents' }, [])
20const wavesSource = atom({ plugin: 'ccf', key: 'wavesSource' }, null)
21const feeling = atom({ plugin: 'ccf', key: 'feeling' }, GREETING)
22
23export function wantsSnapshot(options) {
24 return options.uiBand === true || options.uiBoard === true || options.uiStatusLine === true || options.uiWaves === true
25}
26
27export function wantsWaves(options) {
28 return options.uiWaves === true
29}
30
31export function bandTree(elements, now, plan, running, options) {
32 const line = bandLine(now)
33 if (!line) return null
34 const count = wantsWaves(options) ? waveRows(plan, running, closedTaskIds(now)).flat().filter(task => task.state === 'running').length : 0
35 const suffix = count > 0 ? ` · ${count} worktree agent${count === 1 ? '' : 's'} running` : ''
36 return h(elements.Text, { dimColor: true, wrap: 'truncate-end' }, `${line.text}${suffix}`)
37}
38
39export function shellOutput(ran) {
40 if (ran.deny !== undefined || ran.isError === true) return ''
41 return typeof ran.result?.stdout === 'string' ? ran.result.stdout : ran.text ?? ''
42}
43
44async function reloadWaves($, source) {
45 try {
46 const dir = source?.dir ?? (await $.session.cwd())
47 const tasks = source?.tasks ? ['--tasks', source.tasks] : []
48 const ran = await $.process.run(['node', `${$.plugin.root}/${WAVES_SCRIPT}`, '--dir', dir, ...tasks], { cwd: dir, timeoutMs: WAVES_SCRIPT_LIMIT_MS })
49 const found = wavesFromOutput(ran.stdout)
50 if (found) await update($, waves, () => found)
51 } catch {}
52}
53
54async function speak($, options, mood, line) {
55 await update($, feeling, () => ({ mood, line }))
56 if (!isVoiceOn(options)) return
57 const turn = remVoice.begin()
58 const known = remVoice.cached(mood, line)
59 if (known !== null) {
60 await update($, feeling, () => ({ mood, line: known }))
61 return
62 }
63 $.clock.after(VOICE_DEBOUNCE_MS, async () => {
64 try {
65 if (!remVoice.isCurrent(turn)) return
66 const reply = await $.model.complete(voiceRequest(mood, line))
67 const said = reply.isAnswered ? voiceReply(reply.text, line) : null
68 if (said === null) return
69 remVoice.remember(mood, line, said)
70 await update($, feeling, now => (now.mood === mood && now.line === line ? { mood, line: said } : now))
71 } catch {}
72 })
73}
74
75async function refreshSnapshot($) {
76 try {
77 const dir = (await read($, wavesSource))?.dir ?? (await $.session.cwd())
78 const ran = await $.process.run(['node', `${$.plugin.root}/${SNAPSHOT_SCRIPT}`, '--dir', dir], { cwd: dir, timeoutMs: WAVES_SCRIPT_LIMIT_MS })
79 const found = JSON.parse(ran.stdout)
80 if (found?.ok === true) await update($, snapshot, () => found)
81 } catch {}
82}
83
84function isShellPlanWavesRun(e) {
85 return (e.tool === 'Bash' || e.tool === 'PowerShell') && isPlanWavesRun(e.command)
86}
87
88export function isWavesRun(e, options) {
89 return wantsWaves(options) && isShellPlanWavesRun(e)
90}
91
92export function isWavesOffRun(e, options) {
93 return !wantsWaves(options) && isShellPlanWavesRun(e)
94}
95
96export function dockWaveLines(plan, running, now) {
97 return waveLines(waveRows(plan, running, closedTaskIds(now)))
98}
99
100export function dockDivider(elements, bodyColumns, key) {
101 return h(elements.Text, { key, dimColor: true, wrap: 'truncate-end' }, '─'.repeat(Math.max(1, bodyColumns)))
102}
103
104export function dockBoardRows(elements, now, rows) {
105 const { Box, Raster, Text } = elements
106 const summary = boardSummary(now)
107 const head = h(
108 Box,
109 { key: 'board-head', flexDirection: 'row', gap: 1 },
110 h(Raster, { key: 'progress', columns: BAR_COLUMNS, rows: 1, cells: progressCells(summary.closed, summary.total, BAR_COLUMNS) }),
111 h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { wrap: 'truncate-end' }, summary.headline)),
112 )
113 return rows > 1 ? [head, h(Text, { key: 'board-counts', dimColor: true, wrap: 'truncate-end' }, summary.counts)] : [head]
114}
115
116export function dockWaveRows(elements, lines, window, openWaves) {
117 const { Box, Button, Text } = elements
118 const shown = lines.slice(window.start, window.end)
119 const last = shown.length - 1
120 return shown.map((line, index) => {
121 const mark = last === 0
122 ? hiddenMark(window.hiddenAbove + window.hiddenBelow)
123 : index === 0 ? hiddenMark(window.hiddenAbove) : index === last ? hiddenMark(window.hiddenBelow) : ''
124 const text = line.isHeading ? line.text : `${STATE_GLYPH[line.state]} ${line.text}`
125 return h(
126 Box,
127 { key: `wave-row-${window.start + index}`, flexDirection: 'row', gap: 1 },
128 h(Box, { flexGrow: 1, flexShrink: 1 }, h(Button, { key: `wave-line-${window.start + index}`, plain: true, dimColor: line.state === 'done', onPress: openWaves }, text)),
129 ...(mark ? [h(Text, { dimColor: true }, mark)] : []),
130 )
131 })
132}
133
134const STATE_LABEL = { waiting: 'chờ', running: 'đang chạy', done: 'xong' }
135export const NO_WAVES_LINE = 'Chưa có wave nào. Chạy /ccf:cook.'
136
137export function wavesTabRows(elements, rows, bodyColumns) {
138 const { Box, Raster, Text } = elements
139 if (rows.length === 0) return [h(Text, { key: 'waves-empty', dimColor: true }, NO_WAVES_LINE)]
140 const { waves: summaries, counts } = waveSummary(rows)
141 const total = counts.done + counts.running + counts.waiting
142 const head = h(
143 Box,
144 { key: 'waves-head', flexDirection: 'row', gap: 1 },
145 h(Raster, { key: 'progress', columns: BAR_COLUMNS, rows: 1, cells: progressCells(counts.done, total, BAR_COLUMNS) }),
146 h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, {}, `${rows.length} wave · ${counts.done} xong · ${counts.running} đang chạy · ${counts.waiting} chờ`)),
147 )
148 const body = rows.flatMap((wave, index) => [
149 ...(index > 0 ? [dockDivider(elements, bodyColumns, `waves-divider-${index}`)] : []),
150 h(Text, { key: `waves-heading-${index}`, bold: true, color: ACCENT }, `wave ${index + 1} · ${summaries[index].total} task · ${summaries[index].done} xong`),
151 ...wave.map(task =>
152 h(
153 Box,
154 { key: `waves-task-${index}-${task.id}`, flexDirection: 'row', gap: 1 },
155 h(Text, { dimColor: task.state === 'done' }, `${STATE_GLYPH[task.state]} ${task.id}`),
156 h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { wrap: 'wrap', dimColor: task.state === 'done' }, task.title)),
157 h(Text, { dimColor: true }, STATE_LABEL[task.state]),
158 ),
159 ),
160 ])
161 return [head, ...body]
162}
163
164export function wavesTabLines(plan, running, now) {
165 return waveRows(plan, running, closedTaskIds(now))
166}
167
168export function registerCcfUi(on, options) {
169 on('agent.spawn', async ($, e, next) => {
170 const started = await next(e)
171 try {
172 const brief = `${e.description}\n${e.name ?? ''}\n${e.prompt}`
173 if (wantsWaves(options) && started.agentId && (await read($, waves)).length === 0 && isCookTaskBrief(brief)) await reloadWaves($, await read($, wavesSource))
174 if (wantsSnapshot(options) && started.agentId && isCookTaskBrief(brief)) $.clock.after(1, () => refreshSnapshot($))
175 const taskId = started.agentId ? spawnedTaskId(await read($, waves), brief) : null
176 if (taskId) await update($, agents, list => [...list, { agentId: started.agentId, taskId, isDone: false }])
177 const line = taskId ? runningLine(await read($, waves), await read($, agents)) : null
178 if (line) await speak($, options, 'thinking', line)
179 } catch {}
180 return started
181 })
182}
183hooks/lib/rem-lines.mjs 183 lines1import { isCookTaskBrief, matchWaveTask } from "./ui-model.mjs";
2
3export const GREETING = { mood: "idle", line: "Rem đây. Ngài cần gì cứ gọi nhé." };
4export const DEFAULT_START_LINE = "Để Rem lo việc này.";
5export const DEFAULT_DONE_LINE = "Xong rồi, ngài xem thử nhé.";
6
7const NO_WAVE_TASK = { id: "", title: "", taskFile: "", worktree: "", branch: "" };
8const NO_AGENT = { agentId: "", taskId: "", isDone: false };
9
10const PROMPT_LABELS = new Map([
11 ["ccf:cook", "cook"],
12 ["ccf:check", "soát code theo spec"],
13 ["ccf:plan", "lên kế hoạch"],
14 ["ccf:updatespec", "cập nhật spec"],
15 ["ccf:init", "dựng spec cho dự án"],
16]);
17
18const TEST = /\b(node\s+--test|claude\s+plugin\s+test|(npm|pnpm|yarn|bun)\s+(run\s+)?test|npx\s+(vitest|jest)|vitest|jest|pytest|cargo\s+test|go\s+test|dotnet\s+test|mvn\s+test|gradlew?\s+test)\b/i;
19const BUILD = /\b(dotnet\s+(build|run|publish)|msbuild|(npm|pnpm|yarn)\s+(run\s+)?build|tsc|mvn|gradlew?|cargo\s+build|go\s+build|claude\s+plugin\s+validate)\b/i;
20const CCF_SCRIPT = /\b([\w-]+)\.mjs\b/;
21const GIT = /\bgit\s+(?:-[Cc]\s+\S+\s+)*([a-z][\w-]*)/;
22const SCRIPT_LINES = new Map([
23 ["plan-waves", "Rem đang chia wave cho backlog."],
24 ["worktree-preflight", "Rem đang soát wave trước khi merge."],
25 ["integrate-wave", "Rem đang merge wave và chạy test."],
26]);
27const FAILED = /Build FAILED|: error [A-Z]+\d+|Failed!|npm ERR!|\b[1-9]\d* (failed|errors?)\b|^ℹ fail [1-9]/im;
28const PASS_COUNT = /\b(\d+) pass(?:ed|ing)?\b|\bpass (\d+)\b/i;
29const FAIL_COUNT = /\b(\d+) fail(?:ed|ing|s)?\b|\bfail (\d+)\b/i;
30
31export function promptActivity(text = "") {
32 const found = String(text ?? "").trim().match(/^\/([\w:-]+)(?:\s+(.*))?$/s);
33 if (!found) return null;
34 const command = found[1];
35 const args = String(found[2] ?? "").trim().split(/\s+/)[0] ?? "";
36 const label = PROMPT_LABELS.get(command);
37 if (command === "ccf:cook") return { command, label: args ? `cook task ${args}` : "cook backlog" };
38 return { command, label: label ?? `chạy /${command}` };
39}
40
41export function startLine(activity = { command: "", label: "" }) {
42 return activity ? `Rem đang ${activity.label} đây.` : DEFAULT_START_LINE;
43}
44
45export function commandActivity(command = "") {
46 const text = String(command ?? "");
47 const test = text.match(TEST);
48 if (test) return { kind: "test", what: test[0], line: `Rem đang chạy test (${test[0]}).` };
49 const script = text.match(CCF_SCRIPT);
50 if (script && /\bnode\b/.test(text)) {
51 const name = script[1];
52 return { kind: "script", what: `${name}.mjs`, line: SCRIPT_LINES.get(name) ?? `Rem đang chạy ${name}.mjs.` };
53 }
54 const build = text.match(BUILD);
55 if (build) return { kind: "build", what: build[0], line: `Rem đang build (${build[0]}).` };
56 const git = text.match(GIT);
57 if (git) return { kind: "git", what: `git ${git[1]}`, line: `Rem đang chạy git ${git[1]}.` };
58 return null;
59}
60
61export function testCounts(output = "") {
62 const text = String(output ?? "");
63 const passFound = text.match(PASS_COUNT);
64 const failFound = text.match(FAIL_COUNT);
65 const pass = passFound ? passFound[1] ?? passFound[2] : undefined;
66 const fail = failFound ? failFound[1] ?? failFound[2] : undefined;
67 const parts = [pass === undefined ? "" : `${pass} pass`, fail === undefined ? "" : `${fail} fail`].filter(Boolean);
68 return parts.join(", ");
69}
70
71function scriptFailed(output = "") {
72 const start = output.indexOf("{");
73 const end = output.lastIndexOf("}");
74 if (start < 0 || end < start) return false;
75 try {
76 const json = JSON.parse(output.slice(start, end + 1));
77 return json?.ok === false || json?.ready === false;
78 } catch {
79 return false;
80 }
81}
82
83export function commandResult(activity = { kind: "", what: "", line: "" }, isError = false, output = "") {
84 const text = String(output ?? "");
85 const failed = isError === true || FAILED.test(text) || (activity.kind === "script" && scriptFailed(text));
86 const counts = activity.kind === "test" || activity.kind === "build" ? testCounts(text) : "";
87 const detail = counts ? ` (${counts})` : "";
88 if (failed) return { failed, mood: "worried", outcome: `${activity.what} lỗi${detail}`, line: `${activity.what} lỗi rồi${detail}. Để Rem xem lại.` };
89 if (activity.kind === "test" || activity.kind === "build") return { failed, mood: "happy", outcome: `${activity.what} xanh${detail}`, line: `${activity.what} xanh rồi${detail}! Rem mừng lắm.` };
90 return { failed, mood: "thinking", outcome: `${activity.what} xong`, line: `${activity.what} xong rồi, Rem làm tiếp nhé.` };
91}
92
93export function spawnedTaskId(waves = [[NO_WAVE_TASK]], brief = "") {
94 const text = String(brief ?? "");
95 const matched = matchWaveTask(Array.isArray(waves) ? waves : [], text);
96 if (matched) return matched;
97 if (!isCookTaskBrief(text)) return null;
98 return text.match(/\btask-([A-Za-z0-9]+)-/)?.[1] ?? null;
99}
100
101function taskName(waves = [[NO_WAVE_TASK]], id = "") {
102 const title = waves.flat().find((task) => task.id === id)?.title;
103 return title ? `${id} (${title})` : id;
104}
105
106export function runningLine(waves = [[NO_WAVE_TASK]], agents = [NO_AGENT], extraIds = [""]) {
107 const list = Array.isArray(waves) ? waves : [];
108 const ids = [...new Set([...(Array.isArray(agents) ? agents : []).filter((agent) => !agent.isDone).map((agent) => agent.taskId), ...(Array.isArray(extraIds) ? extraIds : [])].filter(Boolean))];
109 if (ids.length === 0) return null;
110 return `Rem đang cook task ${ids.map((id) => taskName(list, id)).join(", ")}.`;
111}
112
113export function agentDoneLine(waves = [[NO_WAVE_TASK]], agents = [NO_AGENT], doneTaskId = "") {
114 if (!doneTaskId) return null;
115 const list = Array.isArray(waves) ? waves : [];
116 const rest = [...new Set((Array.isArray(agents) ? agents : []).filter((agent) => !agent.isDone && agent.taskId !== doneTaskId).map((agent) => agent.taskId).filter(Boolean))];
117 const head = `Task ${taskName(list, doneTaskId)} xong rồi`;
118 return rest.length > 0 ? `${head}, còn ${rest.join(", ")} đang chạy.` : `${head}, cả wave đã báo về.`;
119}
120
121export function doneLine(activity = { command: "", label: "" }, outcome = "") {
122 const label = activity?.label ?? "";
123 if (label && outcome) return `Rem ${label} xong: ${outcome}. Ngài xem thử nhé.`;
124 if (outcome) return `Xong rồi: ${outcome}. Ngài xem thử nhé.`;
125 if (label) return `Rem ${label} xong rồi, ngài xem thử nhé.`;
126 return DEFAULT_DONE_LINE;
127}
128
129export const VOICE_MODEL = "claude-haiku-4-5";
130export const VOICE_DEBOUNCE_MS = 600;
131export const VOICE_TIMEOUT_MS = 8000;
132const VOICE_MAX_TOKENS = 120;
133const VOICE_MAX_CHARS = 120;
134const VOICE_CACHE_LIMIT = 100;
135const VOICE_SYSTEM = [
136 "Bạn là Rem trong Re:Zero: cô hầu gái tận tụy 17 tuổi của tộc Quỷ, tóc xanh ngắn che mắt phải, làm ở dinh thự của Roswaal, em gái song sinh của chị gái Ram. Giờ Rem đứng cạnh ô chat trong Claude Code. Rem coi ngài là anh hùng của Rem và hết lòng vì ngài.",
137 "Những nét thuộc về Rem: chiếc sừng của tộc Quỷ, chùy sao băng, lòng biết ơn sâu nặng, đôi lúc tự thấy mình kém cỏi so với chị gái Ram, và niềm tin sẽ cùng ngài bắt đầu lại từ con số không. Nhắc tới những nét này thật hiếm và đúng lúc, không nhồi vào câu nào chỉ cho có không khí.",
138 "Rem không bao giờ nhắc tới Subaru hay bất kỳ tên riêng nào của ngài, vì Rem chỉ gọi người dùng là ngài.",
139 "Rem nói tiếng Việt, tự xưng Rem ở ngôi thứ ba, gọi người dùng là ngài, giọng dịu dàng và ấm áp, kiên định khi có chuyện, hay thẹn khi được khen.",
140 "Thỉnh thoảng, chỉ khi hợp tình huống, Rem ví việc code như việc nhà: dọn dẹp, quét bụi, gỡ chỉ rối, mùi khét. Không ví von ở mọi câu.",
141 "Cách nói theo tâm trạng. idle: nhẹ nhàng, sẵn sàng chờ lệnh. thinking: chăm chú, nghiêm túc làm việc. happy: vui, hơi đỏ mặt. worried: lo cho ngài nhưng vẫn vững vàng, sẵn sàng sửa. sleepy: ngái ngủ, nói khẽ. surprised: bối rối, giật mình.",
142 "Nói tự nhiên như người thật đang nói, tránh lối viết công thức, khuôn sáo của máy. Không lặp lại cùng một khuôn câu hay một cách mở đầu.",
143 "Viết lại câu gốc thành đúng MỘT câu ngắn gọn, tối đa 100 ký tự.",
144 "Giữ nguyên mọi sự kiện trong câu gốc: mã task, lệnh bắt đầu bằng /, tên file, mọi con số. Không thêm thông tin mới.",
145 "Tuyệt đối không dùng dấu gạch dài (em-dash). Không emoji, không markdown, không ngoặc kép. Chỉ trả về câu thoại.",
146].join("\n");
147const FACT = /\/[\w:-]+|[\w.-]+\.(?:mjs|md|tsx?|jsx?|json)\b|\d+/g;
148const QUOTES = /^["'“”‘’«»]+|["'“”‘’«»]+$/g;
149
150export function isVoiceOn(options = { remAi: true }) {
151 return options?.remAi !== false;
152}
153
154export function voiceRequest(mood = "idle", line = "") {
155 return { model: VOICE_MODEL, system: VOICE_SYSTEM, prompt: `Tâm trạng: ${mood}\nCâu gốc: ${line}`, maxTokens: VOICE_MAX_TOKENS, timeoutMs: VOICE_TIMEOUT_MS };
156}
157
158export function voiceReply(text = "", line = "") {
159 const first = String(text ?? "").split(/\r?\n/).map((part) => part.trim()).find(Boolean) ?? "";
160 const said = first.replace(QUOTES, "").replace(/\s+/g, " ").trim();
161 if (!said || [...said].length > VOICE_MAX_CHARS || said.includes("—")) return null;
162 const facts = String(line ?? "").match(FACT) ?? [];
163 return facts.every((fact) => said.includes(fact)) ? said : null;
164}
165
166export function createVoice(limit = VOICE_CACHE_LIMIT) {
167 const rewrites = new Map();
168 let latest = 0;
169 const keyOf = (mood = "", line = "") => `${mood}\n${line}`;
170 return {
171 cached: (mood = "", line = "") => rewrites.get(keyOf(mood, line)) ?? null,
172 remember: (mood = "", line = "", said = "") => {
173 rewrites.delete(keyOf(mood, line));
174 rewrites.set(keyOf(mood, line), said);
175 if (rewrites.size > limit) rewrites.delete(rewrites.keys().next().value);
176 },
177 begin: () => ++latest,
178 isCurrent: (id = 0) => id === latest,
179 };
180}
181
182export const remVoice = createVoice();
183types/index.d.ts 39 lines1export type Mood = 'idle' | 'thinking' | 'happy' | 'worried' | 'sleepy' | 'surprised'
2
3export type Feeling = { mood: Mood; line: string }
4
5export type Walk = { x: number; dir: 1 | -1 }
6
7export type CcfColumn = 'todo' | 'in-progress' | 'in-review' | 'done'
8
9export type CcfTask = { id: string; title: string; status: string; column: CcfColumn }
10
11export type CcfSnapshot = { ok: true; active: CcfTask | null; tasks: CcfTask[]; openRisks: number; specStale: boolean }
12
13export type CcfBudget = { bytes: number; lines: number; total: number; isOver: boolean; maxBytes: number; maxLines: number }
14
15export type CcfWaveTask = { id: string; title: string; taskFile: string | null; worktree: string | null; branch: string | null }
16
17export type CcfAgent = { agentId: string; taskId: string; isDone: boolean }
18
19export type CcfWavesSource = { dir: string; tasks: string | null }
20
21declare module 'claude-code' {
22 interface PluginState {
23 ccf: {
24 feeling: Feeling
25 isDismissed: boolean
26 isDocked: boolean
27 snapshot: CcfSnapshot | null
28 budget: CcfBudget | null
29 statusLine: string | null
30 waves: CcfWaveTask[][]
31 agents: CcfAgent[]
32 wavesSource: CcfWavesSource | null
33 hasNoticedWavesOff: boolean
34 wavesOffset: number
35 walk: Walk
36 }
37 }
38}
39