claude-code-kanban 7.18.0 → 7.20.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/public/app.js CHANGED
@@ -2808,7 +2808,7 @@ function isAnyModalOpen() {
2808
2808
 
2809
2809
  // An open modal owns the zoom keys even when it has nothing to scale.
2810
2810
  function isZoomableOnScreen() {
2811
- return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen() || isShowCardFocused();
2811
+ return isAnyModalOpen() ? isZoomableModalOpen() : isAskOnScreen() || isPaneDocOnScreen() || isShowCardFocused();
2812
2812
  }
2813
2813
 
2814
2814
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -3433,24 +3433,34 @@ function agentIdentHtml(a) {
3433
3433
 
3434
3434
  // The count names every running agent; the rows stop at ZEN_AGENTS_MAX, and the Agents Log
3435
3435
  // below the board lists the rest.
3436
- function zenAgentsHtml(agents) {
3437
- const active = agents.filter((a) => a.status === 'active');
3436
+ function zenAgentsHtml(active) {
3438
3437
  if (!active.length) return '';
3439
- const now = Date.now();
3440
3438
  const rows = active.slice(0, ZEN_AGENTS_MAX).map((a) => {
3441
3439
  const task = truncateAtWord(agentTaskText(a), 200);
3442
3440
  return `<button type="button" class="zen-agent" title="${escapeHtml(task)}" onclick="showAgentModal('${escAttrJs(a.agentId)}')">
3443
3441
  <span class="agent-dot active"></span>
3444
3442
  <span class="zen-agent-text"><span class="zen-agent-task">${escapeHtml(task)}</span><span class="agent-ident-row">${agentIdentHtml(a)}</span></span>
3445
- <span class="zen-agent-time">${formatDuration(agentElapsed(a, now))}</span>
3443
+ <span class="zen-agent-time"></span>
3446
3444
  </button>`;
3447
3445
  });
3448
3446
  return `<div class="panel-section-header"><span>Agents</span><span class="panel-section-count">${active.length}</span></div>${rows.join('')}`;
3449
3447
  }
3450
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
3451
  function renderZenAgents(agents) {
3452
3452
  const el = zenMode && sessionsList.querySelector('.zen-agents');
3453
- if (el) el.innerHTML = zenAgentsHtml(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
+ }
3460
+ const now = Date.now();
3461
+ el.querySelectorAll('.zen-agent-time').forEach((t, i) => {
3462
+ t.textContent = formatDuration(agentElapsed(active[i], now));
3463
+ });
3454
3464
  }
3455
3465
 
3456
3466
  // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
@@ -4111,11 +4121,13 @@ function renderSessions() {
4111
4121
  ${renderZenBookmarksHtml(zenSession.id)}
4112
4122
  </div>
4113
4123
  ${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
4114
- <div class="zen-agents">${zenAgentsHtml(visibleAgents())}</div>`;
4124
+ <div class="zen-agents"></div>`;
4125
+ renderZenAgents(visibleAgents());
4115
4126
  bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
4116
4127
  artifactsSection.load(zenSession.id);
4117
4128
  scratchFilesSection.load(zenSession.id);
4118
4129
  if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
4130
+ restoreSessionCursor();
4119
4131
  return;
4120
4132
  }
4121
4133
 
@@ -4364,7 +4376,10 @@ function renderSessions() {
4364
4376
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
4365
4377
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
4366
4378
  }
4379
+ restoreSessionCursor();
4380
+ }
4367
4381
 
4382
+ function restoreSessionCursor() {
4368
4383
  const navItems = getNavigableItems();
4369
4384
  const allSessions = getSessionItems();
4370
4385
  const activeIdx = allSessions.findIndex((el) => el.classList.contains('active'));
@@ -7949,6 +7964,21 @@ document.addEventListener('keydown', (e) => {
7949
7964
  toggleZenMode();
7950
7965
  return;
7951
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
+ }
7952
7982
  if (e.key === '.' || e.key === '>') {
7953
7983
  const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
7954
7984
  if (sid) {
@@ -8089,23 +8119,6 @@ document.addEventListener('keydown', (e) => {
8089
8119
  else toggleScratchpad();
8090
8120
  return;
8091
8121
  }
8092
- if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
8093
- e.preventDefault();
8094
- openCost(contextSid);
8095
- return;
8096
- }
8097
- if (matchKey(e, 'KeyM')) {
8098
- e.preventDefault();
8099
- const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
8100
- openMarketplace(mSession?.project);
8101
- return;
8102
- }
8103
- if (ClaudeHub.comboOf(e) === 'ctrl+m') {
8104
- e.preventDefault();
8105
- const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
8106
- openMemory(mSession?.project);
8107
- return;
8108
- }
8109
8122
  if (ClaudeHub.comboOf(e) === 'ctrl+d') {
8110
8123
  e.preventDefault();
8111
8124
  dismissContextSession();
@@ -8709,7 +8722,9 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
8709
8722
  // first keeps it, and text fields keep every key.
8710
8723
  const PANE_CLAIMS = [
8711
8724
  ...['\\', '[', ']', 'ContextMenu', 'Escape'].map((key) => ({ key, code: '' })),
8712
- ...['|', '{', '}', '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: '' },
8713
8728
  ...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
8714
8729
  ...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
8715
8730
  ...'123456789'.split('').map((n) => ({ ctrlKey: true, altKey: true, key: 'Unidentified', code: `Digit${n}` })),
@@ -9058,6 +9073,7 @@ function reviewOptsOfFrame(frame) {
9058
9073
 
9059
9074
  async function sendPreviewAction(frame, msg) {
9060
9075
  if (!navigator.userActivation?.isActive) return;
9076
+ if (frame === askState.frame) return answerAsk({ action: msg.action, label: msg.label, fields: msg.data });
9061
9077
  const opts = reviewOptsOfFrame(frame);
9062
9078
  if (!opts?.sessionId) return;
9063
9079
  const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
@@ -9197,7 +9213,7 @@ function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-po
9197
9213
  frame.setAttribute('referrerpolicy', 'no-referrer');
9198
9214
  frame.srcdoc = srcdoc;
9199
9215
  bridgedFrames.add(frame);
9200
- frame.addEventListener('load', () => sendBridgeClaimsAll([frame], keyClaims()));
9216
+ frame.addEventListener('load', () => sendBridgeClaimsAll([frame], bridgeClaims(keyClaims())));
9201
9217
  return frame;
9202
9218
  }
9203
9219
 
@@ -10235,6 +10251,8 @@ function setupEventSource() {
10235
10251
 
10236
10252
  if (data.type === 'show:cleared') onShowCleared(data);
10237
10253
  if (data.type === 'show:removed') onShowRemoved(data);
10254
+ if (data.type === 'ask:opened') onAskOpened(data);
10255
+ if (data.type === 'ask:closed') onAskClosed(data);
10238
10256
 
10239
10257
  if (data.type === 'agent-update') {
10240
10258
  if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
@@ -10275,7 +10293,7 @@ function setupEventSource() {
10275
10293
  // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10276
10294
  // CLI prints the link instead.
10277
10295
  if (data.type === 'session:cost' && hub.inHub) {
10278
- openCost(data.id, data.agent);
10296
+ openCost(data.id, { agent: data.agent, pane: false });
10279
10297
  }
10280
10298
 
10281
10299
  if (data.type === 'session:pin') {
@@ -13148,31 +13166,33 @@ function openFolderInEditor(folder, file) {
13148
13166
  postAndToast('/api/open-folder', body, 'folder');
13149
13167
  }
13150
13168
 
13151
- function openCost(sessionId, agentId) {
13152
- if (sessionId && agentId && hub.can('agent.cost'))
13153
- return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
13154
- hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
13169
+ // Opens the app in a pane of `sid` and brings the pane into view. With no session, or `pane: false`
13170
+ // (a Shift-click), the hub switches to the app's tab instead.
13171
+ async function openAppAction(action, params, sid, { pane = true, label }) {
13172
+ if (!sid || !pane) return hub.invoke(action, params);
13173
+ const { url } = await hub.invoke(action, params, { open: 'pane' });
13174
+ if (!url) return;
13175
+ const onScreen = await postPane(sid, { target: url, title: label });
13176
+ if (onScreen !== null) revealPane(sid, onScreen);
13155
13177
  }
13156
13178
 
13157
- function openMarketplace(projectPath) {
13158
- hub.invoke('project.plugins', projectPath ? { project: projectPath } : {});
13179
+ function openCost(sid, { agent, pane } = {}) {
13180
+ const opts = { pane, label: 'Cost' };
13181
+ if (sid && agent && hub.can('agent.cost')) return openAppAction('agent.cost', { session: sid, agent }, sid, opts);
13182
+ return openAppAction('session.cost', sid ? { session: sid } : {}, sid, opts);
13159
13183
  }
13160
13184
 
13161
- function openMemory(projectPath) {
13162
- hub.invoke('project.memory', projectPath ? { project: projectPath } : {});
13185
+ function openProjectApp(action, label, sid, { pane } = {}) {
13186
+ const project = sessions.find((s) => s.id === sid)?.project;
13187
+ return openAppAction(action, project ? { project } : {}, sid, { pane, label });
13163
13188
  }
13189
+ const openMarketplace = (sid, opts) => openProjectApp('project.plugins', 'Marketplace', sid, opts);
13190
+ const openMemory = (sid, opts) => openProjectApp('project.memory', 'Memory', sid, opts);
13164
13191
 
13165
- function openForInfoModalProject(open) {
13166
- const s = sessions.find((x) => x.id === _infoModalSessionId);
13167
- if (s?.project) open(s.project);
13168
- }
13169
13192
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
13170
- function openMarketplaceForInfoModal() {
13171
- openForInfoModalProject(openMarketplace);
13172
- }
13173
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
13174
- function openMemoryForInfoModal() {
13175
- openForInfoModalProject(openMemory);
13193
+ function openAppForInfoModal(open, e) {
13194
+ closeTeamModal();
13195
+ open(_infoModalSessionId, { pane: !e.shiftKey });
13176
13196
  }
13177
13197
 
13178
13198
  //#endregion
@@ -13883,6 +13903,11 @@ async function loadPaneView(sid, pane, view, scroll) {
13883
13903
  frame.setAttribute('referrerpolicy', 'no-referrer');
13884
13904
  frame.title = pane.title;
13885
13905
  frame.src = pane.target;
13906
+ frame.addEventListener('load', () => {
13907
+ view.classList.remove('keys-bridged');
13908
+ frame.hostKeysSig = null;
13909
+ pushTerminalClaims();
13910
+ });
13886
13911
  view.appendChild(frame);
13887
13912
  view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
13888
13913
  // Framed at once; a refusal found by the probe swaps the frame for the card.
@@ -15192,9 +15217,10 @@ function inPageField(e) {
15192
15217
  function terminalShortcut(e, probe = false) {
15193
15218
  const ctrlAlt = hubModDown(e);
15194
15219
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
15195
- // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
15220
+ // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal. An open ask card,
15221
+ // which sits in the terminal pane, takes them for its own text zoom.
15196
15222
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
15197
- if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
15223
+ if (zoom !== undefined && termFrame.inited && (probe || (terminalPaneFocused() && !isAskOnScreen()))) {
15198
15224
  return () => adjustTerminalFontSize(Math.sign(zoom));
15199
15225
  }
15200
15226
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
@@ -15574,23 +15600,49 @@ function bridgeClaims(claims) {
15574
15600
  return { ...claims, keys: [...claims.keys, ...ZOOM_CLAIMS, ...PANE_CLAIMS, ...review] };
15575
15601
  }
15576
15602
 
15577
- function sendBridgeClaimsAll(frames, claims) {
15578
- const base = bridgeClaims(claims);
15603
+ function sendBridgeClaimsAll(frames, base) {
15579
15604
  const rest = frames.filter((f) => f !== showState.frame);
15580
15605
  sendBridgeClaims(rest, base);
15581
15606
  if (rest.length < frames.length)
15582
15607
  sendBridgeClaims([showState.frame], { ...base, keys: [...base.keys, ...SHOW_CLAIMS] });
15583
15608
  }
15584
15609
 
15610
+ // A hub app in a URL pane runs the SDK, which hands back these keys (hub protocol §5.2). Only keys with
15611
+ // Ctrl, Alt or Meta: the page keeps Escape, brackets and its own letter keys.
15612
+ function sendHostKeys(frames, bridged) {
15613
+ const keys = bridged.keys.filter((k) => k.ctrlKey || k.altKey || k.metaKey);
15614
+ const sig = JSON.stringify(keys);
15615
+ for (const f of frames) {
15616
+ const origin = URL.parse(f.src)?.origin;
15617
+ if (!origin || origin === 'null' || f.hostKeysSig === sig) continue;
15618
+ f.hostKeysSig = sig;
15619
+ f.contentWindow?.postMessage({ type: 'hub:hostKeys', keys }, origin);
15620
+ }
15621
+ }
15622
+
15623
+ const urlPaneFrames = () => [...paneViews.querySelectorAll('.pane-url-frame')];
15624
+
15625
+ window.addEventListener('message', (e) => {
15626
+ const type = e.data?.type;
15627
+ if (type !== 'hub:hostKey' && type !== 'hub:hostReady') return;
15628
+ const frame = urlPaneFrames().find((f) => f.contentWindow === e.source);
15629
+ if (!frame || URL.parse(frame.src)?.origin !== e.origin) return;
15630
+ if (type === 'hub:hostReady') frame.parentElement.classList.add('keys-bridged');
15631
+ else replayKey(e.data, frame);
15632
+ });
15633
+
15585
15634
  function pushTerminalClaims() {
15586
15635
  if (terminalClaimsQueued) return;
15587
15636
  terminalClaimsQueued = true;
15588
15637
  queueMicrotask(() => {
15589
15638
  terminalClaimsQueued = false;
15590
15639
  const bridged = bridgedFrameEls();
15591
- if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
15640
+ const urlFrames = urlPaneFrames();
15641
+ if (!termFrame.inited && !kanbotState.inited && !bridged.length && !urlFrames.length) return;
15592
15642
  const claims = keyClaims();
15593
- sendBridgeClaimsAll(bridged, claims);
15643
+ const base = bridgeClaims(claims);
15644
+ sendBridgeClaimsAll(bridged, base);
15645
+ sendHostKeys(urlFrames, base);
15594
15646
  const sig = JSON.stringify(claims);
15595
15647
  sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
15596
15648
  sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
@@ -16369,6 +16421,177 @@ window.addEventListener('message', (e) => {
16369
16421
 
16370
16422
  //#endregion
16371
16423
 
16424
+ //#region ASK
16425
+ // The question the session in the embedded terminal asks with the ask tool; the tool call waits for
16426
+ // the answer. Hiding keeps the frame, so a half-filled card comes back as it was. Only an answer, a
16427
+ // skip or the agent's interrupt ends the question.
16428
+ const ASK_MSG = 'cck-ask:';
16429
+ const askState = { terminalId: null, ask: null, frame: null, frameH: 0, hidden: false, busy: false, seq: 0 };
16430
+
16431
+ // Runs inside the card as source text, ahead of the card's own scripts. The JSON round trip makes
16432
+ // the answer cloneable, and a value that is not JSON throws in the card.
16433
+ function askBridge() {
16434
+ window.cck = {
16435
+ send: (data) => parent.postMessage({ type: 'cck-ask:send', data: JSON.parse(JSON.stringify(data ?? null)) }, '*'),
16436
+ };
16437
+ }
16438
+
16439
+ const ASK_BRIDGE_TAG = `<script>(${askBridge})();</script>`;
16440
+
16441
+ function askEl() {
16442
+ return document.getElementById('ask-modal');
16443
+ }
16444
+
16445
+ function isAskOnScreen() {
16446
+ return !!askState.ask && !askEl().hidden;
16447
+ }
16448
+
16449
+ function syncAsk(tid) {
16450
+ if (tid === askState.terminalId) return;
16451
+ askState.terminalId = tid;
16452
+ setAsk(null);
16453
+ if (tid) loadAsk(tid);
16454
+ }
16455
+
16456
+ async function loadAsk(tid) {
16457
+ const seq = ++askState.seq;
16458
+ const data = await getJson(apiPath`/api/terminals/${tid}/ask`, null, { cache: 'no-store' });
16459
+ if (seq !== askState.seq || tid !== askState.terminalId) return;
16460
+ const a = data?.ask ?? null;
16461
+ if (a?.id !== askState.ask?.id) setAsk(a);
16462
+ }
16463
+
16464
+ function setAsk(a) {
16465
+ const modal = askEl();
16466
+ const body = modal.querySelector('.ask-body');
16467
+ Object.assign(askState, { ask: a, frame: null, frameH: 0, hidden: false, busy: false });
16468
+ body.replaceChildren();
16469
+ if (a) {
16470
+ modal.querySelector('.ask-title').textContent = a.title;
16471
+ modal.querySelector('.ask-box').dataset.size = a.size;
16472
+ if (a.content == null) {
16473
+ body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
16474
+ } else {
16475
+ const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, ASK_BRIDGE_TAG), SHOW_SANDBOX);
16476
+ frame.addEventListener('load', () => {
16477
+ if (askState.frame === frame && !askState.hidden) frame.focus();
16478
+ });
16479
+ askState.frame = frame;
16480
+ body.append(frame);
16481
+ }
16482
+ }
16483
+ renderAsk();
16484
+ }
16485
+
16486
+ function renderAsk() {
16487
+ const a = askState.ask;
16488
+ askEl().hidden = !a || askState.hidden;
16489
+ const chip = document.getElementById('ask-reopen');
16490
+ chip.hidden = !a || !askState.hidden;
16491
+ if (a) {
16492
+ chip.querySelector('.ask-reopen-n').textContent = a.title;
16493
+ chip.title = `Open the question: ${a.title}`;
16494
+ }
16495
+ layoutAsk();
16496
+ }
16497
+
16498
+ // A full card fills the modal; a smaller one takes its content's height, up to the terminal's.
16499
+ function layoutAsk() {
16500
+ const frame = askState.frame;
16501
+ const box = askEl().querySelector('.ask-box');
16502
+ if (!frame || askEl().hidden || box.dataset.size === 'full') return;
16503
+ const host = document.getElementById('terminal-host');
16504
+ const chrome = box.querySelector('.ask-head').offsetHeight + box.querySelector('.ask-foot').offsetHeight + 2;
16505
+ frame.style.height = `${Math.max(SHOW_MIN_H, Math.min(askState.frameH || SHOW_MIN_H, host.clientHeight - 32 - chrome))}px`;
16506
+ }
16507
+
16508
+ function hideAsk() {
16509
+ askState.hidden = true;
16510
+ renderAsk();
16511
+ focusTerminalPane();
16512
+ }
16513
+
16514
+ function reopenAsk() {
16515
+ askState.hidden = false;
16516
+ renderAsk();
16517
+ askState.frame?.focus();
16518
+ }
16519
+
16520
+ async function endAsk(body) {
16521
+ const a = askState.ask;
16522
+ const tid = askState.terminalId;
16523
+ if (!a || !tid || askState.busy) return;
16524
+ askState.busy = true;
16525
+ try {
16526
+ const res = await terminalFetch(apiPath`/api/terminals/${tid}/ask/${a.id}`, 'POST', body);
16527
+ if (res.status === 404) showToast('The question is no longer open');
16528
+ else if (!res.ok) {
16529
+ const out = await res.json().catch(() => ({}));
16530
+ return showToast(out.error || `Send failed (HTTP ${res.status})`, 'error');
16531
+ }
16532
+ if (askState.ask?.id !== a.id) return;
16533
+ setAsk(null);
16534
+ focusTerminalPane();
16535
+ } catch {
16536
+ showToast('Failed to send the answer', 'error');
16537
+ } finally {
16538
+ if (askState.ask?.id === a.id) askState.busy = false;
16539
+ }
16540
+ }
16541
+
16542
+ function answerAsk(data) {
16543
+ return endAsk({ status: 'answered', data });
16544
+ }
16545
+
16546
+ function onAskOpened(d) {
16547
+ if (typeof d.terminalId !== 'string') return;
16548
+ if (d.terminalId === askState.terminalId) return loadAsk(d.terminalId);
16549
+ if (askState.terminalId || !wantsTerminal()) return;
16550
+ if (!terminalIds.has(currentSessionId)) showState.lookup = null;
16551
+ syncShowCard();
16552
+ }
16553
+
16554
+ function onAskClosed(d) {
16555
+ if (d.terminalId !== askState.terminalId || askState.ask?.id !== d.id) return;
16556
+ if (d.status === 'cancelled') showToast('The agent stopped waiting for the answer');
16557
+ setAsk(null);
16558
+ }
16559
+
16560
+ // A script can send without a click, so a send needs transient user activation, as a card action does.
16561
+ window.addEventListener('message', (e) => {
16562
+ const frame = askState.frame;
16563
+ if (!frame || e.source !== frame.contentWindow || typeof e.data?.type !== 'string') return;
16564
+ if (e.data.type === `${ASK_MSG}send`) {
16565
+ if (navigator.userActivation?.isActive) answerAsk(e.data.data);
16566
+ } else if (e.data.type === `${SHOW_MSG}height`) {
16567
+ askState.frameH = Number(e.data.h) || askState.frameH;
16568
+ layoutAsk();
16569
+ }
16570
+ });
16571
+
16572
+ function initAsk() {
16573
+ const modal = askEl();
16574
+ modal.addEventListener('click', (e) => {
16575
+ const cmd = e.target === modal ? 'hide' : e.target.closest('[data-ask]')?.dataset.ask;
16576
+ if (cmd === 'hide') hideAsk();
16577
+ else if (cmd === 'skip') endAsk({ status: 'dismissed' });
16578
+ });
16579
+ document.getElementById('ask-reopen').addEventListener('click', reopenAsk);
16580
+ // Capture, ahead of the board's own Escape. The card's frame hands Escape back as a key on the frame.
16581
+ window.addEventListener(
16582
+ 'keydown',
16583
+ (e) => {
16584
+ if (e.key !== 'Escape' || modal.hidden || !modal.contains(e.target)) return;
16585
+ e.preventDefault();
16586
+ e.stopImmediatePropagation();
16587
+ hideAsk();
16588
+ },
16589
+ true,
16590
+ );
16591
+ window.addEventListener('resize', layoutAsk);
16592
+ }
16593
+ //#endregion
16594
+
16372
16595
  //#region SHOW
16373
16596
  // The card the session in the embedded terminal posts to with the show tool. It belongs to the
16374
16597
  // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
@@ -16581,6 +16804,7 @@ function syncShowCard() {
16581
16804
  loadTerminals().catch(() => {});
16582
16805
  }
16583
16806
  const tid = pty ? (terminalIds.get(pty) ?? null) : null;
16807
+ syncAsk(tid);
16584
16808
  if (tid === showState.terminalId) return;
16585
16809
  const view = (tid && showViews[tid]) || { collapsed: true };
16586
16810
  Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
@@ -16834,8 +17058,8 @@ function withHeadFirst(content, head) {
16834
17058
  const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
16835
17059
 
16836
17060
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
16837
- function showSrcdoc(content, restoreY) {
16838
- const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
17061
+ function showSrcdoc(content, restoreY, extraHead = '') {
17062
+ const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
16839
17063
  const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
16840
17064
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
16841
17065
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
@@ -16941,7 +17165,7 @@ new MutationObserver(() => {
16941
17165
 
16942
17166
  // Builds the message only when a frame is there, because showTokens forces a style recalc.
16943
17167
  function postToShowFrames(makeMsg) {
16944
- const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame')].filter(
17168
+ const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame, .ask-frame')].filter(
16945
17169
  (f) => f?.contentWindow,
16946
17170
  );
16947
17171
  if (!frames.length) return;
@@ -16999,10 +17223,15 @@ async function openShowInPane() {
16999
17223
  }
17000
17224
 
17001
17225
  // `onScreen` is postPane's result: null failed, true is already in view unless the terminal covers it.
17226
+ function revealPane(sid, onScreen) {
17227
+ if (onScreen && !wantsTerminal()) syncPanes();
17228
+ else if (sid === currentSessionId && wantsTerminal()) toggleTerminal();
17229
+ else openSession(sid);
17230
+ }
17231
+
17002
17232
  function toastPaneAdded(sid, onScreen, label) {
17003
17233
  if (onScreen === null || (onScreen && !wantsTerminal())) return;
17004
- const open = () => (sid === currentSessionId && wantsTerminal() ? toggleTerminal() : openSession(sid));
17005
- showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: open });
17234
+ showToast(`${label} added as a pane`, 'success', { label: 'Open', onClick: () => revealPane(sid, false) });
17006
17235
  }
17007
17236
 
17008
17237
  function showCommand(cmd) {
@@ -17876,6 +18105,7 @@ initSessionGroupsDnd();
17876
18105
  initSessionPicker();
17877
18106
  initProjectPicker();
17878
18107
  initShowCard();
18108
+ initAsk();
17879
18109
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
17880
18110
  initSidebarResize();
17881
18111
  applyModalZoom();
package/public/index.html CHANGED
@@ -362,6 +362,10 @@
362
362
  <div id="terminal-pane" class="terminal-pane">
363
363
  <div class="terminal-bar">
364
364
  <span id="terminal-status" class="terminal-status"></span>
365
+ <button id="ask-reopen" class="show-reopen ask-reopen" type="button" hidden>
366
+ <span class="ask-dot" aria-hidden="true">?</span>
367
+ <span class="ask-reopen-n"></span>
368
+ </button>
365
369
  <button id="show-reopen" class="show-reopen" type="button" title="Show the overlay" hidden>
366
370
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M13 4v7h8"/></svg>
367
371
  <span class="show-reopen-n">show</span>
@@ -375,6 +379,23 @@
375
379
  </div>
376
380
  <div id="terminal-host" class="terminal-host">
377
381
  <section id="show-card" class="show-card" aria-label="Show overlay"></section>
382
+ <div id="ask-modal" class="ask-modal" hidden>
383
+ <section class="ask-box" role="dialog" aria-modal="true" aria-labelledby="ask-title">
384
+ <header class="ask-head">
385
+ <span class="ask-dot" aria-hidden="true">?</span>
386
+ <span id="ask-title" class="ask-title"></span>
387
+ <span class="ask-hint">Esc hides</span>
388
+ <button type="button" class="ask-x" data-ask="hide" title="Hide. The question stays open." aria-label="Hide">
389
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
390
+ </button>
391
+ </header>
392
+ <div class="ask-body"></div>
393
+ <footer class="ask-foot">
394
+ <span>The agent waits for your answer.</span>
395
+ <button type="button" data-ask="skip">Skip</button>
396
+ </footer>
397
+ </section>
398
+ </div>
378
399
  </div>
379
400
  <textarea id="terminal-key-proxy" class="terminal-key-proxy" tabindex="-1" aria-hidden="true"></textarea>
380
401
  </div>
@@ -557,13 +578,13 @@
557
578
  <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
579
  <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
580
  </button>
560
- <button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost" onclick="openCost(_infoModalSessionId)">
581
+ <button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost (Shift: Cost tab)" onclick="openAppForInfoModal(openCost, event)">
561
582
  <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
583
  </button>
563
- <button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace" onclick="openMarketplaceForInfoModal()">
584
+ <button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace (Shift: Marketplace tab)" onclick="openAppForInfoModal(openMarketplace, event)">
564
585
  <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
586
  </button>
566
- <button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory" onclick="openMemoryForInfoModal()">
587
+ <button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory (Shift: Memory tab)" onclick="openAppForInfoModal(openMemory, event)">
567
588
  <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
589
  </button>
569
590
  <button class="modal-close" aria-label="Close dialog" onclick="closeTeamModal()">