claude-code-kanban 7.17.0 → 7.19.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/README.md +65 -40
- package/hub-app.json +23 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +45 -6
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +93 -53
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +1 -0
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +79 -13
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +1 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +63 -28
- package/public/app.js +194 -82
- package/public/index.html +3 -3
- package/public/style.css +106 -1
- package/public/vendor/claude-hub-sdk.js +20 -3
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import type { On, ToolCallArgs } from 'claude-code'
|
|
2
|
-
import { expect, test } from 'claude-code/testing'
|
|
3
|
-
import { boardPort,
|
|
2
|
+
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
+
import { boardPort, CLAIM_OFF_TERMINAL, paneText, shownText, TOOL } from '../hooks/show'
|
|
4
4
|
|
|
5
5
|
const CCK_URL = 'http://127.0.0.1:4100'
|
|
6
6
|
const TERMINAL_ID = 'term-1'
|
|
7
7
|
const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
|
|
8
|
-
const
|
|
9
|
-
'The following skills are available for use with the Skill tool:',
|
|
10
|
-
'',
|
|
11
|
-
'- claude-code-kanban:dispatch',
|
|
12
|
-
'- claude-code-kanban:show',
|
|
13
|
-
'- claude-code-kanban:kanban: Kanban board CLI',
|
|
14
|
-
'- meta:show-me: Help the user understand the current topic visually',
|
|
15
|
-
].join('\n')
|
|
8
|
+
const SERVER_FILE = 'C:/cfg/.cck/server.json'
|
|
16
9
|
|
|
17
10
|
type Reply = { status: number; body: unknown } | Error
|
|
18
11
|
type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
|
|
19
12
|
|
|
20
13
|
function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
21
|
-
const registered: string[] = []
|
|
14
|
+
const registered: { name: string; required: unknown }[] = []
|
|
22
15
|
const requests: Request[] = []
|
|
23
|
-
const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}' }
|
|
16
|
+
const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}', [SERVER_FILE]: '{"pid":1,"port":4100}' }
|
|
17
|
+
mock.clock(on)
|
|
24
18
|
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
|
|
25
19
|
on('fs.read', (_$, e) => {
|
|
26
20
|
const text = files[e.path.replaceAll('\\', '/')]
|
|
@@ -30,12 +24,11 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
|
30
24
|
on('session.id', () => ({ value: 'sid-1' }))
|
|
31
25
|
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
32
26
|
on('tool.register', (_$, e) => {
|
|
33
|
-
registered.push(e.name)
|
|
27
|
+
registered.push({ name: e.name, required: (e.inputSchema as { required?: unknown }).required })
|
|
34
28
|
return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
|
|
35
29
|
})
|
|
36
|
-
on('prompt.attachment', (_$, e) => ({ text: e.text }))
|
|
37
30
|
on('http.fetch', (_$, e) => {
|
|
38
|
-
if (
|
|
31
|
+
if (!/\/show$|\/api\/panes\//.test(e.url)) return new Promise(() => {})
|
|
39
32
|
const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
|
|
40
33
|
requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
|
|
41
34
|
if (!reply) throw new Error('no reply')
|
|
@@ -46,10 +39,9 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
|
|
49
|
-
const listing = { type: 'skill_listing', text: LISTING, origin: { kind: 'engine' } } as const
|
|
50
42
|
const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
|
|
51
43
|
|
|
52
|
-
test('helpers: the port of CCK_URL, the answer
|
|
44
|
+
test('helpers: the port of CCK_URL, the answer lines', async () => {
|
|
53
45
|
expect(boardPort('http://127.0.0.1:4100')).toBe('4100')
|
|
54
46
|
expect(boardPort('http://localhost:3541/')).toBe('3541')
|
|
55
47
|
expect(boardPort('http://localhost')).toBe(undefined)
|
|
@@ -58,24 +50,67 @@ test('helpers: the port of CCK_URL, the answer line, the listing filter', async
|
|
|
58
50
|
expect(shownText({ title: 'Plan', key: 'plan', index: 1, count: 1, path: 'C:/s/p1.md' }, true)).toBe(
|
|
59
51
|
'Card "Plan" (plan, 1/1) is ready. Write it to C:/s/p1.md; the card refreshes on each save.',
|
|
60
52
|
)
|
|
61
|
-
expect(
|
|
62
|
-
expect(dropSkillLine('- claude-code-kanban:show: Show a diagram\n- x')).toBe('- x')
|
|
53
|
+
expect(paneText('Plan', false)).toBe('The board already has a tab for this file; it refreshes on each save.')
|
|
63
54
|
})
|
|
64
55
|
|
|
65
|
-
test('
|
|
56
|
+
test('in the terminal: the overlay tool, file optional', async ($, on) => {
|
|
57
|
+
const board = engine(on, GATE)
|
|
58
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
59
|
+
expect(board.registered).toEqual([{ name: 'show', required: ['title'] }])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('outside the terminal: the pane tool, file required', async ($, on) => {
|
|
66
63
|
const board = engine(on, { CCK_URL })
|
|
67
64
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
68
|
-
expect(board.registered).toEqual([])
|
|
69
|
-
const { text } = await $.prompt.attachment(listing)
|
|
70
|
-
expect(text).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
|
|
65
|
+
expect(board.registered).toEqual([{ name: 'show', required: ['title', 'file'] }])
|
|
71
66
|
})
|
|
72
67
|
|
|
73
|
-
test('
|
|
74
|
-
const board = engine(on,
|
|
68
|
+
test('outside the terminal: a file post adds a show pane on the board of server.json', async ($, on) => {
|
|
69
|
+
const board = engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: true } })
|
|
70
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
71
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
|
|
72
|
+
expect(res.result).toBe(paneText('Plan', true))
|
|
73
|
+
expect(board.requests).toEqual([
|
|
74
|
+
{
|
|
75
|
+
url: 'http://127.0.0.1:4100/api/panes/sid-1',
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: { target: 'C:/s/plan.html', title: 'Plan', show: true },
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('outside the terminal: the same file again answers that the tab exists', async ($, on) => {
|
|
84
|
+
engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: false } })
|
|
75
85
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
|
|
87
|
+
expect(res.result).toBe(paneText('Plan', false))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('outside the terminal: a claim sends nothing and asks for a file', async ($, on) => {
|
|
91
|
+
const board = engine(on, {})
|
|
92
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
93
|
+
const res = await $.tool.call(call({ title: 'Plan' }))
|
|
94
|
+
expect(res.result).toBe(CLAIM_OFF_TERMINAL)
|
|
95
|
+
expect(board.requests).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('outside the terminal: no board, no request, an error that says so', async ($, on) => {
|
|
99
|
+
// CCK_URL keeps the doorbell on its open poll: without a board it sleeps, and the unload rejects that sleep.
|
|
100
|
+
const board = engine(on, { CCK_URL })
|
|
101
|
+
delete board.files[SERVER_FILE]
|
|
102
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
103
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.md' }))
|
|
104
|
+
expect(res.deny).toContain('not running')
|
|
105
|
+
expect(board.requests).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('outside the terminal: a board refusal reaches the model with its text', async ($, on) => {
|
|
109
|
+
const error = 'Not a previewable text, markdown, HTML or image file'
|
|
110
|
+
engine(on, {}, { status: 400, body: { error } })
|
|
111
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
112
|
+
const res = await $.tool.call(call({ title: 'Zip', file: 'C:/s/a.zip' }))
|
|
113
|
+
expect(res.deny).toBe(error)
|
|
79
114
|
})
|
|
80
115
|
|
|
81
116
|
test('a -p run gets no tool', async ($, on) => {
|
package/public/app.js
CHANGED
|
@@ -571,6 +571,9 @@ function sessionBarClass(s) {
|
|
|
571
571
|
function isActiveSession(s) {
|
|
572
572
|
return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
573
573
|
}
|
|
574
|
+
function sessionState(s) {
|
|
575
|
+
return isWaitingSession(s) ? 'waiting' : isActiveSession(s) ? 'working' : 'idle';
|
|
576
|
+
}
|
|
574
577
|
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
575
578
|
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
576
579
|
function isSessionLive(s) {
|
|
@@ -815,6 +818,7 @@ async function fetchTasks(sessionId) {
|
|
|
815
818
|
//#region TIMINGS
|
|
816
819
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
817
820
|
const AGENT_LOG_MAX = 8;
|
|
821
|
+
const ZEN_AGENTS_MAX = 5;
|
|
818
822
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
819
823
|
const JUST_NOW_MS = 60 * 1000;
|
|
820
824
|
//#endregion
|
|
@@ -3397,17 +3401,73 @@ function formatDuration(ms) {
|
|
|
3397
3401
|
//#endregion
|
|
3398
3402
|
|
|
3399
3403
|
//#region AGENTS
|
|
3400
|
-
function
|
|
3401
|
-
const
|
|
3402
|
-
const
|
|
3403
|
-
|
|
3404
|
+
function agentTypeParts(a) {
|
|
3405
|
+
const raw = a.type || 'unknown';
|
|
3406
|
+
const colonIdx = raw.indexOf(':');
|
|
3407
|
+
return colonIdx > 0
|
|
3408
|
+
? { raw, ns: raw.substring(0, colonIdx + 1), name: raw.substring(colonIdx + 1) }
|
|
3409
|
+
: { raw, ns: '', name: raw };
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function agentTaskText(a) {
|
|
3413
|
+
if (a.description) return a.description;
|
|
3414
|
+
const prompt = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
|
|
3415
|
+
return prompt || agentTypeParts(a).name;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function agentElapsed(a, now) {
|
|
3419
|
+
return a.status === 'stopped' && a.stoppedAt
|
|
3420
|
+
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
3421
|
+
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
function agentIdentHtml(a) {
|
|
3425
|
+
const { raw, ns, name } = agentTypeParts(a);
|
|
3426
|
+
const fullModel = agentModel(a);
|
|
3427
|
+
const model = shortModelName(fullModel);
|
|
3428
|
+
const modelHtml = model
|
|
3429
|
+
? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(model)}</span><span class="agent-ident-sep">·</span>`
|
|
3430
|
+
: '';
|
|
3431
|
+
return `${modelHtml}<span class="agent-type-name" title="${escapeHtml(raw)}">${ns ? `<span class="agent-type-ns">${escapeHtml(ns)}</span>` : ''}${escapeHtml(name)}</span>`;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// The count names every running agent; the rows stop at ZEN_AGENTS_MAX, and the Agents Log
|
|
3435
|
+
// below the board lists the rest.
|
|
3436
|
+
function zenAgentsHtml(active) {
|
|
3437
|
+
if (!active.length) return '';
|
|
3438
|
+
const rows = active.slice(0, ZEN_AGENTS_MAX).map((a) => {
|
|
3439
|
+
const task = truncateAtWord(agentTaskText(a), 200);
|
|
3440
|
+
return `<button type="button" class="zen-agent" title="${escapeHtml(task)}" onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
3441
|
+
<span class="agent-dot active"></span>
|
|
3442
|
+
<span class="zen-agent-text"><span class="zen-agent-task">${escapeHtml(task)}</span><span class="agent-ident-row">${agentIdentHtml(a)}</span></span>
|
|
3443
|
+
<span class="zen-agent-time"></span>
|
|
3444
|
+
</button>`;
|
|
3445
|
+
});
|
|
3446
|
+
return `<div class="panel-section-header"><span>Agents</span><span class="panel-section-count">${active.length}</span></div>${rows.join('')}`;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
// The 1 s duration tick calls this. Rewriting the rows each time would drop the focus of a row,
|
|
3450
|
+
// so the rows are rewritten only when they change and the times are set in place.
|
|
3451
|
+
function renderZenAgents(agents) {
|
|
3452
|
+
const el = zenMode && sessionsList.querySelector('.zen-agents');
|
|
3453
|
+
if (!el) return;
|
|
3454
|
+
const active = agents.filter((a) => a.status === 'active');
|
|
3455
|
+
const html = zenAgentsHtml(active);
|
|
3456
|
+
if (html !== el.dataset.shape) {
|
|
3457
|
+
el.dataset.shape = html;
|
|
3458
|
+
el.innerHTML = html;
|
|
3459
|
+
}
|
|
3404
3460
|
const now = Date.now();
|
|
3461
|
+
el.querySelectorAll('.zen-agent-time').forEach((t, i) => {
|
|
3462
|
+
t.textContent = formatDuration(agentElapsed(active[i], now));
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3405
3465
|
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3466
|
+
// Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
|
|
3467
|
+
// or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
|
|
3468
|
+
function visibleAgents() {
|
|
3409
3469
|
const byType = {};
|
|
3410
|
-
for (const a of
|
|
3470
|
+
for (const a of currentAgents) {
|
|
3411
3471
|
const groupKey = a.agentName || a.type;
|
|
3412
3472
|
if (!byType[groupKey]) byType[groupKey] = [];
|
|
3413
3473
|
byType[groupKey].push(a);
|
|
@@ -3431,13 +3491,21 @@ function renderAgentFooter() {
|
|
|
3431
3491
|
}
|
|
3432
3492
|
// Sort: active/idle first, then by updatedAt desc
|
|
3433
3493
|
const statusOrder = { active: 0, idle: 1, stopped: 2 };
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
(a
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3494
|
+
return filtered.sort(
|
|
3495
|
+
(a, b) =>
|
|
3496
|
+
(statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
|
|
3497
|
+
new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
|
|
3498
|
+
);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
function renderAgentFooter() {
|
|
3502
|
+
const footer = document.getElementById('agent-footer');
|
|
3503
|
+
const content = document.getElementById('agent-footer-content');
|
|
3504
|
+
const label = document.getElementById('agent-footer-label');
|
|
3505
|
+
const now = Date.now();
|
|
3506
|
+
const agents = visibleAgents();
|
|
3507
|
+
renderZenAgents(agents);
|
|
3508
|
+
const visible = agents.slice(0, AGENT_LOG_MAX);
|
|
3441
3509
|
|
|
3442
3510
|
const permFresh = isWaitingFresh();
|
|
3443
3511
|
|
|
@@ -3461,23 +3529,14 @@ function renderAgentFooter() {
|
|
|
3461
3529
|
permHtml +
|
|
3462
3530
|
visible
|
|
3463
3531
|
.map((a) => {
|
|
3464
|
-
const elapsed =
|
|
3465
|
-
a.status === 'stopped' && a.stoppedAt
|
|
3466
|
-
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
3467
|
-
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
3532
|
+
const elapsed = agentElapsed(a, now);
|
|
3468
3533
|
const statusText =
|
|
3469
3534
|
a.status === 'stopped'
|
|
3470
3535
|
? `stopped · ${formatDuration(elapsed)}`
|
|
3471
3536
|
: a.status === 'idle'
|
|
3472
3537
|
? `idle · ${formatDuration(elapsed)}`
|
|
3473
3538
|
: `active · ${formatDuration(elapsed)}`;
|
|
3474
|
-
const
|
|
3475
|
-
const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
|
|
3476
|
-
const rawType = a.type || 'unknown';
|
|
3477
|
-
const colonIdx = rawType.indexOf(':');
|
|
3478
|
-
const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
|
|
3479
|
-
const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
|
|
3480
|
-
const displayText = descText || promptTrimmed || typeName;
|
|
3539
|
+
const displayText = agentTaskText(a);
|
|
3481
3540
|
const taskHtml = `<div class="agent-task" title="${escapeHtml(displayText)}">${escapeHtml(displayText)}</div>`;
|
|
3482
3541
|
const agentNameVal = a.agentName || null;
|
|
3483
3542
|
const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
|
|
@@ -3487,15 +3546,10 @@ function renderAgentFooter() {
|
|
|
3487
3546
|
const agentColor = resolveNamedColor(a.color);
|
|
3488
3547
|
const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
|
|
3489
3548
|
const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
|
|
3490
|
-
const fullModel = agentModel(a);
|
|
3491
|
-
const cardModel = shortModelName(fullModel);
|
|
3492
|
-
const modelHtml = cardModel
|
|
3493
|
-
? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(cardModel)}</span><span class="agent-ident-sep">·</span>`
|
|
3494
|
-
: '';
|
|
3495
3549
|
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
3496
3550
|
${agentLogButton(a.agentId, 'agent-card-log-btn')}
|
|
3497
3551
|
${taskHtml}
|
|
3498
|
-
<div class="agent-ident-row">${
|
|
3552
|
+
<div class="agent-ident-row">${agentIdentHtml(a)}${nameBadgeHtml}</div>
|
|
3499
3553
|
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
|
|
3500
3554
|
</div>`;
|
|
3501
3555
|
})
|
|
@@ -3889,6 +3943,8 @@ function renderSessions() {
|
|
|
3889
3943
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3890
3944
|
// Group zen renders only its subtree's buckets, so it takes every session unfiltered.
|
|
3891
3945
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3946
|
+
sessionsList.classList.toggle('zen', !!zenSession);
|
|
3947
|
+
sessionsList.dataset.zenState = zenSession ? sessionState(zenSession) : '';
|
|
3892
3948
|
let filteredSessions;
|
|
3893
3949
|
if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
|
|
3894
3950
|
else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
|
|
@@ -4064,11 +4120,14 @@ function renderSessions() {
|
|
|
4064
4120
|
${renderArtifactsHtml(zenSession.id)}
|
|
4065
4121
|
${renderZenBookmarksHtml(zenSession.id)}
|
|
4066
4122
|
</div>
|
|
4067
|
-
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
|
|
4123
|
+
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
|
|
4124
|
+
<div class="zen-agents"></div>`;
|
|
4125
|
+
renderZenAgents(visibleAgents());
|
|
4068
4126
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
4069
4127
|
artifactsSection.load(zenSession.id);
|
|
4070
4128
|
scratchFilesSection.load(zenSession.id);
|
|
4071
4129
|
if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
|
|
4130
|
+
restoreSessionCursor();
|
|
4072
4131
|
return;
|
|
4073
4132
|
}
|
|
4074
4133
|
|
|
@@ -4317,7 +4376,10 @@ function renderSessions() {
|
|
|
4317
4376
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
|
|
4318
4377
|
sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
|
|
4319
4378
|
}
|
|
4379
|
+
restoreSessionCursor();
|
|
4380
|
+
}
|
|
4320
4381
|
|
|
4382
|
+
function restoreSessionCursor() {
|
|
4321
4383
|
const navItems = getNavigableItems();
|
|
4322
4384
|
const allSessions = getSessionItems();
|
|
4323
4385
|
const activeIdx = allSessions.findIndex((el) => el.classList.contains('active'));
|
|
@@ -7902,6 +7964,21 @@ document.addEventListener('keydown', (e) => {
|
|
|
7902
7964
|
toggleZenMode();
|
|
7903
7965
|
return;
|
|
7904
7966
|
}
|
|
7967
|
+
if (e.key === '$' && plainShift) {
|
|
7968
|
+
e.preventDefault();
|
|
7969
|
+
openCost(contextSessionId());
|
|
7970
|
+
return;
|
|
7971
|
+
}
|
|
7972
|
+
if (matchKey(e, 'KeyM')) {
|
|
7973
|
+
e.preventDefault();
|
|
7974
|
+
openMarketplace(contextSessionId());
|
|
7975
|
+
return;
|
|
7976
|
+
}
|
|
7977
|
+
if (ClaudeHub.comboOf(e) === 'ctrl+m') {
|
|
7978
|
+
e.preventDefault();
|
|
7979
|
+
openMemory(contextSessionId());
|
|
7980
|
+
return;
|
|
7981
|
+
}
|
|
7905
7982
|
if (e.key === '.' || e.key === '>') {
|
|
7906
7983
|
const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
|
|
7907
7984
|
if (sid) {
|
|
@@ -8042,23 +8119,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
8042
8119
|
else toggleScratchpad();
|
|
8043
8120
|
return;
|
|
8044
8121
|
}
|
|
8045
|
-
if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
8046
|
-
e.preventDefault();
|
|
8047
|
-
openCost(contextSid);
|
|
8048
|
-
return;
|
|
8049
|
-
}
|
|
8050
|
-
if (matchKey(e, 'KeyM')) {
|
|
8051
|
-
e.preventDefault();
|
|
8052
|
-
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
8053
|
-
openMarketplace(mSession?.project);
|
|
8054
|
-
return;
|
|
8055
|
-
}
|
|
8056
|
-
if (ClaudeHub.comboOf(e) === 'ctrl+m') {
|
|
8057
|
-
e.preventDefault();
|
|
8058
|
-
const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
|
|
8059
|
-
openMemory(mSession?.project);
|
|
8060
|
-
return;
|
|
8061
|
-
}
|
|
8062
8122
|
if (ClaudeHub.comboOf(e) === 'ctrl+d') {
|
|
8063
8123
|
e.preventDefault();
|
|
8064
8124
|
dismissContextSession();
|
|
@@ -8662,7 +8722,9 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
|
8662
8722
|
// first keeps it, and text fields keep every key.
|
|
8663
8723
|
const PANE_CLAIMS = [
|
|
8664
8724
|
...['\\', '[', ']', 'ContextMenu', 'Escape'].map((key) => ({ key, code: '' })),
|
|
8665
|
-
...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8725
|
+
...['|', '{', '}', 'F10', '$'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8726
|
+
{ key: 'Unidentified', code: 'KeyM' },
|
|
8727
|
+
{ ctrlKey: true, key: 'm', code: '' },
|
|
8666
8728
|
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
8667
8729
|
...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
|
|
8668
8730
|
...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
|
|
@@ -9150,7 +9212,7 @@ function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-po
|
|
|
9150
9212
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
9151
9213
|
frame.srcdoc = srcdoc;
|
|
9152
9214
|
bridgedFrames.add(frame);
|
|
9153
|
-
frame.addEventListener('load', () => sendBridgeClaimsAll([frame], keyClaims()));
|
|
9215
|
+
frame.addEventListener('load', () => sendBridgeClaimsAll([frame], bridgeClaims(keyClaims())));
|
|
9154
9216
|
return frame;
|
|
9155
9217
|
}
|
|
9156
9218
|
|
|
@@ -10228,7 +10290,7 @@ function setupEventSource() {
|
|
|
10228
10290
|
// Alone, the page would open a tab without a user gesture, which the browser blocks; the
|
|
10229
10291
|
// CLI prints the link instead.
|
|
10230
10292
|
if (data.type === 'session:cost' && hub.inHub) {
|
|
10231
|
-
openCost(data.id, data.agent);
|
|
10293
|
+
openCost(data.id, { agent: data.agent, pane: false });
|
|
10232
10294
|
}
|
|
10233
10295
|
|
|
10234
10296
|
if (data.type === 'session:pin') {
|
|
@@ -11569,6 +11631,12 @@ function toggleZenMode() {
|
|
|
11569
11631
|
if (!leavingSessionZen) fetchSessions(false);
|
|
11570
11632
|
}
|
|
11571
11633
|
|
|
11634
|
+
function applyHubSettings(s) {
|
|
11635
|
+
sessionsList.classList.toggle('zen-centered', s.zenCentered === true);
|
|
11636
|
+
if (s.zenPattern) sessionsList.dataset.zenPattern = s.zenPattern;
|
|
11637
|
+
else delete sessionsList.dataset.zenPattern;
|
|
11638
|
+
}
|
|
11639
|
+
|
|
11572
11640
|
function renderZenState() {
|
|
11573
11641
|
const btn = document.getElementById('zen-mode-btn');
|
|
11574
11642
|
if (!btn) return;
|
|
@@ -13095,31 +13163,33 @@ function openFolderInEditor(folder, file) {
|
|
|
13095
13163
|
postAndToast('/api/open-folder', body, 'folder');
|
|
13096
13164
|
}
|
|
13097
13165
|
|
|
13098
|
-
|
|
13099
|
-
|
|
13100
|
-
|
|
13101
|
-
hub.invoke(
|
|
13166
|
+
// Opens the app in a pane of `sid` and brings the pane into view. With no session, or `pane: false`
|
|
13167
|
+
// (a Shift-click), the hub switches to the app's tab instead.
|
|
13168
|
+
async function openAppAction(action, params, sid, { pane = true, label }) {
|
|
13169
|
+
if (!sid || !pane) return hub.invoke(action, params);
|
|
13170
|
+
const { url } = await hub.invoke(action, params, { open: 'pane' });
|
|
13171
|
+
if (!url) return;
|
|
13172
|
+
const onScreen = await postPane(sid, { target: url, title: label });
|
|
13173
|
+
if (onScreen !== null) revealPane(sid, onScreen);
|
|
13102
13174
|
}
|
|
13103
13175
|
|
|
13104
|
-
function
|
|
13105
|
-
|
|
13176
|
+
function openCost(sid, { agent, pane } = {}) {
|
|
13177
|
+
const opts = { pane, label: 'Cost' };
|
|
13178
|
+
if (sid && agent && hub.can('agent.cost')) return openAppAction('agent.cost', { session: sid, agent }, sid, opts);
|
|
13179
|
+
return openAppAction('session.cost', sid ? { session: sid } : {}, sid, opts);
|
|
13106
13180
|
}
|
|
13107
13181
|
|
|
13108
|
-
function
|
|
13109
|
-
|
|
13182
|
+
function openProjectApp(action, label, sid, { pane } = {}) {
|
|
13183
|
+
const project = sessions.find((s) => s.id === sid)?.project;
|
|
13184
|
+
return openAppAction(action, project ? { project } : {}, sid, { pane, label });
|
|
13110
13185
|
}
|
|
13186
|
+
const openMarketplace = (sid, opts) => openProjectApp('project.plugins', 'Marketplace', sid, opts);
|
|
13187
|
+
const openMemory = (sid, opts) => openProjectApp('project.memory', 'Memory', sid, opts);
|
|
13111
13188
|
|
|
13112
|
-
function openForInfoModalProject(open) {
|
|
13113
|
-
const s = sessions.find((x) => x.id === _infoModalSessionId);
|
|
13114
|
-
if (s?.project) open(s.project);
|
|
13115
|
-
}
|
|
13116
13189
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
13117
|
-
function
|
|
13118
|
-
|
|
13119
|
-
}
|
|
13120
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
13121
|
-
function openMemoryForInfoModal() {
|
|
13122
|
-
openForInfoModalProject(openMemory);
|
|
13190
|
+
function openAppForInfoModal(open, e) {
|
|
13191
|
+
closeTeamModal();
|
|
13192
|
+
open(_infoModalSessionId, { pane: !e.shiftKey });
|
|
13123
13193
|
}
|
|
13124
13194
|
|
|
13125
13195
|
//#endregion
|
|
@@ -13830,6 +13900,11 @@ async function loadPaneView(sid, pane, view, scroll) {
|
|
|
13830
13900
|
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
13831
13901
|
frame.title = pane.title;
|
|
13832
13902
|
frame.src = pane.target;
|
|
13903
|
+
frame.addEventListener('load', () => {
|
|
13904
|
+
view.classList.remove('keys-bridged');
|
|
13905
|
+
frame.hostKeysSig = null;
|
|
13906
|
+
pushTerminalClaims();
|
|
13907
|
+
});
|
|
13833
13908
|
view.appendChild(frame);
|
|
13834
13909
|
view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
|
|
13835
13910
|
// Framed at once; a refusal found by the probe swaps the frame for the card.
|
|
@@ -15521,23 +15596,49 @@ function bridgeClaims(claims) {
|
|
|
15521
15596
|
return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS, ...review] };
|
|
15522
15597
|
}
|
|
15523
15598
|
|
|
15524
|
-
function sendBridgeClaimsAll(frames,
|
|
15525
|
-
const base = bridgeClaims(claims);
|
|
15599
|
+
function sendBridgeClaimsAll(frames, base) {
|
|
15526
15600
|
const rest = frames.filter((f) => f !== showState.frame);
|
|
15527
15601
|
sendBridgeClaims(rest, base);
|
|
15528
15602
|
if (rest.length < frames.length)
|
|
15529
15603
|
sendBridgeClaims([showState.frame], { ...base, keys: [...base.keys, ...SHOW_CLAIMS] });
|
|
15530
15604
|
}
|
|
15531
15605
|
|
|
15606
|
+
// A hub app in a URL pane runs the SDK, which hands back these keys (hub protocol §5.2). Only keys with
|
|
15607
|
+
// Ctrl, Alt or Meta: the page keeps Escape, brackets and its own letter keys.
|
|
15608
|
+
function sendHostKeys(frames, bridged) {
|
|
15609
|
+
const keys = bridged.keys.filter((k) => k.ctrlKey || k.altKey || k.metaKey);
|
|
15610
|
+
const sig = JSON.stringify(keys);
|
|
15611
|
+
for (const f of frames) {
|
|
15612
|
+
const origin = URL.parse(f.src)?.origin;
|
|
15613
|
+
if (!origin || origin === 'null' || f.hostKeysSig === sig) continue;
|
|
15614
|
+
f.hostKeysSig = sig;
|
|
15615
|
+
f.contentWindow?.postMessage({ type: 'hub:hostKeys', keys }, origin);
|
|
15616
|
+
}
|
|
15617
|
+
}
|
|
15618
|
+
|
|
15619
|
+
const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
|
|
15620
|
+
|
|
15621
|
+
window.addEventListener('message', (e) => {
|
|
15622
|
+
const type = e.data?.type;
|
|
15623
|
+
if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
|
|
15624
|
+
const frame = urlPaneFrames().find((f) => f.contentWindow === e.source);
|
|
15625
|
+
if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
|
|
15626
|
+
if (type === 'hub:hostReady') frame.parentElement.classList.add('keys-bridged');
|
|
15627
|
+
else replayKey(e.data, frame);
|
|
15628
|
+
});
|
|
15629
|
+
|
|
15532
15630
|
function pushTerminalClaims() {
|
|
15533
15631
|
if (terminalClaimsQueued) return;
|
|
15534
15632
|
terminalClaimsQueued = true;
|
|
15535
15633
|
queueMicrotask(() => {
|
|
15536
15634
|
terminalClaimsQueued = false;
|
|
15537
15635
|
const bridged = bridgedFrameEls();
|
|
15538
|
-
|
|
15636
|
+
const urlFrames = urlPaneFrames();
|
|
15637
|
+
if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length) return;
|
|
15539
15638
|
const claims = keyClaims();
|
|
15540
|
-
|
|
15639
|
+
const base = bridgeClaims(claims);
|
|
15640
|
+
sendBridgeClaimsAll(bridged, base);
|
|
15641
|
+
sendHostKeys(urlFrames, base);
|
|
15541
15642
|
const sig = JSON.stringify(claims);
|
|
15542
15643
|
sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
|
|
15543
15644
|
sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
|
|
@@ -16067,17 +16168,18 @@ async function terminalFetch(url, method, body) {
|
|
|
16067
16168
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
16068
16169
|
}
|
|
16069
16170
|
|
|
16070
|
-
const
|
|
16171
|
+
const TERMINAL_DOT_STATE = { working: 'busy', waiting: 'wait', idle: 'idle' };
|
|
16071
16172
|
|
|
16072
16173
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
16073
16174
|
async function endAllTerminals() {
|
|
16074
16175
|
const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
|
|
16075
16176
|
const id = b.dataset.end;
|
|
16076
16177
|
const session = sessions.find((s) => s.id === id);
|
|
16077
|
-
const
|
|
16178
|
+
const label = session ? sessionState(session) : 'idle';
|
|
16179
|
+
const state = TERMINAL_DOT_STATE[label];
|
|
16078
16180
|
const started = Number(b.dataset.started);
|
|
16079
16181
|
const meta = [
|
|
16080
|
-
|
|
16182
|
+
label,
|
|
16081
16183
|
started && `up ${formatDuration(Date.now() - started)}`,
|
|
16082
16184
|
formatTerminalProc(terminalProcStats[id]),
|
|
16083
16185
|
]
|
|
@@ -16775,9 +16877,13 @@ function withHeadFirst(content, head) {
|
|
|
16775
16877
|
return content.slice(0, cut) + inject + content.slice(cut);
|
|
16776
16878
|
}
|
|
16777
16879
|
|
|
16880
|
+
// The board's scrollbar rules do not reach into a frame, which would show the OS default.
|
|
16881
|
+
// Grey, not a board token, because the page paints its own background.
|
|
16882
|
+
const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
|
|
16883
|
+
|
|
16778
16884
|
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
16779
16885
|
function showSrcdoc(content, restoreY) {
|
|
16780
|
-
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}</style>`;
|
|
16886
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
|
|
16781
16887
|
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
16782
16888
|
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
16783
16889
|
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
@@ -16788,7 +16894,7 @@ function showSrcdoc(content, restoreY) {
|
|
|
16788
16894
|
// An HTML file preview gets only the tokens, first in its head: its own :root rules win, and
|
|
16789
16895
|
// a page that declares its defaults in :where(:root) follows the board theme.
|
|
16790
16896
|
function themedSrcdoc(content) {
|
|
16791
|
-
const head = `<style id="cck-tokens">:root{${showTokenCss()}}</style>`;
|
|
16897
|
+
const head = `<style id="cck-tokens">:root{${showTokenCss()}}${FRAME_SCROLLBAR_CSS}</style>`;
|
|
16792
16898
|
return withHeadFirst(content, head) + THEME_BRIDGE_TAG + REVIEW_BRIDGE_TAG;
|
|
16793
16899
|
}
|
|
16794
16900
|
|
|
@@ -16941,10 +17047,15 @@ async function openShowInPane() {
|
|
|
16941
17047
|
}
|
|
16942
17048
|
|
|
16943
17049
|
// `onScreen` is postPane's result: null failed, true is already in view unless the terminal covers it.
|
|
17050
|
+
function revealPane(sid, onScreen) {
|
|
17051
|
+
if (onScreen && !wantsTerminal()) syncPanes();
|
|
17052
|
+
else if (sid === currentSessionId && wantsTerminal()) toggleTerminal();
|
|
17053
|
+
else openSession(sid);
|
|
17054
|
+
}
|
|
17055
|
+
|
|
16944
17056
|
function toastPaneAdded(sid, onScreen, label) {
|
|
16945
17057
|
if (onScreen === null || (onScreen && !wantsTerminal())) return;
|
|
16946
|
-
|
|
16947
|
-
showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: open });
|
|
17058
|
+
showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: () => revealPane(sid, false) });
|
|
16948
17059
|
}
|
|
16949
17060
|
|
|
16950
17061
|
function showCommand(cmd) {
|
|
@@ -18105,6 +18216,7 @@ hub.onActive((active) => {
|
|
|
18105
18216
|
pushTerminalClaims();
|
|
18106
18217
|
});
|
|
18107
18218
|
hub.onStatus(pushTerminalClaims);
|
|
18219
|
+
hub.onSettings(applyHubSettings);
|
|
18108
18220
|
|
|
18109
18221
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
18110
18222
|
let publishTimer = 0;
|
package/public/index.html
CHANGED
|
@@ -557,13 +557,13 @@
|
|
|
557
557
|
<button id="session-info-sticky-btn" class="icon-btn" style="display:none" title="Sticky pin — always show in the list" onclick="toggleSessionSticky(_infoModalSessionId); updateStickyBtnState()">
|
|
558
558
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 L15 9 L22 9 L17 14 L19 22 L12 18 L5 22 L7 14 L2 9 L9 9 Z"/></svg>
|
|
559
559
|
</button>
|
|
560
|
-
<button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost" onclick="openCost
|
|
560
|
+
<button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost (Shift: Cost tab)" onclick="openAppForInfoModal(openCost, event)">
|
|
561
561
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
|
|
562
562
|
</button>
|
|
563
|
-
<button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace" onclick="
|
|
563
|
+
<button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace (Shift: Marketplace tab)" onclick="openAppForInfoModal(openMarketplace, event)">
|
|
564
564
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>
|
|
565
565
|
</button>
|
|
566
|
-
<button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory" onclick="
|
|
566
|
+
<button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory (Shift: Memory tab)" onclick="openAppForInfoModal(openMemory, event)">
|
|
567
567
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
|
568
568
|
</button>
|
|
569
569
|
<button class="modal-close" aria-label="Close dialog" onclick="closeTeamModal()">
|