Band above the prompt and an on-demand pane with the session's plan, agents, files and flow

Personal Claude Code configuration — global instructions, rules, hooks, skills, and tooling. This directory is itself the git repo (jongwoo315/claude-config, public since 2026-07-29), synced across machines. Credentials live in ~/.zshenv and are never committed.
| Path | Purpose |
|---|---|
CLAUDE.md | Global instructions loaded into every session |
rules/ | Domain rules (git, apis, databases, python-env, …) merged into context |
hooks/ | Session/tool lifecycle hooks |
skills/, commands/ | User-invocable skills and slash commands |
orch/ | tmux Claude orchestrator (see below) |
docs/plans/ | Design docs and implementation plans |
kimi-tools/ | Kimi K2 delegation CLIs (token saving) |
Most of this repo is configuration. Three files are not — they hold measurements that were run to settle a question, and they are kept mainly because each one recorded a hypothesis being overturned.
| File | How it was measured | Result |
|---|---|---|
rules/korean-style.md | Same prompts run with and without the rule loaded; coined-term rate per 10k characters | 3.0 → 0.78. One term family went from 5 variants to 0. A full sweep of one repo found 194 occurrences across 7 terms. |
RTK.md | Byte-for-byte diff of a token-saving CLI proxy's output against the raw command, per subcommand | Two subcommands saved 0% — output was byte-identical up to 1.38MB, so they were removed from the rewrite hook. Others were real: 186 → 20 bytes, 274 → 89 bytes. |
judgment-log.md | Per-PR record written in two passes — prediction before reading the diff, evidence after — so the hit rate can't be filled in retroactively | 4 PRs logged. Every factual cell carries its source, because a loop's own PR body is not independent evidence of its own tests passing. |
Each of the three overturned the assumption that prompted it:
Absolute figures without a comparison are not treated as results here — a measurement with no baseline is recorded as "no baseline yet, this is the first", not as a pass.
Automation layer over the tmux-claude-session-manager plugin. Queue tasks; orch auto-spawns claude-orch-* tmux sessions to work them (concurrency-capped) and auto-advances pipeline steps by watching the plugin's @claude_state. Full docs: orch/README.md.
orch is on PATH via ~/.local/bin/orch → ~/.claude/orch/bin/orch.
The plugin's Claude Code hooks (which stamp @claude_state) must be installed — orch can't detect step completion without them.
# Single task — one prompt, one session, runs unattended
orch add ~/prv/myproject "add a healthcheck endpoint and a test"
# dir is optional — one arg targets the current directory
orch add "add a healthcheck endpoint and a test"
# Pipeline — steps fed one-by-one as each turn finishes
orch pipe ~/prv/myproject \
"write failing tests for the parser" \
"make them pass" \
"refactor and run the full suite"
# Fan-out — one task per dir, same prompt
orch add --each ~/prv/a ~/prv/b ~/prv/c "bump deps and run tests"
# Run with --safe (task prompts for permission instead of skip-perms)
orch add --safe ~/prv/prod-repo "review and fix the migration"
# Start the daemon (default 3 concurrent), then watch/manage
orch start --max 2
orch ls # status table: queued / working / done / failed
orch logs <id> # dump a task's session pane
orch stop # stop daemon (running sessions stay alive)
Inspect a running session anytime with the plugin picker (prefix + u).
Warning: tasks run with
--dangerously-skip-permissionsby default. Don't point orch at repos where an unreviewedrm/git push/ prod action could do damage — use--safeand keep--maxlow there.
See orch/README.md for advance/failure semantics and env-var config.
hooks/register.js 846 lines1import { THEMES } from './themes.js'
2import { agentRows, changeRows, countLines, cutDiff, displayWidth, fileRows, flowRows, mergeAgents, parseNumstat, parsePlanTasks, parseStatus, stalePinKeys, turnRows } from './model.js'
3
4const PANE = 'work-board'
5const PLANS_DIR = 'docs/plans'
6const DIFF_LINES = 400 // the diff tab draws this many lines of a diff, or this many characters (Code's limit),
7const DIFF_CHARS = 100000 // cut at a hunk
8const TMP = '/private/tmp'
9let themeName = 'classic'
10const theme = () => THEMES[themeName]
11// Dim text: the theme's muted color when it has one, else the terminal's dim
12const muted = () => (theme().muted ? { color: theme().muted } : { dimColor: true })
13
14// What the pane shows. Module variables: a reload of this file (a save during development) empties them.
15let tab = 'work'
16// The tab row, the order left/right walks and the band's order; a tab's label is its name capitalized
17const TABS = ['work', 'pin', 'changes', 'diff', 'flow', 'agents', 'orch']
18const RING = 'ring'
19const INDENT = 1 // the body's left indent: the width of RING, drawn before the tabs
20let orchTasks = [] // orch's queue as loadOrch reads it, for the band
21let tmuxSession = null // the tmux session this one runs in: orch tasks started from it name it their parent
22let isWorking = false // between turn.start and turn.complete: the newest turn is still running
23// /board off: no band, no pane, no polling. Kept in the store under 'off' so every session starts off;
24// /board itself stays registered, or there would be nothing left to turn it back on with
25let isOff = false
26let turnFilter = null // the turn (index in turnRows) the changes and diff tabs are narrowed to, or null
27let detailTurn = null // the turn (index in turnRows) the work tab has opened, or null for the list
28// This session's pins, kept in the store under pinKey ('pins:<session id>'): a turn's reply
29// { turn, prompt, text }, or what was pasted after /board pin { isPasted, at, text }
30// so a reload or a resume keeps them; each session has its own key, or two would overwrite each other
31let pins = []
32let pinKey = null
33let pinError = null // why the last write of the pins failed, shown on the pin tab
34const PIN_SESSIONS = 20 // the sessions whose pins the store keeps
35// Markdown takes tab and newline as its only control characters and draws 100000 characters in one drawing
36const PIN_CHARS = 10000
37const DRAWN_CHARS = 90000
38const pinId = (p) => (p.isPasted ? 'paste-' + p.at : String(p.turn))
39const clip = (text) =>
40 text
41 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
42 .trim()
43 .slice(0, PIN_CHARS)
44let seenAgents = []
45let hasRunningAgent = false
46let flowAt = null // mtime of the newest archify candidate, or null
47let selected = null // the path the changes tab last picked, shown on the diff tab
48
49const MARK = { in_progress: '◐', pending: '○', completed: '●', unknown: '·' }
50// The tags rules/after-pr.md puts on a changed-flow diagram
51const tagColor = (tag) => (tag === '새로 생김' ? theme().add : tag === '없어짐' ? theme().del : /바뀜|좁아짐/.test(tag) ? theme().warn : undefined)
52const agentColor = (status) => {
53 const t = theme()
54 return { running: t.warn, waiting: t.warn, completed: t.add, failed: t.del, killed: t.del }[status]
55}
56
57// Reads the newest plan file under a worktree. No plan, no Task list.
58async function loadPlan($, base) {
59 const dir = base + '/' + PLANS_DIR
60 if (!(await $.fs.exists(dir))) return null
61 const files = (await $.fs.list(dir)).filter((f) => f.kind === 'file' && /-plan-.*\.md$/.test(f.name))
62 if (files.length === 0) return null
63 const newest = files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
64 const tasks = parsePlanTasks(await $.fs.read(dir + '/' + newest.name))
65 return tasks.length > 0 ? { name: newest.name, tasks } : null
66}
67
68// The tmux session this one runs in, read the way orch reads a task's parent (orch/lib/task.sh)
69async function loadTmuxSession($) {
70 const pane = await $.env.get('TMUX_PANE')
71 if (!pane) return null
72 const out = await $.process.run(['tmux', 'display-message', '-pt', pane, '#{session_name}'])
73 return out.exitCode === 0 ? out.stdout.trim() || null : null
74}
75
76// The orch tasks started from tmux session parent (~/.claude/orch/queue/task-*.json), running
77// first, each with its worktree's plan. An orch session starts none, so it sees none.
78// Plans live in the worktrees orch runs, not under the main session's directory.
79const ORCH_ORDER = { running: 0, queued: 1, failed: 2, done: 3 }
80async function loadOrch($, parent) {
81 const queue = (await $.env.get('HOME')) + '/.claude/orch/queue'
82 if (parent === null || !(await $.fs.exists(queue))) return []
83 const tasks = []
84 for (const f of await $.fs.list(queue)) {
85 if (f.kind !== 'file' || !/^task-.*\.json$/.test(f.name)) continue
86 let task
87 try {
88 task = JSON.parse(await $.fs.read(queue + '/' + f.name))
89 } catch {
90 continue // orch is rewriting it, or it is gone: the next read has it
91 }
92 if (task.parent !== parent) continue
93 const plan = task.target ? await loadPlan($, task.target).catch(() => null) : null
94 tasks.push({ ...task, plan })
95 }
96 return tasks.sort((a, b) => (ORCH_ORDER[a.status] ?? 4) - (ORCH_ORDER[b.status] ?? 4))
97}
98
99// The newest archify candidate.json of this session: archify's default <cwd>/.archify, or this
100// session's scratchpad (rules/after-pr.md keeps company diagrams there) — either its .archify or a
101// directory straight under it, since -o names any folder. Another session's is not this work.
102// The scratchpad's path names the session; <cwd>/.archify is every session's that opened the cwd,
103// so a folder there counts only when this session's tool calls name it (a Write of its candidate, a finalize)
104async function loadFlow($) {
105 const sid = await $.session.id()
106 const shared = (await $.session.cwd()) + '/.archify'
107 const roots = [shared]
108 const messages = await $.session.messages().catch(() => [])
109 const said = JSON.stringify(messages.flatMap((m) => (m.toolUses ?? []).map((use) => use.input)))
110 try {
111 for (const tmp of await $.fs.list(TMP)) {
112 if (tmp.kind !== 'dir' || !tmp.name.startsWith('claude-')) continue
113 for (const project of await $.fs.list(TMP + '/' + tmp.name)) {
114 if (project.kind !== 'dir') continue
115 const scratchpad = [TMP, tmp.name, project.name, sid, 'scratchpad'].join('/')
116 roots.push(scratchpad, scratchpad + '/.archify')
117 }
118 }
119 } catch {
120 // The file system may refuse /private/tmp: then only the cwd counts
121 }
122 let newest = null
123 for (const root of roots) {
124 if (!(await $.fs.exists(root))) continue
125 for (const dir of await $.fs.list(root)) {
126 if (dir.kind !== 'dir' || (root === shared && !said.includes(dir.name))) continue
127 const files = await $.fs.list(root + '/' + dir.name)
128 const candidate = files.find((f) => f.name === 'candidate.json')
129 if (candidate && (!newest || candidate.mtimeMs > newest.mtimeMs)) {
130 const html = files.find((f) => f.name.endsWith('.html'))
131 newest = { dir: root + '/' + dir.name, mtimeMs: candidate.mtimeMs, html: html ? root + '/' + dir.name + '/' + html.name : null }
132 }
133 }
134 }
135 if (!newest) return null
136 return { ...newest, candidate: JSON.parse(await $.fs.read(newest.dir + '/candidate.json')) }
137}
138
139// When the newest archify diagram was written, for the band. Read at session start and turn end
140// rather than on every draw, since it walks /private/tmp.
141async function loadFlowAt($) {
142 try {
143 return (await loadFlow($))?.mtimeMs ?? null
144 } catch {
145 return null
146 }
147}
148
149// The first line of what git or the runtime said, to put after a note: the reason a count is missing
150const why = (text) => {
151 const first = String(text ?? '').split('\n')[0].trim()
152 return first ? ' (' + first + ')' : ''
153}
154
155// Writes this session's pins (none: drops its key) and drops the keys of all but the newest sessions
156async function savePins($) {
157 try {
158 pinKey ??= 'pins:' + (await $.session.id())
159 if (pins.length === 0) await $.store.delete(pinKey)
160 else await $.store.set(pinKey, { at: await $.clock.now(), pins })
161 // This session's own key is never the stale one, whatever its clock says
162 const others = (await $.store.keys()).filter((key) => key.startsWith('pins:') && key !== pinKey)
163 if (others.length >= PIN_SESSIONS) {
164 const entries = await Promise.all(others.map(async (key) => ({ key, at: (await $.store.get(key))?.at ?? 0 })))
165 for (const key of stalePinKeys(entries, PIN_SESSIONS - 1)) await $.store.delete(key)
166 }
167 pinError = null
168 } catch (err) {
169 pinError = '저장하지 못했다' + why(err?.message ?? err) + '. 이 세션이 끝나면 사라진다'
170 }
171}
172
173// The changes tab's rows: what the conversation edited that git still reports, with +/-. Where git can't
174// say (no repo, git failing) the rows are the transcript's edited files alone and `note` says why.
175async function loadChanges($) {
176 const cwd = await $.session.cwd()
177 const messages = await $.session.messages()
178 // A picked turn narrows to its own messages. A cd made in an earlier turn is not seen then, so a
179 // relative path a Bash command wrote resolves against the session's directory
180 const turn = turnFilter === null ? undefined : turnRows(messages)[turnFilter]
181 const files = fileRows(turn ? messages.slice(turn.start, turn.end) : messages, cwd, await $.env.get('HOME'))
182 const withoutGit = (note) => ({
183 changed: files.edited.map((f) => ({ ...f, rel: null, add: null, del: null, isBinary: false, isUntracked: false })),
184 settled: [],
185 read: files.read,
186 note,
187 })
188 try {
189 const run = (argv, dir) => $.process.run(argv, { cwd: dir })
190 const top = await run(['git', 'rev-parse', '--show-toplevel', '--show-prefix'], cwd)
191 if (top.exitCode !== 0) return withoutGit('git repo가 아니라 +/−를 못 센다' + why(top.stderr))
192 const [root, prefix = ''] = top.stdout.split('\n')
193 // git names the root by its real path; the transcript's paths go by the session's, which may run through a symlink
194 const logicalRoot = prefix && cwd.endsWith('/' + prefix.replace(/\/$/, '')) ? cwd.slice(0, cwd.length - prefix.length) : prefix ? root : cwd
195 const status = await run(['git', 'status', '--porcelain', '-z', '--untracked-files=all'], root)
196 const diff = await run(['git', 'diff', '--numstat', '-z', 'HEAD'], root)
197 if (status.exitCode !== 0 || diff.exitCode !== 0) return withoutGit('git이 실패해서 +/−를 못 센다' + why(status.exitCode !== 0 ? status.stderr : diff.stderr))
198 const args = { files, status: parseStatus(status.stdout), stats: parseNumstat(diff.stdout), lines: {}, root: logicalRoot, cwd, home: await $.env.get('HOME') }
199 // An untracked file has no diff stat: its lines are all added. One that can't be read (over 4 MiB, gone
200 // since git looked) is left without a count; the other rows keep theirs.
201 const untracked = changeRows(args).changed.filter((r) => r.isUntracked)
202 for (const r of untracked) args.lines[r.rel] = await $.fs.read(root + '/' + r.rel).then(countLines, () => undefined)
203 return { ...changeRows(args), root, note: null }
204 } catch (e) {
205 return withoutGit('git이 실패해서 +/−를 못 센다' + why(e?.message ?? e))
206 }
207}
208
209// The diff of one changes row: tracked against HEAD, untracked against /dev/null (which exits 1 when the
210// files differ). Resolves { source, total } or { error }.
211async function loadDiff($, root, file) {
212 try {
213 // A renamed file's old path goes in too, or git shows the new one as all added
214 const paths = [file.rel, ...(file.oldRel ? [file.oldRel] : [])]
215 const argv = file.isUntracked ? ['git', 'diff', '--no-index', '--', '/dev/null', file.rel] : ['git', 'diff', 'HEAD', '--', ...paths]
216 const out = await $.process.run(argv, { cwd: root })
217 if (out.exitCode > (file.isUntracked ? 1 : 0)) return { error: 'git diff가 실패했다' }
218 if (out.stdout === '') return { error: 'diff가 비어 있다' }
219 const cut = cutDiff(out.stdout, DIFF_LINES, DIFF_CHARS)
220 return cut.isTooBig ? { error: 'hunk가 없어 자를 수 없다 (' + cut.total + '줄)' } : cut
221 } catch {
222 return { error: 'git diff가 실패했다' }
223 }
224}
225
226const openPane = ($) => $.ui.open({ id: PANE, title: 'Work', focus: true, closeOnEscape: true })
227
228function seconds(ms) {
229 if (ms === null || ms === undefined) return '…'
230 return ms < 60000 ? Math.round(ms / 1000) + 's' : Math.round(ms / 60000) + 'm'
231}
232
233export function register(on) {
234 on('session.start', async ($, e, next) => {
235 await $.command.register({ name: 'board', description: 'Open the work board, turn it on or off, or pick its colors', argumentHint: '[on | off | theme [name] | pin <pasted text>]', immediate: true })
236 isOff = (await $.store.get('off').catch(() => undefined)) === true
237 // The saved theme is a nicety: a store that can't be read leaves the classic colors
238 const saved = await $.store.get('theme').catch(() => undefined)
239 if (typeof saved === 'string' && Object.hasOwn(THEMES, saved)) themeName = saved
240 // The pane opens only when asked (/board): a docked pane always on screen
241 // was drawn over by the / typeahead. The band above the prompt sums it up meanwhile.
242 pinKey = await $.session
243 .id()
244 .then((sid) => 'pins:' + sid)
245 .catch(() => null)
246 const savedPins = pinKey && (await $.store.get(pinKey).catch(() => undefined))
247 pins = Array.isArray(savedPins?.pins) ? savedPins.pins : []
248 tmuxSession = await loadTmuxSession($).catch(() => null)
249 orchTasks = await loadOrch($, tmuxSession).catch(() => [])
250 flowAt = await loadFlowAt($)
251 $.ui.invalidate('ui.render')
252 // Subagent status changes don't arrive as events, so poll while one is running
253 $.clock.every(2000, () => {
254 if (hasRunningAgent && !isOff) $.ui.invalidate('ui.render')
255 })
256 // Nor do orch's: its queue files change under a task's own session
257 $.clock.every(5000, async () => {
258 if (isOff) return
259 const latest = await loadOrch($, tmuxSession).catch(() => orchTasks)
260 if (JSON.stringify(latest) !== JSON.stringify(orchTasks)) {
261 orchTasks = latest
262 $.ui.invalidate('ui.render')
263 }
264 })
265 return next(e)
266 })
267
268 on('command.run', { command: 'board' }, async ($, e) => {
269 const [sub, name] = e.args.trim().split(/\s+/)
270 if (sub === 'off' || sub === 'on') {
271 isOff = sub === 'off'
272 if (isOff) await $.ui.close({ id: PANE }).catch(() => {})
273 else orchTasks = await loadOrch($, tmuxSession).catch(() => orchTasks)
274 $.ui.invalidate('ui.render')
275 const said = isOff ? '보드를 껐다. /board on으로 다시 켠다' : '보드를 켰다'
276 try {
277 if (isOff) await $.store.set('off', true)
278 else await $.store.delete('off')
279 } catch {
280 return { text: said + '. 저장하지 못해서 새 세션에서는 이전 상태로 열린다' }
281 }
282 return { text: said }
283 }
284 if (isOff) return { text: '보드가 꺼져 있다. /board on으로 켠다' }
285 // A part of a reply: the engine sees no selection with fullscreen off, but the terminal copies
286 // one from its scrollback, and it comes back pasted after the command
287 if (sub === 'pin') {
288 const text = clip(e.args.trim().slice('pin'.length))
289 if (text === '') return { text: '붙여 넣을 글이 없다. 응답에서 고른 부분을 복사해 /board pin 뒤에 붙여 넣는다' }
290 if (!pins.some((p) => p.text === text)) pins = [...pins, { isPasted: true, at: await $.clock.now(), text }]
291 $.ui.invalidate('ui.render')
292 await savePins($)
293 return { text: pinError ?? '붙여 넣은 ' + text.length + '자를 pin했다' }
294 }
295 if (sub !== 'theme') {
296 await openPane($)
297 return {}
298 }
299 const names = Object.keys(THEMES).join(' · ')
300 if (!name) return { text: '테마: ' + names + '\n현재: ' + themeName }
301 if (!Object.hasOwn(THEMES, name)) return { text: '없는 테마: ' + name + '\n테마: ' + names }
302 themeName = name
303 $.ui.invalidate('ui.render')
304 try {
305 await $.store.set('theme', name)
306 } catch {
307 return { text: '테마를 ' + name + '(으)로 바꿨지만 저장하지 못했다. 새 세션에서는 이전 테마로 열린다' }
308 }
309 return { text: '테마를 ' + name + '(으)로 바꿨다' }
310 })
311
312 // The turns themselves come from the transcript (turnRows); only whether one is running is kept here
313 on('turn.start', async ($, e, next) => {
314 isWorking = true
315 $.ui.invalidate('ui.render')
316 return next(e)
317 })
318
319 on('turn.complete', async ($, e, next) => {
320 isWorking = false
321 flowAt = await loadFlowAt($)
322 $.ui.invalidate('ui.render')
323 return next(e)
324 })
325
326 on('agent.spawn', async ($, e, next) => {
327 const out = await next(e)
328 // The id only exists once core has started the agent
329 if (out.agentId) {
330 const agent = { id: out.agentId, description: e.description, type: e.subagentType, status: 'running', parentId: e.parentAgentId }
331 seenAgents = mergeAgents(seenAgents, [agent])
332 hasRunningAgent = true
333 $.ui.invalidate('ui.render')
334 }
335 return out
336 })
337
338 on('classic.SubagentStop', async ($, e, next) => {
339 seenAgents = seenAgents.map((a) => (a.id === e.agent_id && a.status === 'running' ? { ...a, status: 'completed' } : a))
340 $.ui.invalidate('ui.render')
341 return next(e)
342 })
343
344 // One line above the prompt summing up the tabs, and a button to open them
345 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
346 if (e.props.hasSurvey || isOff) return next(e)
347 const { Box, Text, Button } = $.ui.resolve(e)
348 // One-cell symbols in the family Claude Code draws with (✻ ⏺), East Asian Width N so no
349 // width table counts them as two. ✎ and ⇢ are the marks the changes and flow tabs use.
350 // In the tabs' order (TABS)
351 const parts = []
352 if (pins.length > 0) parts.push('⚑ Pin ' + pins.length)
353 const { edited } = fileRows(await $.session.messages(), await $.session.cwd(), await $.env.get('HOME'))
354 if (edited.length > 0) parts.push('✎ Changes ' + edited.length)
355 if (flowAt !== null) parts.push('⇢ Flow ' + Math.max(0, Math.round(((await $.clock.now()) - flowAt) / 60000)) + 'm ago')
356 const running = mergeAgents(seenAgents, await $.agent.list()).filter((a) => a.status === 'running').length
357 if (running > 0) parts.push('✢ Agents ' + running + ' running')
358 const orchCounts = Object.keys(ORCH_ORDER)
359 .map((status) => [status, orchTasks.filter((t) => t.status === status).length])
360 .filter(([, n]) => n > 0)
361 if (orchCounts.length > 0) parts.push('⚙ Orch ' + orchCounts.map(([status, n]) => n + ' ' + status).join(', '))
362 // The band has one instance: draw what the plugins beneath drew (prompt-rail) under this line.
363 // With no plugin beneath, next(e) is the engine's own band; if it fails the line is drawn alone
364 const below = await next(e).catch(() => undefined)
365 return Box({
366 flexDirection: 'column',
367 // A blank row between the transcript above and the band
368 marginTop: 1,
369 children: [
370 // The width sits on the line, not the outer Box: the engine refuses its own band under a Box with a width
371 Box({
372 flexDirection: 'row',
373 columnGap: 2,
374 width: e.props.bodyColumns,
375 children: [
376 Box({ flexShrink: 1, children: [Text({ ...muted(), wrap: 'truncate-end', children: [parts.join(' · ') || '✧ No pins, changes, flow, agents or orch task yet'] })] }),
377 ],
378 }),
379 ...(below ? [below] : []),
380 ],
381 })
382 })
383
384 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
385 if (e.requestId !== PANE) return next(e)
386 const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
387 const redraw = () => $.ui.invalidate('ui.render')
388 // Shown at once, then written: a store that fails leaves the pin on screen and says so on the pin tab
389 const setPins = async (list) => {
390 pins = list
391 redraw()
392 await savePins($)
393 redraw()
394 }
395
396 // Where the ring rests (see ui.focus): one space in the background's color on the background, so
397 // the engine's inverse of it is the same cell. Drawn first in the tab row, so it is the ring's
398 // first stop; the body is indented by its width (INDENT) to line up with the tabs
399 const ringStop = Button({
400 key: RING,
401 plain: true,
402 onPress: () => {},
403 children: [Text({ color: theme().bg, backgroundColor: theme().bg, children: [' '] })],
404 })
405 // The body's width once indented
406 const bodyColumns = e.props.bodyColumns - INDENT
407 const tabButton = (name, label) =>
408 Button({
409 key: 'tab-' + name,
410 // [Work], glowup's look: a plain Button with its own brackets, colored text and no
411 // background. The open tab in the theme's accent, the others in its muted
412 plain: true,
413 children: [Text(tab === name ? { color: theme().accent, bold: true, children: ['[' + label + ']'] } : { ...muted(), children: ['[' + label + ']'] })],
414 onPress: () => {
415 tab = name
416 redraw()
417 },
418 })
419
420 const line = (text, { dimColor, ...style } = {}) => Text({ wrap: 'truncate-end', ...style, ...(dimColor ? muted() : {}), children: [text] })
421
422 let body
423 if (tab === 'orch') {
424 const tasks = await loadOrch($, tmuxSession).catch(() => [])
425 const ORCH_MARK = { running: '◐', queued: '○', done: '●', failed: '✗' }
426 const progress = (plan) => ' plan ' + plan.tasks.filter((t) => t.status === 'completed').length + '/' + plan.tasks.length
427 body =
428 tasks.length === 0
429 ? [line('이 세션이 띄운 orch 태스크가 없다 (orch clean이 끝난 태스크를 지운다)', { dimColor: true })]
430 : tasks.flatMap((t) => {
431 const steps = t.steps?.length ?? 0
432 const step = t.status === 'done' ? steps : Math.min((t.cursor ?? 0) + 1, steps)
433 const head = (ORCH_MARK[t.status] ?? '·') + ' ' + t.id + ' ' + t.status + ' step ' + step + '/' + steps + (t.plan ? progress(t.plan) : '')
434 // A running task's Tasks under it, in the plan's order so the next one reads below the done ones
435 const tasksOf = t.status === 'running' && t.plan ? t.plan.tasks : []
436 return [
437 line(head, { bold: t.status === 'running', dimColor: t.status === 'done', color: t.status === 'failed' ? theme().del : undefined }),
438 ...tasksOf.map((task) =>
439 Box({ key: 'orch-' + t.id + '-' + task.id, paddingLeft: 2, children: [line(MARK[task.status] + ' ' + task.text, { dimColor: task.status === 'completed', bold: task.status === 'in_progress' })] }),
440 ),
441 ]
442 })
443 } else if (tab === 'work') {
444 // A transcript that can't be read leaves the tab with the reason, not the whole pane undrawn
445 const messages = await $.session.messages().catch((err) => ({ error: '대화 기록을 못 읽었다' + why(err?.message ?? err) }))
446 const turns = messages.error ? [] : turnRows(messages)
447 const cwd = turns.length > 0 ? await $.session.cwd() : null
448 const home = turns.length > 0 ? await $.env.get('HOME') : null
449 const opened = detailTurn === null ? undefined : turns[detailTurn]
450 // An opened turn: its prompt, its reply as markdown, and what to do with it
451 const turnView = (t, i) => {
452 const isPinned = pins.some((p) => !p.isPasted && p.turn === i)
453 const action = (key, hotkey, label, onPress, style = muted()) => Button({ key, plain: true, hotkey, onPress, children: [Text({ ...style, children: [label] })] })
454 return [
455 line('턴 ' + (i + 1) + ' ' + (t.prompt || '(빈 프롬프트)'), { bold: true }),
456 Box({
457 flexDirection: 'row',
458 columnGap: 2,
459 children: [
460 ...(t.reply ? [action('turn-pin', 'p', isPinned ? 'pin 풀기' : 'pin', () => setPins(isPinned ? pins.filter((p) => p.isPasted || p.turn !== i) : [...pins, { turn: i, prompt: t.prompt, text: clip(t.reply) }]), { color: theme().accent })] : []),
461 action('turn-changes', 'c', '이 턴의 Changes', () => {
462 turnFilter = i
463 detailTurn = null
464 tab = 'changes'
465 redraw()
466 }),
467 action('turn-back', '0', '목록으로', () => {
468 detailTurn = null
469 redraw()
470 }),
471 ],
472 }),
473 Text({ children: [' '] }),
474 t.reply ? Markdown({ key: 'turn-reply', text: clip(t.reply) }) : line('이 턴에는 응답 글이 없다', { dimColor: true }),
475 ]
476 }
477 // Newest first, a letter on each of the first 26: it opens that turn
478 body = opened
479 ? turnView(opened, detailTurn)
480 : turns.length === 0
481 ? [line(messages.error ?? '아직 턴이 없다', { dimColor: true })]
482 : turns
483 .map((t, i) => ({ t, i }))
484 .reverse()
485 .flatMap(({ t, i }, n) => {
486 const isRunning = isWorking && i === turns.length - 1
487 const edited = fileRows(messages.slice(t.start, t.end), cwd, home).edited.length
488 const tools = Object.entries(t.tools).map(([name, count]) => name + ' ' + count)
489 const reply = t.reply?.trim().split('\n')[0]
490 const summary = [...tools, ...(edited > 0 ? ['파일 ' + edited] : []), ...(reply ? [reply] : [])].join(' · ') || '도구 없음'
491 return [
492 Button({
493 key: 'turn-' + i,
494 plain: true,
495 ...(n < 26 ? { hotkey: String.fromCharCode(97 + n) } : {}),
496 onPress: () => {
497 detailTurn = i
498 redraw()
499 },
500 children: [Text({ wrap: 'truncate-end', bold: isRunning, children: [(isRunning ? '◐ ' : '● ') + (i + 1) + ' ' + (t.prompt || '(빈 프롬프트)')] })],
501 }),
502 Box({ key: 'turn-summary-' + i, paddingLeft: 5, children: [line(summary, { dimColor: true })] }),
503 ]
504 })
505 } else if (tab === 'pin') {
506 // Newest first, as many as one drawing takes
507 let room = DRAWN_CHARS
508 const drawn = [...pins].reverse().filter((p) => (room -= p.text.length) >= 0)
509 body = [
510 ...(pinError ? [line(pinError, { color: theme().del }), Text({ children: [' '] })] : []),
511 ...(pins.length === 0
512 ? [line('pin이 없다. Work에서 턴을 고르고 p를 누르거나, 복사한 글을 /board pin 뒤에 붙여 넣는다', { dimColor: true })]
513 : drawn.flatMap((p, n) => [
514 Box({
515 key: 'pin-' + pinId(p),
516 flexDirection: 'row',
517 columnGap: 2,
518 children: [
519 Box({ flexShrink: 1, children: [line(p.isPasted ? '⚑ 붙여 넣음' : '⚑ 턴 ' + (p.turn + 1) + ' ' + p.prompt, { bold: true, color: theme().accent })] }),
520 Button({
521 key: 'pin-del-' + pinId(p),
522 plain: true,
523 ...(n < 26 ? { hotkey: String.fromCharCode(97 + n) } : {}),
524 onPress: () => setPins(pins.filter((q) => pinId(q) !== pinId(p))),
525 children: [Text({ ...muted(), children: ['지우기'] })],
526 }),
527 ],
528 }),
529 // Pasted text is what the terminal drew, markdown already rendered
530 p.isPasted ? Text({ key: 'pin-text-' + pinId(p), children: [p.text] }) : Markdown({ key: 'pin-text-' + pinId(p), text: p.text }),
531 Text({ children: [' '] }),
532 ])),
533 ...(drawn.length < pins.length ? [line('한 화면에 그릴 수 있는 글자 수를 넘어 오래된 pin ' + (pins.length - drawn.length) + '개는 그리지 않았다', { dimColor: true })] : []),
534 ]
535 } else if (tab === 'changes') {
536 const { changed, settled, read, note } = await loadChanges($)
537 // The ring stays off the pane's buttons (see ui.focus), so a row is picked by its letter
538 const changeRow = (f, i) =>
539 Button({
540 key: 'change-' + f.path,
541 plain: true,
542 ...(i < 26 ? { hotkey: String.fromCharCode(97 + i) } : {}),
543 onPress: () => {
544 selected = f.path
545 tab = 'diff'
546 redraw()
547 },
548 children: [
549 '✎ ' + f.path + ' ',
550 ...(f.add === null
551 ? []
552 : f.isBinary
553 ? [Text({ ...muted(), children: ['바이너리'] })]
554 : [Text({ color: theme().add, children: ['+' + f.add] }), ' ', Text({ color: theme().del, children: ['−' + f.del] })]),
555 ],
556 })
557 // Path truncates at its start so the file name at the end stays visible
558 const readLine = (f) =>
559 Box({
560 key: 'read-' + f.path,
561 flexDirection: 'row',
562 columnGap: 1,
563 children: [
564 line('·', { dimColor: true }),
565 Box({ flexGrow: 1, flexShrink: 1, children: [line(f.path, { wrap: 'truncate-start', dimColor: true })] }),
566 line('×' + f.count, { dimColor: true }),
567 ],
568 })
569 // The band counts every file edited in the conversation; the ones git no longer shows are listed too
570 const settledLine = (f) =>
571 Box({
572 key: 'settled-' + f.path,
573 flexDirection: 'row',
574 columnGap: 1,
575 children: [
576 line('✓', { dimColor: true }),
577 Box({ flexGrow: 1, flexShrink: 1, children: [line(f.path, { wrap: 'truncate-start', dimColor: true })] }),
578 line('×' + f.count, { dimColor: true }),
579 ],
580 })
581 const groups = [
582 changed.map(changeRow),
583 settled.length > 0 ? [line('커밋했거나 이 repo 밖 ' + settled.length, { dimColor: true }), ...settled.map(settledLine)] : [],
584 read.map(readLine),
585 ].filter((g) => g.length > 0)
586 const filterRow =
587 turnFilter === null
588 ? []
589 : [
590 Box({
591 flexDirection: 'row',
592 columnGap: 1,
593 children: [
594 line('턴 ' + (turnFilter + 1) + '만 보는 중 ·', { color: theme().accent }),
595 Button({ key: 'turn-filter-off', plain: true, hotkey: '0', onPress: () => ((turnFilter = null), redraw()), children: [Text({ ...muted(), children: ['전체 보기'] })] }),
596 ],
597 }),
598 Text({ children: [' '] }),
599 ]
600 body =
601 changed.length + settled.length + read.length === 0
602 ? [...filterRow, line(turnFilter === null ? '아직 건드린 파일이 없다' : '이 턴은 파일을 건드리지 않았다', { dimColor: true })]
603 : [
604 ...filterRow,
605 line('변경 ' + changed.length + (settled.length > 0 ? ' · 커밋했거나 repo 밖 ' + settled.length : '') + ' · 읽은 파일 ' + read.length, { bold: true }),
606 Text({ children: [' '] }),
607 ...groups.flatMap((g, i) => (i === 0 ? g : [Text({ children: [' '] }), ...g])),
608 Text({ children: [' '] }),
609 ...(note ? [line(note, { dimColor: true })] : []),
610 line('Bash는 sed -i · > · tee 로 쓴 파일만 센다. python 등이 쓴 파일은 빠진다', { dimColor: true }),
611 ]
612 } else if (tab === 'diff') {
613 const { changed, root, note } = await loadChanges($)
614 const file = changed.find((f) => f.path === selected) ?? changed[0]
615 const diff = file && (file.rel === null ? { error: 'git 없이는 diff를 못 만든다' + (note ? ' — ' + note : '') } : await loadDiff($, root, file))
616 body = !file
617 ? [line('보여 줄 변경이 없다', { dimColor: true })]
618 : [
619 line(file.path, { bold: true }),
620 line('Changes 탭에서 행 앞 글자를 누르면 다른 파일', { dimColor: true }),
621 ...(turnFilter === null ? [] : [line('턴 ' + (turnFilter + 1) + '의 파일 중에서 고른다. diff는 그 턴만이 아니라 HEAD 대비 전체다', { dimColor: true })]),
622 Text({ children: [' '] }),
623 ...(diff.error
624 ? [line(diff.error, { dimColor: true })]
625 : [
626 Code({ source: diff.source, format: 'diff', path: file.path }),
627 ...(diff.shown < diff.total ? [Text({ children: [' '] }), line(diff.total + '줄 중 ' + diff.shown + '줄', { dimColor: true })] : []),
628 ]),
629 ]
630 } else if (tab === 'flow') {
631 const found = await loadFlow($)
632 const flow = found && flowRows(found.candidate)
633 const minutes = found && Math.max(0, Math.round(((await $.clock.now()) - found.mtimeMs) / 60000))
634 const footer = found?.html ? [Text({ children: [' '] }), line('HTML: ' + found.html, { dimColor: true, wrap: 'wrap' })] : []
635 if (!flow) {
636 body = [
637 line('archify 그림이 없다', { dimColor: true }),
638 line('Claude에게 「바뀐 흐름 archify로 그려」라고 하면 여기 뜬다', { dimColor: true }),
639 ]
640 } else if (flow.unsupported) {
641 body = [line(flow.unsupported + ' 그림은 아직 못 그린다', { dimColor: true }), ...footer]
642 } else if (flow.stages) {
643 // A dataflow: one column per stage, left to right. Flows go many to many, so each box
644 // lists where its data goes instead of drawing connectors. Columns wrap when the pane
645 // is too narrow for all stages side by side.
646 const columnWidth = Math.max(22, Math.floor(bodyColumns / Math.max(1, flow.stages.length)) - 1)
647 body = [
648 line((flow.title ?? '흐름') + ' · ' + minutes + '분 전', { bold: true }),
649 ...(flow.dashedLabel ? [line('⇢ 는 ' + flow.dashedLabel, { dimColor: true })] : []),
650 Text({ children: [' '] }),
651 Box({
652 flexDirection: 'row',
653 flexWrap: 'wrap',
654 columnGap: 1,
655 rowGap: 1,
656 width: bodyColumns,
657 children: flow.stages.map((stage, i) =>
658 Box({
659 key: 'stage-' + i,
660 flexDirection: 'column',
661 width: columnWidth,
662 flexShrink: 0,
663 rowGap: 1,
664 children: [
665 line(stage.label, { bold: true, color: theme().accent }),
666 ...stage.nodes.map((n) =>
667 Box({
668 key: 'node-' + n.id,
669 flexDirection: 'column',
670 borderStyle: 'round',
671 borderDimColor: true,
672 paddingX: 1,
673 children: [
674 line(n.label, { bold: true, wrap: 'wrap' }),
675 ...(n.sublabel ? [line(n.sublabel, { dimColor: true, wrap: 'wrap' })] : []),
676 ...n.edges.map((edge) =>
677 line((edge.isDashed ? '⇢ ' : '→ ') + edge.to + (edge.label ? ' (' + edge.label + ')' : ''), {
678 wrap: 'wrap',
679 dimColor: edge.isDashed,
680 }),
681 ),
682 ],
683 }),
684 ),
685 ],
686 }),
687 ),
688 }),
689 ...footer,
690 ]
691 } else {
692 // A node's box; `others` are the arrows with no connector of their own, one line each
693 const nodeBox = (n, width, others, mark) =>
694 Box({
695 key: 'node-' + n.id,
696 flexDirection: 'column',
697 width,
698 flexShrink: 0,
699 borderStyle: 'round',
700 borderColor: n.isRemoved ? theme().del : tagColor(n.tag),
701 borderDimColor: !n.isRemoved && !tagColor(n.tag),
702 paddingX: 1,
703 children: [
704 line(n.label, { bold: true, wrap: 'wrap', color: n.isRemoved ? theme().del : undefined }),
705 ...(n.tag ? [line('[' + n.tag + ']', { color: n.isRemoved ? theme().del : tagColor(n.tag), dimColor: n.tag === '그대로' })] : []),
706 ...(n.sublabel ? [line(n.sublabel, { dimColor: true, wrap: 'wrap' })] : []),
707 ...others.map((edge) => line(mark(edge) + ' ' + edge.to + (edge.label ? ' (' + edge.label + ')' : ''), { wrap: 'wrap', dimColor: !edge.isMain })),
708 ],
709 })
710 const connectorText = (edge) => (edge.label ? '─ ' + edge.label + ' ─▶' : '──▶')
711 const header = [line((flow.title ?? '흐름') + ' · ' + minutes + '분 전', { bold: true }), Text({ children: [' '] })]
712
713 // archify's grid: one row per lane, its name first, each node in its column so the
714 // columns line up across lanes. The arrow to the next cell of the same lane sits between
715 // the boxes; an arrow to another lane or further along is a line in the box it leaves.
716 const cols = flow.lanes[0]?.cells.length ?? 0
717 const laneWidth = Math.min(12, Math.max(0, ...flow.lanes.map((l) => displayWidth(l.label)))) + 1
718 const nextEdges = flow.nodes.flatMap((n) => n.edges.filter((edge) => edge.direction === 'next'))
719 const gap = Math.min(16, Math.max(3, ...nextEdges.map((edge) => displayWidth(connectorText(edge))))) + 1
720 const colWidth = Math.floor((bodyColumns - laneWidth - (cols - 1) * gap) / Math.max(1, cols))
721 const MARK = { down: '↓', up: '↑', back: '←', forward: '→', next: '→' }
722
723 if (colWidth >= 16) {
724 body = [
725 ...header,
726 Box({
727 flexDirection: 'column',
728 rowGap: 1,
729 width: bodyColumns,
730 children: flow.lanes.map((lane, l) =>
731 Box({
732 key: 'lane-' + l,
733 flexDirection: 'row',
734 alignItems: 'center',
735 children: [
736 Box({ width: laneWidth, flexShrink: 0, children: [line(lane.label, { bold: true, wrap: 'wrap', color: lane.isException ? theme().del : theme().accent })] }),
737 ...lane.cells.flatMap((cell, c) => {
738 const connector = cell.flatMap((n) => n.edges).find((edge) => edge.direction === 'next')
739 return [
740 Box({
741 key: 'cell-' + l + '-' + c,
742 flexDirection: 'column',
743 width: colWidth,
744 flexShrink: 0,
745 children: cell.map((n) => nodeBox(n, colWidth, n.edges.filter((edge) => edge !== connector), (edge) => MARK[edge.direction])),
746 }),
747 ...(c < cols - 1
748 ? [Box({ key: 'gap-' + l + '-' + c, width: gap, flexShrink: 0, children: connector ? [line(connectorText(connector), { dimColor: true })] : [] })]
749 : []),
750 ]
751 }),
752 ],
753 }),
754 ),
755 }),
756 ...footer,
757 ]
758 } else {
759 // Too narrow for the grid: boxes in a row that wraps at the pane's width, the main arrow
760 // to the next box a connector between them
761 body = [
762 ...header,
763 Box({
764 flexDirection: 'row',
765 flexWrap: 'wrap',
766 alignItems: 'center',
767 rowGap: 1,
768 width: bodyColumns,
769 children: flow.nodes.flatMap((n, i) => {
770 const following = flow.nodes[i + 1]
771 const connector = following && n.edges.find((edge) => edge.isMain && edge.toId === following.id)
772 return [
773 nodeBox(n, Math.min(bodyColumns, 26), n.edges.filter((edge) => edge !== connector), () => '⇢'),
774 ...(connector ? [Text({ key: 'arrow-' + n.id, dimColor: true, children: [connectorText(connector)] })] : []),
775 ]
776 }),
777 }),
778 ...footer,
779 ]
780 }
781 }
782 } else {
783 // ui.render can read state but not write it, so the merge is local to this draw
784 const agents = mergeAgents(seenAgents, await $.agent.list())
785 hasRunningAgent = agents.some((a) => a.status === 'running' || a.status === 'waiting' || a.status === 'pending')
786 const rows = agentRows(agents)
787 body = [
788 line('main', { bold: true }),
789 ...(rows.length === 0
790 ? [line(' subagent 없음', { dimColor: true })]
791 : rows.map((r) =>
792 Box({
793 key: 'agent-' + r.id,
794 flexDirection: 'row',
795 columnGap: 1,
796 children: [
797 line(' '.repeat(r.depth) + '└ ' + r.label),
798 line(r.status, { color: agentColor(r.status), dimColor: !agentColor(r.status) }),
799 ],
800 }),
801 )),
802 ]
803 }
804
805 return Box({
806 flexDirection: 'column',
807 width: e.props.bodyColumns,
808 // Fill the whole body so the color reaches the bottom, not just the drawn rows
809 minHeight: e.props.scroll.bodyRows,
810 backgroundColor: theme().bg,
811 children: [
812 Box({
813 flexDirection: 'row',
814 children: [
815 ringStop,
816 Box({ flexDirection: 'row', columnGap: 1, flexGrow: 1, children: TABS.map((name) => tabButton(name, name[0].toUpperCase() + name.slice(1))) }),
817 ],
818 }),
819 Text({ children: [' '] }),
820 Box({ flexDirection: 'column', paddingLeft: INDENT, children: body }),
821 ],
822 })
823 })
824
825 // Tab, or left/right as ~/.claude/keybindings.json binds them in the Pane context,
826 // opens a tab as the focus lands on it, with no Enter
827 // The ring never rests on a tab: the engine inverts whatever it holds, a colored block over the
828 // open tab. It rests on RING, an invisible one-space Button (see ringStop), and the open tab is
829 // drawn in color instead. The engine's aim says the person's direction:
830 // from RING or from nothing next -> RING or tab-work, previous -> its close mark (it wraps)
831 // The engine's close mark takes no color, so it is no stop here: a move aimed at it is kept (a
832 // move is redirected only between this plugin's elements). Esc, ctrl+x x or a click closes.
833 // A click aims at what it hit; its press opens it.
834 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
835 if (e.origin.kind !== 'person') return next(e)
836 const el = e.element
837 const step = el === RING || el === 'tab-' + TABS[0] ? 1 : el === undefined ? -1 : 0
838 if (step !== 0) {
839 tab = TABS[(TABS.indexOf(tab) + step + TABS.length) % TABS.length]
840 $.ui.invalidate('ui.render')
841 }
842 if (el === undefined) return { deny: 'work-board keeps the ring on its anchor' }
843 return el === RING ? next(e) : next({ ...e, element: RING })
844 })
845}
846hooks/themes.js 18 lines1// The color themes of the work board. Every color the pane and band use is a token:
2// bg pane background
3// accent headings: stage and lane names
4// add / del / warn the green, red and yellow roles: new, removed, changed; done, failed, running
5// muted dim text; absent, text keeps the terminal's own dim
6// classic is the board's own look; #262626 matches the dock's own top and bottom rows. The rest are
7// glowup's presets (NovusEdge/glowup hooks/presets.ts, MIT): bg = panel, accent, add = pass,
8// del = fail, warn = edit, muted = dim.
9export const THEMES = {
10 classic: { bg: '#262626', accent: 'cyan', add: 'green', del: 'red', warn: 'yellow', muted: undefined },
11 glowup: { bg: '#1d1924', accent: '#ffc857', add: '#7ee0a1', del: '#ff6f7d', warn: '#ffa94d', muted: '#8e86a0' },
12 aurora: { bg: '#131c23', accent: '#5ef1c6', add: '#5ef1c6', del: '#ff7a8a', warn: '#ffd479', muted: '#7f97a3' },
13 dusk: { bg: '#171a2b', accent: '#b69cff', add: '#8ef0b0', del: '#ff7088', warn: '#ffcf7a', muted: '#8a8cad' },
14 cyberpunk: { bg: '#140c22', accent: '#ff2bd6', add: '#39ff88', del: '#ff3b5c', warn: '#ffb000', muted: '#7d7398' },
15 vaporwave: { bg: '#24123f', accent: '#ff71ce', add: '#05ffa1', del: '#ff6b6b', warn: '#fffb96', muted: '#a08bc0' },
16 'high-contrast': { bg: '#000000', accent: '#ffff00', add: '#00ff00', del: '#ff4040', warn: '#ffaa00', muted: '#d0d0d0' },
17}
18hooks/model.js 480 lines1// Pure state updates for the work board. No $ and no drawing here, so tests can call them directly.
2
3// "## Task N ..." headings of a dev-workflow plan. Status comes from the "- [x]" / "- [ ]" boxes under
4// each heading: all checked = completed, some = in_progress, none = pending, no boxes = unknown.
5export function parsePlanTasks(markdown) {
6 const tasks = []
7 let current = null
8 for (const line of markdown.split('\n')) {
9 const heading = line.match(/^##\s+(.*)$/)
10 if (heading) {
11 current = /^Task\b/.test(heading[1]) ? { text: heading[1].trim(), done: 0, total: 0 } : null
12 if (current) tasks.push(current)
13 continue
14 }
15 const box = current && line.match(/^\s*[-*]\s+\[( |x|X)\]/)
16 if (box) {
17 current.total += 1
18 if (box[1] !== ' ') current.done += 1
19 }
20 }
21 return tasks.map((t, i) => ({
22 id: 'task-' + i,
23 text: t.text,
24 status: t.total === 0 ? 'unknown' : t.done === t.total ? 'completed' : t.done > 0 ? 'in_progress' : 'pending',
25 }))
26}
27
28// The work tab's turns, rebuilt from the transcript so a reload or a resume keeps them. A turn starts at
29// a prompt: a user message with text that is no tool result and no engine markup (<command-name>, ...).
30// { prompt (first line), start, end (message indexes, end exclusive), tools: { Read: 2, ... }, reply }.
31// reply is the turn's last assistant text, its answer, or null: what it says between tool calls is passed over
32export function turnRows(messages) {
33 const turns = []
34 messages.forEach((m, i) => {
35 const isPrompt = m.role === 'user' && m.text.trim() !== '' && !m.text.trimStart().startsWith('<') && !(m.toolResults?.length > 0)
36 if (isPrompt) {
37 if (turns.length > 0) turns[turns.length - 1].end = i
38 turns.push({ prompt: m.text.split('\n')[0].trim(), start: i, end: messages.length, tools: {}, reply: null })
39 } else if (turns.length > 0) {
40 const turn = turns[turns.length - 1]
41 for (const use of m.toolUses ?? []) turn.tools[use.tool] = (turn.tools[use.tool] ?? 0) + 1
42 if (m.role === 'assistant' && m.text.trim() !== '') turn.reply = m.text
43 }
44 })
45 return turns
46}
47
48// The pins of each session sit under their own store key (two sessions writing one key would undo each
49// other). entries: [{ key, at }], at = the session's last pin write. The keys past the newest `keep`.
50export function stalePinKeys(entries, keep) {
51 return [...entries]
52 .sort((a, b) => b.at - a.at)
53 .slice(keep)
54 .map((e) => e.key)
55}
56
57// $.agent.list() drops an agent once it has finished, so keep every agent we have seen.
58// The live list wins for status; seen-only agents keep what we stored.
59export function mergeAgents(seen, live) {
60 const byId = new Map(seen.map((a) => [a.id, a]))
61 for (const a of live) byId.set(a.id, { ...byId.get(a.id), ...a })
62 return [...byId.values()]
63}
64
65// Flatten $.agent.list() into rows in tree order: each agent, then its children one level deeper.
66// An agent whose parent isn't in the list goes to the top level, so nothing disappears.
67export function agentRows(list) {
68 const ids = new Set(list.map((a) => a.id))
69 const childrenOf = new Map()
70 const roots = []
71 for (const a of list) {
72 if (a.parentId && ids.has(a.parentId)) {
73 if (!childrenOf.has(a.parentId)) childrenOf.set(a.parentId, [])
74 childrenOf.get(a.parentId).push(a)
75 } else {
76 roots.push(a)
77 }
78 }
79 const rows = []
80 const walk = (a, depth) => {
81 rows.push({ id: a.id, depth, label: a.description || a.name || a.type, type: a.type, status: a.status })
82 for (const c of childrenOf.get(a.id) ?? []) walk(c, depth + 1)
83 }
84 for (const r of roots) walk(r, 0)
85 return rows
86}
87
88// The changes tab starts from these rows. Built from the transcript, not from hooks, so it survives a reload and a /resume.
89const EDIT_PATH = { Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
90
91// messages: $.session.messages(). Returns { edited, read }, each [{ path, count }] sorted by
92// directory, then name. A file both read and edited is listed once, under edited.
93export function fileRows(messages, cwd, home) {
94 const files = new Map() // absolute path -> { count, isEdited }
95 const touch = (path, isEdited) => {
96 const f = files.get(path) ?? { count: 0, isEdited: false }
97 files.set(path, { count: f.count + 1, isEdited: f.isEdited || isEdited })
98 }
99 // Claude Code's shell keeps its directory from one Bash call to the next, until it says it reset it
100 let shellDir = cwd
101 for (const m of messages) {
102 for (const use of m.toolUses ?? []) {
103 if (use.tool === 'Bash' && typeof use.input?.command === 'string') {
104 if (!use.isError) {
105 const out = bashTargets(use.input.command, shellDir, home)
106 out.paths.forEach((path) => touch(path, true))
107 shellDir = out.cwd
108 }
109 // Claude Code adds this as its own line; a match inside printed output is not a reset
110 const reset = use.text?.match(/^Shell cwd was reset to (\/\S*)$/m)
111 if (reset) shellDir = reset[1]
112 continue
113 }
114 if (use.isError) continue
115 const key = EDIT_PATH[use.tool] ?? (use.tool === 'Read' ? 'file_path' : null)
116 const path = key && use.input?.[key]
117 if (typeof path === 'string') touch(path, use.tool in EDIT_PATH)
118 }
119 }
120 const rows = [...files].map(([path, f]) => ({ path: shortPath(path, cwd, home), count: f.count, isEdited: f.isEdited }))
121 rows.sort((a, b) => compareKeys(dirKey(a.path), dirKey(b.path)))
122 const pick = (isEdited) => rows.filter((r) => r.isEdited === isEdited).map(({ path, count }) => ({ path, count }))
123 return { edited: pick(true), read: pick(false) }
124}
125
126// The changes tab. `git status --porcelain -z` entries: "XY path\0", a rename or copy ("R"/"C" in X)
127// followed by its old path as one more word. Paths are relative to the repo root.
128export function parseStatus(output) {
129 const words = output.split('\0')
130 const entries = []
131 for (let i = 0; i < words.length; i++) {
132 const word = words[i]
133 if (word.length < 4) continue
134 const isMoved = /[RC]/.test(word[0])
135 entries.push({ path: word.slice(3), isUntracked: word.startsWith('??'), ...(isMoved ? { oldPath: words[i + 1] } : {}) })
136 if (isMoved) i += 1
137 }
138 return entries
139}
140
141// `git diff --numstat -z`: "add\tdel\tpath\0", a binary file "-\t-", a rename "add\tdel\t\0old\0new\0"
142export function parseNumstat(output) {
143 const words = output.split('\0')
144 const stats = []
145 for (let i = 0; i < words.length; i++) {
146 const m = words[i].match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
147 if (!m) continue
148 const path = m[3] === '' ? words[(i += 2)] : m[3]
149 const isBinary = m[1] === '-'
150 stats.push({ path, add: isBinary ? 0 : Number(m[1]), del: isBinary ? 0 : Number(m[2]), isBinary })
151 }
152 return stats
153}
154
155// The part of a unified diff the diff tab can draw: Code does not parse a diff cut mid-hunk (and draws 100000
156// characters at most), so the cut falls on a hunk boundary. When the first hunk alone is over the limit, it is
157// cut and its "@@ -a,b +c,d @@" counts are rewritten to what is kept. { source, total, shown } or, when the
158// diff has no hunk to cut, { total, isTooBig }.
159export function cutDiff(text, maxLines, maxChars) {
160 const lines = text.replace(/\n$/, '').split('\n')
161 const total = lines.length
162 if (total <= maxLines && text.length <= maxChars) return { source: text, total, shown: total }
163 let n = Math.min(maxLines, total)
164 let chars = 0
165 for (let i = 0; i < n; i++) {
166 chars += lines[i].length + 1
167 if (chars > maxChars) n = i
168 }
169 const first = lines.findIndex((l) => l.startsWith('@@'))
170 for (let k = n; k > first && k > 0; k--) {
171 if (lines[k]?.startsWith('@@')) return { source: lines.slice(0, k).join('\n'), total, shown: k }
172 }
173 const head = first >= 0 && n > first + 1 && lines[first].match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/)
174 if (!head) return { total, isTooBig: true }
175 const body = lines.slice(first + 1, n)
176 const kept = (marks) => body.filter((l) => !l.startsWith('\\') && !marks.includes(l[0])).length
177 const oldCount = body.filter((l) => l[0] === '-').length + kept(['+', '-'])
178 const newCount = body.filter((l) => l[0] === '+').length + kept(['+', '-'])
179 const header = '@@ -' + head[1] + ',' + oldCount + ' +' + head[2] + ',' + newCount + ' @@' + head[3]
180 return { source: [...lines.slice(0, first), header, ...body].join('\n'), total, shown: n }
181}
182
183export const countLines = (text) => (text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0))
184
185// files: fileRows(); status: parseStatus(); stats: parseNumstat() of `git diff HEAD`; lines: line count of
186// each untracked file by repo path. Returns { changed, read }: the files the conversation edited that git
187// still reports, with +/-, and the files only read.
188export function changeRows({ files, status, stats, lines, root, cwd, home }) {
189 const byPath = new Map(status.map((s) => [shortPath(root + '/' + s.path, cwd, home), s]))
190 const statOf = new Map(stats.map((s) => [s.path, s]))
191 const changed = files.edited
192 .filter((f) => byPath.has(f.path))
193 .map((f) => {
194 const entry = byPath.get(f.path)
195 const stat = statOf.get(entry.path)
196 return {
197 path: f.path,
198 rel: entry.path,
199 oldRel: entry.oldPath ?? null,
200 count: f.count,
201 add: entry.isUntracked ? (lines[entry.path] ?? null) : (stat?.add ?? 0),
202 del: entry.isUntracked ? 0 : (stat?.del ?? 0),
203 isBinary: stat?.isBinary ?? false,
204 isUntracked: entry.isUntracked,
205 }
206 })
207 // Edited in the conversation but gone from git status: committed, reverted, or outside this repo
208 const settled = files.edited.filter((f) => !byPath.has(f.path))
209 return { changed, settled, read: files.read }
210}
211
212function shortPath(path, cwd, home) {
213 if (cwd && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
214 if (home && path.startsWith(home + '/')) return '~' + path.slice(home.length)
215 return path
216}
217
218// Files under the cwd first, then under ~, then elsewhere; within each, by directory, then name.
219// Plain code-point order, so "hooks" comes before "hooks/sub".
220function dirKey(path) {
221 const slash = path.lastIndexOf('/')
222 return [path.startsWith('/') ? 2 : path.startsWith('~') ? 1 : 0, slash < 0 ? '' : path.slice(0, slash), path.slice(slash + 1)]
223}
224
225function compareKeys(a, b) {
226 for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1
227 return 0
228}
229
230// The files a Bash command writes, as absolute paths, and the directory its shell ends in:
231// { paths, cwd }. Paths are `sed -i` files, `>`/`>>`/`&>` targets and
232// `tee` arguments. A path holding $, ` or a glob is skipped: only running the shell would tell it.
233// ponytail: a guess from the command text; a script that writes files itself (python, node) is missed
234export function bashTargets(command, cwd, home) {
235 const found = []
236 let dir = cwd
237 const resolve = (word) => {
238 if (word.isOpaque || word.text === '' || word.text.startsWith('/dev/')) return null
239 const text = word.text
240 const path = text === '~' || text.startsWith('~/') ? home + text.slice(1) : text.startsWith('/') ? text : dir && dir + '/' + text
241 return path ? normalize(path) : null
242 }
243 const write = (word) => {
244 const path = resolve(word)
245 if (path && !found.includes(path)) found.push(path)
246 }
247 const run = (segment) => {
248 const words = []
249 for (let i = 0; i < segment.length; i++) {
250 const t = segment[i]
251 if (!t.op) {
252 words.push(t)
253 continue
254 }
255 // Every operator takes the word after it; only a stdout redirect's word is a written file
256 const target = segment[i + 1]
257 i += 1
258 if (t.op === '>' && t.fd !== '2' && target && !target.op) write(target)
259 }
260 while (words.length > 0 && /^\w+=/.test(words[0].text)) words.shift()
261 const [name, ...args] = words
262 if (name?.text === 'cd') {
263 dir = args[0] ? (args[0].isOpaque ? null : resolve(args[0])) : home
264 } else if (name?.text === 'sed') {
265 let isInPlace = false
266 let hasScript = false
267 const files = []
268 for (let k = 0; k < args.length; k++) {
269 const a = args[k].text
270 if (/^-[A-Za-z]*i$/.test(a) || a === '--in-place') {
271 isInPlace = true
272 // macOS sed takes the backup suffix as its own word, usually ''
273 if (args[k + 1]?.text === '') k += 1
274 } else if (/^-[A-Za-z]*i./.test(a) || a.startsWith('--in-place=')) {
275 isInPlace = true
276 } else if (a === '-e' || a === '-f' || a === '--expression' || a === '--file') {
277 hasScript = true
278 k += 1
279 } else if (a.startsWith('-') && a !== '-') {
280 // another flag
281 } else if (!hasScript) {
282 hasScript = true
283 } else {
284 files.push(args[k])
285 }
286 }
287 if (isInPlace) files.forEach(write)
288 } else if (name?.text === 'tee') {
289 args.filter((a) => !a.text.startsWith('-')).forEach(write)
290 }
291 }
292 let segment = []
293 for (const t of tokenize(stripHeredocs(command))) {
294 if (t.op === 'sep') {
295 run(segment)
296 segment = []
297 } else {
298 segment.push(t)
299 }
300 }
301 run(segment)
302 return { paths: found, cwd: dir }
303}
304
305function normalize(path) {
306 const parts = []
307 for (const part of path.split('/')) {
308 if (part === '' || part === '.') continue
309 if (part === '..') parts.pop()
310 else parts.push(part)
311 }
312 return '/' + parts.join('/')
313}
314
315// Drops the body of each heredoc, so `>` inside a file's text is not read as a redirect
316function stripHeredocs(command) {
317 const kept = []
318 const delimiters = []
319 for (const line of command.split('\n')) {
320 if (delimiters.length > 0) {
321 if (line.trim() === delimiters[0]) delimiters.shift()
322 continue
323 }
324 kept.push(line)
325 for (const m of line.matchAll(/(?<!<)<<(?!<)-?\s*(['"]?)([A-Za-z_]\w*)\1/g)) delimiters.push(m[2])
326 }
327 return kept.join('\n')
328}
329
330// Words with their quotes removed, and operators: { op: 'sep' } between commands, { op: '>', fd }
331// for an output redirect, { op: '<' } and { op: 'dup' } for the rest (each takes the next word).
332function tokenize(command) {
333 const tokens = []
334 let word = null
335 const start = () => (word ??= { text: '', isOpaque: false })
336 const end = () => {
337 if (word) tokens.push(word)
338 word = null
339 }
340 for (let i = 0; i < command.length; i++) {
341 const c = command[i]
342 const next = command[i + 1]
343 if (c === "'") {
344 const close = command.indexOf("'", i + 1)
345 start().text += command.slice(i + 1, close < 0 ? command.length : close)
346 i = close < 0 ? command.length : close
347 } else if (c === '"') {
348 start()
349 for (i += 1; i < command.length && command[i] !== '"'; i++) {
350 if (command[i] === '\\' && i + 1 < command.length) i += 1
351 else if (command[i] === '$' || command[i] === '`') word.isOpaque = true
352 word.text += command[i]
353 }
354 } else if (c === '\\' && next === '\n') {
355 // A line continued with a backslash is one line
356 i += 1
357 } else if (c === '\\') {
358 start().text += next ?? ''
359 i += 1
360 } else if (c === ' ' || c === '\t') {
361 end()
362 } else if (c === '\n' || c === ';' || c === '|' || c === '(' || c === ')' || (c === '&' && next !== '>')) {
363 end()
364 tokens.push({ op: 'sep' })
365 if ((c === '|' || c === '&') && next === c) i += 1
366 } else if (c === '>' || (c === '&' && next === '>')) {
367 // A digit word right before > is its file descriptor (2>), not an argument
368 const fd = c === '&' ? 'all' : word && /^\d+$/.test(word.text) ? word.text : null
369 if (fd && c !== '&') word = null
370 else end()
371 if (c === '&') i += 1
372 if (command[i + 1] === '>') i += 1
373 if (command[i + 1] === '&') {
374 i += 1
375 tokens.push({ op: 'dup' })
376 } else {
377 tokens.push({ op: '>', fd })
378 }
379 } else if (c === '<') {
380 end()
381 while (command[i + 1] === '<') i += 1
382 tokens.push({ op: '<' })
383 } else {
384 start().text += c
385 if (c === '$' || c === '`' || c === '*' || c === '?' || c === '[') word.isOpaque = true
386 }
387 }
388 end()
389 return tokens
390}
391
392// Terminal cells a string takes: Hangul and other CJK characters take two
393export const displayWidth = (text) =>
394 [...text].reduce((w, c) => w + (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(c) ? 2 : 1), 0)
395
396// The flow tab. An archify workflow candidate two ways: `lanes`, archify's own grid (one row per
397// lane, one cell per column), and `nodes`, a flat list for a pane too narrow for that grid — the
398// main path in its own order, then every other node by column and lane.
399export function flowRows(candidate) {
400 const title = candidate.meta?.title ?? null
401 const type = candidate.diagram_type ?? 'workflow'
402 if (type === 'dataflow') return dataflowRows(candidate, title)
403 if (type !== 'workflow') return { title, unsupported: type }
404 const lanes = [...(candidate.lanes ?? [])]
405 for (const n of candidate.nodes) if (!lanes.some((l) => l.id === n.lane)) lanes.push({ id: n.lane })
406 // Only the columns some node sits in, so a gap in archify's numbering takes no room
407 const cols = [...new Set(candidate.nodes.map((n) => n.col ?? 0))].sort((a, b) => a - b)
408 const byId = new Map(candidate.nodes.map((n) => [n.id, n]))
409 const laneOf = (n) => lanes.findIndex((l) => l.id === n.lane)
410 const colOf = (n) => cols.indexOf(n.col ?? 0)
411 // Where an arrow goes on the grid: `next` is the cell right after in the same lane, the one
412 // arrow drawn between boxes; every other one is a line in the box it leaves
413 const direction = (from, to) => {
414 if (!to) return 'forward'
415 const lane = laneOf(to) - laneOf(from)
416 if (lane !== 0) return lane > 0 ? 'down' : 'up'
417 const col = colOf(to) - colOf(from)
418 return col === 1 ? 'next' : col > 0 ? 'forward' : 'back'
419 }
420 const main = candidate.mainPath ?? []
421 const rank = (n) => (main.includes(n.id) ? main.indexOf(n.id) : main.length)
422 const sorted = [...candidate.nodes].sort((a, b) => rank(a) - rank(b) || colOf(a) - colOf(b) || laneOf(a) - laneOf(b))
423 const rows = new Map(
424 sorted.map((n) => [
425 n.id,
426 {
427 id: n.id,
428 label: n.label ?? n.id,
429 sublabel: n.sublabel ?? null,
430 tag: n.tag ?? null,
431 // rules/after-pr.md tags a step that went away; its lane alone doesn't say so, since an
432 // exception lane may as well hold a failure path
433 isRemoved: n.tag === '없어짐',
434 edges: (candidate.edges ?? [])
435 .filter((e) => e.from === n.id)
436 .map((e) => ({
437 toId: e.to,
438 to: byId.get(e.to)?.label ?? e.to,
439 label: e.label ?? null,
440 isMain: !e.role || e.role === 'main',
441 direction: direction(n, byId.get(e.to)),
442 })),
443 },
444 ]),
445 )
446 return {
447 title,
448 lanes: lanes.map((l) => ({
449 label: l.label ?? l.id ?? '',
450 isException: l.variant === 'exception',
451 cells: cols.map((_, c) => sorted.filter((n) => n.lane === l.id && colOf(n) === c).map((n) => rows.get(n.id))),
452 })),
453 nodes: sorted.map((n) => rows.get(n.id)),
454 }
455}
456
457// An archify dataflow candidate: one column per stage, its nodes top to bottom by row, each
458// with the flows that leave it. A dashed flow is one archify draws as not there yet.
459function dataflowRows(candidate, title) {
460 const byId = new Map(candidate.nodes.map((n) => [n.id, n]))
461 return {
462 title,
463 dashedLabel: candidate.meta?.legend?.entries?.dashed?.label ?? null,
464 stages: (candidate.stages ?? []).map((stage, i) => ({
465 label: stage.label ?? String(i + 1),
466 nodes: candidate.nodes
467 .filter((n) => n.stage === i)
468 .sort((a, b) => (a.row ?? 0) - (b.row ?? 0))
469 .map((n) => ({
470 id: n.id,
471 label: n.label ?? n.id,
472 sublabel: n.sublabel ?? null,
473 edges: (candidate.flows ?? [])
474 .filter((f) => f.from === n.id)
475 .map((f) => ({ to: byId.get(f.to)?.label ?? f.to, label: f.label ?? null, isDashed: f.variant === 'dashed' })),
476 })),
477 })),
478 }
479}
480