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.
@@ -1,26 +1,20 @@
1
1
  import type { On, ToolCallArgs } from 'claude-code'
2
- import { expect, test } from 'claude-code/testing'
3
- import { boardPort, dropSkillLine, shownText, TOOL } from '../hooks/show'
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 LISTING = [
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 (!e.url.includes('/show')) return new Promise(() => {})
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 line, the listing filter', async () => {
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(dropSkillLine(LISTING)).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
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('gate off: no tool, and the skill line is dropped', async ($, on) => {
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('gate on: the tool is registered and the skill line stays', async ($, on) => {
74
- const board = engine(on, GATE)
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
- expect(board.registered).toEqual(['show'])
77
- const { text } = await $.prompt.attachment(listing)
78
- expect(text).toBe(LISTING)
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 renderAgentFooter() {
3401
- const footer = document.getElementById('agent-footer');
3402
- const content = document.getElementById('agent-footer-content');
3403
- const label = document.getElementById('agent-footer-label');
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
- const agents = currentAgents;
3407
- // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
3408
- // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
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 agents) {
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
- const visible = filtered
3435
- .sort(
3436
- (a, b) =>
3437
- (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
3438
- new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
3439
- )
3440
- .slice(0, AGENT_LOG_MAX);
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 descText = a.description || '';
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">${modelHtml}<span class="agent-type-name" title="${escapeHtml(rawType)}">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
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
- function openCost(sessionId, agentId) {
13099
- if (sessionId && agentId && hub.can('agent.cost'))
13100
- return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
13101
- hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
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 openMarketplace(projectPath) {
13105
- hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
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 openMemory(projectPath) {
13109
- hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
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 openMarketplaceForInfoModal() {
13118
- openForInfoModalProject(openMarketplace);
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, claims) {
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
- if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
15636
+ const urlFrames = urlPaneFrames();
15637
+ if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length) return;
15539
15638
  const claims = keyClaims();
15540
- sendBridgeClaimsAll(bridged, claims);
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 TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
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 state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
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
- TERMINAL_STATE_LABEL[state],
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
- const open = () => (sid === currentSessionId && wantsTerminal() ? toggleTerminal() : openSession(sid));
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(_infoModalSessionId)">
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="openMarketplaceForInfoModal()">
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="openMemoryForInfoModal()">
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()">