claude-code-kanban 7.13.0 → 7.15.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 +35 -1
- package/lib/kanbot.js +49 -7
- package/lib/parsers.js +28 -3
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +18 -13
- package/plugin/plugins/claude-code-kanban/skills/visualize/blocks.html +68 -0
- package/plugin/plugins/claude-code-kanban/skills/visualize/template.html +5 -59
- package/public/app.js +122 -30
- package/public/index.html +9 -3
- package/public/style.css +56 -0
- package/server.js +17 -2
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/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
|
|
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/lib/parsers.js
CHANGED
|
@@ -673,16 +673,41 @@ function countTranscriptsBornBefore(dir, birthtimeMs) {
|
|
|
673
673
|
// Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
|
|
674
674
|
// Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
|
|
675
675
|
// to it (#50), so only a file that grew, shrank or was replaced counts as activity.
|
|
676
|
+
// When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
|
|
677
|
+
// mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
|
|
676
678
|
const logActivityCache = new Map();
|
|
679
|
+
const BOOKKEEPING_APPEND_MAX = 64 * 1024;
|
|
680
|
+
|
|
681
|
+
function isBookkeepingAppend(jsonlPath, from, to) {
|
|
682
|
+
if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
|
|
683
|
+
// Reads from one byte early, so the append is known to start on a line boundary.
|
|
684
|
+
const buf = Buffer.allocUnsafe(to - from + 1);
|
|
685
|
+
try {
|
|
686
|
+
const fd = fs.openSync(jsonlPath, 'r');
|
|
687
|
+
try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
|
|
688
|
+
} catch { return false; }
|
|
689
|
+
const text = buf.toString('utf8');
|
|
690
|
+
if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
|
|
691
|
+
return text.slice(1, -1).split('\n').every((line) => {
|
|
692
|
+
if (!line) return true;
|
|
693
|
+
try {
|
|
694
|
+
const obj = JSON.parse(line);
|
|
695
|
+
return obj !== null && typeof obj === 'object';
|
|
696
|
+
} catch { return false; }
|
|
697
|
+
});
|
|
698
|
+
}
|
|
677
699
|
|
|
678
700
|
function transcriptActivityMs(jsonlPath, stat) {
|
|
679
701
|
const prev = logActivityCache.get(jsonlPath);
|
|
680
|
-
|
|
702
|
+
const sameFile = prev && prev.ino === stat.ino;
|
|
703
|
+
if (sameFile && prev.size === stat.size) return prev.mtimeMs;
|
|
704
|
+
const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
|
|
705
|
+
const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
|
|
681
706
|
logActivityCache.delete(jsonlPath);
|
|
682
|
-
logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs
|
|
707
|
+
logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
|
|
683
708
|
if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
|
|
684
709
|
sessionCacheDirty = true;
|
|
685
|
-
return
|
|
710
|
+
return mtimeMs;
|
|
686
711
|
}
|
|
687
712
|
|
|
688
713
|
const sessionCachesDirty = () => sessionCacheDirty;
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
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 —
|
|
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
|
|
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. **
|
|
16
|
-
2. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|---|---|---|
|
|
@@ -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>
|
|
@@ -158,75 +158,21 @@ a.cite:hover { text-decoration: underline; }
|
|
|
158
158
|
</head>
|
|
159
159
|
<body>
|
|
160
160
|
<main>
|
|
161
|
-
<p class="kicker">Ticket or context · kind of page</p>
|
|
162
161
|
<h1>Page Title</h1>
|
|
163
|
-
<p class="
|
|
164
|
-
<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>
|
|
165
|
-
|
|
166
|
-
<div class="lineup">
|
|
167
|
-
<div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
|
|
168
|
-
<div><b>2</b><span>Option B</span><small>too static</small></div>
|
|
169
|
-
<div><b>3</b><span>Option C</span><small>separate project</small></div>
|
|
170
|
-
</div>
|
|
171
|
-
|
|
172
|
-
<div class="kpis">
|
|
173
|
-
<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>
|
|
174
|
-
<div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
|
|
175
|
-
</div>
|
|
162
|
+
<p class="lead">The answer, with <b>the main finding</b> named.</p>
|
|
176
163
|
|
|
177
164
|
<section>
|
|
178
|
-
<
|
|
179
|
-
<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>
|
|
180
166
|
<figure class="wide">
|
|
181
|
-
<svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the
|
|
182
|
-
<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>
|
|
183
169
|
</figure>
|
|
184
170
|
</section>
|
|
185
171
|
|
|
186
|
-
<section>
|
|
187
|
-
<p class="eyebrow">Options</p>
|
|
188
|
-
<h2>Cards lead with the verdict</h2>
|
|
189
|
-
<div class="cards">
|
|
190
|
-
<div class="card" data-tone="accent">
|
|
191
|
-
<h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
|
|
192
|
-
<p class="verdict">The verdict, in one line.</p>
|
|
193
|
-
<p>What it is, in one sentence. <a class="cite" href="#src-2">[2]</a></p>
|
|
194
|
-
<pre>on 429: wait, then retry</pre>
|
|
195
|
-
<ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
|
|
196
|
-
</div>
|
|
197
|
-
</div>
|
|
198
|
-
</section>
|
|
199
|
-
|
|
200
|
-
<section>
|
|
201
|
-
<p class="eyebrow">Compare</p>
|
|
202
|
-
<h2>A table holds the matrix</h2>
|
|
203
|
-
<div class="table"><table>
|
|
204
|
-
<thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
|
|
205
|
-
<tbody>
|
|
206
|
-
<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>
|
|
207
|
-
<tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
|
|
208
|
-
<tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
|
|
209
|
-
</tbody>
|
|
210
|
-
</table></div>
|
|
211
|
-
<ul class="points">
|
|
212
|
-
<li><b>A point leads with its claim.</b> One or two sentences of support.</li>
|
|
213
|
-
<li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
|
|
214
|
-
</ul>
|
|
215
|
-
</section>
|
|
216
|
-
|
|
217
|
-
<section>
|
|
218
|
-
<p class="eyebrow">Next</p>
|
|
219
|
-
<h2>Steps say what to do next</h2>
|
|
220
|
-
<ol class="steps"><li>First action.</li><li>Second action.</li></ol>
|
|
221
|
-
</section>
|
|
222
|
-
|
|
223
172
|
<div class="sources">
|
|
224
173
|
<ol>
|
|
225
|
-
<li id="src-1"><code>
|
|
226
|
-
<li id="src-2"><code>services/api/src/middleware/rate-limit/retry-after-handler.ts:42</code><span>the retry on 429</span></li>
|
|
227
|
-
<li id="src-3"><a href="https://example.com">Title of the page</a><span>line counts in the table</span></li>
|
|
174
|
+
<li id="src-1"><code>path/to/file.ts:42</code><span>what it backs</span></li>
|
|
228
175
|
</ol>
|
|
229
|
-
<p>Estimates are marked as estimates.</p>
|
|
230
176
|
</div>
|
|
231
177
|
</main>
|
|
232
178
|
</body>
|
package/public/app.js
CHANGED
|
@@ -369,7 +369,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
|
|
|
369
369
|
schedulePublishSession();
|
|
370
370
|
}
|
|
371
371
|
// A list too short to scroll gives no scroll event, so fill it until it can scroll.
|
|
372
|
-
if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
|
|
372
|
+
if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
|
|
373
373
|
|
|
374
374
|
if (includeTasks && searchQuery) await loadSearchTasks();
|
|
375
375
|
} catch (error) {
|
|
@@ -418,11 +418,11 @@ function resetSessionPage() {
|
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
const LOAD_MORE_MARGIN_PX = 240;
|
|
421
|
-
function loadMoreIfNearEnd() {
|
|
422
|
-
const { scrollTop, clientHeight, scrollHeight } =
|
|
423
|
-
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX)
|
|
421
|
+
function loadMoreIfNearEnd(el = sessionsList, load = loadMoreSessions) {
|
|
422
|
+
const { scrollTop, clientHeight, scrollHeight } = el;
|
|
423
|
+
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) load();
|
|
424
424
|
}
|
|
425
|
-
sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
|
|
425
|
+
sessionsList.addEventListener('scroll', () => loadMoreIfNearEnd(), { passive: true });
|
|
426
426
|
|
|
427
427
|
// Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
|
|
428
428
|
async function fetchSessionsByIds(ids) {
|
|
@@ -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.
|
|
@@ -6437,6 +6446,7 @@ const SHORTCUT_TABS = [
|
|
|
6437
6446
|
{ keys: ['I'], label: 'Session info' },
|
|
6438
6447
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
6439
6448
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6449
|
+
{ keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
|
|
6440
6450
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6441
6451
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
6442
6452
|
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
@@ -7556,16 +7566,19 @@ function shiftOnly(e, code) {
|
|
|
7556
7566
|
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7557
7567
|
}
|
|
7558
7568
|
|
|
7569
|
+
// xterm runs in another process, so blur() in the board leaves it focused and the modal's arrow keys go
|
|
7570
|
+
// to it. Focusing a board element takes the focus.
|
|
7559
7571
|
function toggleLastMessage() {
|
|
7560
7572
|
if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7561
7573
|
closeMsgDetailModal();
|
|
7562
|
-
|
|
7563
|
-
msgDetailFollowLatest = true;
|
|
7564
|
-
showWaitingDetail();
|
|
7565
|
-
} else if (currentMessages.length) {
|
|
7566
|
-
msgDetailFollowLatest = true;
|
|
7567
|
-
showMsgDetail(currentMessages.length - 1);
|
|
7574
|
+
return;
|
|
7568
7575
|
}
|
|
7576
|
+
if (!isWaitingFresh() && !currentMessages.length) return;
|
|
7577
|
+
msgDetailFromTerminal = terminalPaneFocused();
|
|
7578
|
+
msgDetailFollowLatest = true;
|
|
7579
|
+
if (isWaitingFresh()) showWaitingDetail();
|
|
7580
|
+
else showMsgDetail(currentMessages.length - 1);
|
|
7581
|
+
if (msgDetailFromTerminal) document.querySelector('#msg-detail-modal .msg-detail-dialog').focus();
|
|
7569
7582
|
}
|
|
7570
7583
|
|
|
7571
7584
|
function dismissSessionFromList(sid) {
|
|
@@ -10178,6 +10191,12 @@ function setupEventSource() {
|
|
|
10178
10191
|
handleSessionOpenEvent(data);
|
|
10179
10192
|
}
|
|
10180
10193
|
|
|
10194
|
+
// Alone, the page would open a tab without a user gesture, which the browser blocks; the
|
|
10195
|
+
// CLI prints the link instead.
|
|
10196
|
+
if (data.type === 'session:cost' && hub.inHub) {
|
|
10197
|
+
openCost(data.id, data.agent);
|
|
10198
|
+
}
|
|
10199
|
+
|
|
10181
10200
|
if (data.type === 'session:pin') {
|
|
10182
10201
|
handleSessionPinEvent(data);
|
|
10183
10202
|
}
|
|
@@ -12255,6 +12274,52 @@ let spFromTerminal = false;
|
|
|
12255
12274
|
let spRenderedQuery = null;
|
|
12256
12275
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
12257
12276
|
const spPeekCache = new Map();
|
|
12277
|
+
// 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
|
|
12278
|
+
// every session, newest first, paged like the sidebar's All filter. Each open starts on 'board'.
|
|
12279
|
+
let spScope = 'board';
|
|
12280
|
+
let spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
|
|
12281
|
+
let spRowEls = [];
|
|
12282
|
+
const SP_RECENT_PAGE = 50;
|
|
12283
|
+
const SP_RECENT_TAIL = 5;
|
|
12284
|
+
|
|
12285
|
+
// The first load asks for the board's count plus the tail, so the tail is there even when
|
|
12286
|
+
// the newest sessions are all on the board.
|
|
12287
|
+
async function spLoadRecent() {
|
|
12288
|
+
const state = spRecent;
|
|
12289
|
+
if (state.loading || !state.hasMore) return;
|
|
12290
|
+
state.loading = true;
|
|
12291
|
+
state.limit = state.limit ? state.limit + SP_RECENT_PAGE : spSource.length + SP_RECENT_TAIL;
|
|
12292
|
+
try {
|
|
12293
|
+
const res = await api('/api/sessions', { query: { limit: state.limit, pins: 'off' } });
|
|
12294
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
12295
|
+
const rows = await res.json();
|
|
12296
|
+
if (state !== spRecent) return;
|
|
12297
|
+
state.rows = rows;
|
|
12298
|
+
state.hasMore = res.headers.get('X-Has-More') === 'true';
|
|
12299
|
+
} catch (error) {
|
|
12300
|
+
console.error('Failed to load recent sessions:', error);
|
|
12301
|
+
state.hasMore = false;
|
|
12302
|
+
} finally {
|
|
12303
|
+
state.loading = false;
|
|
12304
|
+
}
|
|
12305
|
+
if (!isSessionPickerOpen()) return;
|
|
12306
|
+
renderSessionPicker();
|
|
12307
|
+
spLoadMoreNearEnd();
|
|
12308
|
+
}
|
|
12309
|
+
|
|
12310
|
+
function spSetScope(scope) {
|
|
12311
|
+
spScope = scope;
|
|
12312
|
+
for (const el of document.querySelectorAll('#session-picker-scope [data-scope]')) {
|
|
12313
|
+
el.setAttribute('aria-checked', String(el.dataset.scope === scope));
|
|
12314
|
+
}
|
|
12315
|
+
renderSessionPicker();
|
|
12316
|
+
spLoadMoreNearEnd();
|
|
12317
|
+
}
|
|
12318
|
+
|
|
12319
|
+
// Also run after each load: a page too short to scroll gives no scroll event.
|
|
12320
|
+
function spLoadMoreNearEnd() {
|
|
12321
|
+
if (spScope === 'all') loadMoreIfNearEnd(document.getElementById('session-picker-list'), spLoadRecent);
|
|
12322
|
+
}
|
|
12258
12323
|
|
|
12259
12324
|
const spSearch = sessionSearch(() => {
|
|
12260
12325
|
if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
|
|
@@ -12267,9 +12332,11 @@ function openSessionPicker() {
|
|
|
12267
12332
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
12268
12333
|
spPeekCache.clear();
|
|
12269
12334
|
spRenderedQuery = null;
|
|
12335
|
+
spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
|
|
12270
12336
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
12271
|
-
|
|
12337
|
+
spSetScope('board');
|
|
12272
12338
|
input.focus();
|
|
12339
|
+
spLoadRecent();
|
|
12273
12340
|
}
|
|
12274
12341
|
|
|
12275
12342
|
// refocus: false when a session is being opened, which moves the terminal focus itself.
|
|
@@ -12385,29 +12452,39 @@ function spPinHtml(session) {
|
|
|
12385
12452
|
function renderSessionPicker() {
|
|
12386
12453
|
const list = document.getElementById('session-picker-list');
|
|
12387
12454
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
12388
|
-
//
|
|
12389
|
-
const
|
|
12390
|
-
|
|
12391
|
-
|
|
12455
|
+
// Rows that arrive for the same query and scope append below, so the cursor stays on its row.
|
|
12456
|
+
const renderKey = `${spScope}\n${query}`;
|
|
12457
|
+
const selectedId = renderKey === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
12458
|
+
spRenderedQuery = renderKey;
|
|
12459
|
+
const all = spScope === 'all';
|
|
12460
|
+
const board = new Set(spSource.map((s) => s.id));
|
|
12461
|
+
const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
|
|
12392
12462
|
const found = spSearch.rows(query, local);
|
|
12393
|
-
|
|
12463
|
+
const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
|
|
12464
|
+
const extra = [...found.rows, ...tail];
|
|
12465
|
+
spRows = [...local, ...extra];
|
|
12394
12466
|
if (!spRows.length) {
|
|
12395
12467
|
spIdx = -1;
|
|
12396
|
-
|
|
12397
|
-
|
|
12398
|
-
|
|
12399
|
-
? '
|
|
12400
|
-
:
|
|
12468
|
+
spRowEls = [];
|
|
12469
|
+
const empty =
|
|
12470
|
+
found.pending || (all && spRecent.loading)
|
|
12471
|
+
? 'Searching all sessions…'
|
|
12472
|
+
: all || spSource.length || query.length >= SESSION_SEARCH_MIN
|
|
12473
|
+
? 'No session matches'
|
|
12474
|
+
: 'No sessions in the current sidebar filter';
|
|
12401
12475
|
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
12402
12476
|
return;
|
|
12403
12477
|
}
|
|
12404
12478
|
|
|
12405
|
-
// Rows
|
|
12479
|
+
// Rows not on the board show their full project path; in the board scope they also sit below
|
|
12480
|
+
// the board's rows, under a label.
|
|
12481
|
+
const section = !all && extra.length ? '<div class="sp-section">Not on board</div>' : '';
|
|
12406
12482
|
list.innerHTML = spRows
|
|
12407
12483
|
.map((s, i) => {
|
|
12408
|
-
const outside =
|
|
12484
|
+
const outside = !board.has(s.id);
|
|
12485
|
+
const below = !all && i >= local.length;
|
|
12409
12486
|
const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
|
|
12410
|
-
return
|
|
12487
|
+
return `${below && i === local.length ? section : ''}<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${below ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
|
|
12411
12488
|
${spDotHtml(s)}
|
|
12412
12489
|
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
12413
12490
|
<span class="sp-project">${escapeHtml(project)}</span>
|
|
@@ -12418,18 +12495,21 @@ function renderSessionPicker() {
|
|
|
12418
12495
|
</button>`;
|
|
12419
12496
|
})
|
|
12420
12497
|
.join('');
|
|
12498
|
+
// The section label sits between rows, so row index and child index differ.
|
|
12499
|
+
spRowEls = list.querySelectorAll('.sp-row');
|
|
12500
|
+
spIdx = -1;
|
|
12421
12501
|
const keep = spRows.findIndex((s) => s.id === selectedId);
|
|
12422
12502
|
spSelect(Math.max(0, keep));
|
|
12423
12503
|
}
|
|
12424
12504
|
|
|
12425
12505
|
function spSelect(idx) {
|
|
12426
|
-
|
|
12427
|
-
list.children[spIdx]?.classList.remove('selected');
|
|
12506
|
+
spRowEls[spIdx]?.classList.remove('selected');
|
|
12428
12507
|
spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
|
|
12429
|
-
const row =
|
|
12508
|
+
const row = spRowEls[spIdx];
|
|
12430
12509
|
row?.classList.add('selected');
|
|
12431
12510
|
row?.scrollIntoView({ block: 'nearest' });
|
|
12432
12511
|
spSchedulePeek();
|
|
12512
|
+
if (spScope === 'all' && spIdx >= spRows.length - 5) spLoadRecent();
|
|
12433
12513
|
}
|
|
12434
12514
|
|
|
12435
12515
|
async function spOpen(idx) {
|
|
@@ -12450,14 +12530,22 @@ function initSessionPicker() {
|
|
|
12450
12530
|
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
12451
12531
|
else if (e.key === 'Enter') spOpen(spIdx);
|
|
12452
12532
|
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
12533
|
+
else if (e.key === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) spSetScope(spScope === 'all' ? 'board' : 'all');
|
|
12453
12534
|
else return;
|
|
12454
12535
|
e.preventDefault();
|
|
12455
12536
|
e.stopPropagation();
|
|
12456
12537
|
});
|
|
12457
|
-
document.getElementById('session-picker-list')
|
|
12538
|
+
const list = document.getElementById('session-picker-list');
|
|
12539
|
+
list.addEventListener('click', (e) => {
|
|
12458
12540
|
const row = e.target.closest('.sp-row');
|
|
12459
12541
|
if (row) spOpen(Number(row.dataset.idx));
|
|
12460
12542
|
});
|
|
12543
|
+
list.addEventListener('scroll', spLoadMoreNearEnd, { passive: true });
|
|
12544
|
+
document.getElementById('session-picker-scope').addEventListener('click', (e) => {
|
|
12545
|
+
const scope = e.target.closest('[data-scope]')?.dataset.scope;
|
|
12546
|
+
if (scope) spSetScope(scope);
|
|
12547
|
+
input.focus();
|
|
12548
|
+
});
|
|
12461
12549
|
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
|
|
12462
12550
|
// A blur that no click in the picker caused, while the window keeps focus, is that Escape.
|
|
12463
12551
|
const modal = document.getElementById('session-picker-modal');
|
|
@@ -12968,7 +13056,9 @@ function openFolderInEditor(folder, file) {
|
|
|
12968
13056
|
postAndToast('/api/open-folder', body, 'folder');
|
|
12969
13057
|
}
|
|
12970
13058
|
|
|
12971
|
-
function openCost(sessionId) {
|
|
13059
|
+
function openCost(sessionId, agentId) {
|
|
13060
|
+
if (sessionId && agentId && hub.can('agent.cost'))
|
|
13061
|
+
return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
|
|
12972
13062
|
hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
|
|
12973
13063
|
}
|
|
12974
13064
|
|
|
@@ -15025,6 +15115,7 @@ function terminalShortcut(e, probe = false) {
|
|
|
15025
15115
|
if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
|
|
15026
15116
|
if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
|
|
15027
15117
|
if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
|
|
15118
|
+
if (ctrlShift && e.code === 'Period') return currentSessionId ? () => toggleSessionSticky(currentSessionId) : null;
|
|
15028
15119
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
15029
15120
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
15030
15121
|
}
|
|
@@ -15345,6 +15436,7 @@ const TERMINAL_PROBE_KEYS = [
|
|
|
15345
15436
|
['Backquote', '`', '~'],
|
|
15346
15437
|
['Equal', '=', '+'],
|
|
15347
15438
|
['Minus', '-', '_'],
|
|
15439
|
+
['Period', '.', '>'],
|
|
15348
15440
|
['NumpadAdd', '+', '+'],
|
|
15349
15441
|
['NumpadSubtract', '-', '-'],
|
|
15350
15442
|
['Numpad0', '0', '0'],
|
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;">
|
|
@@ -885,12 +885,18 @@
|
|
|
885
885
|
|
|
886
886
|
<div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
|
|
887
887
|
<div class="modal session-picker" onclick="event.stopPropagation()">
|
|
888
|
-
<
|
|
888
|
+
<div class="sp-head">
|
|
889
|
+
<input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
|
|
890
|
+
<div id="session-picker-scope" class="sp-scope" role="radiogroup" aria-label="Sessions" title="Tab switches scope">
|
|
891
|
+
<button type="button" role="radio" tabindex="-1" data-scope="board">Board</button>
|
|
892
|
+
<button type="button" role="radio" tabindex="-1" data-scope="all">All</button>
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
889
895
|
<div class="sp-body">
|
|
890
896
|
<div id="session-picker-list" class="sp-list"></div>
|
|
891
897
|
<div id="session-picker-peek" class="sp-peek"></div>
|
|
892
898
|
</div>
|
|
893
|
-
<div class="sp-hint"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <kbd>Esc</kbd> close</div>
|
|
899
|
+
<div class="sp-hint"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Tab</kbd> board / all · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <kbd>Esc</kbd> close</div>
|
|
894
900
|
</div>
|
|
895
901
|
</div>
|
|
896
902
|
|
package/public/style.css
CHANGED
|
@@ -7623,6 +7623,10 @@ body.light .msg-assistant .msg-text {
|
|
|
7623
7623
|
overflow-y: auto;
|
|
7624
7624
|
}
|
|
7625
7625
|
|
|
7626
|
+
.modal.msg-detail-dialog:focus {
|
|
7627
|
+
outline: none;
|
|
7628
|
+
}
|
|
7629
|
+
|
|
7626
7630
|
/* Before .modal.fullscreen on purpose: at equal specificity the later
|
|
7627
7631
|
fullscreen rule must win, same as for every other per-modal size. */
|
|
7628
7632
|
.modal.msg-detail-dialog {
|
|
@@ -8742,6 +8746,54 @@ select.form-input option:checked {
|
|
|
8742
8746
|
overflow: hidden;
|
|
8743
8747
|
}
|
|
8744
8748
|
|
|
8749
|
+
.sp-head {
|
|
8750
|
+
display: flex;
|
|
8751
|
+
align-items: center;
|
|
8752
|
+
gap: 8px;
|
|
8753
|
+
}
|
|
8754
|
+
|
|
8755
|
+
.sp-head .sp-input {
|
|
8756
|
+
flex: 1;
|
|
8757
|
+
}
|
|
8758
|
+
|
|
8759
|
+
.sp-scope {
|
|
8760
|
+
flex: none;
|
|
8761
|
+
display: flex;
|
|
8762
|
+
gap: 2px;
|
|
8763
|
+
padding: 2px;
|
|
8764
|
+
border: 1px solid var(--border);
|
|
8765
|
+
border-radius: 6px;
|
|
8766
|
+
}
|
|
8767
|
+
|
|
8768
|
+
.sp-scope button {
|
|
8769
|
+
padding: 4px 10px;
|
|
8770
|
+
background: none;
|
|
8771
|
+
border: none;
|
|
8772
|
+
border-radius: 4px;
|
|
8773
|
+
font-family: var(--mono);
|
|
8774
|
+
font-size: 11.5px;
|
|
8775
|
+
color: var(--text-muted);
|
|
8776
|
+
cursor: pointer;
|
|
8777
|
+
}
|
|
8778
|
+
|
|
8779
|
+
.sp-scope button:hover {
|
|
8780
|
+
color: var(--text-secondary);
|
|
8781
|
+
}
|
|
8782
|
+
|
|
8783
|
+
.sp-scope button[aria-checked='true'] {
|
|
8784
|
+
background: var(--bg-deep);
|
|
8785
|
+
color: var(--text-primary);
|
|
8786
|
+
}
|
|
8787
|
+
|
|
8788
|
+
.sp-section {
|
|
8789
|
+
margin-top: 8px;
|
|
8790
|
+
padding: 2px 8px;
|
|
8791
|
+
color: var(--text-muted);
|
|
8792
|
+
font-size: 10.5px;
|
|
8793
|
+
letter-spacing: 0.04em;
|
|
8794
|
+
text-transform: uppercase;
|
|
8795
|
+
}
|
|
8796
|
+
|
|
8745
8797
|
.sp-input {
|
|
8746
8798
|
padding: 10px 12px;
|
|
8747
8799
|
background: var(--bg-deep);
|
|
@@ -8905,6 +8957,10 @@ select.form-input option:checked {
|
|
|
8905
8957
|
margin-top: 8px;
|
|
8906
8958
|
}
|
|
8907
8959
|
|
|
8960
|
+
.sp-section + .sp-row.outside {
|
|
8961
|
+
margin-top: 0;
|
|
8962
|
+
}
|
|
8963
|
+
|
|
8908
8964
|
.sp-row.current .sp-name {
|
|
8909
8965
|
color: var(--accent-text);
|
|
8910
8966
|
}
|
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 =
|
|
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 });
|