claude-code-kanban 7.19.0 → 7.21.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
@@ -1939,6 +1939,8 @@ const ICON_AGENT_ACTIVE =
1939
1939
  const ICON_TERMINAL =
1940
1940
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
1941
1941
  const TERMINAL_TITLE = 'Running in a terminal here';
1942
+ const ICON_FORK =
1943
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/></svg>';
1942
1944
  const ICON_DISPATCHED =
1943
1945
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>';
1944
1946
  const ICON_CHAT =
@@ -2808,7 +2810,7 @@ function isAnyModalOpen() {
2808
2810
 
2809
2811
  // An open modal owns the zoom keys even when it has nothing to scale.
2810
2812
  function isZoomableOnScreen() {
2811
- return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen() || isShowCardFocused();
2813
+ return isAnyModalOpen() ? isZoomableModalOpen() : isAskOnScreen() || isPaneDocOnScreen() || isShowCardFocused();
2812
2814
  }
2813
2815
 
2814
2816
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -3982,16 +3984,15 @@ function renderSessions() {
3982
3984
  return out;
3983
3985
  };
3984
3986
 
3985
- const renderSessionCard = (session) => {
3986
- if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
3987
+ const renderSessionCard = (session, hostProject = null) => {
3988
+ if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id), hostProject);
3987
3989
  const total = session.taskCount;
3988
3990
  const pct = (n) => Math.round((n / total) * 100);
3989
3991
  const isActive = session.id === currentSessionId && viewMode === 'session';
3990
3992
  const isLive = isSessionLive(session);
3991
3993
  const sessionName = sessionDisplayName(session);
3992
- const projectHtml = renderProjectIdentity(session);
3994
+ const projectHtml = renderProjectIdentity(session, hostProject);
3993
3995
 
3994
- const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
3995
3996
  const isTeam = session.isTeam;
3996
3997
  const memberCount = session.memberCount || 0;
3997
3998
 
@@ -4025,8 +4026,7 @@ function renderSessions() {
4025
4026
  <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
4026
4027
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
4027
4028
  <div class="session-name">${escapeHtml(sessionName)}</div>
4028
- ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
4029
- ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
4029
+ ${renderSessionIdentity(projectHtml, session.gitBranch)}
4030
4030
  ${metricsHtml ? `<div class="session-metrics">${metricsHtml}</div>` : ''}
4031
4031
  <div class="session-footer">
4032
4032
  <div class="session-time">${formatDate(session.modifiedAt)}</div>
@@ -4042,6 +4042,7 @@ function renderSessions() {
4042
4042
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
4043
4043
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
4044
4044
  ${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
4045
+ ${session.forkedFromSessionId ? `<span class="fork-badge" onclick="event.stopPropagation(); revealSession('${escAttrJs(session.forkedFromSessionId)}')" title="Forked from ${escapeHtml(session.forkedFromSessionId)}, click to reveal">${ICON_FORK}</span>` : ''}
4045
4046
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
4046
4047
  ${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
4047
4048
  ${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
@@ -4072,7 +4073,7 @@ function renderSessions() {
4072
4073
  SECTION_OUTSIDE,
4073
4074
  `Outside current filters ${countHtml(outside.rows)}`,
4074
4075
  false,
4075
- collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
4076
+ collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map((s) => renderSessionCard(s)).join(''),
4076
4077
  )
4077
4078
  : '';
4078
4079
 
@@ -4134,10 +4135,11 @@ function renderSessions() {
4134
4135
  const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
4135
4136
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
4136
4137
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
4137
- const renderGroupSessions = (list, pinKey) => {
4138
- if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
4138
+ const renderGroupSessions = (list, pinKey, hostProject = null) => {
4139
+ const card = (s) => renderSessionCard(s, hostProject);
4140
+ if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(card).join('');
4139
4141
  const gPinned = nestChildren(list.filter(isInPinnedGroup));
4140
- if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
4142
+ if (gPinned.length === 0) return nestChildren(list).map(card).join('');
4141
4143
  const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
4142
4144
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
4143
4145
  return (
@@ -4157,10 +4159,10 @@ function renderSessions() {
4157
4159
  '<div class="pinned-sub-items' +
4158
4160
  (pinCollapsed ? ' collapsed' : '') +
4159
4161
  '">' +
4160
- gPinned.map(renderSessionCard).join('') +
4162
+ gPinned.map(card).join('') +
4161
4163
  '</div>' +
4162
4164
  '</div>' +
4163
- gUnpinned.map(renderSessionCard).join('')
4165
+ gUnpinned.map(card).join('')
4164
4166
  );
4165
4167
  };
4166
4168
 
@@ -4192,7 +4194,7 @@ function renderSessions() {
4192
4194
  </div>
4193
4195
  <div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
4194
4196
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
4195
- ${renderGroupSessions(projectSessions, pinKey(projectPath))}
4197
+ ${renderGroupSessions(projectSessions, pinKey(projectPath), projectPath)}
4196
4198
  </div>
4197
4199
  `;
4198
4200
  };
@@ -4255,7 +4257,7 @@ function renderSessions() {
4255
4257
  let body = '';
4256
4258
  if (flat) {
4257
4259
  body = nestChildren([...bucket].sort(byMemberOrder))
4258
- .map(renderSessionCard)
4260
+ .map((s) => renderSessionCard(s))
4259
4261
  .join('');
4260
4262
  } else {
4261
4263
  const loose = [];
@@ -4361,7 +4363,7 @@ function renderSessions() {
4361
4363
  </div>
4362
4364
  `;
4363
4365
  } else {
4364
- projectsHtml += ungrouped.map(renderSessionCard).join('');
4366
+ projectsHtml += ungrouped.map((s) => renderSessionCard(s)).join('');
4365
4367
  }
4366
4368
  }
4367
4369
 
@@ -4372,7 +4374,7 @@ function renderSessions() {
4372
4374
  } else {
4373
4375
  const sectioned = sessionGroups.length > 0;
4374
4376
  const restHtml =
4375
- sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
4377
+ sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map((s) => renderSessionCard(s)).join('');
4376
4378
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
4377
4379
  sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
4378
4380
  }
@@ -4444,7 +4446,7 @@ function renderSession() {
4444
4446
  const completed = currentTasks.filter((t) => t.status === 'completed').length;
4445
4447
  const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
4446
4448
 
4447
- progressPercent.textContent = `${percent}%`;
4449
+ progressPercent.textContent = `${percent}% done`;
4448
4450
  progressBar.style.width = `${percent}%`;
4449
4451
  const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
4450
4452
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
@@ -4468,7 +4470,7 @@ function renderProjectView() {
4468
4470
  const completed = currentTasks.filter((t) => t.status === 'completed').length;
4469
4471
  const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
4470
4472
 
4471
- progressPercent.textContent = `${percent}%`;
4473
+ progressPercent.textContent = `${percent}% done`;
4472
4474
  progressBar.style.width = `${percent}%`;
4473
4475
  const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
4474
4476
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
@@ -9073,6 +9075,7 @@ function reviewOptsOfFrame(frame) {
9073
9075
 
9074
9076
  async function sendPreviewAction(frame, msg) {
9075
9077
  if (!navigator.userActivation?.isActive) return;
9078
+ if (frame === askState.frame) return answerAsk({ action: msg.action, label: msg.label, fields: msg.data });
9076
9079
  const opts = reviewOptsOfFrame(frame);
9077
9080
  if (!opts?.sessionId) return;
9078
9081
  const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
@@ -9446,7 +9449,7 @@ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = prev
9446
9449
  try {
9447
9450
  const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
9448
9451
  if (!r.ok) {
9449
- showToast('Preview file unavailable', 'error');
9452
+ showToast('Preview file unavailable', 'error', { label: 'Copy path', onClick: () => copyText(filePath, 'path') });
9450
9453
  return;
9451
9454
  }
9452
9455
  const data = await r.json();
@@ -10250,6 +10253,8 @@ function setupEventSource() {
10250
10253
 
10251
10254
  if (data.type === 'show:cleared') onShowCleared(data);
10252
10255
  if (data.type === 'show:removed') onShowRemoved(data);
10256
+ if (data.type === 'ask:opened') onAskOpened(data);
10257
+ if (data.type === 'ask:closed') onAskClosed(data);
10253
10258
 
10254
10259
  if (data.type === 'agent-update') {
10255
10260
  if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
@@ -10368,6 +10373,8 @@ const CONTEXT_COLORS = {
10368
10373
  red: 'var(--danger)',
10369
10374
  };
10370
10375
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
10376
+ // Status colors as small text measure 3.7–3.9:1 on light surfaces; bars and dots keep the pure color.
10377
+ const textTone = (color) => `color-mix(in srgb, ${color} 70%, var(--text-primary))`;
10371
10378
  const MODEL_THRESHOLDS = [
10372
10379
  { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
10373
10380
  { match: /haiku/i, yellow: 100000, orange: 130000, red: 150000 },
@@ -10474,7 +10481,7 @@ function renderContextBar(session) {
10474
10481
  ${renderMarkers(ctx.markers)}
10475
10482
  </div>
10476
10483
  <div class="context-bar-labels">
10477
- <span style="color:${color}">${Math.round(ctx.pct)}% (${used})</span>
10484
+ <span>ctx <span style="color:${textTone(color)}">${Math.round(ctx.pct)}% (${used})</span></span>
10478
10485
  <span>${Math.round(ctx.remaining)}% free</span>
10479
10486
  </div>
10480
10487
  </div>`;
@@ -10515,7 +10522,7 @@ function formatCacheLeft(expiresAt, expiredText) {
10515
10522
  if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
10516
10523
  const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
10517
10524
  const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
10518
- return { text, color };
10525
+ return { text, color: textTone(color) };
10519
10526
  }
10520
10527
 
10521
10528
  let cacheTimerInterval = null;
@@ -10584,7 +10591,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
10584
10591
  </div>
10585
10592
  </div>
10586
10593
  <div class="detail-context-summary">
10587
- <span style="color:${color}">${Math.round(ctx.pct)}% used</span>
10594
+ <span style="color:${textTone(color)}">${Math.round(ctx.pct)}% used</span>
10588
10595
  <span${ctx.compacts ? ` title="Auto-compact window · model window ${escapeHtml(formatTokens(ctx.modelSize / 1000))}"` : ''}>${formatTokens(ctx.usedTokens / 1000)} / ${formatTokens(ctx.size / 1000)}</span>
10589
10596
  </div>`
10590
10597
  : '';
@@ -10596,11 +10603,11 @@ function renderContextDetail(session, { tokens = true } = {}) {
10596
10603
  <div class="detail-context-stats">
10597
10604
  ${usageRows}
10598
10605
  ${totalRows}
10599
- ${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
10606
+ ${ctx ? `<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${textTone(getCostColor(cost.total_cost_usd))}">${formatCost(cost.total_cost_usd)}</span></div>` : ''}
10600
10607
  ${reply ? renderCacheTimer(reply) : ''}
10601
10608
  ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
10602
10609
  ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
10603
- ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
10610
+ ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${textTone(CONTEXT_COLORS.green)}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${textTone(CONTEXT_COLORS.red)}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
10604
10611
  </div>
10605
10612
  </div>`;
10606
10613
  }
@@ -10873,8 +10880,8 @@ function showWaitingDetail() {
10873
10880
  // With actions on screen the form/buttons are the content — tuck the raw
10874
10881
  // JSON behind a toggle, but keep what the user is approving visible: for a
10875
10882
  // permission ask surface the command (or description) as a one-line summary
10883
+ let summaryHtml = '';
10876
10884
  if ((actionsHtml || footerHtml) && inputHtml) {
10877
- let summaryHtml = '';
10878
10885
  if (currentWaiting.kind === 'plan' && typeof params?.plan === 'string' && params.plan) {
10879
10886
  // A plan ask IS the plan — render it in full so it can be reviewed and
10880
10887
  // approved right here (follow mode included) instead of a JSON dump. The
@@ -10897,7 +10904,8 @@ function showWaitingDetail() {
10897
10904
  autoSizeModal(modal, body);
10898
10905
  setMsgDetailFollow(msgDetailFollowLatest);
10899
10906
  const overlay = document.getElementById('msg-detail-modal');
10900
- const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
10907
+ // The permission card's header already shows how long the ask has waited.
10908
+ const meta = summaryHtml ? [] : [formatDate(currentWaiting.timestamp), 'waiting'];
10901
10909
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
10902
10910
  currentPinDetailId = null;
10903
10911
  updateMsgDetailPinState();
@@ -10927,17 +10935,34 @@ function pathBasename(p) {
10927
10935
 
10928
10936
  // `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
10929
10937
  // worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
10930
- function renderProjectIdentity(session) {
10938
+ // Under its own project group the card leaves the repo name out: the group header shows it.
10939
+ function renderProjectIdentity(session, hostProject) {
10931
10940
  if (!session.project) return null;
10941
+ const underHost = !!hostProject && sessionProjectKey(session) === hostProject;
10932
10942
  const wt = session.worktree;
10933
- if (!wt) return escapeHtml(pathBasename(session.project));
10943
+ if (!wt) {
10944
+ return underHost ? null : `<span class="session-project">${escapeHtml(pathBasename(session.project))}</span>`;
10945
+ }
10934
10946
  const repo = pathBasename(wt.repo);
10935
10947
  // Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
10936
10948
  // repo inside a chip that already sits next to it wastes the whole width of the card.
10937
10949
  const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
10938
- return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
10950
+ const chip = `<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
10951
+ return underHost ? chip : `<span class="session-project">${escapeHtml(repo)}</span>${chip}`;
10939
10952
  }
10940
10953
 
10954
+ function renderSessionIdentity(projectHtml, gitBranch) {
10955
+ if (!projectHtml && !gitBranch) return '';
10956
+ const branch = gitBranch
10957
+ ? `<span class="session-branch" title="${escapeHtml(gitBranch)}">${escapeHtml(gitBranch)}</span>`
10958
+ : '';
10959
+ const sep = projectHtml && branch ? '<span class="agent-ident-sep">·</span>' : '';
10960
+ return `<div class="session-secondary">${projectHtml || ''}${sep}${branch}</div>`;
10961
+ }
10962
+
10963
+ const SHORT_DATE = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
10964
+ const SHORT_DATE_YEAR = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
10965
+
10941
10966
  function formatDate(dateStr) {
10942
10967
  const date = new Date(dateStr);
10943
10968
  const now = new Date();
@@ -10946,7 +10971,8 @@ function formatDate(dateStr) {
10946
10971
  if (diff < JUST_NOW_MS) return 'just now';
10947
10972
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
10948
10973
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
10949
- return date.toLocaleDateString();
10974
+ if (diff < 7 * 86400000) return `${Math.floor(diff / 86400000)}d ago`;
10975
+ return (date.getFullYear() === now.getFullYear() ? SHORT_DATE : SHORT_DATE_YEAR).format(date);
10950
10976
  }
10951
10977
 
10952
10978
  // Terminal output reaches the transcript raw. A CSI-only pattern left OSC
@@ -11272,8 +11298,9 @@ function loadMermaid() {
11272
11298
  }
11273
11299
 
11274
11300
  let mermaidQueue = Promise.resolve();
11301
+ let mermaidSeq = 0;
11275
11302
 
11276
- // mermaid.run is async, and two runs that overlap draw diagrams over each other.
11303
+ // mermaid.render is async, and two renders that overlap draw diagrams over each other.
11277
11304
  function queueMermaid(work) {
11278
11305
  mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
11279
11306
  }
@@ -11300,10 +11327,15 @@ function initMermaidBlocks(container) {
11300
11327
  return;
11301
11328
  }
11302
11329
  queueMermaid(async () => {
11303
- // One block per run, so a bad diagram shows its own error and the others still render.
11330
+ // One block per render, so a bad diagram shows its own error and the others still render.
11331
+ // render, not run: run measures labels in place, and under a modal's CSS zoom the measured
11332
+ // widths are off, so node boxes clip their text.
11304
11333
  for (const b of stampPending()) {
11305
11334
  try {
11306
- await mermaid.run({ nodes: [b] });
11335
+ const { svg, bindFunctions } = await mermaid.render(`mermaid-${++mermaidSeq}`, b.textContent.trim());
11336
+ b.innerHTML = svg;
11337
+ b.setAttribute('data-processed', 'true');
11338
+ bindFunctions?.(b);
11307
11339
  } catch (err) {
11308
11340
  showMermaidError(b, err);
11309
11341
  }
@@ -12360,8 +12392,8 @@ function updateDismissBtnState() {
12360
12392
  const btn = document.getElementById('session-info-dismiss-btn');
12361
12393
  if (!btn || !_infoModalSessionId) return;
12362
12394
  const isDismissed = dismissedSessionIds.has(_infoModalSessionId);
12363
- btn.textContent = isDismissed ? 'Restore' : 'Dismiss';
12364
- btn.title = isDismissed ? 'Restore — show in active list again' : 'Dismiss — hide from active list';
12395
+ btn.textContent = isDismissed ? 'Show session' : 'Hide session';
12396
+ btn.title = isDismissed ? 'Show in the active list again' : 'Hide from the active list';
12365
12397
  }
12366
12398
 
12367
12399
  let _planSessionId = null;
@@ -15214,9 +15246,10 @@ function inPageField(e) {
15214
15246
  function terminalShortcut(e, probe = false) {
15215
15247
  const ctrlAlt = hubModDown(e);
15216
15248
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
15217
- // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
15249
+ // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal. An open ask card,
15250
+ // which sits in the terminal pane, takes them for its own text zoom.
15218
15251
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
15219
- if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
15252
+ if (zoom !== undefined && termFrame.inited && (probe || (terminalPaneFocused() && !isAskOnScreen()))) {
15220
15253
  return () => adjustTerminalFontSize(Math.sign(zoom));
15221
15254
  }
15222
15255
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
@@ -16417,6 +16450,177 @@ window.addEventListener('message', (e) => {
16417
16450
 
16418
16451
  //#endregion
16419
16452
 
16453
+ //#region ASK
16454
+ // The question the session in the embedded terminal asks with the ask tool; the tool call waits for
16455
+ // the answer. Hiding keeps the frame, so a half-filled card comes back as it was. Only an answer, a
16456
+ // skip or the agent's interrupt ends the question.
16457
+ const ASK_MSG = 'cck-ask:';
16458
+ const askState = { terminalId: null, ask: null, frame: null, frameH: 0, hidden: false, busy: false, seq: 0 };
16459
+
16460
+ // Runs inside the card as source text, ahead of the card's own scripts. The JSON round trip makes
16461
+ // the answer cloneable, and a value that is not JSON throws in the card.
16462
+ function askBridge() {
16463
+ window.cck = {
16464
+ send: (data) => parent.postMessage({ type: 'cck-ask:send', data: JSON.parse(JSON.stringify(data ?? null)) }, '*'),
16465
+ };
16466
+ }
16467
+
16468
+ const ASK_BRIDGE_TAG = `<script>(${askBridge})();</script>`;
16469
+
16470
+ function askEl() {
16471
+ return document.getElementById('ask-modal');
16472
+ }
16473
+
16474
+ function isAskOnScreen() {
16475
+ return !!askState.ask && !askEl().hidden;
16476
+ }
16477
+
16478
+ function syncAsk(tid) {
16479
+ if (tid === askState.terminalId) return;
16480
+ askState.terminalId = tid;
16481
+ setAsk(null);
16482
+ if (tid) loadAsk(tid);
16483
+ }
16484
+
16485
+ async function loadAsk(tid) {
16486
+ const seq = ++askState.seq;
16487
+ const data = await getJson(apiPath`/api/terminals/${tid}/ask`, null, { cache: 'no-store' });
16488
+ if (seq !== askState.seq || tid !== askState.terminalId) return;
16489
+ const a = data?.ask ?? null;
16490
+ if (a?.id !== askState.ask?.id) setAsk(a);
16491
+ }
16492
+
16493
+ function setAsk(a) {
16494
+ const modal = askEl();
16495
+ const body = modal.querySelector('.ask-body');
16496
+ Object.assign(askState, { ask: a, frame: null, frameH: 0, hidden: false, busy: false });
16497
+ body.replaceChildren();
16498
+ if (a) {
16499
+ modal.querySelector('.ask-title').textContent = a.title;
16500
+ modal.querySelector('.ask-box').dataset.size = a.size;
16501
+ if (a.content == null) {
16502
+ body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
16503
+ } else {
16504
+ const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, ASK_BRIDGE_TAG), SHOW_SANDBOX);
16505
+ frame.addEventListener('load', () => {
16506
+ if (askState.frame === frame && !askState.hidden) frame.focus();
16507
+ });
16508
+ askState.frame = frame;
16509
+ body.append(frame);
16510
+ }
16511
+ }
16512
+ renderAsk();
16513
+ }
16514
+
16515
+ function renderAsk() {
16516
+ const a = askState.ask;
16517
+ askEl().hidden = !a || askState.hidden;
16518
+ const chip = document.getElementById('ask-reopen');
16519
+ chip.hidden = !a || !askState.hidden;
16520
+ if (a) {
16521
+ chip.querySelector('.ask-reopen-n').textContent = a.title;
16522
+ chip.title = `Open the question: ${a.title}`;
16523
+ }
16524
+ layoutAsk();
16525
+ }
16526
+
16527
+ // A full card fills the modal; a smaller one takes its content's height, up to the terminal's.
16528
+ function layoutAsk() {
16529
+ const frame = askState.frame;
16530
+ const box = askEl().querySelector('.ask-box');
16531
+ if (!frame || askEl().hidden || box.dataset.size === 'full') return;
16532
+ const host = document.getElementById('terminal-host');
16533
+ const chrome = box.querySelector('.ask-head').offsetHeight + box.querySelector('.ask-foot').offsetHeight + 2;
16534
+ frame.style.height = `${Math.max(SHOW_MIN_H, Math.min(askState.frameH || SHOW_MIN_H, host.clientHeight - 32 - chrome))}px`;
16535
+ }
16536
+
16537
+ function hideAsk() {
16538
+ askState.hidden = true;
16539
+ renderAsk();
16540
+ focusTerminalPane();
16541
+ }
16542
+
16543
+ function reopenAsk() {
16544
+ askState.hidden = false;
16545
+ renderAsk();
16546
+ askState.frame?.focus();
16547
+ }
16548
+
16549
+ async function endAsk(body) {
16550
+ const a = askState.ask;
16551
+ const tid = askState.terminalId;
16552
+ if (!a || !tid || askState.busy) return;
16553
+ askState.busy = true;
16554
+ try {
16555
+ const res = await terminalFetch(apiPath`/api/terminals/${tid}/ask/${a.id}`, 'POST', body);
16556
+ if (res.status === 404) showToast('The question is no longer open');
16557
+ else if (!res.ok) {
16558
+ const out = await res.json().catch(() => ({}));
16559
+ return showToast(out.error || `Send failed (HTTP ${res.status})`, 'error');
16560
+ }
16561
+ if (askState.ask?.id !== a.id) return;
16562
+ setAsk(null);
16563
+ focusTerminalPane();
16564
+ } catch {
16565
+ showToast('Failed to send the answer', 'error');
16566
+ } finally {
16567
+ if (askState.ask?.id === a.id) askState.busy = false;
16568
+ }
16569
+ }
16570
+
16571
+ function answerAsk(data) {
16572
+ return endAsk({ status: 'answered', data });
16573
+ }
16574
+
16575
+ function onAskOpened(d) {
16576
+ if (typeof d.terminalId !== 'string') return;
16577
+ if (d.terminalId === askState.terminalId) return loadAsk(d.terminalId);
16578
+ if (askState.terminalId || !wantsTerminal()) return;
16579
+ if (!terminalIds.has(currentSessionId)) showState.lookup = null;
16580
+ syncShowCard();
16581
+ }
16582
+
16583
+ function onAskClosed(d) {
16584
+ if (d.terminalId !== askState.terminalId || askState.ask?.id !== d.id) return;
16585
+ if (d.status === 'cancelled') showToast('The agent stopped waiting for the answer');
16586
+ setAsk(null);
16587
+ }
16588
+
16589
+ // A script can send without a click, so a send needs transient user activation, as a card action does.
16590
+ window.addEventListener('message', (e) => {
16591
+ const frame = askState.frame;
16592
+ if (!frame || e.source !== frame.contentWindow || typeof e.data?.type !== 'string') return;
16593
+ if (e.data.type === `${ASK_MSG}send`) {
16594
+ if (navigator.userActivation?.isActive) answerAsk(e.data.data);
16595
+ } else if (e.data.type === `${SHOW_MSG}height`) {
16596
+ askState.frameH = Number(e.data.h) || askState.frameH;
16597
+ layoutAsk();
16598
+ }
16599
+ });
16600
+
16601
+ function initAsk() {
16602
+ const modal = askEl();
16603
+ modal.addEventListener('click', (e) => {
16604
+ const cmd = e.target === modal ? 'hide' : e.target.closest('[data-ask]')?.dataset.ask;
16605
+ if (cmd === 'hide') hideAsk();
16606
+ else if (cmd === 'skip') endAsk({ status: 'dismissed' });
16607
+ });
16608
+ document.getElementById('ask-reopen').addEventListener('click', reopenAsk);
16609
+ // Capture, ahead of the board's own Escape. The card's frame hands Escape back as a key on the frame.
16610
+ window.addEventListener(
16611
+ 'keydown',
16612
+ (e) => {
16613
+ if (e.key !== 'Escape' || modal.hidden || !modal.contains(e.target)) return;
16614
+ e.preventDefault();
16615
+ e.stopImmediatePropagation();
16616
+ hideAsk();
16617
+ },
16618
+ true,
16619
+ );
16620
+ window.addEventListener('resize', layoutAsk);
16621
+ }
16622
+ //#endregion
16623
+
16420
16624
  //#region SHOW
16421
16625
  // The card the session in the embedded terminal posts to with the show tool. It belongs to the
16422
16626
  // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
@@ -16629,6 +16833,7 @@ function syncShowCard() {
16629
16833
  loadTerminals().catch(() => {});
16630
16834
  }
16631
16835
  const tid = pty ? (terminalIds.get(pty) ?? null) : null;
16836
+ syncAsk(tid);
16632
16837
  if (tid === showState.terminalId) return;
16633
16838
  const view = (tid && showViews[tid]) || { collapsed: true };
16634
16839
  Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
@@ -16882,8 +17087,8 @@ function withHeadFirst(content, head) {
16882
17087
  const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
16883
17088
 
16884
17089
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
16885
- function showSrcdoc(content, restoreY) {
16886
- const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
17090
+ function showSrcdoc(content, restoreY, extraHead = '') {
17091
+ const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
16887
17092
  const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
16888
17093
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
16889
17094
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
@@ -16989,7 +17194,7 @@ new MutationObserver(() => {
16989
17194
 
16990
17195
  // Builds the message only when a frame is there, because showTokens forces a style recalc.
16991
17196
  function postToShowFrames(makeMsg) {
16992
- const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame')].filter(
17197
+ const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame, .ask-frame')].filter(
16993
17198
  (f) => f?.contentWindow,
16994
17199
  );
16995
17200
  if (!frames.length) return;
@@ -17292,13 +17497,13 @@ function forgetPlaceholder(id) {
17292
17497
  return true;
17293
17498
  }
17294
17499
 
17295
- function renderPlaceholderCard(session, depth) {
17500
+ function renderPlaceholderCard(session, depth, hostProject) {
17296
17501
  const isActive = session.id === currentSessionId && viewMode === 'session';
17297
- const projectHtml = renderProjectIdentity(session);
17502
+ const projectHtml = renderProjectIdentity(session, hostProject);
17298
17503
  return `
17299
17504
  <button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
17300
17505
  <div class="session-name">${escapeHtml(session.name)}</div>
17301
- ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
17506
+ ${renderSessionIdentity(projectHtml, null)}
17302
17507
  ${
17303
17508
  trustWaiting.has(session.id)
17304
17509
  ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
@@ -17929,6 +18134,7 @@ initSessionGroupsDnd();
17929
18134
  initSessionPicker();
17930
18135
  initProjectPicker();
17931
18136
  initShowCard();
18137
+ initAsk();
17932
18138
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
17933
18139
  initSidebarResize();
17934
18140
  applyModalZoom();
@@ -18010,7 +18216,13 @@ function makeLimitCell(label, bucket) {
18010
18216
  function makeLimitSpan(rl) {
18011
18217
  const span = document.createElement('span');
18012
18218
  span.className = 'footer-limits';
18013
- span.append(makeLimitCell('5h', rl?.five_hour), document.createTextNode(' · '), makeLimitCell('7d', rl?.seven_day));
18219
+ span.title = 'Plan usage: 5-hour and 7-day limits, used % (time until reset)';
18220
+ span.append(
18221
+ 'Usage ',
18222
+ makeLimitCell('5h', rl?.five_hour),
18223
+ document.createTextNode(' · '),
18224
+ makeLimitCell('7d', rl?.seven_day),
18225
+ );
18014
18226
  return span;
18015
18227
  }
18016
18228
  function renderSidebarFooter(rateLimits) {
package/public/index.html CHANGED
@@ -243,7 +243,7 @@
243
243
  <div class="progress-bar">
244
244
  <div id="progress-bar" class="progress-fill" style="width: 0%"></div>
245
245
  </div>
246
- <span id="progress-percent" class="progress-text">0%</span>
246
+ <span id="progress-percent" class="progress-text">0% done</span>
247
247
  </div>
248
248
  <button id="terminal-toggle" class="icon-btn" onclick="event.shiftKey ? openTerminalManager() : toggleTerminal()" title="Show / hide terminal (Ctrl+`) · Focus (Alt+`) · End (Alt+Shift+`) · Shift+click or Ctrl+Shift+` for all terminals" aria-label="Toggle terminal" style="display: none;">
249
249
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -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>
@@ -575,7 +596,7 @@
575
596
  </div>
576
597
  <div id="team-modal-body" class="modal-body modal-zoomable"></div>
577
598
  <div class="modal-footer">
578
- <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Dismiss</button>
599
+ <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Hide session</button>
579
600
  <button class="btn btn-secondary" onclick="showToolStatsModal(_infoModalSessionId)" title="Tool invocation statistics">Tool Stats</button>
580
601
  <button class="btn btn-secondary" onclick="closeTeamModal()">Close</button>
581
602
  </div>
@@ -706,7 +727,7 @@
706
727
  </div>
707
728
  <div id="agent-modal-body" class="modal-body modal-zoomable" style="overflow-y: auto; overflow-x: hidden; flex: 0 1 auto; min-height: 0;"></div>
708
729
  <div class="modal-footer">
709
- <button id="agent-modal-dismiss-btn" class="btn agent-dismiss-btn" style="display:none" onclick="dismissAgent(currentAgentModalId);closeAgentModal()">Dismiss</button>
730
+ <button id="agent-modal-dismiss-btn" class="btn btn-secondary" style="display:none" onclick="dismissAgent(currentAgentModalId);closeAgentModal()" title="Mark this agent as stopped">Mark stopped</button>
710
731
  <button class="btn btn-secondary" onclick="closeAgentModal()">Close</button>
711
732
  </div>
712
733
  </div>