SLOPSHOPPER

work-board

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

panebandcommandprocesstimeragents
v0.2.0no licenseupdated 2026-10-26jongwoo315/claude-config/mods/work-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-board
│ ┃ Work ✕ › fix the failing auth test and add an audit log call │ ┃ ringtab-work tab-pin tab-changes tab-diff ta │ ┃ ⏺ Read(src/auth.ts) │ ┃ a: turn-0 ⎿ Read 6 lines │ ┃ undefined 1 · I updated src/auth.ts to ⏺ 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 │ ┃ │ ┃ › /board │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ✧ No pins, changes, flow, agents or orch task yet ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✧ No pins, changes, flow, agents or orch task yet ⟨Claude Code's own drawing⟩
Pane · Work
ringtab-work tab-pin tab-changes tab-diff tab-flow tab-agent a: turn-0 undefined 1 · I updated src/auth.ts to reject exp…
README

claude-config

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.

Layout

PathPurpose
CLAUDE.mdGlobal 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)

What's measured here

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.

FileHow it was measuredResult
rules/korean-style.mdSame prompts run with and without the rule loaded; coined-term rate per 10k characters3.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.mdByte-for-byte diff of a token-saving CLI proxy's output against the raw command, per subcommandTwo 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.mdPer-PR record written in two passes — prediction before reading the diff, evidence after — so the hit rate can't be filled in retroactively4 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:

  • The style rule was written on the assumption that terse output causes bad coinages. The more compressed arm scored lower (2.07 vs 5.57 per 10k) — the cause is translation, not compression. A 12-question probe then suggested technical vocabulary was safe; the 194-occurrence repo sweep found the exact opposite, and every flagged term was technical.
  • The proxy was assumed to save tokens everywhere. Measuring each subcommand separately is what found the two that saved nothing, and an upstream issue's stated cause (locale) did not reproduce.
  • The judgment log's format changed after the fact that a fixed 7-line checklist left 4 lines blank on every entry, while the numbers that actually decided the call had no column and ended up in prose.

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.

orch — tmux Claude orchestrator

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.

Prerequisite

The plugin's Claude Code hooks (which stamp @claude_state) must be installed — orch can't detect step completion without them.

Usage

# 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-permissions by default. Don't point orch at repos where an unreviewed rm / git push / prod action could do damage — use --safe and keep --max low there.

See orch/README.md for advance/failure semantics and env-var config.

Source 3 files
hooks/register.js 846 lines
1import { 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}
846
hooks/themes.js 18 lines
1// 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}
18
hooks/model.js 480 lines
1// 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