SLOPSHOPPER

ccf

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

newpanebandguardtoaststatus
★ 9v0.22.0MITupdated 2026-10-07naniiluja/ccf/plugins/ccf
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccf
│ ┃ rem ✕ › fix the failing auth test and add an aud╭────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ ccf │ │ ┃ │ Xong rồi: bun test lỗi (3 pass, 1 fail). │ ⏺ Read(src/auth.ts) │ Rem làm xong rồi (42s) │ │ ┃ │ Ngài xem thử nhé. │ ⎿ Read 6 lines ╰────────────────────────╯ │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ▂ ▂▄ ⎿ Added 2 lines, removed 1 line │ ┃ ▂ ▟ ▐▆▟▄▄▄▄▆▄ ⏺ Bash(bun test) │ ┃ ▝ ▆▆ ▆▆▟ ▆▗▝▖ ⎿ 3 pass, 1 fail │ ┃ ▄▞▞▗▄▄ ▗▖▟ ▙▐ │ ┃ ▝▝▟▐▙▟▄▆ ▖ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▂▜▐▟ ▗▞▂▟ ▐ │ ┃ ▝▌▆▐▗▄ ▐▖▖▖ ▖ ▗▟▗ ✻ Worked for 42s · done 4:20 PM │ ┃ ▀▝▟▞▄▝ ▗▖ ▆▆▆▐▗ │ ┃ ▗▂▆▂▂▂▜▀▝▘ │ ┃ ▗▖▄▚▘▆▗▟▄▙▆ │ ┃ ▗▄▖▗▌▆▆▆▘▄▂▘ │ ┃ ▀▘▐▆▄▟ ▄▟▖ │ ┃ ▀▄▂▄▂▂▝▙▟▘ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Xong rồi: bun test lỗi (3 pass, 1 fail). Ngài xem thử nhé. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Xong rồi: bun test lỗi (3 pass, 1 fail). Ngài xem thử nhé. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · rem
╭──────────────────────────────────────────────────────────╮ │ Xong rồi: bun test lỗi (3 pass, 1 fail). Ngài xem thử │ │ nhé. │ ╰──────────────────────────────────────────────────────────╯ ▂ ▂▄ ▂ ▟ ▐▆▟▄▄▄▄▆▄ ▝ ▆▆ ▆▆▟ ▆▗▝▖ ▄▞▞▗▄▄ ▗▖▟ ▙▐ ▝▝▟▐▙▟▄▆ ▖ ▂▜▐▟ ▗▞▂▟ ▐ ▝▌▆▐▗▄ ▐▖▖▖ ▖ ▗▟▗ ▀▝▟▞▄▝ ▗▖ ▆▆▆▐▗ ▗▂▆▂▂▂▜▀▝▘ ▗▖▄▚▘▆▗▟▄▙▆ ▗▄▖▗▌▆▆▆▘▄▂▘ ▀▘▐▆▄▟ ▄▟▖ ▀▄▂▄▂▂▝▙▟▘
Pane · waves
Chưa có wave nào. Chạy /ccf:cook.
README

CCF Plugin

The Claude Context First plugin. See the root README for installation and an overview.

Layout

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

Agents (all read-only)

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.

AgentRole
ccf-codebase-analyzerReads one slice of the codebase, reports what exists. Fanned out 5x by /ccf:init and /ccf:plan.
ccf-best-practice-researcherFetches cited best practices from Context7 / MS Learn.
ccf-spec-writerDrafts spec text from a decisions summary; the main thread writes the files.
ccf-spec-checkerFresh-context reviewer used by /ccf:check.
ccf-scope-checkerSecond reviewer in /ccf:check: does the diff stay inside the task's files and criteria, and cover all of them.
ccf-finding-refuterRuns 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.

Hooks

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.

HookEventBehavior
plan-mode-guardUserPromptSubmitBlocks /ccf:plan outside plan mode (exit 2).
plan-skill-injectUserPromptSubmitIn plan mode, nudges the model toward the ccf:plan skill, once per session. Never blocks.
session-startSessionStartRe-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-nudgeStopAdvisory only. Five nudges: verify your work, run check then updatespec, mark done tasks, archive closed iterations, prune old archived task files.
auto-verifyStopOpt-in (--auto-verify). The only blocking Stop hook: drives one verify step (/ccf:check, then /ccf:updatespec) via decision: "block".
completion-evidenceStopOpt-in (--completion-evidence + TYPESAFE_API_KEY). Advisory: asks Jev whether the diff meets the task's criteria. The diff leaves your machine.
explore-guide-injectSubagentStart (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

UI layer (function-hooks mod, opt-in)

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):

OptionWhat it shows
uiBandA line above the prompt: the active task, its lifecycle step and the next command, e.g. 070 ●━━●━━◉━━○ in-review · next: /ccf:check.
uiBoardThe 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).
uiWavesOn /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.
uiStatusLineWhether 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.

Scripts

Human-run CLIs. File-mutating actions belong here, never in a hook.

ScriptWhat it does
archive-plan.mjsRetire a fully-closed iteration: PLAN.md → ARCHIVE.md (--apply to perform; default previews).
jev-slice-check.mjsAdvisory: ask Jev which open tasks depend on each other (needs TYPESAFE_API_KEY).
jev-backtest.mjsBacktest Jev's dependency recall against the archived task corpus (needs TYPESAFE_API_KEY).
jev-verify-findings.mjsAdvisory: ask Jev whether each FAIL: in a /ccf:check report is really in the diff. Annotates only.
plan-waves.mjsPrint the wave split for the backlog; --inline lets Jev mark small tasks to run in the main session.
worktree-preflight.mjsRead-only pre-merge check of a parallel wave (scope, overlap, merge conflicts).
integrate-wave.mjsMerge a wave: preflight, --no-ff merge per branch, tests after every merge, reset to last green on red.
prune-archive.mjsKeep 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.mjsRead-only: measure CLAUDE.md plus every recursive @import (bytes, lines, depth, total); /ccf:updatespec prints it before → after.
eval-changelog.mjsWrite 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.mjsRead-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.

Templates

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

Source 6 files
hooks/register.tsx 461 lines
1import { 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}
461
hooks/sprite.ts 284 lines
1import 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}
284
hooks/lib/ui-model.mjs 250 lines
1export 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}
250
hooks/ui/ccf-ui.mjs 183 lines
1import { 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}
183
hooks/lib/rem-lines.mjs 183 lines
1import { 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();
183
types/index.d.ts 39 lines
1export 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