claude-code-kanban 7.12.1 → 7.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -141,6 +141,17 @@ const COMMANDS = {
141
141
  examples: ['claude-code-kanban session open $CLAUDE_SESSION_ID'],
142
142
  run: runSessionOpenCli,
143
143
  },
144
+ cost: {
145
+ summary: 'Show a session (or one subagent) in the Cost app',
146
+ usage: 'claude-code-kanban session cost <id> [--agent <agent id>]',
147
+ flags: {
148
+ '<id>': 'Full session id, or a unique prefix',
149
+ '--agent <agent id>': 'A subagent of the session (agent-…), as claude-code-cost session prints it',
150
+ },
151
+ notes: 'Under the hub the board switches to the Cost tab. The command also prints the cost link, for a board that runs alone.',
152
+ examples: ['claude-code-kanban session cost 3fa9c1 --agent agent-a10ff42'],
153
+ run: runSessionCostCli,
154
+ },
144
155
  view: {
145
156
  summary: 'Show full session stats (metadata + context window + cost) and the transcript path',
146
157
  usage: 'claude-code-kanban session view <id> [--json]',
@@ -943,6 +954,29 @@ async function runSessionOpenCli(args) {
943
954
  } catch (e) { reportCliError(e); return 1; }
944
955
  }
945
956
 
957
+ const AGENT_ID_RE = /^agent-[\w-]+$/;
958
+
959
+ async function runSessionCostCli(args) {
960
+ const entry = COMMANDS.session.verbs.cost;
961
+ const [idArg] = positionals(args, ['--agent']);
962
+ if (!idArg) {
963
+ printLeafHelp(entry);
964
+ return 1;
965
+ }
966
+ const agent = getArgValue(args, 'agent');
967
+ const agentGiven = args.some((a) => a === '--agent' || a.startsWith('--agent='));
968
+ if (agentGiven && !AGENT_ID_RE.test(agent || '')) return usageError(entry, '--agent takes an agent-… id.');
969
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
970
+ if (!resolved) return 1;
971
+ try {
972
+ const out = await cliPostJson('/api/session/cost', { id: resolved.id, ...(agent && { agent }) }, 'Cost');
973
+ if (!out) return 1;
974
+ console.log(out.delivered ? 'Sent to the board: it opens the Cost tab.' : 'No hub board page is open to show it.');
975
+ if (out.url) console.log(`Cost view: ${out.url}`);
976
+ return out.delivered || out.url ? 0 : 1;
977
+ } catch (e) { reportCliError(e); return 1; }
978
+ }
979
+
946
980
  async function runSessionPinCli(args) {
947
981
  const idArg = args.find(a => !a.startsWith('--'));
948
982
  if (!idArg) {
@@ -1521,4 +1555,4 @@ async function runSkillsGetCli(args) {
1521
1555
  return 0;
1522
1556
  }
1523
1557
 
1524
- module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };
1558
+ module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE };
package/hub-app.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "icon": "columns",
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
- "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
8
+ "keys": { "keeps": ["ctrl+alt+1", "ctrl+alt+2", "ctrl+alt+3", "ctrl+alt+4", "ctrl+alt+5", "ctrl+alt+6", "ctrl+alt+7", "ctrl+alt+8", "ctrl+alt+9", "ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
9
9
  "plugin": {
10
10
  "marketplace": "plugin",
11
11
  "name": "claude-code-kanban",
package/lib/kanbot.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { encodeProjectDirName } = require('./claude-dir');
6
6
  const { MODELS } = require('./terminal');
7
+ const { isPidAlive } = require('./live-sessions');
7
8
  const { topHelp } = require('../cli');
8
9
 
9
10
  const PLUGIN_DIR = path.join(__dirname, '..', 'kanbot');
@@ -19,15 +20,56 @@ function kanbotModel(cfg) {
19
20
  return MODELS.has(model) ? model : null;
20
21
  }
21
22
 
22
- function writeHelpPrompt(cwd, boardUrl) {
23
+ function costSection(costCli) {
24
+ if (!costCli) return '';
25
+ return [
26
+ '\n# Costs\n',
27
+ `\`${costCli} help\` lists the commands for Claude Code costs: by day, model, project, session and subagent.`,
28
+ 'To show a session, or one of its subagents, to the user: `claude-code-kanban session cost <id> [--agent <agent id>]`.\n',
29
+ ].join('\n');
30
+ }
31
+
32
+ function writeHelpPrompt(cwd, boardUrl, costCli) {
23
33
  const file = path.join(cwd, 'cli-help.md');
24
34
  const board = boardUrl ? `This board runs at ${boardUrl}; \`$CCK_URL\` holds the same URL, so the CLI talks to it.\n\n` : '';
25
- fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n`);
35
+ fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n${costSection(costCli)}`);
26
36
  return file;
27
37
  }
28
38
 
29
- function kanbotArgs(cwd, claudeDir, boardUrl) {
30
- return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl), '--add-dir', claudeDir];
39
+ function kanbotArgs(cwd, claudeDir, boardUrl, costCli) {
40
+ return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl, costCli), '--add-dir', claudeDir];
41
+ }
42
+
43
+ // A claude-code-cost before its CLI starts a dashboard on any command, so a bin on PATH counts only
44
+ // when its package has lib/cli.js. npm puts the package next to a Windows shim, and a POSIX bin is
45
+ // a symlink to server.js.
46
+ function costPackageHasCli(bin) {
47
+ const dirs = [path.join(path.dirname(bin), 'node_modules', 'claude-code-cost')];
48
+ try {
49
+ dirs.push(path.dirname(fs.realpathSync(bin)));
50
+ } catch {}
51
+ return dirs.some((d) => fs.existsSync(path.join(d, 'lib', 'cli.js')));
52
+ }
53
+
54
+ // The hub gives cck no cost URL. The cost server of this config dir records itself in server.json
55
+ // ({pid, port, script}); a killed one leaves the file behind.
56
+ function readCostServer(claudeDir) {
57
+ let rec;
58
+ try {
59
+ rec = JSON.parse(fs.readFileSync(path.join(claudeDir, '.cost', 'server.json'), 'utf8'));
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!Number.isInteger(rec?.pid) || !Number.isInteger(rec?.port) || !isPidAlive(rec.pid)) return null;
64
+ return { url: `http://localhost:${rec.port}`, script: typeof rec.script === 'string' ? rec.script : null };
65
+ }
66
+
67
+ // Under the hub the CLI is not on PATH, so the running cost server names its own script.
68
+ function findCostCli({ claudeDir, which }) {
69
+ const script = readCostServer(claudeDir)?.script;
70
+ if (script) return `node "${script.replace(/\\/g, '/')}"`;
71
+ const bin = which('claude-code-cost');
72
+ return bin && costPackageHasCli(bin) ? 'claude-code-cost' : null;
31
73
  }
32
74
 
33
75
  function createKanbot({ cckDir, projectsDir }) {
@@ -70,10 +112,10 @@ function createKanbot({ cckDir, projectsDir }) {
70
112
  sessionId = id;
71
113
  ownIds.add(id);
72
114
  },
73
- startSpec({ model = null, boardUrl = null } = {}) {
115
+ startSpec({ model = null, boardUrl = null, costCli = null } = {}) {
74
116
  fs.mkdirSync(cwd, { recursive: true });
75
117
  sessionId = newestTranscript();
76
- const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl);
118
+ const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl, costCli);
77
119
  // Not restored on a cck restart: a restore would bring it back without the agent args.
78
120
  return { cwd, name: 'kanbot', model, extraArgs, restore: false, ...(sessionId && { resume: sessionId }) };
79
121
  },
@@ -83,4 +125,4 @@ function createKanbot({ cckDir, projectsDir }) {
83
125
  };
84
126
  }
85
127
 
86
- module.exports = { kanbotOn, kanbotModel, createKanbot, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
128
+ module.exports = { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.12.1",
3
+ "version": "7.14.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.8",
3
+ "version": "3.7.11",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }
@@ -16,7 +16,7 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
16
16
  |---|---|
17
17
  | `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
18
18
  | `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
19
- | `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
19
+ | `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `cost <id> [--agent <agent id>]` opens its costs · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
20
20
  | `group` | `list` the sidebar groups, by name and id · `create <name>` · `rename <group> <name>` · `rm <group>` · `prune` the empty ones |
21
21
  | `task` | `list` for a session, a project or the whole board |
22
22
  | `project` | `list` |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: visualize
3
- description: Present a topic as one self-contained HTML page — findings, options and a pick, a system overview. Use when the user asks to visualize something, or when an answer outgrows chat (options plus a pick, a diagram plus a table).
3
+ description: Present a topic as one self-contained HTML page whose form fits the content — a diagram of a system, a chart of numbers over time, a model the reader can change, a comparison with a pick. Use when the user asks to visualize something, or when an answer outgrows chat.
4
4
  argument-hint: '[what to present]'
5
5
  ---
6
6
 
@@ -8,30 +8,35 @@ argument-hint: '[what to present]'
8
8
 
9
9
  Present this: $ARGUMENTS. With no argument, present the subject of this conversation.
10
10
 
11
- The page is a document to read: its text and static exhibits are in the HTML, and it works offline. Two leading words run through it: the **pick** is the option the page recommends (on a page with no options, its main path), and an **exhibit** is the one table, figure or block that proves a section's claim.
11
+ The page is a document to read: its text and static exhibits are in the HTML, and it works offline. Two leading words run through it: the **pick** is the option the page recommends, and an **exhibit** is the one table, figure or block that proves a section's claim. A page with no options has no pick: never invent one to recommend.
12
+
13
+ The content sets the page's shape. The template gives the theme and a bare frame; [blocks.html](blocks.html) holds ready parts that share the theme, and a page takes only the parts its content has.
12
14
 
13
15
  ## Steps
14
16
 
15
- 1. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`; the `<style>` block stays byte for byte, and your own CSS goes in a second `<style>` after it.
16
- 2. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point:
17
- 1. `.kicker`, then `<title>` and `<h1>`: a name of 2 to 5 words. `.sub`: the question, in one line.
18
- 2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
19
- 3. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and at most 2 short paragraphs under it.
20
- 4. Close with what to do next and `.sources`.
21
- 3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit can be interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
22
- 4. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
23
- 5. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
17
+ 1. **Choose the form.** Before you open the template, name the content's shape (a system, a process, a timeline, a comparison, numbers that change, a set of findings) and the page's main exhibit: the one picture a reader remembers. State both in one line. The form makes the claim obvious at a glance. When the content has parts that connect, steps in order or states that change, the main exhibit is a diagram. Else a chart, an annotated timeline, small multiples, a working model or a ready part, whichever proves the claim.
18
+ 2. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`. Leave the template's `<style>` block as it is, because it holds the theme tokens, and put your own CSS in a second `<style>` after it.
19
+ 3. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point. A page has a title, a lead, sections and, when it cites, sources; every other part is there only when the content has that shape:
20
+ 1. `<title>` and `<h1>`: a name of 2 to 5 words. `.kicker` and `.sub` (the question, in one line) when they add something.
21
+ 2. `.lead`: the answer, short enough that a reader who stops here has it. When the page has a pick, name it in `<b>`; else `<b>` holds the main finding, or nothing.
22
+ 3. A lineup when the page compares options, stat tiles when it has a few headline numbers.
23
+ 4. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and the short text that supports it.
24
+ 5. Close with what to do next, when there is a next step, and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
25
+ 4. **Build each exhibit.** Each section's claim gets the form that makes it obvious, as in step 1: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map, or a ready part. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit is interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
26
+ 5. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when a reader who sees only a thumbnail can tell what the page is about, no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
27
+ 6. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
24
28
 
25
29
  ## Principles
26
30
 
27
31
  Every exhibit, ready or new, keeps these:
28
32
 
29
- - **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. In a chart that compares options, the pick's series keeps its series color and the accent goes on its label or end point. The rest stays muted. Status colors mean status.
33
+ - **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. In a chart that compares options, the pick's series keeps its series color and the accent goes on its label or end point. With no pick, the accent marks the one thing the claim is about, or nothing. The rest stays muted. Status colors mean status.
30
34
  - **Chart data takes series colors**: `--color-series-1` for the main series, `--color-text-muted` or `--color-series-3` for a second, up to 8 series; skip `--color-series-2`, which is the accent's hue; past that, fold the tail into "Other" or split into small multiples. The accent can sit close to `--color-danger`, so a whole data series in it reads as an alarm.
31
35
  - **Tokens color everything**: `var(--color-*)`, tints by `color-mix(in srgb, var(--color-accent) 12%, var(--color-bg))`. Then the page follows light, dark and a host's theme with no script.
32
36
  - **Fit**: an SVG has a `viewBox` up to 960 wide, no `width` attribute, text of 12 units or more. A flow runs left to right when it has 6 steps or fewer.
33
37
  - **Remove test**: each node, arrow, label and decoration stays only if removing it loses meaning. A diagram past 9 nodes or 12 arrows splits into an overview and a detail. Each SVG has `role="img"` and `aria-labelledby` pointing at its `<title>`, with a per-figure id prefix.
34
38
  - **Real**: real names, numbers and `file:line`, estimates and invented data marked, each claim cited. Repeated marks, such as small multiples, are generated while you write the page.
39
+ - **Structure follows the content**: the sections, their order and the parts in them come from the data and the question, so two pages on different topics have different shapes. A part the content does not call for stays out: stat tiles with no headline numbers, next steps nobody asked for, a lineup with no options.
35
40
 
36
41
  ## Interactive exhibits
37
42
 
@@ -45,7 +50,7 @@ Every exhibit, ready or new, keeps these:
45
50
 
46
51
  ## Blocks
47
52
 
48
- Ready parts in the template's CSS; its body has one of each.
53
+ Ready parts in the template's CSS. [blocks.html](blocks.html) shows the markup of each; read the rows you use, in the order the story needs.
49
54
 
50
55
  | Block | Markup | Good for |
51
56
  |---|---|---|
@@ -62,6 +67,7 @@ Ready parts in the template's CSS; its body has one of each.
62
67
  | Callout | `div.callout` | A warning |
63
68
  | Steps | `ol.steps` | What to do next |
64
69
  | Tag, dot, legend | `span.tag`, `span.dot`, `.legend > span > i` | Labels and keys |
65
- | Sources | `div.sources` | Where each claim comes from |
70
+ | Sources | `.sources > ol > li#src-n > code + span`, then `p` | Where each claim comes from: one source per `li`, numbered in order (CSS prints `[n]`), the path or `a` link, and in `span` what it backs; a closing line, such as how estimates are marked, goes in the `p` |
71
+ | Citation | `a.cite[href="#src-n"]` holding `[n]` | An in-text marker that jumps to its source row |
66
72
 
67
73
  `data-tone` takes `accent`, `info`, `success`, `warning`, `danger` or `muted` on any block, legend keys included.
@@ -0,0 +1,68 @@
1
+ <!-- Markup of each ready part; the CSS is in template.html. Copy a part only when the content has its shape. -->
2
+
3
+ <!-- Kicker and sub -->
4
+ <p class="kicker">Ticket or context · kind of page</p>
5
+ <p class="sub">The question this page answers.</p>
6
+
7
+ <!-- Lineup: a page that compares options; data-tone="accent" on the pick -->
8
+ <div class="lineup">
9
+ <div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
10
+ <div><b>2</b><span>Option B</span><small>too static</small></div>
11
+ </div>
12
+
13
+ <!-- Stat tiles: a few headline numbers -->
14
+ <div class="kpis">
15
+ <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes <a class="cite" href="#src-1">[1]</a></small></div>
16
+ <div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
17
+ </div>
18
+
19
+ <!-- Section with a label -->
20
+ <section>
21
+ <p class="eyebrow">Overview</p>
22
+ <h2>A heading is a claim that can be false</h2>
23
+ </section>
24
+
25
+ <!-- Cards: trade-offs of options -->
26
+ <div class="cards">
27
+ <div class="card" data-tone="accent">
28
+ <h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
29
+ <p class="verdict">The verdict, in one line.</p>
30
+ <p>What it is. <a class="cite" href="#src-2">[2]</a></p>
31
+ <pre>on 429: wait, then retry</pre>
32
+ <ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- Table and heatmap cells -->
37
+ <div class="table"><table>
38
+ <thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
39
+ <tbody>
40
+ <tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
41
+ <tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
42
+ <tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
43
+ </tbody>
44
+ </table></div>
45
+
46
+ <!-- Points: findings, each led by its claim -->
47
+ <ul class="points">
48
+ <li><b>A point leads with its claim.</b> The support.</li>
49
+ <li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
50
+ </ul>
51
+
52
+ <!-- Callout -->
53
+ <div class="callout" data-tone="warning"><p>A warning.</p></div>
54
+
55
+ <!-- Steps: what to do next -->
56
+ <ol class="steps"><li>First action.</li><li>Second action.</li></ol>
57
+
58
+ <!-- Legend -->
59
+ <div class="legend"><span><i data-tone="accent"></i>Pick</span><span><i></i>Other</span></div>
60
+
61
+ <!-- Sources with a closing line -->
62
+ <div class="sources">
63
+ <ol>
64
+ <li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
65
+ <li id="src-2"><a href="https://example.com">Title of the page</a><span>the retry on 429</span></li>
66
+ </ol>
67
+ <p>Estimates are marked as estimates.</p>
68
+ </div>
@@ -111,7 +111,8 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
111
111
  td.c { text-align: center; white-space: nowrap; }
112
112
  td.yes { --tone: var(--color-success); } td.no { --tone: var(--color-danger); } td.part { --tone: var(--color-warning); }
113
113
  td.yes, td.no, td.part { color: var(--tone); font-weight: 600; background: color-mix(in srgb, var(--tone) 13%, transparent); }
114
- td.yes::before { content: "● "; } td.part::before { content: "◐ "; } td.no::before { content: "○ "; }
114
+ td.yes::before, td.part::before, td.no::before { content: ""; display: inline-block; width: 8px; height: 8px; border: 1.5px solid; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
115
+ td.yes::before { background: currentColor; } td.part::before { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
115
116
  tr[data-tone] > td:first-child { box-shadow: inset 3px 0 0 var(--tone); background: color-mix(in srgb, var(--tone) 10%, transparent); }
116
117
 
117
118
  figure { margin: 0 0 20px; padding: 24px; border: 1px solid var(--color-border); border-radius: 14px; background: var(--color-bg-subtle) radial-gradient(color-mix(in srgb, var(--color-text) 9%, transparent) 1px, transparent 1.5px) 0 0 / 18px 18px; }
@@ -131,9 +132,18 @@ ol.steps > li { counter-increment: step; position: relative; padding: 2px 0 20px
131
132
  ol.steps > li::before { content: counter(step); position: absolute; left: 0; top: 0; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font: 600 13px var(--font-mono); color: var(--color-bg); background: var(--color-accent); }
132
133
  ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 36px; bottom: 4px; width: 1px; background: var(--color-border); }
133
134
 
134
- .sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 12px/1.7 var(--font-mono); color: var(--color-text-muted); }
135
- .sources p { max-width: none; }
136
- .sources code { font-size: inherit; background: none; border: 0; padding: 0; }
135
+ .sources { margin-top: 88px; padding-top: 16px; border-top: 1px solid var(--color-border); font: 13px/20px var(--font-sans); color: var(--color-text-muted); }
136
+ .sources::before { content: "Sources"; display: block; font: 500 12px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; margin: 0 0 10px; }
137
+ .sources ol { list-style: none; counter-reset: src; padding: 0; margin: 0; }
138
+ .sources li { counter-increment: src; position: relative; max-width: none; margin: 0; padding: 4px 8px 4px calc(5ch + 8px); border-radius: 6px; scroll-margin-top: 24px; }
139
+ .sources li::before { content: "[" counter(src) "]"; position: absolute; left: 8px; font: 12.5px/20px var(--font-mono); }
140
+ .sources li:target { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
141
+ .sources li > span { margin-left: 10px; }
142
+ .sources code { font-size: 12.5px; color: var(--color-text); background: none; border: 0; padding: 0; overflow-wrap: anywhere; }
143
+ .sources a { overflow-wrap: anywhere; }
144
+ .sources > p { max-width: none; margin: 12px 0 0; padding-left: 8px; }
145
+ a.cite { font: 500 .8em var(--font-mono); text-decoration: none; white-space: nowrap; }
146
+ a.cite:hover { text-decoration: underline; }
137
147
 
138
148
  @media (max-width: 600px) {
139
149
  main { padding-top: 36px; }
@@ -148,68 +158,22 @@ ol.steps > li:not(:last-child)::after { content: ""; position: absolute; left: 1
148
158
  </head>
149
159
  <body>
150
160
  <main>
151
- <p class="kicker">Ticket or context · kind of page</p>
152
161
  <h1>Page Title</h1>
153
- <p class="sub">The question this page answers.</p>
154
- <p class="lead">The answer in one or two sentences, with <b>the pick</b> named, so a reader who stops here has it.</p>
155
-
156
- <div class="lineup">
157
- <div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
158
- <div><b>2</b><span>Option B</span><small>too static</small></div>
159
- <div><b>3</b><span>Option C</span><small>separate project</small></div>
160
- </div>
161
-
162
- <div class="kpis">
163
- <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes [1]</small></div>
164
- <div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
165
- </div>
162
+ <p class="lead">The answer, with <b>the main finding</b> named.</p>
166
163
 
167
164
  <section>
168
- <p class="eyebrow">Overview</p>
169
- <h2>Each heading is a claim that can be true or false</h2>
165
+ <h2>A heading is a claim that can be false</h2>
170
166
  <figure class="wide">
171
- <svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the diagram shows</title></svg>
172
- <figcaption>One sentence: what to notice.</figcaption>
167
+ <svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the figure shows</title></svg>
168
+ <figcaption>What to notice.</figcaption>
173
169
  </figure>
174
170
  </section>
175
171
 
176
- <section>
177
- <p class="eyebrow">Options</p>
178
- <h2>Cards lead with the verdict</h2>
179
- <div class="cards">
180
- <div class="card" data-tone="accent">
181
- <h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
182
- <p class="verdict">The verdict, in one line.</p>
183
- <p>What it is, in one sentence.</p>
184
- <pre>on 429: wait, then retry</pre>
185
- <ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
186
- </div>
187
- </div>
188
- </section>
189
-
190
- <section>
191
- <p class="eyebrow">Compare</p>
192
- <h2>A table holds the matrix</h2>
193
- <div class="table"><table>
194
- <thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
195
- <tbody>
196
- <tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
197
- <tr><td><span class="dot">2</span>Option B</td><td class="c no">no</td><td class="num">300</td></tr>
198
- </tbody>
199
- </table></div>
200
- <ul class="points">
201
- <li><b>A point leads with its claim.</b> One or two sentences of support.</li>
202
- <li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
203
- </ul>
204
- </section>
205
-
206
- <section>
207
- <p class="eyebrow">Next</p>
208
- <h2>Steps say what to do next</h2>
209
- <ol class="steps"><li>First action.</li><li>Second action.</li></ol>
210
- </section>
211
-
212
- <div class="sources"><p>Sources: [1] <code>path/to/file.md</code> · [2] <a href="https://example.com">Title</a>. Estimates are marked as estimates.</p></div>
172
+ <div class="sources">
173
+ <ol>
174
+ <li id="src-1"><code>path/to/file.ts:42</code><span>what it backs</span></li>
175
+ </ol>
176
+ </div>
213
177
  </main>
214
178
  </body>
215
179
  </html>
package/public/app.js CHANGED
@@ -2604,9 +2604,18 @@ function replyLocateHint(timestamp, sid) {
2604
2604
  return `If that reply is not in your context, it is in ${transcript}, the assistant entry with timestamp ${timestamp}.`;
2605
2605
  }
2606
2606
 
2607
+ let msgDetailFromTerminal = false;
2608
+
2607
2609
  function closeMsgDetailModal() {
2608
2610
  unmountMsgReview();
2609
2611
  hideModalOverlay('msg-detail-modal');
2612
+ // A task later, so a modal opened right after this one (View Agent) keeps the focus.
2613
+ if (msgDetailFromTerminal) {
2614
+ setTimeout(() => {
2615
+ if (!isAnyModalOpen() && wantsTerminal()) focusTerminalPane();
2616
+ });
2617
+ }
2618
+ msgDetailFromTerminal = false;
2610
2619
  msgDetailFollowLatest = false;
2611
2620
  waitingDetailAutoOpened = false;
2612
2621
  // Drop the message highlight on close, mirroring task-card behavior.
@@ -3918,7 +3927,7 @@ function renderSessions() {
3918
3927
  const renderSessionCard = (session) => {
3919
3928
  if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
3920
3929
  const total = session.taskCount;
3921
- const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3930
+ const pct = (n) => Math.round((n / total) * 100);
3922
3931
  const isActive = session.id === currentSessionId && viewMode === 'session';
3923
3932
  const isLive = isSessionLive(session);
3924
3933
  const sessionName = sessionDisplayName(session);
@@ -3951,7 +3960,7 @@ function renderSessions() {
3951
3960
  const sid = escAttrJs(session.id);
3952
3961
  const progressHtml =
3953
3962
  total > 0
3954
- ? `<div class="session-progress" title="${escapeHtml(session.completed)} of ${total} tasks done"><div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div><span class="progress-text">${session.completed}/${total}</span></div>`
3963
+ ? `<div class="session-progress" title="${escapeHtml(`${session.pending} pending, ${session.inProgress} in progress, ${session.completed} completed`)}"><div class="progress-bar"><div class="progress-fill done" style="width: ${pct(session.completed)}%"></div>${session.inProgress > 0 ? `<div class="progress-fill wip" style="width: ${pct(session.inProgress)}%"></div>` : ''}</div><span class="progress-text">${session.completed + session.inProgress}/${total}</span></div>`
3955
3964
  : '';
3956
3965
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3957
3966
  return `
@@ -6391,6 +6400,12 @@ const SHORTCUT_TABS = [
6391
6400
  title: 'Tabs',
6392
6401
  rows: [
6393
6402
  { keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
6403
+ {
6404
+ keys: ['Ctrl', 'Alt', '1…9'],
6405
+ combo: true,
6406
+ yields: 'ctrl+alt+1',
6407
+ label: 'Go to tab or group N (the board is 1)',
6408
+ },
6394
6409
  { keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
6395
6410
  { keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
6396
6411
  { keys: ['{', '}'], label: 'Focus left / right side of a split' },
@@ -6542,6 +6557,8 @@ function termKbd() {
6542
6557
  function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
6543
6558
  const live = row.action && keyLabel(row.action);
6544
6559
  if (live) return live;
6560
+ // `yields`: the row's key gives way when the hub binds that combo.
6561
+ if (row.yields && hub.forwardCombos().includes(row.yields)) return [];
6545
6562
  if (!mac) return row.keys;
6546
6563
  if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
6547
6564
  return row.keys.map((k) => MAC_KEYS[k] || k);
@@ -7548,16 +7565,19 @@ function shiftOnly(e, code) {
7548
7565
  return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
7549
7566
  }
7550
7567
 
7568
+ // xterm runs in another process, so blur() in the board leaves it focused and the modal's arrow keys go
7569
+ // to it. Focusing a board element takes the focus.
7551
7570
  function toggleLastMessage() {
7552
7571
  if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
7553
7572
  closeMsgDetailModal();
7554
- } else if (isWaitingFresh()) {
7555
- msgDetailFollowLatest = true;
7556
- showWaitingDetail();
7557
- } else if (currentMessages.length) {
7558
- msgDetailFollowLatest = true;
7559
- showMsgDetail(currentMessages.length - 1);
7573
+ return;
7560
7574
  }
7575
+ if (!isWaitingFresh() && !currentMessages.length) return;
7576
+ msgDetailFromTerminal = terminalPaneFocused();
7577
+ msgDetailFollowLatest = true;
7578
+ if (isWaitingFresh()) showWaitingDetail();
7579
+ else showMsgDetail(currentMessages.length - 1);
7580
+ if (msgDetailFromTerminal) document.querySelector('#msg-detail-modal .msg-detail-dialog').focus();
7561
7581
  }
7562
7582
 
7563
7583
  function dismissSessionFromList(sid) {
@@ -7825,6 +7845,12 @@ document.addEventListener('keydown', (e) => {
7825
7845
  }
7826
7846
  return;
7827
7847
  }
7848
+ // The hub binds Ctrl+Alt+digit on macOS for its app keys.
7849
+ if (hubModDown(e) && /^Digit[1-9]$/.test(e.code) && !hub.forwards(e) && selectPaneByNumber(Number(e.code.slice(5)))) {
7850
+ e.preventDefault();
7851
+ focusActivePane();
7852
+ return;
7853
+ }
7828
7854
  if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
7829
7855
  e.preventDefault();
7830
7856
  return;
@@ -8631,6 +8657,7 @@ const PANE_CLAIMS = [
8631
8657
  ...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
8632
8658
  ...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
8633
8659
  ...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
8660
+ ...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
8634
8661
  ];
8635
8662
 
8636
8663
  // Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
@@ -10163,6 +10190,12 @@ function setupEventSource() {
10163
10190
  handleSessionOpenEvent(data);
10164
10191
  }
10165
10192
 
10193
+ // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10194
+ // CLI prints the link instead.
10195
+ if (data.type === 'session:cost' && hub.inHub) {
10196
+ openCost(data.id, data.agent);
10197
+ }
10198
+
10166
10199
  if (data.type === 'session:pin') {
10167
10200
  handleSessionPinEvent(data);
10168
10201
  }
@@ -12953,7 +12986,9 @@ function openFolderInEditor(folder, file) {
12953
12986
  postAndToast('/api/open-folder', body, 'folder');
12954
12987
  }
12955
12988
 
12956
- function openCost(sessionId) {
12989
+ function openCost(sessionId, agentId) {
12990
+ if (sessionId && agentId && hub.can('agent.cost'))
12991
+ return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
12957
12992
  hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
12958
12993
  }
12959
12994
 
@@ -13237,6 +13272,7 @@ function setActivePaneId(sid, id) {
13237
13272
  if (getActivePaneId(sid) === id) return;
13238
13273
  paneActive = savePaneState(PANE_ACTIVE_KEY, paneActive, sid, id === 'board' ? null : id);
13239
13274
  notePairFocus(sid, id);
13275
+ noteGroupFocus(sid, id);
13240
13276
  }
13241
13277
 
13242
13278
  // {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
@@ -13297,18 +13333,75 @@ function neighborPair(sid, id, pairs) {
13297
13333
  return i > 0 ? { l: free[i - 1], r: id } : { l: id, r: free[1] };
13298
13334
  }
13299
13335
 
13300
- // The strip in order, as {ids, pair}: a lone tab, or a pair at the place of its first tab.
13301
- function paneStripItems(sid) {
13336
+ // {[sessionId]: [{id, name, color, ids, closed, last}]}; `ids` are pane ids, `last` the one shown
13337
+ // last. Like pairs, groups live in this browser tab only. A group shows at the place of its first
13338
+ // tab, so grouping leaves the server's order alone; a drag saves the order on screen.
13339
+ const PANE_GROUPS_KEY = 'pane-groups';
13340
+ const PANE_GROUP_COLORS = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'red'];
13341
+ let paneGroups = loadPaneState(PANE_GROUPS_KEY);
13342
+
13343
+ // Tabs that are gone, or in an earlier group, drop out, and so do groups left empty.
13344
+ function getPaneGroups(sid) {
13345
+ if (!paneGroups[sid]) return [];
13346
+ const live = new Set(paneLayout(sid).panes.map((p) => p.id));
13347
+ const taken = new Set();
13348
+ const groups = [];
13349
+ for (const g of paneGroups[sid]) {
13350
+ const ids = g.ids.filter((id) => live.has(id) && !taken.has(id));
13351
+ for (const id of ids) taken.add(id);
13352
+ const color = PANE_GROUP_COLORS.includes(g.color) ? g.color : PANE_GROUP_COLORS[0];
13353
+ if (ids.length) groups.push({ id: g.id, name: g.name || '', color, ids, closed: !!g.closed, last: g.last });
13354
+ }
13355
+ return groups;
13356
+ }
13357
+
13358
+ function setPaneGroups(sid, groups) {
13359
+ paneGroups = savePaneState(PANE_GROUPS_KEY, paneGroups, sid, groups.length ? groups : null);
13360
+ }
13361
+
13362
+ // A pair is in the group of either side, and the board is in none.
13363
+ function groupOfTabs(groups, ids) {
13364
+ return ids.includes('board') ? null : groups.find((g) => ids.some((t) => g.ids.includes(t))) || null;
13365
+ }
13366
+
13367
+ function noteGroupFocus(sid, id) {
13368
+ const pair = pairOf(sid, id);
13369
+ const group = groupOfTabs(getPaneGroups(sid), pair ? [pair.l, pair.r] : [id]);
13370
+ if (group && (group.closed || group.last !== id)) updatePaneGroup(sid, group.id, { closed: false, last: id });
13371
+ }
13372
+
13373
+ // The strip as the number keys count it, {group, items}: a group is one stop, open or collapsed.
13374
+ // An item is {ids, pair, group}: a lone tab, or a pair at the place of its first tab. A group's
13375
+ // items follow its first one.
13376
+ function paneStripStops(sid) {
13302
13377
  const pairs = getPanePairs(sid);
13303
- const items = [];
13378
+ const groups = getPaneGroups(sid);
13379
+ const stops = [];
13380
+ const groupStops = new Map();
13381
+ const seen = new Set();
13304
13382
  for (const id of paneTabIds(sid)) {
13305
13383
  const pair = pairs.find((p) => splitSideOf(p, id)) || null;
13306
- if (!pair) items.push({ ids: [id], pair });
13307
- else if (!items.some((it) => it.pair === pair)) items.push({ ids: [pair.l, pair.r], pair });
13384
+ if (pair && seen.has(pair)) continue;
13385
+ if (pair) seen.add(pair);
13386
+ const ids = pair ? [pair.l, pair.r] : [id];
13387
+ const group = groupOfTabs(groups, ids);
13388
+ const item = { ids, pair, group };
13389
+ const stop = group && groupStops.get(group);
13390
+ if (stop) stop.items.push(item);
13391
+ else {
13392
+ stops.push({ group, items: [item] });
13393
+ if (group) groupStops.set(group, stops.at(-1));
13394
+ }
13308
13395
  }
13309
- return items;
13396
+ return stops;
13310
13397
  }
13311
13398
 
13399
+ const paneStripItems = (sid) => paneStripStops(sid).flatMap((s) => s.items);
13400
+
13401
+ // The items a click or a key can reach: a collapsed group hides its tabs.
13402
+ const isOpenItem = (it) => !it.group?.closed;
13403
+ const openStripItems = (sid) => paneStripItems(sid).filter(isOpenItem);
13404
+
13312
13405
  function unpairPanes(sid, pair, keep) {
13313
13406
  setPanePairs(sid, withoutTabs(getPanePairs(sid), pair.l));
13314
13407
  if (keep) setActivePaneId(sid, keep);
@@ -13502,7 +13595,7 @@ function paneMenuLabel(pane, label) {
13502
13595
 
13503
13596
  function renderPaneTabs(sid, layout, split) {
13504
13597
  // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
13505
- if (paneDragId || paneRenameId) return;
13598
+ if (paneDragId || stripRename) return;
13506
13599
  const active = getActivePaneId(sid);
13507
13600
  const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
13508
13601
  const tab = (id, cls, inner, tip) =>
@@ -13531,9 +13624,17 @@ function renderPaneTabs(sid, layout, split) {
13531
13624
  pair
13532
13625
  ? `<div class="pane-pair${pair === split ? ' on' : ''}">${ids.map(tabHtml).join('<span class="pane-pair-sep"></span>')}</div>`
13533
13626
  : tabHtml(ids[0]);
13534
- const [pinned, ...rest] = paneStripItems(sid).map(itemHtml);
13535
- const pin = pinned + (rest.length ? '<span class="pane-sep"></span>' : '');
13536
- const html = rest.join('');
13627
+ const groupHtml = (g, items) => {
13628
+ const n = items.flatMap((it) => it.ids).length;
13629
+ const tip = g.closed ? `Expand group (${n} tabs)` : 'Collapse group';
13630
+ const label =
13631
+ (g.name ? `<span>${escapeHtml(g.name)}</span>` : '') +
13632
+ (g.closed ? `<span class="pane-group-count">${n}</span>` : '');
13633
+ return `<div class="pane-group${g.closed ? ' closed' : ''}" style="${paneGroupStyle(g.color)}"><button class="pane-group-chip" data-group="${escapeHtml(g.id)}" title="${escapeHtml(tip)}">${label}</button>${g.closed ? '' : items.map(itemHtml).join('')}</div>`;
13634
+ };
13635
+ const [first, ...rest] = paneStripStops(sid);
13636
+ const pin = itemHtml(first.items[0]) + (rest.length ? '<span class="pane-sep"></span>' : '');
13637
+ const html = rest.map(({ group, items }) => (group ? groupHtml(group, items) : itemHtml(items[0]))).join('');
13537
13638
  // syncPanes runs on every task refresh and search keystroke; skip the rebuild and the reflows.
13538
13639
  if (pin + html === paneTabsHtml) return;
13539
13640
  paneTabsHtml = pin + html;
@@ -13901,17 +14002,90 @@ function cyclePane(step) {
13901
14002
  return true;
13902
14003
  }
13903
14004
 
14005
+ function selectStripItem({ ids, pair }) {
14006
+ selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
14007
+ }
14008
+
14009
+ function selectPaneByNumber(n) {
14010
+ const sid = paneSessionId();
14011
+ if (!sid || wantsTerminal()) return false;
14012
+ const stop = paneStripStops(sid)[n - 1];
14013
+ if (stop) selectStripItem(stop.items.find((it) => it.ids.includes(stop.group?.last)) || stop.items[0]);
14014
+ return true;
14015
+ }
14016
+
13904
14017
  function cyclePaneStrip(step) {
13905
14018
  const sid = paneSessionId();
13906
14019
  if (!sid || wantsTerminal()) return false;
13907
14020
  const active = getActivePaneId(sid);
13908
- const items = paneStripItems(sid);
14021
+ const items = openStripItems(sid);
13909
14022
  const i = items.findIndex((it) => it.ids.includes(active));
13910
- const { ids, pair } = items[(i + step + items.length) % items.length];
13911
- selectPane(pair ? pair[pair.f || 'l'] : ids[0]);
14023
+ selectStripItem(items[(i + step + items.length) % items.length]);
13912
14024
  return true;
13913
14025
  }
13914
14026
 
14027
+ function updatePaneGroup(sid, groupId, change) {
14028
+ const groups = getPaneGroups(sid);
14029
+ const group = groups.find((g) => g.id === groupId);
14030
+ if (!group) return;
14031
+ Object.assign(group, change);
14032
+ setPaneGroups(sid, groups);
14033
+ }
14034
+
14035
+ // `to` is a group id, 'new', or null to take the tab out of its group. A split moves as one.
14036
+ function groupPaneTab(id, to) {
14037
+ const sid = paneSessionId();
14038
+ const item = sid && paneStripItems(sid).find((it) => it.ids.includes(id));
14039
+ if (!item || item.ids.includes('board')) return;
14040
+ const groups = getPaneGroups(sid);
14041
+ for (const g of groups) g.ids = g.ids.filter((t) => !item.ids.includes(t));
14042
+ let target = groups.find((g) => g.id === to);
14043
+ if (to === 'new') {
14044
+ const used = new Set(groups.filter((g) => g.ids.length).map((g) => g.color));
14045
+ const color =
14046
+ PANE_GROUP_COLORS.find((c) => !used.has(c)) || PANE_GROUP_COLORS[groups.length % PANE_GROUP_COLORS.length];
14047
+ target = { id: `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, name: '', color, ids: [] };
14048
+ groups.push(target);
14049
+ }
14050
+ if (target) Object.assign(target, { ids: [...target.ids, ...item.ids], closed: false });
14051
+ setPaneGroups(sid, groups);
14052
+ syncPanes();
14053
+ if (to === 'new') beginPaneGroupRename(target.id);
14054
+ }
14055
+
14056
+ // A collapsed group cannot hold the tab on screen, so collapsing it moves to the next open item.
14057
+ function togglePaneGroup(groupId) {
14058
+ const sid = paneSessionId();
14059
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14060
+ if (!group) return;
14061
+ const closing = !group.closed;
14062
+ updatePaneGroup(sid, groupId, { closed: closing });
14063
+ const items = paneStripItems(sid);
14064
+ const i = items.findIndex((it) => it.ids.includes(getActivePaneId(sid)));
14065
+ if (!closing || items[i]?.group?.id !== groupId) {
14066
+ syncPanes();
14067
+ return;
14068
+ }
14069
+ selectStripItem(items.slice(i).find(isOpenItem) || items.slice(0, i).findLast(isOpenItem));
14070
+ }
14071
+
14072
+ function ungroupPanes(groupId) {
14073
+ const sid = paneSessionId();
14074
+ if (!sid) return;
14075
+ setPaneGroups(
14076
+ sid,
14077
+ getPaneGroups(sid).filter((g) => g.id !== groupId),
14078
+ );
14079
+ syncPanes();
14080
+ }
14081
+
14082
+ function colorPaneGroup(groupId, color) {
14083
+ const sid = paneSessionId();
14084
+ if (!sid) return;
14085
+ updatePaneGroup(sid, groupId, { color });
14086
+ syncPanes();
14087
+ }
14088
+
13915
14089
  function isMenuKey(e) {
13916
14090
  return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
13917
14091
  }
@@ -14057,44 +14231,105 @@ async function deleteBoardTasks(onlyCompleted) {
14057
14231
  await refreshCurrentView();
14058
14232
  }
14059
14233
 
14060
- let paneRenameId = null;
14234
+ // The save function of the tab or group name being edited.
14235
+ let stripRename = null;
14236
+
14237
+ function beginStripRename(el, { className, value, label, maxLength, save }) {
14238
+ stripRename = save;
14239
+ const input = document.createElement('input');
14240
+ input.className = `strip-rename ${className}`;
14241
+ input.value = value;
14242
+ if (maxLength) input.maxLength = maxLength;
14243
+ input.setAttribute('aria-label', label);
14244
+ el.replaceWith(input);
14245
+ input.focus();
14246
+ input.select();
14247
+ }
14248
+
14249
+ async function endStripRename(input, save) {
14250
+ const onSave = stripRename;
14251
+ if (!onSave) return;
14252
+ stripRename = null;
14253
+ paneTabsHtml = '';
14254
+ if (save) await onSave(input.value.trim());
14255
+ syncPanes();
14256
+ }
14061
14257
 
14062
14258
  function beginPaneRename(id) {
14063
14259
  const pane = paneById(id);
14064
14260
  const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
14065
14261
  const title = tab?.querySelector('.pane-title');
14066
14262
  if (!pane || !title) return;
14067
- paneRenameId = id;
14068
14263
  tab.draggable = false;
14069
- const input = document.createElement('input');
14070
- input.className = 'pane-title-input';
14071
- input.value = pane.title;
14072
- input.setAttribute('aria-label', 'Pane title');
14073
- title.replaceWith(input);
14074
- input.focus();
14075
- input.select();
14264
+ beginStripRename(title, {
14265
+ className: 'pane-title-input',
14266
+ value: pane.title,
14267
+ label: 'Pane title',
14268
+ save: async (text) => {
14269
+ const sid = paneSessionId();
14270
+ if (!sid || !paneById(id) || text === paneById(id).title) return;
14271
+ const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, { title: text });
14272
+ if (res.error) showToast(res.error, 'error');
14273
+ else applyPaneLayout(sid, res.layout);
14274
+ },
14275
+ });
14076
14276
  }
14077
14277
 
14078
- async function endPaneRename(input, save) {
14079
- const id = paneRenameId;
14080
- if (!id) return;
14081
- paneRenameId = null;
14082
- paneTabsHtml = '';
14278
+ const PANE_GROUP_NAME_MAX = 40;
14279
+
14280
+ function beginPaneGroupRename(groupId) {
14083
14281
  const sid = paneSessionId();
14084
- const pane = paneById(id);
14085
- const title = input.value.trim();
14086
- if (save && sid && pane && title !== pane.title) {
14087
- const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, {
14088
- title,
14089
- });
14090
- if (res.error) showToast(res.error, 'error');
14091
- else applyPaneLayout(sid, res.layout);
14092
- }
14093
- syncPanes();
14282
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14283
+ const chip = paneStrip.querySelector(`[data-group="${CSS.escape(groupId)}"]`);
14284
+ if (!group || !chip) return;
14285
+ beginStripRename(chip, {
14286
+ className: 'pane-group-input',
14287
+ value: group.name,
14288
+ label: 'Group name',
14289
+ maxLength: PANE_GROUP_NAME_MAX,
14290
+ save: (name) => updatePaneGroup(sid, groupId, { name: name.slice(0, PANE_GROUP_NAME_MAX) }),
14291
+ });
14094
14292
  }
14095
14293
 
14096
- const paneMenuItem = (act, label) =>
14097
- `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
14294
+ const paneMenuItem = (act, label, arg) =>
14295
+ `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}"${arg == null ? '' : ` data-arg="${escapeHtml(arg)}"`}>${label}</button>`;
14296
+ const paneGroupStyle = (color) => escapeHtml(`--g: var(--owner-${color})`);
14297
+ const paneGroupDot = (g) => `<span class="pane-menu-dot" style="${paneGroupStyle(g.color)}"></span>`;
14298
+
14299
+ function paneGroupMenuItems(id) {
14300
+ const sid = paneSessionId();
14301
+ const item = paneStripItems(sid).find((it) => it.ids.includes(id));
14302
+ if (!item || item.ids.includes('board')) return '';
14303
+ const others = getPaneGroups(sid).filter((g) => g.id !== item.group?.id);
14304
+ return (
14305
+ paneMenuItem('group', 'Add to new group', 'new') +
14306
+ others
14307
+ .map((g) => paneMenuItem('group', `Add to ${paneGroupDot(g)}${escapeHtml(g.name || 'unnamed group')}`, g.id))
14308
+ .join('') +
14309
+ (item.group ? paneMenuItem('group', 'Remove from group', '') : '')
14310
+ );
14311
+ }
14312
+
14313
+ function openPaneGroupMenu(x, y, groupId) {
14314
+ const sid = paneSessionId();
14315
+ const group = sid && getPaneGroups(sid).find((g) => g.id === groupId);
14316
+ if (!group) return;
14317
+ const swatches = PANE_GROUP_COLORS.map(
14318
+ (c) =>
14319
+ `<button class="pane-menu-item pane-menu-swatch${c === group.color ? ' on' : ''}" role="menuitemradio" aria-checked="${escapeHtml(String(c === group.color))}" aria-label="${escapeHtml(c)}" title="${escapeHtml(c)}" data-pane-act="groupColor" data-arg="${escapeHtml(c)}" style="${paneGroupStyle(c)}"></button>`,
14320
+ ).join('');
14321
+ showPaneMenu(
14322
+ x,
14323
+ y,
14324
+ group.id,
14325
+ 'Group actions',
14326
+ `<div class="pane-menu-label">${escapeHtml(group.name || 'Unnamed group')}</div>` +
14327
+ paneMenuItem('groupRename', 'Rename') +
14328
+ paneMenuItem('groupToggle', group.closed ? 'Expand' : 'Collapse') +
14329
+ `<div class="pane-menu-swatches">${swatches}</div>` +
14330
+ paneMenuItem('ungroup', 'Ungroup'),
14331
+ );
14332
+ }
14098
14333
 
14099
14334
  function openPaneMenu(x, y, id) {
14100
14335
  if (id === 'board') {
@@ -14127,6 +14362,7 @@ function openPaneMenu(x, y, id) {
14127
14362
  : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
14128
14363
  paneMenuItem('rename', 'Rename') +
14129
14364
  paneMenuItem('reload', 'Reload') +
14365
+ paneGroupMenuItems(pane.id) +
14130
14366
  paneMenuItem('close', 'Close pane'),
14131
14367
  );
14132
14368
  return true;
@@ -14169,6 +14405,11 @@ const PANE_MENU_ACTIONS = {
14169
14405
  rename: beginPaneRename,
14170
14406
  reload: reloadPane,
14171
14407
  close: closePane,
14408
+ group: (id, to) => groupPaneTab(id, to || null),
14409
+ groupRename: beginPaneGroupRename,
14410
+ groupToggle: togglePaneGroup,
14411
+ groupColor: colorPaneGroup,
14412
+ ungroup: ungroupPanes,
14172
14413
  deleteCompleted: () => deleteBoardTasks(true),
14173
14414
  deleteAll: () => deleteBoardTasks(false),
14174
14415
  };
@@ -14358,18 +14599,23 @@ function closePanePop() {
14358
14599
  }
14359
14600
 
14360
14601
  paneStrip.addEventListener('keydown', (e) => {
14361
- const input = e.target.closest?.('.pane-title-input');
14602
+ const input = e.target.closest?.('.strip-rename');
14362
14603
  if (input && (e.key === 'Enter' || e.key === 'Escape')) {
14363
14604
  e.preventDefault();
14364
- endPaneRename(input, e.key === 'Enter');
14605
+ endStripRename(input, e.key === 'Enter');
14365
14606
  }
14366
14607
  });
14367
14608
  paneStrip.addEventListener('focusout', (e) => {
14368
- const input = e.target.closest?.('.pane-title-input');
14369
- if (input) endPaneRename(input, true);
14609
+ const input = e.target.closest?.('.strip-rename');
14610
+ if (input) endStripRename(input, true);
14370
14611
  });
14371
14612
  paneStrip.addEventListener('click', (e) => {
14372
- if (e.target.closest('.pane-title-input')) return;
14613
+ if (e.target.closest('.strip-rename')) return;
14614
+ const chip = e.target.closest('[data-group]');
14615
+ if (chip) {
14616
+ togglePaneGroup(chip.dataset.group);
14617
+ return;
14618
+ }
14373
14619
  const close = e.target.closest('[data-close]');
14374
14620
  if (close) {
14375
14621
  closePane(close.dataset.close);
@@ -14385,16 +14631,19 @@ paneStrip.addEventListener('auxclick', (e) => {
14385
14631
  if (e.button === 1 && tab && tab.dataset.pane !== 'board') closePane(tab.dataset.pane);
14386
14632
  });
14387
14633
  paneStrip.addEventListener('contextmenu', (e) => {
14634
+ const chip = e.target.closest('[data-group]');
14388
14635
  const tab = e.target.closest('.pane-tab');
14389
- if (!tab) return;
14636
+ if (!chip && !tab) return;
14390
14637
  e.preventDefault();
14391
- openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
14638
+ if (chip) openPaneGroupMenu(e.clientX, e.clientY, chip.dataset.group);
14639
+ else openPaneMenu(e.clientX, e.clientY, tab.dataset.pane);
14392
14640
  });
14393
14641
  document.addEventListener('click', (e) => {
14394
14642
  const menu = document.getElementById('pane-menu');
14395
14643
  if (!menu) return;
14396
- const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
14397
- if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
14644
+ const item = e.target.closest('.pane-menu-item');
14645
+ const act = item?.dataset.paneAct;
14646
+ if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane, item.dataset.arg);
14398
14647
  if (act) closePaneMenu();
14399
14648
  else if (!menu.contains(e.target)) closePaneMenu(true);
14400
14649
  });
@@ -14470,15 +14719,18 @@ paneStrip.addEventListener('dragstart', (e) => {
14470
14719
  });
14471
14720
 
14472
14721
  // A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
14473
- // holds the board and stays put. Elsewhere on the strip, a split tab's drop ends its split.
14722
+ // holds the board and stays put. A moved item joins the group it lands in, and a drop on a
14723
+ // group's chip adds it to that group. Elsewhere on the strip, a split tab's drop ends its split.
14474
14724
  function paneDropAction(e) {
14475
14725
  const sid = paneSessionId();
14476
14726
  const tab = e.target.closest?.('.pane-tab');
14727
+ const chip = e.target.closest?.('[data-group]');
14477
14728
  if (!sid || !paneDragId) return null;
14478
- if (tab) {
14729
+ if (chip || tab) {
14479
14730
  const [fixed, ...movable] = paneStripItems(sid);
14480
14731
  const itemOf = (id) => movable.find((it) => it.ids.includes(id));
14481
14732
  const from = itemOf(paneDragId);
14733
+ if (chip) return from ? { kind: 'group', sid, el: chip, group: chip.dataset.group } : null;
14482
14734
  const to = itemOf(tab.dataset.pane);
14483
14735
  if (from && to && from !== to) {
14484
14736
  const el = to.pair ? tab.closest('.pane-pair') : tab;
@@ -14492,9 +14744,11 @@ function paneDropAction(e) {
14492
14744
 
14493
14745
  function markPaneDrop(action) {
14494
14746
  paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
14495
- for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
14496
- el.classList.toggle('drop-before', el === action?.el && !action.after);
14497
- el.classList.toggle('drop-after', el === action?.el && action.after);
14747
+ for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair, .pane-group-chip')) {
14748
+ const on = el === action?.el;
14749
+ el.classList.toggle('drop-in', on && action.kind === 'group');
14750
+ el.classList.toggle('drop-before', on && action.kind === 'move' && !action.after);
14751
+ el.classList.toggle('drop-after', on && action.kind === 'move' && action.after);
14498
14752
  }
14499
14753
  }
14500
14754
 
@@ -14521,11 +14775,15 @@ paneStrip.addEventListener('drop', (e) => {
14521
14775
  if (!action) return;
14522
14776
  e.preventDefault();
14523
14777
  const { sid, pair } = action;
14778
+ const id = paneDragId;
14524
14779
  const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
14525
- const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
14780
+ const keep = shown ? pair[otherSide(splitSideOf(pair, id))] : null;
14526
14781
  endPaneDrag();
14527
- if (action.kind === 'move') movePaneItem(action);
14528
- else unpairPanes(sid, pair, keep);
14782
+ if (action.kind === 'group') groupPaneTab(id, action.group);
14783
+ else if (action.kind === 'move') {
14784
+ if (action.from.group?.id !== action.to.group?.id) groupPaneTab(id, action.to.group?.id ?? null);
14785
+ movePaneItem(action);
14786
+ } else unpairPanes(sid, pair, keep);
14529
14787
  });
14530
14788
  paneDrop.addEventListener('dragover', (e) => {
14531
14789
  if (!paneDragId) return;
package/public/index.html CHANGED
@@ -430,7 +430,7 @@
430
430
 
431
431
  <!-- Message Detail Modal -->
432
432
  <div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
433
- <div class="modal msg-detail-dialog review-host" onclick="event.stopPropagation()">
433
+ <div class="modal msg-detail-dialog review-host" tabindex="-1" onclick="event.stopPropagation()">
434
434
  <div class="modal-header">
435
435
  <h3 class="modal-title" id="msg-detail-title">Message</h3>
436
436
  <div style="display:flex;gap:4px;align-items:center;">
package/public/style.css CHANGED
@@ -1204,7 +1204,6 @@ body.booting .boot-splash {
1204
1204
 
1205
1205
  /* The live-workflow bar takes the muted fill too: its card already carries a pulsing
1206
1206
  accent dot, and a second accent bar under it shouts. */
1207
- .session-item:not(.active) .progress-fill,
1208
1207
  .wf-live-bar .progress-fill {
1209
1208
  background: color-mix(in srgb, var(--text-tertiary) 55%, transparent);
1210
1209
  }
@@ -1216,6 +1215,14 @@ body.booting .boot-splash {
1216
1215
  font-variant-numeric: tabular-nums;
1217
1216
  }
1218
1217
 
1218
+ .session-progress .progress-bar {
1219
+ display: flex;
1220
+ }
1221
+
1222
+ .progress-fill.done {
1223
+ background: var(--success);
1224
+ }
1225
+
1219
1226
  .context-bar-labels {
1220
1227
  display: flex;
1221
1228
  justify-content: space-between;
@@ -4489,6 +4496,75 @@ body.show-resizing iframe {
4489
4496
  background: var(--border);
4490
4497
  }
4491
4498
 
4499
+ .pane-group {
4500
+ position: relative;
4501
+ display: flex;
4502
+ gap: 2px;
4503
+ min-width: 0;
4504
+ }
4505
+
4506
+ /* Over the tabs, so a tab's own background does not hide it. */
4507
+ .pane-group:not(.closed)::after {
4508
+ content: '';
4509
+ position: absolute;
4510
+ inset: auto 0 1px;
4511
+ z-index: 1;
4512
+ height: 2px;
4513
+ background: var(--g);
4514
+ pointer-events: none;
4515
+ }
4516
+
4517
+ .pane-group-chip,
4518
+ .pane-group-input {
4519
+ flex: none;
4520
+ align-self: center;
4521
+ height: 18px;
4522
+ margin: 0 2px;
4523
+ padding: 0 8px;
4524
+ border: 0;
4525
+ border-radius: 6px;
4526
+ font-family: var(--font-mono);
4527
+ font-size: 11px;
4528
+ font-weight: 600;
4529
+ letter-spacing: 0.3px;
4530
+ }
4531
+
4532
+ .pane-group-chip {
4533
+ min-width: 14px;
4534
+ max-width: 140px;
4535
+ overflow: hidden;
4536
+ text-overflow: ellipsis;
4537
+ white-space: nowrap;
4538
+ background: var(--g);
4539
+ color: var(--bg-deep);
4540
+ cursor: pointer;
4541
+ }
4542
+
4543
+ .pane-group-chip:hover {
4544
+ filter: brightness(1.12);
4545
+ }
4546
+
4547
+ .pane-group-count {
4548
+ font-weight: 400;
4549
+ opacity: 0.75;
4550
+ }
4551
+
4552
+ .pane-group-count:not(:first-child) {
4553
+ margin-left: 6px;
4554
+ }
4555
+
4556
+ .pane-group-chip.drop-in {
4557
+ box-shadow: 0 0 0 2px var(--accent);
4558
+ }
4559
+
4560
+ .pane-group-input {
4561
+ width: 110px;
4562
+ color: var(--text-primary);
4563
+ background: var(--bg-deep);
4564
+ box-shadow: inset 0 0 0 1px var(--g);
4565
+ outline: none;
4566
+ }
4567
+
4492
4568
  .pane-strip.drop-hot {
4493
4569
  background: var(--accent-dim);
4494
4570
  }
@@ -7547,6 +7623,10 @@ body.light .msg-assistant .msg-text {
7547
7623
  overflow-y: auto;
7548
7624
  }
7549
7625
 
7626
+ .modal.msg-detail-dialog:focus {
7627
+ outline: none;
7628
+ }
7629
+
7550
7630
  /* Before .modal.fullscreen on purpose: at equal specificity the later
7551
7631
  fullscreen rule must win, same as for every other per-modal size. */
7552
7632
  .modal.msg-detail-dialog {
@@ -10441,4 +10521,32 @@ pre.mermaid[data-mermaid-error]::before {
10441
10521
  color: var(--text-primary);
10442
10522
  font-weight: 600;
10443
10523
  }
10524
+ .pane-menu-dot {
10525
+ flex: none;
10526
+ width: 8px;
10527
+ height: 8px;
10528
+ border-radius: 50%;
10529
+ background: var(--g);
10530
+ }
10531
+ .pane-menu-swatches {
10532
+ display: flex;
10533
+ gap: 4px;
10534
+ padding: 4px 8px;
10535
+ }
10536
+ .pane-menu-item.pane-menu-swatch {
10537
+ flex: none;
10538
+ width: 16px;
10539
+ height: 16px;
10540
+ padding: 0;
10541
+ border-radius: 50%;
10542
+ background: var(--g);
10543
+ }
10544
+ .pane-menu-item.pane-menu-swatch:hover,
10545
+ .pane-menu-item.pane-menu-swatch:focus-visible,
10546
+ .pane-menu-swatch.on {
10547
+ background: var(--g);
10548
+ box-shadow:
10549
+ 0 0 0 2px var(--bg-elevated),
10550
+ 0 0 0 3px var(--text-primary);
10551
+ }
10444
10552
  /* #endregion */
package/server.js CHANGED
@@ -48,7 +48,8 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
48
48
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
49
49
  const { readTerminalConfig } = require('./lib/terminal');
50
50
  const { createTerminalClient } = require('./lib/terminal-client');
51
- const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
51
+ const { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer } = require('./lib/kanbot');
52
+ const { AGENT_ID_RE } = require('./cli');
52
53
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
53
54
  const { readLiveSessions, isPidAlive, noteReplacedSessions } = require('./lib/live-sessions');
54
55
  const { createProcStats } = require('./lib/proc-stats');
@@ -3526,6 +3527,7 @@ async function ensureKanbot() {
3526
3527
  const started = await terminal.startNew(kanbot.startSpec({
3527
3528
  model: kanbotModelSetting(),
3528
3529
  boardUrl: `http://127.0.0.1:${boardPort}`,
3530
+ costCli: findCostCli({ claudeDir: CLAUDE_DIR, which: whichSync }),
3529
3531
  }));
3530
3532
  if (started.error) return started;
3531
3533
  kanbot.ptyId = started.id;
@@ -3799,7 +3801,7 @@ async function confirmKind(absPath, kind) {
3799
3801
  }
3800
3802
 
3801
3803
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
3802
- const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
3804
+ const PREVIEW_MAX_BYTES = 16 * 1024 * 1024;
3803
3805
  // Source in a modal is for reading, not for scrolling a generated bundle.
3804
3806
  const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
3805
3807
 
@@ -3989,6 +3991,19 @@ app.post('/api/session/open', async (req, res) => {
3989
3991
  }
3990
3992
  });
3991
3993
 
3994
+ app.post('/api/session/cost', (req, res) => {
3995
+ const { id, agent } = req.body || {};
3996
+ if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
3997
+ if (agent !== undefined && (typeof agent !== 'string' || !AGENT_ID_RE.test(agent))) {
3998
+ return res.status(400).json({ error: 'agent must be an agent-… id' });
3999
+ }
4000
+ broadcast({ type: 'session:cost', id, ...(agent && { agent }) });
4001
+ const query = new URLSearchParams({ view: 'detail', session: id, ...(agent && { agent }) });
4002
+ const costUrl = COST_URL || readCostServer(CLAUDE_DIR)?.url;
4003
+ // A page opens the Cost tab only under the hub.
4004
+ res.json({ delivered: process.env.CLAUDE_HUB ? clients.size : 0, url: costUrl ? `${new URL(`?${query}`, costUrl)}` : null });
4005
+ });
4006
+
3992
4007
  // #region SESSION_PINS
3993
4008
  function broadcastPins(changed) {
3994
4009
  for (const { id, state } of changed) broadcast({ type: 'session:pin', id, state });