claude-code-kanban 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/claude-dir.js +7 -1
- package/lib/git-branch.js +47 -0
- package/lib/net-guard.js +41 -14
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +12 -9
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +19 -5
- package/lib/worktrees.js +105 -0
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +661 -380
- package/public/index.html +7 -3
- package/public/project-match.js +6 -4
- package/public/style.css +258 -79
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +163 -145
- package/skill-guides/dispatch.md +4 -4
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/public/app.js
CHANGED
|
@@ -52,7 +52,12 @@ const collapsedProjectGroups = new Set();
|
|
|
52
52
|
const SECTION_GROUPS = '__section_groups__';
|
|
53
53
|
const SECTION_PROJECTS = '__section_projects__';
|
|
54
54
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
55
|
-
|
|
55
|
+
// Project paths and session ids in Active-view sidebar order, kept across reloads so live
|
|
56
|
+
// activity does not reshuffle the list. Each holds only what the loaded list still has.
|
|
57
|
+
const PROJECT_ORDER_KEY = 'project-order';
|
|
58
|
+
const SESSION_ORDER_KEY = 'session-order';
|
|
59
|
+
let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
|
|
60
|
+
let sessionOrder = readStoredList(SESSION_ORDER_KEY);
|
|
56
61
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
57
62
|
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
58
63
|
let sgReleased = new Set();
|
|
@@ -587,6 +592,9 @@ let lastCurrentTasksHash = '';
|
|
|
587
592
|
async function fetchTasks(sessionId) {
|
|
588
593
|
try {
|
|
589
594
|
const refresh = viewMode === 'session' && sessionId === currentSessionId;
|
|
595
|
+
// The PTY already runs, so the attach need not wait for the session fetch and the render after it.
|
|
596
|
+
if (!refresh && termState.sessionId !== sessionId && runningTerminals.has(sessionId) && wantsTerminalFor(sessionId))
|
|
597
|
+
openTerminal(sessionId, terminalOpenMode(sessionId));
|
|
590
598
|
viewMode = 'session';
|
|
591
599
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
592
600
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
@@ -1533,7 +1541,7 @@ function renderWaitingBody(tool, params) {
|
|
|
1533
1541
|
const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
|
|
1534
1542
|
|
|
1535
1543
|
// Answerable only when the marker carries a request id (written by
|
|
1536
|
-
//
|
|
1544
|
+
// the plugin's mod, which is polling for a decision) and the gate hasn't
|
|
1537
1545
|
// lapsed past its wait window — older or lapsed markers are only answerable
|
|
1538
1546
|
// in the terminal.
|
|
1539
1547
|
function isWaitingAnswerable() {
|
|
@@ -1592,9 +1600,9 @@ function rejectWaitingPlan(inputId) {
|
|
|
1592
1600
|
respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
|
|
1593
1601
|
}
|
|
1594
1602
|
|
|
1595
|
-
// UI-driven approvals: POST the decision the
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1603
|
+
// UI-driven approvals: POST the decision the plugin's mod is polling for.
|
|
1604
|
+
// First writer wins (D5) — a 409/410 means the ask was answered in the
|
|
1605
|
+
// terminal or superseded, so just drop the card.
|
|
1598
1606
|
async function respondWaiting(payload) {
|
|
1599
1607
|
if (!currentSessionId || !currentWaiting?.id) return;
|
|
1600
1608
|
try {
|
|
@@ -1694,6 +1702,8 @@ let pinnedCollapsed = false;
|
|
|
1694
1702
|
|
|
1695
1703
|
const PIN_SVG =
|
|
1696
1704
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
|
1705
|
+
const DETAIL_EDIT_SVG =
|
|
1706
|
+
'<svg class="detail-edit-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
|
1697
1707
|
const MSG_ICON_USER =
|
|
1698
1708
|
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
|
1699
1709
|
const MSG_ICON_ASSISTANT =
|
|
@@ -2043,6 +2053,7 @@ function isAnyPinned(sessionId) {
|
|
|
2043
2053
|
}
|
|
2044
2054
|
|
|
2045
2055
|
function _renderPinToDetail(pin) {
|
|
2056
|
+
unmountMsgReview();
|
|
2046
2057
|
const body = document.getElementById('msg-detail-body');
|
|
2047
2058
|
const agentBtn = document.getElementById('msg-detail-agent-btn');
|
|
2048
2059
|
agentBtn.style.display = 'none';
|
|
@@ -2258,11 +2269,10 @@ function showMsgDetail(idx) {
|
|
|
2258
2269
|
body.innerHTML = userExtras || '<em>No content</em>';
|
|
2259
2270
|
}
|
|
2260
2271
|
}
|
|
2261
|
-
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
2262
|
-
autoSizeModal(modal, body);
|
|
2263
|
-
modal.classList.toggle('live', msgDetailFollowLatest);
|
|
2264
2272
|
const overlay = document.getElementById('msg-detail-modal');
|
|
2265
|
-
overlay.
|
|
2273
|
+
const modal = overlay.querySelector('.modal');
|
|
2274
|
+
autoSizeModal(modal, body);
|
|
2275
|
+
setMsgDetailFollow(msgDetailFollowLatest);
|
|
2266
2276
|
|
|
2267
2277
|
const meta = [formatDate(m.timestamp)];
|
|
2268
2278
|
if (m.model) meta.unshift(m.model);
|
|
@@ -2271,9 +2281,34 @@ function showMsgDetail(idx) {
|
|
|
2271
2281
|
currentPinDetailId = null;
|
|
2272
2282
|
updateMsgDetailPinState();
|
|
2273
2283
|
overlay.classList.add('visible');
|
|
2284
|
+
mountReplyReview(m, body, modal);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function setMsgDetailFollow(on) {
|
|
2288
|
+
msgDetailFollowLatest = on;
|
|
2289
|
+
const overlay = document.getElementById('msg-detail-modal');
|
|
2290
|
+
overlay.classList.toggle('live-overlay', on);
|
|
2291
|
+
overlay.querySelector('.modal').classList.toggle('live', on);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function unmountMsgReview() {
|
|
2295
|
+
unmountReview(document.getElementById('msg-review-panel'));
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Only the main session's replies: the agent has them in context, so a quote is enough.
|
|
2299
|
+
function mountReplyReview(m, body, modal) {
|
|
2300
|
+
if (m.type !== 'assistant' || agentLogMode || !m.timestamp) return unmountMsgReview();
|
|
2301
|
+
mountReview({
|
|
2302
|
+
contentEl: body,
|
|
2303
|
+
panelEl: document.getElementById('msg-review-panel'),
|
|
2304
|
+
hostEl: modal,
|
|
2305
|
+
source: { kind: 'reply', id: m.timestamp, label: `your reply at ${new Date(m.timestamp).toLocaleTimeString()}` },
|
|
2306
|
+
onComment: () => setMsgDetailFollow(false),
|
|
2307
|
+
});
|
|
2274
2308
|
}
|
|
2275
2309
|
|
|
2276
2310
|
function closeMsgDetailModal() {
|
|
2311
|
+
unmountMsgReview();
|
|
2277
2312
|
hideModalOverlay('msg-detail-modal');
|
|
2278
2313
|
msgDetailFollowLatest = false;
|
|
2279
2314
|
waitingDetailAutoOpened = false;
|
|
@@ -2890,8 +2925,9 @@ function autoSizeModal(modal, body) {
|
|
|
2890
2925
|
// hasPre is already computed and short-circuits the textContent walk, which is
|
|
2891
2926
|
// no longer cheap now that colourised output puts thousands of spans in there.
|
|
2892
2927
|
const desired = hasTable ? 1100 : hasPre || body.textContent.length > 2000 ? 960 : 860;
|
|
2893
|
-
const
|
|
2894
|
-
|
|
2928
|
+
const style = getComputedStyle(modal);
|
|
2929
|
+
const current = (parseFloat(style.maxWidth) || 0) - (parseFloat(style.getPropertyValue('--review-extra')) || 0);
|
|
2930
|
+
if (desired > current) modal.style.maxWidth = `calc(${desired}px + var(--review-extra, 0px))`;
|
|
2895
2931
|
}
|
|
2896
2932
|
|
|
2897
2933
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
@@ -3400,7 +3436,7 @@ function getFilteredSessions() {
|
|
|
3400
3436
|
// above already makes, for the same reason.
|
|
3401
3437
|
filteredSessions = filteredSessions.filter(
|
|
3402
3438
|
(s) =>
|
|
3403
|
-
matchesProjectFilter(s.project) ||
|
|
3439
|
+
matchesProjectFilter(s.project, s.worktree?.repo) ||
|
|
3404
3440
|
s.id === currentSessionId ||
|
|
3405
3441
|
(filterProject === '__recent__' && isAnyPinned(s.id)),
|
|
3406
3442
|
);
|
|
@@ -3462,6 +3498,39 @@ function getFilteredSessions() {
|
|
|
3462
3498
|
return filteredSessions;
|
|
3463
3499
|
}
|
|
3464
3500
|
|
|
3501
|
+
function saveOrder(key, prev, next) {
|
|
3502
|
+
if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
|
|
3503
|
+
try {
|
|
3504
|
+
store.setItem(key, JSON.stringify(next));
|
|
3505
|
+
} catch (_) {}
|
|
3506
|
+
return next;
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// An entry keeps its slot while `loaded` has it; one not seen before enters at the top.
|
|
3510
|
+
function mergeOrder(key, prev, loaded, incoming) {
|
|
3511
|
+
const kept = prev.filter((id) => loaded.has(id));
|
|
3512
|
+
const known = new Set(kept);
|
|
3513
|
+
return saveOrder(key, prev, [...incoming.filter((id) => !known.has(id)), ...kept]);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
// Only the Active view groups into blocks; the other views stay in recency order.
|
|
3517
|
+
function applyStableSessionOrder(list) {
|
|
3518
|
+
if (sessionFilter !== 'active' || sessions.length === 0) return list;
|
|
3519
|
+
const loaded = new Set(sessions.map((s) => s.id));
|
|
3520
|
+
const ids = list.map((s) => s.id);
|
|
3521
|
+
sessionOrder = mergeOrder(SESSION_ORDER_KEY, sessionOrder, loaded, ids);
|
|
3522
|
+
const rank = new Map(sessionOrder.map((id, i) => [id, i]));
|
|
3523
|
+
return [...list].sort((a, b) => rank.get(a.id) - rank.get(b.id));
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
function insertNear(arr, isRef, item, after) {
|
|
3527
|
+
const at = arr.findIndex(isRef);
|
|
3528
|
+
if (at < 0) return null;
|
|
3529
|
+
const out = [...arr];
|
|
3530
|
+
out.splice(at + (after ? 1 : 0), 0, item);
|
|
3531
|
+
return out;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3465
3534
|
// A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
|
|
3466
3535
|
// would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
|
|
3467
3536
|
function zenPanelIsEditing() {
|
|
@@ -3478,7 +3547,7 @@ function renderSessions() {
|
|
|
3478
3547
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3479
3548
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3480
3549
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3481
|
-
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
|
|
3550
|
+
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
|
|
3482
3551
|
|
|
3483
3552
|
if (filteredSessions.length === 0) {
|
|
3484
3553
|
let emptyMsg = 'No sessions found';
|
|
@@ -3787,7 +3856,7 @@ function renderSessions() {
|
|
|
3787
3856
|
ungrouped.sort(pinSort);
|
|
3788
3857
|
}
|
|
3789
3858
|
|
|
3790
|
-
// Stable group order: preserve existing order,
|
|
3859
|
+
// Stable group order: preserve existing order, put new groups first, sorted by recency.
|
|
3791
3860
|
// Grouped projects stay in the array (they are just not rendered here), so dragging one
|
|
3792
3861
|
// back out restores its old slot instead of jumping to the top.
|
|
3793
3862
|
const latestByPath = new Map();
|
|
@@ -3796,11 +3865,9 @@ function renderSessions() {
|
|
|
3796
3865
|
const t = new Date(s.modifiedAt).getTime();
|
|
3797
3866
|
latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
|
|
3798
3867
|
}
|
|
3799
|
-
const
|
|
3800
|
-
const
|
|
3801
|
-
|
|
3802
|
-
if (newPaths.length > 1) newPaths.sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3803
|
-
stableGroupOrder = [...keptOrder, ...newPaths];
|
|
3868
|
+
const byRecency = [...latestByPath.keys()].sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
|
|
3869
|
+
const loadedPaths = new Set(sessions.map((s) => s.project));
|
|
3870
|
+
stableGroupOrder = mergeOrder(PROJECT_ORDER_KEY, stableGroupOrder, loadedPaths, byRecency);
|
|
3804
3871
|
const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
|
|
3805
3872
|
|
|
3806
3873
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
@@ -3941,11 +4008,13 @@ function renderProjectView() {
|
|
|
3941
4008
|
}
|
|
3942
4009
|
|
|
3943
4010
|
// Claude Code keeps a blocker's id in blockedBy after the blocker completes.
|
|
3944
|
-
function
|
|
4011
|
+
function findSiblingTask(task, id) {
|
|
3945
4012
|
const list = (t) => t.sessionId || t._taskDir;
|
|
3946
|
-
return (
|
|
3947
|
-
|
|
3948
|
-
|
|
4013
|
+
return currentTasks.find((t) => t.id === id && list(t) === list(task));
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
function openBlockers(task) {
|
|
4017
|
+
return (task.blockedBy || []).filter((id) => findSiblingTask(task, id)?.status !== 'completed');
|
|
3949
4018
|
}
|
|
3950
4019
|
|
|
3951
4020
|
function renderTaskCard(task) {
|
|
@@ -3970,7 +4039,7 @@ function renderTaskCard(task) {
|
|
|
3970
4039
|
aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
|
|
3971
4040
|
<div class="task-id">
|
|
3972
4041
|
<span>#${taskId}</span>
|
|
3973
|
-
${isBlocked ?
|
|
4042
|
+
${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
|
|
3974
4043
|
${
|
|
3975
4044
|
task.owner
|
|
3976
4045
|
? (
|
|
@@ -3984,7 +4053,6 @@ function renderTaskCard(task) {
|
|
|
3984
4053
|
</div>
|
|
3985
4054
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3986
4055
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3987
|
-
${isBlocked ? `<div class="task-blocked">Waiting on ${blockers.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3988
4056
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
3989
4057
|
</div>
|
|
3990
4058
|
`;
|
|
@@ -4545,19 +4613,70 @@ function sgCommitRename(input, save) {
|
|
|
4545
4613
|
//#region SESSION_GROUPS_DND
|
|
4546
4614
|
// At most one zone is lit, so the reference is enough - dragover fires many times a second.
|
|
4547
4615
|
let sgLitZone = null;
|
|
4616
|
+
let sgDragEl = null;
|
|
4617
|
+
const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
|
|
4618
|
+
const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
|
|
4548
4619
|
|
|
4549
4620
|
function sgClearDropTargets() {
|
|
4550
|
-
sgLitZone?.classList.remove(
|
|
4621
|
+
sgLitZone?.classList.remove(...SG_LIT_CLASSES);
|
|
4551
4622
|
sgLitZone = null;
|
|
4552
4623
|
}
|
|
4553
4624
|
|
|
4554
|
-
function sgLightDropTarget(zone) {
|
|
4555
|
-
if (sgLitZone === zone) return;
|
|
4625
|
+
function sgLightDropTarget(zone, cls = 'sg-drop-over') {
|
|
4626
|
+
if (sgLitZone === zone && zone.classList.contains(cls)) return;
|
|
4556
4627
|
sgClearDropTargets();
|
|
4557
|
-
zone.classList.add(
|
|
4628
|
+
zone.classList.add(cls);
|
|
4558
4629
|
sgLitZone = zone;
|
|
4559
4630
|
}
|
|
4560
4631
|
|
|
4632
|
+
// A session dropped on the edge of a card in its own list moves next to it. `share` is the part
|
|
4633
|
+
// of the card's height on each edge that means insert; the middle keeps its group gesture.
|
|
4634
|
+
function sgInsertHit(card, e, share) {
|
|
4635
|
+
const rect = card.getBoundingClientRect();
|
|
4636
|
+
const y = e.clientY - rect.top;
|
|
4637
|
+
const after = y > rect.height * (1 - share);
|
|
4638
|
+
if (!after && y >= rect.height * share) return null;
|
|
4639
|
+
return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4642
|
+
function sgSiblingCard(target) {
|
|
4643
|
+
if (sgDrag?.kind !== 'session' || sessionFilter !== 'active' || !sgDragEl) return null;
|
|
4644
|
+
const card = target.closest('.session-item');
|
|
4645
|
+
return card && card !== sgDragEl && card.parentElement === sgDragEl.parentElement ? card : null;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
function sgDropZone(target, e) {
|
|
4649
|
+
const sibling = sgSiblingCard(target);
|
|
4650
|
+
const hit = sgGroupDropZone(target);
|
|
4651
|
+
// Where the middle of a card does nothing, the whole card is an insert target.
|
|
4652
|
+
return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
function sgInsertSession(dragId, { card, after }) {
|
|
4656
|
+
const ref = card.dataset.sessionId;
|
|
4657
|
+
const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
|
|
4658
|
+
if (group && sgGroupOf('session', dragId)?.id === group.id) {
|
|
4659
|
+
// Loose sessions in a named group follow the group's member order, not the sidebar order.
|
|
4660
|
+
const members = insertNear(
|
|
4661
|
+
group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
|
|
4662
|
+
(m) => m.type === 'session' && m.ref === ref,
|
|
4663
|
+
{ type: 'session', ref: dragId },
|
|
4664
|
+
after,
|
|
4665
|
+
);
|
|
4666
|
+
if (!members) return;
|
|
4667
|
+
group.members = members;
|
|
4668
|
+
persistSessionGroups();
|
|
4669
|
+
return;
|
|
4670
|
+
}
|
|
4671
|
+
const order = insertNear(
|
|
4672
|
+
sessionOrder.filter((id) => id !== dragId),
|
|
4673
|
+
(id) => id === ref,
|
|
4674
|
+
dragId,
|
|
4675
|
+
after,
|
|
4676
|
+
);
|
|
4677
|
+
if (order) sessionOrder = saveOrder(SESSION_ORDER_KEY, sessionOrder, order);
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4561
4680
|
function sgDragPayload(el) {
|
|
4562
4681
|
if (el.classList.contains('session-group-header')) return { kind: 'group', ref: el.dataset.groupId };
|
|
4563
4682
|
// data-project-path is on real project headers only, so the sentinels drop out by themselves.
|
|
@@ -4571,7 +4690,7 @@ function sgDragPayload(el) {
|
|
|
4571
4690
|
// Resolve the element under the pointer to a legal drop zone for the in-flight drag.
|
|
4572
4691
|
// Returns {zone} to ungroup, {zone, groupId} to assign, or {zone, pairWith}/{zone, pairSession}
|
|
4573
4692
|
// to stack two ungrouped items into a brand-new group.
|
|
4574
|
-
function
|
|
4693
|
+
function sgGroupDropZone(target) {
|
|
4575
4694
|
if (!sgDrag) return null;
|
|
4576
4695
|
const base =
|
|
4577
4696
|
'.sg-ungroup-zone, .session-group-header, .session-group-sessions, .project-group-header, .project-group-sessions';
|
|
@@ -4655,6 +4774,7 @@ function sgOnDragStart(e) {
|
|
|
4655
4774
|
return;
|
|
4656
4775
|
}
|
|
4657
4776
|
sgDrag = payload;
|
|
4777
|
+
sgDragEl = el;
|
|
4658
4778
|
el.classList.add('sg-dragging');
|
|
4659
4779
|
sessionsList.classList.add('sg-dragging-active');
|
|
4660
4780
|
e.dataTransfer.effectAllowed = 'move';
|
|
@@ -4664,7 +4784,7 @@ function sgOnDragStart(e) {
|
|
|
4664
4784
|
}
|
|
4665
4785
|
|
|
4666
4786
|
function sgOnDragOver(e) {
|
|
4667
|
-
const hit = sgDropZone(e.target);
|
|
4787
|
+
const hit = sgDropZone(e.target, e);
|
|
4668
4788
|
// Moving onto a spot that refuses the drop has to put the previous highlight out, or two zones
|
|
4669
4789
|
// stay lit and the one under the pointer is not the one that would receive the drop.
|
|
4670
4790
|
if (!hit) {
|
|
@@ -4673,15 +4793,20 @@ function sgOnDragOver(e) {
|
|
|
4673
4793
|
}
|
|
4674
4794
|
e.preventDefault();
|
|
4675
4795
|
e.dataTransfer.dropEffect = 'move';
|
|
4676
|
-
sgLightDropTarget(hit.zone);
|
|
4796
|
+
sgLightDropTarget(hit.zone, hit.cls);
|
|
4677
4797
|
}
|
|
4678
4798
|
|
|
4679
4799
|
function sgOnDrop(e) {
|
|
4680
|
-
const hit = sgDropZone(e.target);
|
|
4800
|
+
const hit = sgDropZone(e.target, e);
|
|
4681
4801
|
if (!hit) return;
|
|
4682
4802
|
e.preventDefault();
|
|
4683
4803
|
e.stopPropagation();
|
|
4684
4804
|
const drag = sgDrag;
|
|
4805
|
+
if (hit.insert) {
|
|
4806
|
+
sgInsertSession(drag.ref, hit.insert);
|
|
4807
|
+
sgFinishDrag();
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4685
4810
|
if (hit.pairWith || hit.pairSession) {
|
|
4686
4811
|
// Stacked onto an ungrouped item: seed a group, then let the user name it in place.
|
|
4687
4812
|
const target = hit.pairWith
|
|
@@ -4712,6 +4837,7 @@ function sgOnDrop(e) {
|
|
|
4712
4837
|
|
|
4713
4838
|
function sgFinishDrag() {
|
|
4714
4839
|
sgDrag = null;
|
|
4840
|
+
sgDragEl = null;
|
|
4715
4841
|
sgClearDropTargets();
|
|
4716
4842
|
sessionsList.classList.remove('sg-dragging-active');
|
|
4717
4843
|
// biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
|
|
@@ -4732,7 +4858,7 @@ function initSessionGroupsDnd() {
|
|
|
4732
4858
|
sessionsList.addEventListener('dragstart', sgOnDragStart);
|
|
4733
4859
|
sessionsList.addEventListener('dragover', sgOnDragOver);
|
|
4734
4860
|
sessionsList.addEventListener('dragleave', (e) => {
|
|
4735
|
-
const zone = e.target.closest?.(
|
|
4861
|
+
const zone = e.target.closest?.(SG_LIT_SELECTOR);
|
|
4736
4862
|
if (zone && !zone.contains(e.relatedTarget)) sgClearDropTargets();
|
|
4737
4863
|
});
|
|
4738
4864
|
sessionsList.addEventListener('drop', sgOnDrop);
|
|
@@ -5349,22 +5475,33 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5349
5475
|
const blockers = openBlockers(task);
|
|
5350
5476
|
const isBlocked = blockers.length > 0;
|
|
5351
5477
|
const actualSessionId = task.sessionId || sessionId || currentSessionId;
|
|
5352
|
-
|
|
5478
|
+
const isProjectView = viewMode === 'project';
|
|
5479
|
+
const pencil = isProjectView ? '' : DETAIL_EDIT_SVG;
|
|
5480
|
+
const editable = isProjectView ? '' : ' editable';
|
|
5481
|
+
const depChip = (id, kind) => {
|
|
5482
|
+
const dep = findSiblingTask(task, id);
|
|
5483
|
+
const label = dep ? `#${id} ${dep.subject}` : `#${id}`;
|
|
5484
|
+
return `<button type="button" class="detail-dep ${escapeHtml(kind)}" data-dep-id="${escapeHtml(id)}" title="${escapeHtml(label)}"><span class="dot"></span><span class="detail-dep-text">${escapeHtml(label)}</span></button>`;
|
|
5485
|
+
};
|
|
5486
|
+
const deps = [
|
|
5487
|
+
isBlocked ? ['wait', 'Waiting on', blockers] : null,
|
|
5488
|
+
task.blocks?.length ? ['next', 'Unblocks', task.blocks] : null,
|
|
5489
|
+
].filter(Boolean);
|
|
5490
|
+
detailContent.classList.toggle('blocked', isBlocked && task.status === 'pending');
|
|
5353
5491
|
detailContent.innerHTML = `
|
|
5354
5492
|
<div class="detail-section">
|
|
5355
|
-
<div class="detail-label">Task #${task.id}</div>
|
|
5356
|
-
<h2 class="detail-title">${escapeHtml(task.subject)}</h2>
|
|
5493
|
+
<div class="detail-label">Task #${task.id}${pencil}</div>
|
|
5494
|
+
<h2 class="detail-title${escapeHtml(editable)}">${escapeHtml(task.subject)}</h2>
|
|
5357
5495
|
</div>
|
|
5358
5496
|
|
|
5359
5497
|
<div class="detail-section" style="display: flex; gap: 12px; align-items: center;">
|
|
5360
5498
|
<div>${statusLabels[task.status] || ''}</div>
|
|
5361
5499
|
${task.owner ? `<div style="font-size: 13px; color: ${getOwnerColor(task.owner).color}; font-weight: 500;">${escapeHtml(task.owner)}</div>` : ''}
|
|
5362
|
-
${isBlocked && task.status !== 'in_progress' ? '<div style="font-size: 10px; color: var(--warning);">Blocked</div>' : ''}
|
|
5363
5500
|
</div>
|
|
5364
5501
|
|
|
5365
5502
|
<div class="detail-section">
|
|
5366
|
-
<div class="detail-label">Description</div>
|
|
5367
|
-
<div class="detail-desc">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
|
|
5503
|
+
<div class="detail-label">Description${pencil}</div>
|
|
5504
|
+
<div class="detail-desc${escapeHtml(editable)}">${task.description ? renderMarkdown(task.description) : '<em style="color: var(--text-muted);">No description</em>'}</div>
|
|
5368
5505
|
</div>
|
|
5369
5506
|
|
|
5370
5507
|
${
|
|
@@ -5380,33 +5517,24 @@ async function showTaskDetail(taskId, sessionId = null) {
|
|
|
5380
5517
|
}
|
|
5381
5518
|
|
|
5382
5519
|
${
|
|
5383
|
-
|
|
5520
|
+
deps.length
|
|
5384
5521
|
? `
|
|
5385
5522
|
<div class="detail-section">
|
|
5386
|
-
<div class="detail-label">
|
|
5523
|
+
<div class="detail-label">Dependencies</div>
|
|
5387
5524
|
<div class="detail-deps">
|
|
5388
|
-
|
|
5389
|
-
</div>
|
|
5390
|
-
</div>`
|
|
5391
|
-
: ''
|
|
5392
|
-
}
|
|
5393
|
-
|
|
5394
|
-
${
|
|
5395
|
-
task.blocks && task.blocks.length > 0
|
|
5396
|
-
? `
|
|
5397
|
-
<div class="detail-section">
|
|
5398
|
-
<div class="detail-label">Blocks</div>
|
|
5399
|
-
<div class="detail-deps">
|
|
5400
|
-
<div class="detail-box blocks"><strong>Blocks:</strong> ${task.blocks.map((id) => `#${id}`).join(', ')}</div>
|
|
5525
|
+
${deps.map(([kind, label, ids]) => `<span class="detail-deps-label ${escapeHtml(kind)}">${label}</span><div class="detail-deps-list">${ids.map((id) => depChip(id, kind)).join('')}</div>`).join('')}
|
|
5401
5526
|
</div>
|
|
5402
5527
|
</div>`
|
|
5403
5528
|
: ''
|
|
5404
5529
|
}
|
|
5405
5530
|
`;
|
|
5406
5531
|
|
|
5532
|
+
for (const chip of detailContent.querySelectorAll('.detail-dep')) {
|
|
5533
|
+
chip.onclick = () => showTaskDetail(chip.dataset.depId, actualSessionId);
|
|
5534
|
+
}
|
|
5535
|
+
|
|
5407
5536
|
// Setup button handlers (read-only in project view)
|
|
5408
5537
|
const deleteBtn = document.getElementById('delete-task-btn');
|
|
5409
|
-
const isProjectView = viewMode === 'project';
|
|
5410
5538
|
deleteBtn.style.display = isProjectView ? 'none' : '';
|
|
5411
5539
|
if (!isProjectView) deleteBtn.onclick = () => deleteTask(task.id, actualSessionId);
|
|
5412
5540
|
|
|
@@ -5439,7 +5567,7 @@ function editTitle(titleEl, task, sessionId) {
|
|
|
5439
5567
|
if (val && val !== task.subject) {
|
|
5440
5568
|
await saveTaskField(task.id, sessionId, 'subject', val);
|
|
5441
5569
|
} else {
|
|
5442
|
-
showTaskDetail(task.id, sessionId);
|
|
5570
|
+
await showTaskDetail(task.id, sessionId);
|
|
5443
5571
|
}
|
|
5444
5572
|
};
|
|
5445
5573
|
|
|
@@ -5449,6 +5577,18 @@ function editTitle(titleEl, task, sessionId) {
|
|
|
5449
5577
|
save();
|
|
5450
5578
|
}
|
|
5451
5579
|
if (e.key === 'Escape') showTaskDetail(task.id, sessionId);
|
|
5580
|
+
// Saving re-renders the panel and drops the input, so native Tab would land on <body>.
|
|
5581
|
+
if (e.key === 'Tab') {
|
|
5582
|
+
e.preventDefault();
|
|
5583
|
+
input.onblur = null;
|
|
5584
|
+
const shift = e.shiftKey;
|
|
5585
|
+
save().then(() => {
|
|
5586
|
+
const target = shift
|
|
5587
|
+
? document.getElementById('delete-task-btn')
|
|
5588
|
+
: detailContent.querySelector('.detail-dep') || document.getElementById('close-detail');
|
|
5589
|
+
target?.focus();
|
|
5590
|
+
});
|
|
5591
|
+
}
|
|
5452
5592
|
};
|
|
5453
5593
|
input.onblur = () => save();
|
|
5454
5594
|
}
|
|
@@ -5488,8 +5628,10 @@ function editDescription(descEl, task, sessionId) {
|
|
|
5488
5628
|
|
|
5489
5629
|
saveBtn.onclick = save;
|
|
5490
5630
|
cancelBtn.onclick = () => showTaskDetail(task.id, sessionId);
|
|
5491
|
-
|
|
5492
|
-
if (e.key
|
|
5631
|
+
wrapper.onkeydown = (e) => {
|
|
5632
|
+
if (e.key !== 'Escape') return;
|
|
5633
|
+
e.stopPropagation();
|
|
5634
|
+
showTaskDetail(task.id, sessionId);
|
|
5493
5635
|
};
|
|
5494
5636
|
}
|
|
5495
5637
|
|
|
@@ -5504,7 +5646,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
5504
5646
|
if (res.ok) {
|
|
5505
5647
|
lastCurrentTasksHash = null;
|
|
5506
5648
|
await fetchTasks(sessionId);
|
|
5507
|
-
showTaskDetail(taskId, sessionId);
|
|
5649
|
+
await showTaskDetail(taskId, sessionId);
|
|
5508
5650
|
}
|
|
5509
5651
|
} catch (error) {
|
|
5510
5652
|
console.error('Failed to update task:', error);
|
|
@@ -6463,11 +6605,21 @@ function dismissSessionFromList(sid) {
|
|
|
6463
6605
|
if (pinnedIdx >= 0) selectSessionByIndex(pinnedIdx, newItems);
|
|
6464
6606
|
return;
|
|
6465
6607
|
}
|
|
6608
|
+
// Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
|
|
6609
|
+
const forget = (x) => (x === sid ? null : x);
|
|
6610
|
+
const fallback = currentSessionId === sid && lastSessionId === sid ? previousSessionId : null;
|
|
6611
|
+
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
6466
6612
|
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6467
6613
|
// If the dismissed session is currently open, navigate to the previous one
|
|
6468
6614
|
if (currentSessionId === sid || selectedSessionId === sid) {
|
|
6469
6615
|
selectedSessionId = null;
|
|
6470
|
-
if (
|
|
6616
|
+
if (fallback && !dismissedSessionIds.has(fallback) && sessions.some((s) => s.id === fallback)) {
|
|
6617
|
+
revealSession(fallback).then(() => {
|
|
6618
|
+
const items = getNavigableItems();
|
|
6619
|
+
const idx = items.findIndex((el) => el.dataset?.sessionId === fallback);
|
|
6620
|
+
if (idx >= 0) selectSessionByIndex(idx, items);
|
|
6621
|
+
});
|
|
6622
|
+
} else if (targetIdx >= 0) {
|
|
6471
6623
|
const targetSid = newItems[targetIdx]?.dataset?.sessionId;
|
|
6472
6624
|
if (targetSid) {
|
|
6473
6625
|
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
@@ -6483,16 +6635,28 @@ function dismissSessionFromList(sid) {
|
|
|
6483
6635
|
}
|
|
6484
6636
|
}
|
|
6485
6637
|
|
|
6486
|
-
async function
|
|
6638
|
+
async function confirmDismissBlockers(sid) {
|
|
6639
|
+
const sticky = stickySessionIds.has(sid);
|
|
6640
|
+
const terminal = runningTerminals.has(sid);
|
|
6641
|
+
if (!sticky && !terminal) return true;
|
|
6487
6642
|
const session = sessions.find((s) => s.id === sid);
|
|
6488
|
-
const
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6643
|
+
const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
|
|
6644
|
+
const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
|
|
6645
|
+
const [title, verb, okLabel] =
|
|
6646
|
+
sticky && terminal
|
|
6647
|
+
? ['Unpin and Close Terminal', 'Unpin, close the terminal', 'Unpin, close and dismiss']
|
|
6648
|
+
: sticky
|
|
6649
|
+
? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
|
|
6650
|
+
: ['Close Terminal', 'Close the terminal', 'Close and dismiss'];
|
|
6651
|
+
const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
|
|
6652
|
+
if (!ok) return false;
|
|
6653
|
+
if (sticky) unpinSession(sid);
|
|
6654
|
+
if (terminal) closeTerminalSession(sid);
|
|
6655
|
+
return true;
|
|
6656
|
+
}
|
|
6657
|
+
|
|
6658
|
+
async function dismissSession(sid) {
|
|
6659
|
+
if (await confirmDismissBlockers(sid)) dismissSessionFromList(sid);
|
|
6496
6660
|
}
|
|
6497
6661
|
|
|
6498
6662
|
const MODAL_ESC_PRIORITY = [
|
|
@@ -6678,6 +6842,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6678
6842
|
}
|
|
6679
6843
|
|
|
6680
6844
|
// Tab toggles focus zone
|
|
6845
|
+
if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
|
|
6681
6846
|
if (e.key === 'Tab') {
|
|
6682
6847
|
e.preventDefault();
|
|
6683
6848
|
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
@@ -6829,7 +6994,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6829
6994
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
6830
6995
|
e.preventDefault();
|
|
6831
6996
|
if (!contextSid) return;
|
|
6832
|
-
(
|
|
6997
|
+
dismissSession(contextSid);
|
|
6833
6998
|
return;
|
|
6834
6999
|
}
|
|
6835
7000
|
if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
@@ -6894,9 +7059,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6894
7059
|
const reviewDrafts = new Map();
|
|
6895
7060
|
const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
6896
7061
|
const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
|
|
7062
|
+
const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
|
|
6897
7063
|
if (reviewHighlight) {
|
|
6898
7064
|
CSS.highlights.set('review', reviewHighlight);
|
|
6899
7065
|
CSS.highlights.set('review-hot', reviewHotHighlight);
|
|
7066
|
+
CSS.highlights.set('review-pending', reviewPendingHighlight);
|
|
6900
7067
|
}
|
|
6901
7068
|
const REVIEW_MSG = 'cck-review:';
|
|
6902
7069
|
let activeReview = null;
|
|
@@ -6906,7 +7073,7 @@ let reviewHoverAt = null;
|
|
|
6906
7073
|
let reviewHoverFrame = 0;
|
|
6907
7074
|
|
|
6908
7075
|
function reviewKey(sessionId, source) {
|
|
6909
|
-
return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.label}`;
|
|
7076
|
+
return `${sessionId}|${source.kind}|${source.path ? canonicalPath(source.path) : source.id || source.label}`;
|
|
6910
7077
|
}
|
|
6911
7078
|
|
|
6912
7079
|
function reviewItems() {
|
|
@@ -6914,8 +7081,18 @@ function reviewItems() {
|
|
|
6914
7081
|
}
|
|
6915
7082
|
|
|
6916
7083
|
// `context(range)` adds view-specific citation fields to a comment on the host DOM;
|
|
6917
|
-
// `onSent()` runs after a batch is delivered.
|
|
6918
|
-
function mountReview({
|
|
7084
|
+
// `onComment()` runs after a comment is added; `onSent()` runs after a batch is delivered.
|
|
7085
|
+
function mountReview({
|
|
7086
|
+
contentEl,
|
|
7087
|
+
panelEl,
|
|
7088
|
+
hostEl,
|
|
7089
|
+
source,
|
|
7090
|
+
frame,
|
|
7091
|
+
context,
|
|
7092
|
+
onComment,
|
|
7093
|
+
onSent,
|
|
7094
|
+
sessionId = currentSessionId,
|
|
7095
|
+
}) {
|
|
6919
7096
|
unmountReview();
|
|
6920
7097
|
if (!sessionId) return;
|
|
6921
7098
|
const ctl = new AbortController();
|
|
@@ -6928,6 +7105,7 @@ function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSen
|
|
|
6928
7105
|
source,
|
|
6929
7106
|
frame,
|
|
6930
7107
|
context,
|
|
7108
|
+
onComment,
|
|
6931
7109
|
onSent,
|
|
6932
7110
|
sessionId,
|
|
6933
7111
|
key,
|
|
@@ -6940,6 +7118,7 @@ function mountReview({ contentEl, panelEl, hostEl, source, frame, context, onSen
|
|
|
6940
7118
|
}
|
|
6941
7119
|
window.addEventListener('message', onReviewFrameMessage, { signal });
|
|
6942
7120
|
document.addEventListener('mousedown', closeReviewPop, { signal });
|
|
7121
|
+
document.addEventListener('keydown', onReviewOfferKey, { capture: true, signal });
|
|
6943
7122
|
document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
|
|
6944
7123
|
panelEl.addEventListener('click', onReviewPanelClick, { signal });
|
|
6945
7124
|
panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
|
|
@@ -7068,12 +7247,14 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7068
7247
|
let hoverFrame = 0;
|
|
7069
7248
|
const hl = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
|
|
7070
7249
|
const hot = hl ? new Highlight() : null;
|
|
7250
|
+
const pend = hl ? new Highlight() : null;
|
|
7071
7251
|
if (hl) {
|
|
7072
7252
|
CSS.highlights.set('cck-review', hl);
|
|
7073
7253
|
CSS.highlights.set('cck-review-hot', hot);
|
|
7254
|
+
CSS.highlights.set('cck-review-pending', pend);
|
|
7074
7255
|
const style = document.createElement('style');
|
|
7075
7256
|
style.textContent =
|
|
7076
|
-
'::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot){background-color:rgba(240,180,41,.7)}';
|
|
7257
|
+
'::highlight(cck-review){background-color:rgba(240,180,41,.35)}::highlight(cck-review-hot),::highlight(cck-review-pending){background-color:rgba(240,180,41,.7)}';
|
|
7077
7258
|
(document.head || document.documentElement).append(style);
|
|
7078
7259
|
}
|
|
7079
7260
|
const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
|
|
@@ -7143,6 +7324,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt) {
|
|
|
7143
7324
|
hl?.add(pending);
|
|
7144
7325
|
pending = null;
|
|
7145
7326
|
getSelection().removeAllRanges();
|
|
7327
|
+
} else if (type === 'pend' && pending) {
|
|
7328
|
+
pend?.add(pending);
|
|
7329
|
+
} else if (type === 'unpend') {
|
|
7330
|
+
pend?.clear();
|
|
7146
7331
|
} else if (type === 'hot') {
|
|
7147
7332
|
hot?.clear();
|
|
7148
7333
|
if (range) hot?.add(range);
|
|
@@ -7172,12 +7357,16 @@ function reviewPopEl() {
|
|
|
7172
7357
|
|
|
7173
7358
|
function placeReviewPop(el, r) {
|
|
7174
7359
|
el.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - el.offsetWidth - 8))}px`;
|
|
7175
|
-
const
|
|
7176
|
-
el.style.top = `${
|
|
7360
|
+
const above = r.top - el.offsetHeight - 6;
|
|
7361
|
+
el.style.top = `${above >= 8 ? above : r.bottom + 6}px`;
|
|
7177
7362
|
}
|
|
7178
7363
|
|
|
7179
7364
|
function closeReviewPop() {
|
|
7180
7365
|
if (!reviewPopMode) return;
|
|
7366
|
+
if (reviewPopMode === 'editor') {
|
|
7367
|
+
reviewPendingHighlight?.clear();
|
|
7368
|
+
if (activeReview?.frame) postToReviewFrame('unpend');
|
|
7369
|
+
}
|
|
7181
7370
|
reviewPopMode = null;
|
|
7182
7371
|
const el = reviewPopEl();
|
|
7183
7372
|
el.className = '';
|
|
@@ -7198,17 +7387,25 @@ function showReviewOffer(picked) {
|
|
|
7198
7387
|
const el = reviewPopEl();
|
|
7199
7388
|
reviewPopMode = 'offer';
|
|
7200
7389
|
el.className = 'visible offer';
|
|
7201
|
-
el.innerHTML = `<button type="button" class="review-offer-btn">
|
|
7390
|
+
el.innerHTML = `<button type="button" class="review-offer-btn" title="Comment (C)">${ICON_CHAT}Comment<kbd>C</kbd></button>`;
|
|
7202
7391
|
el.firstElementChild.addEventListener('click', () => openReviewEditor(picked));
|
|
7203
7392
|
placeReviewPop(el, picked.rect);
|
|
7204
7393
|
}
|
|
7205
7394
|
|
|
7395
|
+
function onReviewOfferKey(e) {
|
|
7396
|
+
if (reviewPopMode !== 'offer' || !matchKey(e, 'c', 'C')) return;
|
|
7397
|
+
e.preventDefault();
|
|
7398
|
+
e.stopImmediatePropagation();
|
|
7399
|
+
reviewPopEl().querySelector('.review-offer-btn').click();
|
|
7400
|
+
}
|
|
7401
|
+
|
|
7206
7402
|
function openReviewEditor(picked) {
|
|
7207
7403
|
const el = reviewPopEl();
|
|
7208
7404
|
reviewPopMode = 'editor';
|
|
7209
7405
|
el.className = 'visible editor';
|
|
7210
|
-
|
|
7211
|
-
|
|
7406
|
+
if (picked.range) reviewPendingHighlight?.add(picked.range);
|
|
7407
|
+
else if (picked.inFrame) postToReviewFrame('pend');
|
|
7408
|
+
el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
|
|
7212
7409
|
<div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
|
|
7213
7410
|
const ta = el.querySelector('textarea');
|
|
7214
7411
|
const add = () => {
|
|
@@ -7258,6 +7455,7 @@ function addReviewComment(picked, comment) {
|
|
|
7258
7455
|
items.sort((a, b) => a.pos - b.pos);
|
|
7259
7456
|
closeReviewPop();
|
|
7260
7457
|
renderReviewPanel();
|
|
7458
|
+
activeReview.onComment?.();
|
|
7261
7459
|
}
|
|
7262
7460
|
|
|
7263
7461
|
function unmarkReviewItem(c) {
|
|
@@ -8566,7 +8764,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
8566
8764
|
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
8567
8765
|
<div class="stat-divider"></div>
|
|
8568
8766
|
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
8569
|
-
|
|
8767
|
+
${ctx.outputTokens ? `<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>` : ''}
|
|
8570
8768
|
<div class="stat-divider"></div>`
|
|
8571
8769
|
: '';
|
|
8572
8770
|
|
|
@@ -8586,9 +8784,9 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
8586
8784
|
<div class="detail-context-stats">
|
|
8587
8785
|
${tokenRows}
|
|
8588
8786
|
<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>
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8787
|
+
${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>` : ''}
|
|
8788
|
+
${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>` : ''}
|
|
8789
|
+
${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>` : ''}
|
|
8592
8790
|
</div>
|
|
8593
8791
|
</div>`;
|
|
8594
8792
|
}
|
|
@@ -8752,6 +8950,7 @@ function submitWaitingAnswers() {
|
|
|
8752
8950
|
function showWaitingDetail() {
|
|
8753
8951
|
if (!isWaitingFresh()) return;
|
|
8754
8952
|
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
8953
|
+
unmountMsgReview();
|
|
8755
8954
|
msgHighlightDimmed = false;
|
|
8756
8955
|
highlightSelectedMsg();
|
|
8757
8956
|
// Follow-mode poll ticks re-run this whole render — carry any typed reject
|
|
@@ -8807,9 +9006,8 @@ function showWaitingDetail() {
|
|
|
8807
9006
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
8808
9007
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
8809
9008
|
autoSizeModal(modal, body);
|
|
8810
|
-
|
|
9009
|
+
setMsgDetailFollow(msgDetailFollowLatest);
|
|
8811
9010
|
const overlay = document.getElementById('msg-detail-modal');
|
|
8812
|
-
overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
|
|
8813
9011
|
const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
|
|
8814
9012
|
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
8815
9013
|
currentPinDetailId = null;
|
|
@@ -9461,13 +9659,13 @@ function projectFilterText() {
|
|
|
9461
9659
|
}
|
|
9462
9660
|
|
|
9463
9661
|
let filterMatcher = { query: null, match: null };
|
|
9464
|
-
function matchesProjectFilter(project) {
|
|
9662
|
+
function matchesProjectFilter(project, repo) {
|
|
9465
9663
|
if (!filterProject) return true;
|
|
9466
9664
|
if (filterProject === '__recent__') return recentProjects.has(project);
|
|
9467
9665
|
if (filterMatcher.query !== filterProject) {
|
|
9468
9666
|
filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
|
|
9469
9667
|
}
|
|
9470
|
-
return filterMatcher.match(project);
|
|
9668
|
+
return filterMatcher.match(project, repo);
|
|
9471
9669
|
}
|
|
9472
9670
|
|
|
9473
9671
|
//#endregion
|
|
@@ -9642,7 +9840,9 @@ async function refreshProjectList() {
|
|
|
9642
9840
|
const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
9643
9841
|
const prevRecent = recentProjects;
|
|
9644
9842
|
recentProjects = new Set(
|
|
9645
|
-
projects
|
|
9843
|
+
projects
|
|
9844
|
+
.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff)
|
|
9845
|
+
.flatMap((p) => [p.path, ...(p.worktrees || [])]),
|
|
9646
9846
|
);
|
|
9647
9847
|
|
|
9648
9848
|
renderFilterState();
|
|
@@ -10198,8 +10398,11 @@ function openSessionFromInfo(sessionId) {
|
|
|
10198
10398
|
}
|
|
10199
10399
|
|
|
10200
10400
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10201
|
-
function toggleDismissSession(sessionId) {
|
|
10202
|
-
|
|
10401
|
+
async function toggleDismissSession(sessionId) {
|
|
10402
|
+
const dismiss = !dismissedSessionIds.has(sessionId);
|
|
10403
|
+
if (dismiss && !(await confirmDismissBlockers(sessionId))) return;
|
|
10404
|
+
if (dismiss) dismissSessionFromList(sessionId);
|
|
10405
|
+
else setSessionDismissed(sessionId, false);
|
|
10203
10406
|
}
|
|
10204
10407
|
|
|
10205
10408
|
function updateDismissBtnState() {
|
|
@@ -11175,31 +11378,44 @@ const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
|
11175
11378
|
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
11176
11379
|
const TERMINAL_FONT_MIN = 8;
|
|
11177
11380
|
const TERMINAL_FONT_MAX = 32;
|
|
11178
|
-
const
|
|
11381
|
+
const TERMINAL_FRAME_ISOLATED_MS = 5000;
|
|
11382
|
+
const TERMINAL_FRAME_LOAD_MS = 10000;
|
|
11383
|
+
const TERMINAL_FRAME_ALIVE_MS = 5000;
|
|
11384
|
+
const TERMINAL_MSG = 'cck-term:';
|
|
11179
11385
|
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
11180
11386
|
const TERMINAL_STABLE_MS = 5000;
|
|
11181
11387
|
const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
|
|
11182
11388
|
// Read at load, before the first updateUrl() rewrites the URL without the fragment.
|
|
11183
11389
|
let terminalToken = readTerminalToken();
|
|
11184
11390
|
const termState = {
|
|
11185
|
-
|
|
11186
|
-
term: null,
|
|
11187
|
-
fit: null,
|
|
11188
|
-
webgl: null,
|
|
11189
|
-
ws: null,
|
|
11391
|
+
socket: null,
|
|
11190
11392
|
sessionId: null,
|
|
11393
|
+
fontSize: null,
|
|
11191
11394
|
syncedView: null,
|
|
11192
11395
|
panelHidden: false,
|
|
11193
11396
|
shown: false,
|
|
11194
11397
|
attached: false,
|
|
11195
11398
|
closeGuard: false,
|
|
11196
|
-
leaving: false,
|
|
11197
11399
|
focusNext: false,
|
|
11198
11400
|
openedByUser: null,
|
|
11199
|
-
ackPending: 0,
|
|
11200
|
-
ackTimer: null,
|
|
11201
11401
|
retryTimer: null,
|
|
11202
11402
|
};
|
|
11403
|
+
const termFrame = {
|
|
11404
|
+
el: null,
|
|
11405
|
+
origin: null,
|
|
11406
|
+
ready: null,
|
|
11407
|
+
resolve: null,
|
|
11408
|
+
inited: false,
|
|
11409
|
+
reload: false,
|
|
11410
|
+
claims: '',
|
|
11411
|
+
theme: '',
|
|
11412
|
+
sameOrigin: false,
|
|
11413
|
+
};
|
|
11414
|
+
|
|
11415
|
+
function resetTerminalFrame(patch) {
|
|
11416
|
+
Object.assign(termFrame, { inited: false, reload: false, claims: '', theme: '', ...patch });
|
|
11417
|
+
}
|
|
11418
|
+
let terminalSocketSeq = 0;
|
|
11203
11419
|
|
|
11204
11420
|
// The hub passes the token in the fragment, which is never sent to a server or logged.
|
|
11205
11421
|
function readTerminalToken() {
|
|
@@ -11259,7 +11475,11 @@ function setTerminalMode(sessionId, on) {
|
|
|
11259
11475
|
}
|
|
11260
11476
|
|
|
11261
11477
|
function wantsTerminal() {
|
|
11262
|
-
return
|
|
11478
|
+
return viewMode === 'session' && wantsTerminalFor(currentSessionId);
|
|
11479
|
+
}
|
|
11480
|
+
|
|
11481
|
+
function wantsTerminalFor(sessionId) {
|
|
11482
|
+
return terminalAvailable() && !!sessionId && terminalModes().has(sessionId);
|
|
11263
11483
|
}
|
|
11264
11484
|
|
|
11265
11485
|
function toggleTerminal() {
|
|
@@ -11280,12 +11500,13 @@ function inPageField(e) {
|
|
|
11280
11500
|
return !!field && !document.getElementById('terminal-pane').contains(field);
|
|
11281
11501
|
}
|
|
11282
11502
|
|
|
11283
|
-
|
|
11503
|
+
// `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
|
|
11504
|
+
function terminalShortcut(e, probe = false) {
|
|
11284
11505
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
11285
11506
|
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
11286
11507
|
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
11287
11508
|
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
11288
|
-
if (zoom !== undefined &&
|
|
11509
|
+
if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
|
|
11289
11510
|
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
11290
11511
|
}
|
|
11291
11512
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
@@ -11364,7 +11585,13 @@ function syncTerminal() {
|
|
|
11364
11585
|
btn.classList.toggle('active', on);
|
|
11365
11586
|
}
|
|
11366
11587
|
sessionView.classList.toggle('terminal-mode', on);
|
|
11588
|
+
pushTerminalClaims();
|
|
11367
11589
|
if (!on) {
|
|
11590
|
+
// A hidden xterm textarea drops focus, but a display:none iframe keeps it and would take keys unseen.
|
|
11591
|
+
if (termState.shown && terminalPaneFocused()) {
|
|
11592
|
+
terminalHadFocus = false;
|
|
11593
|
+
document.activeElement.blur();
|
|
11594
|
+
}
|
|
11368
11595
|
termState.shown = false;
|
|
11369
11596
|
termState.focusNext = false;
|
|
11370
11597
|
termState.openedByUser = null;
|
|
@@ -11409,8 +11636,7 @@ function promptAwaitsUser(sessionId) {
|
|
|
11409
11636
|
|
|
11410
11637
|
function onTerminalShown(focus = true) {
|
|
11411
11638
|
requestAnimationFrame(() => {
|
|
11412
|
-
|
|
11413
|
-
repaintTerminal();
|
|
11639
|
+
terminalFrameSend('refresh');
|
|
11414
11640
|
if (focus) focusTerminalPane();
|
|
11415
11641
|
});
|
|
11416
11642
|
}
|
|
@@ -11418,37 +11644,210 @@ function onTerminalShown(focus = true) {
|
|
|
11418
11644
|
function focusTerminalPane() {
|
|
11419
11645
|
const prompt = document.getElementById('terminal-prompt');
|
|
11420
11646
|
if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
|
|
11421
|
-
else
|
|
11647
|
+
else if (termFrame.inited) {
|
|
11648
|
+
termFrame.el.focus();
|
|
11649
|
+
terminalFrameSend('focus');
|
|
11650
|
+
}
|
|
11422
11651
|
}
|
|
11423
11652
|
|
|
11424
11653
|
function leaveTerminalPane() {
|
|
11425
|
-
|
|
11654
|
+
terminalHadFocus = false;
|
|
11426
11655
|
document.activeElement.blur();
|
|
11427
|
-
termState.leaving = false;
|
|
11428
11656
|
setFocusZone('sidebar');
|
|
11429
11657
|
}
|
|
11430
11658
|
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
11443
|
-
|
|
11659
|
+
// Chrome puts frames in one process per site, and a site ignores the port. Served from another
|
|
11660
|
+
// loopback name, the terminal runs in its own renderer, so board work never delays a key or a frame.
|
|
11661
|
+
// localhost maps to [::1], not 127.0.0.1: Chrome reaches localhost over IPv6, and some Windows
|
|
11662
|
+
// machines drop fresh IPv4 loopback connects in bursts, which stalls a socket for seconds.
|
|
11663
|
+
// A board on [::1] keeps the terminal in its process, because frame-ancestors cannot name an IPv6
|
|
11664
|
+
// literal; so does any other host, which has no second name.
|
|
11665
|
+
function terminalFrameOrigin(loc = location) {
|
|
11666
|
+
const other = { localhost: '[::1]', '127.0.0.1': 'localhost' }[loc.hostname];
|
|
11667
|
+
return other ? `${loc.protocol}//${other}${loc.port ? `:${loc.port}` : ''}` : loc.origin;
|
|
11668
|
+
}
|
|
11669
|
+
|
|
11670
|
+
function ensureTerminalFrame() {
|
|
11671
|
+
if (termFrame.ready) return termFrame.ready;
|
|
11672
|
+
const isolated = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
|
|
11673
|
+
// A host that does not answer on the other name (IPv6 turned off) still gets a terminal, in this
|
|
11674
|
+
// process, and later loads skip the wait.
|
|
11675
|
+
const ready = loadTerminalFrame(isolated, TERMINAL_FRAME_ISOLATED_MS).catch(async (err) => {
|
|
11676
|
+
if (isolated === location.origin) throw err;
|
|
11677
|
+
await loadTerminalFrame(location.origin, TERMINAL_FRAME_LOAD_MS);
|
|
11678
|
+
termFrame.sameOrigin = true;
|
|
11679
|
+
});
|
|
11680
|
+
termFrame.ready = ready;
|
|
11681
|
+
ready.catch(() => {
|
|
11682
|
+
if (termFrame.ready === ready) termFrame.ready = null;
|
|
11683
|
+
});
|
|
11684
|
+
return ready;
|
|
11685
|
+
}
|
|
11686
|
+
|
|
11687
|
+
function loadTerminalFrame(origin, timeoutMs) {
|
|
11688
|
+
termFrame.el?.remove();
|
|
11689
|
+
const el = document.createElement('iframe');
|
|
11690
|
+
el.className = 'terminal-frame';
|
|
11691
|
+
el.title = 'Terminal';
|
|
11692
|
+
el.allow = 'clipboard-read; clipboard-write';
|
|
11693
|
+
el.src = `${origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
|
|
11694
|
+
resetTerminalFrame({ el, origin });
|
|
11695
|
+
return new Promise((resolve, reject) => {
|
|
11696
|
+
const timer = setTimeout(() => {
|
|
11697
|
+
if (termFrame.el !== el || termFrame.inited) return;
|
|
11698
|
+
el.remove();
|
|
11699
|
+
termFrame.el = null;
|
|
11700
|
+
reject(new Error('The terminal did not load.'));
|
|
11701
|
+
}, timeoutMs);
|
|
11702
|
+
termFrame.resolve = () => {
|
|
11703
|
+
clearTimeout(timer);
|
|
11704
|
+
resolve();
|
|
11705
|
+
};
|
|
11706
|
+
document.getElementById('terminal-host').appendChild(el);
|
|
11707
|
+
});
|
|
11708
|
+
}
|
|
11709
|
+
|
|
11710
|
+
function sendTerminalTheme() {
|
|
11711
|
+
if (!termFrame.inited) return;
|
|
11712
|
+
const options = terminalThemeOptions();
|
|
11713
|
+
const key = JSON.stringify(options);
|
|
11714
|
+
if (key === termFrame.theme) return;
|
|
11715
|
+
termFrame.theme = key;
|
|
11716
|
+
postToTerminalFrame('theme', { options });
|
|
11717
|
+
}
|
|
11718
|
+
|
|
11719
|
+
// Keyed on the colors, and style is watched, because under the hub a theme change lands in two
|
|
11720
|
+
// steps: the class flips while the old pick's inline vars are still on body, then the SDK swaps them.
|
|
11721
|
+
new MutationObserver(sendTerminalTheme).observe(document.body, {
|
|
11722
|
+
attributes: true,
|
|
11723
|
+
attributeFilter: ['class', 'data-color-theme', 'style'],
|
|
11724
|
+
});
|
|
11725
|
+
|
|
11726
|
+
function postToTerminalFrame(t, data) {
|
|
11727
|
+
termFrame.el.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, termFrame.origin);
|
|
11728
|
+
}
|
|
11729
|
+
|
|
11730
|
+
function terminalFrameSend(t, data) {
|
|
11731
|
+
if (termFrame.inited) postToTerminalFrame(t, data);
|
|
11732
|
+
}
|
|
11733
|
+
|
|
11734
|
+
window.addEventListener('message', (e) => {
|
|
11735
|
+
if (!termFrame.el || e.source !== termFrame.el.contentWindow || e.origin !== termFrame.origin) return;
|
|
11736
|
+
const m = e.data;
|
|
11737
|
+
if (typeof m?.type === 'string' && m.type.startsWith(TERMINAL_MSG))
|
|
11738
|
+
onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
|
|
11739
|
+
});
|
|
11740
|
+
|
|
11741
|
+
function onTerminalFrameMessage(t, m) {
|
|
11742
|
+
const socket = termState.socket;
|
|
11743
|
+
const current = socket && m.socketId === socket.id;
|
|
11744
|
+
if (t === 'loaded') {
|
|
11745
|
+
// A second load is the frame reloaded (its context menu), with no terminal and no socket.
|
|
11746
|
+
const themeOptions = terminalThemeOptions();
|
|
11747
|
+
resetTerminalFrame({ reload: termFrame.reload || termFrame.inited, theme: JSON.stringify(themeOptions) });
|
|
11748
|
+
postToTerminalFrame('init', {
|
|
11749
|
+
fontFamily:
|
|
11750
|
+
appConfig.terminal.fontFamily ||
|
|
11751
|
+
getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
|
|
11752
|
+
'monospace',
|
|
11753
|
+
fontSize: currentTerminalFontSize(),
|
|
11754
|
+
scrollback: appConfig.terminal.scrollback,
|
|
11755
|
+
themeOptions,
|
|
11444
11756
|
});
|
|
11445
|
-
|
|
11446
|
-
|
|
11757
|
+
} else if (t === 'term') {
|
|
11758
|
+
termFrame.inited = true;
|
|
11759
|
+
pushTerminalClaims();
|
|
11760
|
+
// A theme change between init and now was dropped.
|
|
11761
|
+
sendTerminalTheme();
|
|
11762
|
+
termFrame.resolve?.();
|
|
11763
|
+
if (termFrame.reload) {
|
|
11764
|
+
termFrame.reload = false;
|
|
11765
|
+
reconnectTerminal();
|
|
11766
|
+
}
|
|
11767
|
+
} else if (t === 'key') replayTerminalKey(m);
|
|
11768
|
+
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
11769
|
+
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
11770
|
+
else if (t === 'opening') {
|
|
11771
|
+
if (current) clearTimeout(socket.aliveTimer);
|
|
11772
|
+
} else if (t === 'output') {
|
|
11773
|
+
if (current && terminalBootTimer) hideTerminalBoot();
|
|
11774
|
+
} else if (t === 'ws') {
|
|
11775
|
+
if (!current) return;
|
|
11776
|
+
if (m.msg?.t === 'ready') socket.readyAt = Date.now();
|
|
11777
|
+
else if (m.msg?.t === 'error') socket.refused = true;
|
|
11778
|
+
onTerminalMessage(socket.sessionId, m.msg, m.tail);
|
|
11779
|
+
} else if (t === 'close') {
|
|
11780
|
+
if (current) onTerminalSocketClose(socket, m.code);
|
|
11781
|
+
}
|
|
11782
|
+
}
|
|
11783
|
+
|
|
11784
|
+
// The frame hands back the keys the board acts on. Replaying them on a textarea inside the pane gives
|
|
11785
|
+
// the document listeners the target they saw when xterm lived here: the shortcut runs, the SDK
|
|
11786
|
+
// forwards a hub key, and the text-field guard keeps the board's own keys out.
|
|
11787
|
+
function replayTerminalKey(m) {
|
|
11788
|
+
const target = document.getElementById('terminal-key-proxy');
|
|
11789
|
+
target.dispatchEvent(
|
|
11790
|
+
new KeyboardEvent('keydown', {
|
|
11791
|
+
key: m.key,
|
|
11792
|
+
code: m.code,
|
|
11793
|
+
ctrlKey: !!m.ctrl,
|
|
11794
|
+
altKey: !!m.alt,
|
|
11795
|
+
shiftKey: !!m.shift,
|
|
11796
|
+
metaKey: !!m.meta,
|
|
11797
|
+
bubbles: true,
|
|
11798
|
+
cancelable: true,
|
|
11799
|
+
}),
|
|
11447
11800
|
);
|
|
11448
|
-
|
|
11449
|
-
|
|
11801
|
+
}
|
|
11802
|
+
|
|
11803
|
+
// [code, key, key with Shift] on a US layout. Each is probed by code alone and by key alone, so a claim
|
|
11804
|
+
// holds on any layout exactly when the shortcut would. Hub keys are not probed: the frame gets the
|
|
11805
|
+
// hub's combos and matches them with the SDK's comboOf.
|
|
11806
|
+
const TERMINAL_PROBE_KEYS = [
|
|
11807
|
+
...[...'abcdefghijklmnopqrstuvwxyz'].map((k) => [`Key${k.toUpperCase()}`, k, k.toUpperCase()]),
|
|
11808
|
+
...[...')!@#$%^&*('].map((s, n) => [`Digit${n}`, String(n), s]),
|
|
11809
|
+
['Backquote', '`', '~'],
|
|
11810
|
+
['Equal', '=', '+'],
|
|
11811
|
+
['Minus', '-', '_'],
|
|
11812
|
+
['NumpadAdd', '+', '+'],
|
|
11813
|
+
['NumpadSubtract', '-', '-'],
|
|
11814
|
+
['Numpad0', '0', '0'],
|
|
11815
|
+
...['Left', 'Right', 'Up', 'Down'].map((d) => [`Arrow${d}`, `Arrow${d}`, `Arrow${d}`]),
|
|
11816
|
+
];
|
|
11817
|
+
|
|
11818
|
+
function terminalClaims() {
|
|
11819
|
+
const keys = [];
|
|
11820
|
+
for (let bits = 0; bits < 16; bits++) {
|
|
11821
|
+
const mods = { ctrlKey: !!(bits & 1), altKey: !!(bits & 2), shiftKey: !!(bits & 4), metaKey: !!(bits & 8) };
|
|
11822
|
+
for (const [code, base, shifted] of TERMINAL_PROBE_KEYS) {
|
|
11823
|
+
const key = mods.shiftKey ? shifted : base;
|
|
11824
|
+
for (const probe of [
|
|
11825
|
+
{ ...mods, key: 'Unidentified', code },
|
|
11826
|
+
{ ...mods, key, code: '' },
|
|
11827
|
+
]) {
|
|
11828
|
+
if (terminalShortcut(probe, true)) keys.push(probe);
|
|
11829
|
+
}
|
|
11830
|
+
}
|
|
11831
|
+
}
|
|
11832
|
+
return keys;
|
|
11833
|
+
}
|
|
11834
|
+
|
|
11835
|
+
let terminalClaimsQueued = false;
|
|
11836
|
+
|
|
11837
|
+
// The claims depend on state (the hub's welcome, a slow connect, the session), so every change re-sends
|
|
11838
|
+
// them. One state change calls this several times, and the probe is about 1,500 shortcut checks.
|
|
11839
|
+
function pushTerminalClaims() {
|
|
11840
|
+
if (!termFrame.inited || terminalClaimsQueued) return;
|
|
11841
|
+
terminalClaimsQueued = true;
|
|
11842
|
+
queueMicrotask(() => {
|
|
11843
|
+
terminalClaimsQueued = false;
|
|
11844
|
+
if (!termFrame.inited) return;
|
|
11845
|
+
const claims = { keys: terminalClaims(), forward: hub.forwardCombos() };
|
|
11846
|
+
const sig = JSON.stringify(claims);
|
|
11847
|
+
if (sig === termFrame.claims) return;
|
|
11848
|
+
termFrame.claims = sig;
|
|
11849
|
+
terminalFrameSend('claims', claims);
|
|
11450
11850
|
});
|
|
11451
|
-
return termState.loaded;
|
|
11452
11851
|
}
|
|
11453
11852
|
|
|
11454
11853
|
function terminalTheme() {
|
|
@@ -11477,141 +11876,6 @@ function terminalThemeOptions() {
|
|
|
11477
11876
|
};
|
|
11478
11877
|
}
|
|
11479
11878
|
|
|
11480
|
-
// Returning false hands the key back to the page: xterm skips it and the document
|
|
11481
|
-
// listeners (the toggle, hub forwarding, native paste) see it instead.
|
|
11482
|
-
function terminalKeyFilter(e) {
|
|
11483
|
-
if (e.type !== 'keydown') return true;
|
|
11484
|
-
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
11485
|
-
if (terminalShortcut(e)) return false;
|
|
11486
|
-
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
|
|
11487
|
-
e.preventDefault();
|
|
11488
|
-
navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
|
|
11489
|
-
termState.term.clearSelection();
|
|
11490
|
-
return false;
|
|
11491
|
-
}
|
|
11492
|
-
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
11493
|
-
return !hub.forwards(e);
|
|
11494
|
-
}
|
|
11495
|
-
|
|
11496
|
-
function oscColor(hex) {
|
|
11497
|
-
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
11498
|
-
if (!m) return null;
|
|
11499
|
-
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
11500
|
-
}
|
|
11501
|
-
|
|
11502
|
-
function ensureTerm() {
|
|
11503
|
-
if (termState.term) return termState.term;
|
|
11504
|
-
const cfg = appConfig.terminal;
|
|
11505
|
-
const host = document.getElementById('terminal-host');
|
|
11506
|
-
const fontFamily =
|
|
11507
|
-
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
11508
|
-
const fontSize = terminalFontSize();
|
|
11509
|
-
const term = new window.Terminal({
|
|
11510
|
-
fontFamily,
|
|
11511
|
-
fontSize,
|
|
11512
|
-
scrollback: cfg.scrollback,
|
|
11513
|
-
cursorBlink: true,
|
|
11514
|
-
allowProposedApi: true,
|
|
11515
|
-
...terminalThemeOptions(),
|
|
11516
|
-
});
|
|
11517
|
-
termState.fit = new window.FitAddon.FitAddon();
|
|
11518
|
-
term.loadAddon(termState.fit);
|
|
11519
|
-
term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
|
|
11520
|
-
term.unicode.activeVersion = '11';
|
|
11521
|
-
term.open(host);
|
|
11522
|
-
try {
|
|
11523
|
-
const gl = new window.WebglAddon.WebglAddon();
|
|
11524
|
-
gl.onContextLoss(() => {
|
|
11525
|
-
gl.dispose();
|
|
11526
|
-
termState.webgl = null;
|
|
11527
|
-
repaintTerminal();
|
|
11528
|
-
});
|
|
11529
|
-
term.loadAddon(gl);
|
|
11530
|
-
termState.webgl = gl;
|
|
11531
|
-
} catch (_) {
|
|
11532
|
-
// No WebGL: xterm falls back to its DOM renderer.
|
|
11533
|
-
}
|
|
11534
|
-
document.fonts
|
|
11535
|
-
?.load(`${fontSize}px ${fontFamily}`)
|
|
11536
|
-
.then(repaintTerminal)
|
|
11537
|
-
.catch(() => {});
|
|
11538
|
-
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
11539
|
-
// xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
|
|
11540
|
-
// background (Claude Code's Auto theme) assume dark even on a light theme.
|
|
11541
|
-
for (const [code, key] of [
|
|
11542
|
-
[10, 'foreground'],
|
|
11543
|
-
[11, 'background'],
|
|
11544
|
-
]) {
|
|
11545
|
-
term.parser.registerOscHandler(code, (data) => {
|
|
11546
|
-
if (data !== '?') return false;
|
|
11547
|
-
const rgb = oscColor(term.options.theme[key]);
|
|
11548
|
-
if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
|
|
11549
|
-
return true;
|
|
11550
|
-
});
|
|
11551
|
-
}
|
|
11552
|
-
term.onData((d) => terminalSend({ t: 'in', d }));
|
|
11553
|
-
term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
|
|
11554
|
-
// One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
|
|
11555
|
-
// back from 0×0, which is also how a hidden hub iframe or pane shows again.
|
|
11556
|
-
let frame = 0;
|
|
11557
|
-
let visible = false;
|
|
11558
|
-
new ResizeObserver(() => {
|
|
11559
|
-
if (frame) return;
|
|
11560
|
-
frame = requestAnimationFrame(() => {
|
|
11561
|
-
frame = 0;
|
|
11562
|
-
const nowVisible = host.offsetWidth > 0;
|
|
11563
|
-
fitTerminal();
|
|
11564
|
-
if (nowVisible && !visible) repaintTerminal();
|
|
11565
|
-
visible = nowVisible;
|
|
11566
|
-
});
|
|
11567
|
-
}).observe(host);
|
|
11568
|
-
const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
|
|
11569
|
-
let appliedTheme = themeKey();
|
|
11570
|
-
new MutationObserver(() => {
|
|
11571
|
-
if (themeKey() === appliedTheme) return;
|
|
11572
|
-
appliedTheme = themeKey();
|
|
11573
|
-
Object.assign(term.options, terminalThemeOptions());
|
|
11574
|
-
repaintTerminal();
|
|
11575
|
-
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
|
|
11576
|
-
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
|
|
11577
|
-
// A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
|
|
11578
|
-
let pointerDown = false;
|
|
11579
|
-
document.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
11580
|
-
document.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
11581
|
-
term.textarea.addEventListener('blur', () => {
|
|
11582
|
-
if (pointerDown || termState.leaving) return;
|
|
11583
|
-
requestAnimationFrame(() => {
|
|
11584
|
-
if (!termState.attached || !host.offsetWidth) return;
|
|
11585
|
-
if (!document.hasFocus() || document.activeElement !== document.body) return;
|
|
11586
|
-
terminalSend({ t: 'in', d: '\x1b' });
|
|
11587
|
-
term.focus();
|
|
11588
|
-
});
|
|
11589
|
-
});
|
|
11590
|
-
termState.term = term;
|
|
11591
|
-
return term;
|
|
11592
|
-
}
|
|
11593
|
-
|
|
11594
|
-
// The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
|
|
11595
|
-
// display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
|
|
11596
|
-
// repaint on its own when the canvas is shown again.
|
|
11597
|
-
function repaintTerminal() {
|
|
11598
|
-
const term = termState.term;
|
|
11599
|
-
if (!term) return;
|
|
11600
|
-
try {
|
|
11601
|
-
termState.webgl?.clearTextureAtlas();
|
|
11602
|
-
} catch (_) {}
|
|
11603
|
-
term.refresh(0, term.rows - 1);
|
|
11604
|
-
}
|
|
11605
|
-
|
|
11606
|
-
// The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
|
|
11607
|
-
function fitTerminal() {
|
|
11608
|
-
const host = document.getElementById('terminal-host');
|
|
11609
|
-
if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
11610
|
-
try {
|
|
11611
|
-
termState.fit.fit();
|
|
11612
|
-
} catch (_) {}
|
|
11613
|
-
}
|
|
11614
|
-
|
|
11615
11879
|
function clampTerminalFont(v) {
|
|
11616
11880
|
return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
|
|
11617
11881
|
}
|
|
@@ -11621,33 +11885,25 @@ function terminalFontSize() {
|
|
|
11621
11885
|
return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
|
|
11622
11886
|
}
|
|
11623
11887
|
|
|
11888
|
+
function currentTerminalFontSize() {
|
|
11889
|
+
termState.fontSize ??= terminalFontSize();
|
|
11890
|
+
return termState.fontSize;
|
|
11891
|
+
}
|
|
11892
|
+
|
|
11624
11893
|
function adjustTerminalFontSize(step) {
|
|
11625
|
-
const
|
|
11626
|
-
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(
|
|
11627
|
-
if (size !==
|
|
11894
|
+
const current = currentTerminalFontSize();
|
|
11895
|
+
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(current + step);
|
|
11896
|
+
if (size !== current) {
|
|
11628
11897
|
if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
|
|
11629
11898
|
else store.setItem(TERMINAL_FONT_KEY, String(size));
|
|
11630
|
-
|
|
11631
|
-
|
|
11899
|
+
termState.fontSize = size;
|
|
11900
|
+
terminalFrameSend('font', { size });
|
|
11632
11901
|
}
|
|
11633
11902
|
showToast(`Terminal ${size}px`);
|
|
11634
11903
|
}
|
|
11635
11904
|
|
|
11636
11905
|
function terminalSend(msg) {
|
|
11637
|
-
if (termState.
|
|
11638
|
-
}
|
|
11639
|
-
|
|
11640
|
-
function ackTerminal(ws, n) {
|
|
11641
|
-
if (termState.ws !== ws) return;
|
|
11642
|
-
termState.ackPending += n;
|
|
11643
|
-
const flush = () => {
|
|
11644
|
-
clearTimeout(termState.ackTimer);
|
|
11645
|
-
termState.ackTimer = null;
|
|
11646
|
-
if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
|
|
11647
|
-
termState.ackPending = 0;
|
|
11648
|
-
};
|
|
11649
|
-
if (termState.ackPending >= ACK_BATCH_BYTES) flush();
|
|
11650
|
-
else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
|
|
11906
|
+
if (termState.socket) terminalFrameSend('send', { msg });
|
|
11651
11907
|
}
|
|
11652
11908
|
|
|
11653
11909
|
function setTerminalStatus(text) {
|
|
@@ -11687,6 +11943,7 @@ function clearTerminalSlow() {
|
|
|
11687
11943
|
clearTimeout(terminalSlowTimer);
|
|
11688
11944
|
terminalSlowTimer = null;
|
|
11689
11945
|
document.getElementById('terminal-boot').classList.remove('slow');
|
|
11946
|
+
pushTerminalClaims();
|
|
11690
11947
|
}
|
|
11691
11948
|
|
|
11692
11949
|
function terminalConnectSlow() {
|
|
@@ -11715,22 +11972,12 @@ function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
|
11715
11972
|
el.querySelector('button')?.focus();
|
|
11716
11973
|
}
|
|
11717
11974
|
|
|
11718
|
-
function terminalTail(count) {
|
|
11719
|
-
const buf = termState.term?.buffer.active;
|
|
11720
|
-
if (!buf) return '';
|
|
11721
|
-
const lines = [];
|
|
11722
|
-
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
11723
|
-
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
11724
|
-
if (text.trim() || lines.length) lines.unshift(text);
|
|
11725
|
-
}
|
|
11726
|
-
return lines.join('\n');
|
|
11727
|
-
}
|
|
11728
|
-
|
|
11729
11975
|
// Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
|
|
11730
11976
|
// Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
|
|
11731
11977
|
function setTerminalAttached(on) {
|
|
11732
11978
|
termState.attached = on;
|
|
11733
11979
|
syncCloseGuard();
|
|
11980
|
+
pushTerminalClaims();
|
|
11734
11981
|
}
|
|
11735
11982
|
|
|
11736
11983
|
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
@@ -11758,6 +12005,26 @@ document.addEventListener('focusout', (e) => {
|
|
|
11758
12005
|
if (document.hasFocus()) terminalHadFocus = terminalPaneFocused();
|
|
11759
12006
|
});
|
|
11760
12007
|
});
|
|
12008
|
+
// The hub hands focus back by focusing this frame after a palette closes, and focus does not
|
|
12009
|
+
// pass on into the nested terminal frame by itself. The hub makes this frame inert while the
|
|
12010
|
+
// palette is open, which can blur the terminal frame, so activeElement no longer says it had focus.
|
|
12011
|
+
// Without inert (the app launcher), activeElement stays the frame element while the frame has no focus.
|
|
12012
|
+
// Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
|
|
12013
|
+
// frame, so it waits a task and gives way to whatever took focus.
|
|
12014
|
+
let boardPointerDown = false;
|
|
12015
|
+
document.addEventListener('pointerdown', () => (boardPointerDown = true), true);
|
|
12016
|
+
for (const type of ['pointerup', 'pointercancel']) {
|
|
12017
|
+
document.addEventListener(type, () => (boardPointerDown = false), true);
|
|
12018
|
+
}
|
|
12019
|
+
window.addEventListener('focus', () => {
|
|
12020
|
+
if (!terminalHadFocus || boardPointerDown) return;
|
|
12021
|
+
setTimeout(() => {
|
|
12022
|
+
const active = document.activeElement;
|
|
12023
|
+
if (terminalHadFocus && termState.shown && (active === document.body || active === termFrame.el)) {
|
|
12024
|
+
focusTerminalPane();
|
|
12025
|
+
}
|
|
12026
|
+
});
|
|
12027
|
+
});
|
|
11761
12028
|
window.addEventListener('pagehide', () => {
|
|
11762
12029
|
try {
|
|
11763
12030
|
if (termState.attached && termState.shown && terminalHadFocus) {
|
|
@@ -11782,109 +12049,87 @@ document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
|
11782
12049
|
});
|
|
11783
12050
|
|
|
11784
12051
|
function detachTerminal() {
|
|
11785
|
-
const
|
|
11786
|
-
termState.
|
|
12052
|
+
const had = termState.socket;
|
|
12053
|
+
termState.socket = null;
|
|
11787
12054
|
termState.sessionId = null;
|
|
11788
|
-
clearTimeout(termState.ackTimer);
|
|
11789
|
-
termState.ackTimer = null;
|
|
11790
|
-
termState.ackPending = 0;
|
|
11791
12055
|
clearTimeout(termState.retryTimer);
|
|
11792
12056
|
termState.retryTimer = null;
|
|
11793
12057
|
setTerminalAttached(false);
|
|
11794
|
-
if (
|
|
11795
|
-
ws.onclose = null;
|
|
11796
|
-
ws.close();
|
|
11797
|
-
}
|
|
12058
|
+
if (had) terminalFrameSend('detach');
|
|
11798
12059
|
hideTerminalPrompt();
|
|
11799
12060
|
hideTerminalBoot();
|
|
11800
12061
|
}
|
|
11801
12062
|
|
|
12063
|
+
// The frame owns the socket and xterm; the board picks the session, the mode and when to retry.
|
|
11802
12064
|
async function openTerminal(sessionId, mode, attempt = 0) {
|
|
11803
12065
|
detachTerminal();
|
|
11804
12066
|
termState.sessionId = sessionId;
|
|
12067
|
+
const socket = { id: ++terminalSocketSeq, sessionId, mode, attempt, readyAt: 0, refused: false };
|
|
12068
|
+
termState.socket = socket;
|
|
11805
12069
|
if (!attempt) setTerminalStatus('');
|
|
11806
|
-
|
|
11807
|
-
|
|
11808
|
-
|
|
11809
|
-
|
|
11810
|
-
|
|
12070
|
+
// Even a settled await resumes only after the caller's render, so a loaded frame is used at once.
|
|
12071
|
+
if (!termFrame.inited) {
|
|
12072
|
+
try {
|
|
12073
|
+
await ensureTerminalFrame();
|
|
12074
|
+
} catch (e) {
|
|
12075
|
+
if (termState.socket === socket) setTerminalStatus(e.message);
|
|
12076
|
+
return;
|
|
12077
|
+
}
|
|
12078
|
+
if (termState.socket !== socket) return;
|
|
11811
12079
|
}
|
|
11812
|
-
if (termState.sessionId !== sessionId) return;
|
|
11813
|
-
const term = ensureTerm();
|
|
11814
|
-
if (!attempt) showTerminalBoot();
|
|
11815
|
-
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
11816
|
-
// session had already queued would still be drawn after it. A retry keeps the last screen
|
|
11817
|
-
// until the replay arrives.
|
|
11818
|
-
const reset = () => term.write('\x1bc', repaintTerminal);
|
|
11819
|
-
if (!attempt) reset();
|
|
11820
|
-
fitTerminal();
|
|
11821
|
-
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
11822
|
-
ws.binaryType = 'arraybuffer';
|
|
11823
|
-
termState.ws = ws;
|
|
11824
12080
|
if (!attempt) {
|
|
12081
|
+
showTerminalBoot();
|
|
11825
12082
|
terminalSlowTimer = setTimeout(() => {
|
|
11826
|
-
if (termState.
|
|
12083
|
+
if (termState.socket !== socket) return;
|
|
12084
|
+
document.getElementById('terminal-boot').classList.add('slow');
|
|
12085
|
+
pushTerminalClaims();
|
|
11827
12086
|
}, TERMINAL_SLOW_CONNECT_MS);
|
|
11828
12087
|
}
|
|
11829
12088
|
const spec = newSpecs.get(sessionId);
|
|
11830
|
-
|
|
11831
|
-
|
|
11832
|
-
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
11836
|
-
|
|
11837
|
-
|
|
11838
|
-
|
|
11839
|
-
|
|
11840
|
-
|
|
11841
|
-
|
|
11842
|
-
|
|
11843
|
-
name: spec.name,
|
|
11844
|
-
worktree: spec.worktree,
|
|
11845
|
-
model: spec.model,
|
|
11846
|
-
prompt: spec.prompt,
|
|
11847
|
-
}),
|
|
12089
|
+
terminalFrameSend('open', {
|
|
12090
|
+
socketId: socket.id,
|
|
12091
|
+
reset: !attempt,
|
|
12092
|
+
hello: {
|
|
12093
|
+
token: terminalToken,
|
|
12094
|
+
id: sessionId,
|
|
12095
|
+
mode,
|
|
12096
|
+
...(spec?.mode === mode && {
|
|
12097
|
+
cwd: spec.cwd,
|
|
12098
|
+
name: spec.name,
|
|
12099
|
+
worktree: spec.worktree,
|
|
12100
|
+
model: spec.model,
|
|
12101
|
+
prompt: spec.prompt,
|
|
11848
12102
|
}),
|
|
11849
|
-
|
|
11850
|
-
|
|
11851
|
-
|
|
11852
|
-
|
|
11853
|
-
|
|
11854
|
-
|
|
11855
|
-
|
|
11856
|
-
|
|
11857
|
-
|
|
11858
|
-
|
|
11859
|
-
|
|
11860
|
-
|
|
11861
|
-
|
|
11862
|
-
|
|
11863
|
-
|
|
11864
|
-
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
|
|
11868
|
-
|
|
11869
|
-
|
|
11870
|
-
|
|
11871
|
-
|
|
11872
|
-
|
|
11873
|
-
|
|
11874
|
-
|
|
11875
|
-
|
|
11876
|
-
|
|
11877
|
-
|
|
11878
|
-
|
|
11879
|
-
refreshTerminalToken().then((ok) => {
|
|
11880
|
-
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
11881
|
-
});
|
|
11882
|
-
return;
|
|
11883
|
-
}
|
|
11884
|
-
if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
11885
|
-
const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
|
|
11886
|
-
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
11887
|
-
};
|
|
12103
|
+
},
|
|
12104
|
+
});
|
|
12105
|
+
// Chrome shows a crashed frame as a sad face and does not reload it, so a dead frame only shows as silence.
|
|
12106
|
+
socket.aliveTimer = setTimeout(() => {
|
|
12107
|
+
if (termState.socket !== socket) return;
|
|
12108
|
+
dropTerminalFrame();
|
|
12109
|
+
openTerminal(sessionId, mode, attempt);
|
|
12110
|
+
}, TERMINAL_FRAME_ALIVE_MS);
|
|
12111
|
+
}
|
|
12112
|
+
|
|
12113
|
+
function dropTerminalFrame() {
|
|
12114
|
+
termFrame.el?.remove();
|
|
12115
|
+
resetTerminalFrame({ el: null, ready: null });
|
|
12116
|
+
}
|
|
12117
|
+
|
|
12118
|
+
function onTerminalSocketClose(socket, code) {
|
|
12119
|
+
termState.socket = null;
|
|
12120
|
+
setTerminalAttached(false);
|
|
12121
|
+
const { sessionId, mode, attempt } = socket;
|
|
12122
|
+
// A refusal already shows the server's reason, and retrying would only repeat it, unless the
|
|
12123
|
+
// reason was a token that the hub has since replaced (4001).
|
|
12124
|
+
if (socket.refused && code === 4001) {
|
|
12125
|
+
refreshTerminalToken().then((ok) => {
|
|
12126
|
+
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
12127
|
+
});
|
|
12128
|
+
return;
|
|
12129
|
+
}
|
|
12130
|
+
if (socket.refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
12131
|
+
const stable = socket.readyAt && Date.now() - socket.readyAt > TERMINAL_STABLE_MS;
|
|
12132
|
+
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
11888
12133
|
}
|
|
11889
12134
|
|
|
11890
12135
|
function terminalOpenMode(sessionId) {
|
|
@@ -11923,7 +12168,7 @@ function retryTerminal(sessionId, attempt) {
|
|
|
11923
12168
|
}, delay);
|
|
11924
12169
|
}
|
|
11925
12170
|
|
|
11926
|
-
function onTerminalMessage(sessionId, msg) {
|
|
12171
|
+
function onTerminalMessage(sessionId, msg, tail = '') {
|
|
11927
12172
|
if (msg.t === 'ready') {
|
|
11928
12173
|
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
11929
12174
|
const spec = newSpecs.get(sessionId);
|
|
@@ -11932,8 +12177,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
11932
12177
|
hideTerminalPrompt();
|
|
11933
12178
|
setTerminalStatus('');
|
|
11934
12179
|
setTerminalAttached(true);
|
|
11935
|
-
|
|
11936
|
-
if (takeTerminalFocus(sessionId)) termState.term.focus();
|
|
12180
|
+
if (takeTerminalFocus(sessionId)) focusTerminalPane();
|
|
11937
12181
|
} else if (msg.t === 'rekey' && typeof msg.id === 'string') {
|
|
11938
12182
|
adoptPickedSession(sessionId, msg.id);
|
|
11939
12183
|
if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
|
|
@@ -11974,7 +12218,7 @@ function onTerminalMessage(sessionId, msg) {
|
|
|
11974
12218
|
['fork', 'Fork'],
|
|
11975
12219
|
['shell', 'Shell'],
|
|
11976
12220
|
],
|
|
11977
|
-
clean ? '' :
|
|
12221
|
+
clean ? '' : tail,
|
|
11978
12222
|
);
|
|
11979
12223
|
} else if (msg.t === 'error') {
|
|
11980
12224
|
hideTerminalBoot();
|
|
@@ -12004,12 +12248,42 @@ function openTerminalManager() {
|
|
|
12004
12248
|
if (terminalPaneFocused()) leaveTerminalPane();
|
|
12005
12249
|
document.getElementById('terminal-manager-modal').classList.add('visible');
|
|
12006
12250
|
renderTerminalManager();
|
|
12251
|
+
clearInterval(terminalProcTimer);
|
|
12252
|
+
pollTerminalProcStats();
|
|
12253
|
+
terminalProcTimer = setInterval(pollTerminalProcStats, TERMINAL_PROC_POLL_MS);
|
|
12007
12254
|
}
|
|
12008
12255
|
|
|
12009
12256
|
function closeTerminalManager() {
|
|
12257
|
+
clearInterval(terminalProcTimer);
|
|
12010
12258
|
hideModalOverlay('terminal-manager-modal');
|
|
12011
12259
|
}
|
|
12012
12260
|
|
|
12261
|
+
// Each poll costs one OS process query on the server, so it runs only while the modal is on screen.
|
|
12262
|
+
const TERMINAL_PROC_POLL_MS = 5000;
|
|
12263
|
+
let terminalProcTimer = 0;
|
|
12264
|
+
let terminalProcStats = {};
|
|
12265
|
+
|
|
12266
|
+
function formatTerminalProc(s) {
|
|
12267
|
+
if (!s) return '';
|
|
12268
|
+
return `${Math.round(s.rss / 1048576)} MB${s.cpu === null ? '' : ` · ${s.cpu}% CPU`}`;
|
|
12269
|
+
}
|
|
12270
|
+
|
|
12271
|
+
async function pollTerminalProcStats() {
|
|
12272
|
+
if (!isOnScreen()) return;
|
|
12273
|
+
let cck;
|
|
12274
|
+
try {
|
|
12275
|
+
const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
|
|
12276
|
+
if (!res.ok) return;
|
|
12277
|
+
({ cck, terminals: terminalProcStats } = await res.json());
|
|
12278
|
+
} catch (_) {
|
|
12279
|
+
return;
|
|
12280
|
+
}
|
|
12281
|
+
document.getElementById('terminal-manager-stats').textContent = cck ? `cck ${formatTerminalProc(cck)}` : '';
|
|
12282
|
+
for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
|
|
12283
|
+
el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
|
|
12284
|
+
}
|
|
12285
|
+
}
|
|
12286
|
+
|
|
12013
12287
|
async function renderTerminalManager() {
|
|
12014
12288
|
const body = document.getElementById('terminal-manager-body');
|
|
12015
12289
|
let list = [];
|
|
@@ -12043,6 +12317,7 @@ async function renderTerminalManager() {
|
|
|
12043
12317
|
<span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
|
|
12044
12318
|
<span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
|
|
12045
12319
|
<span>${attached}</span>
|
|
12320
|
+
<span class="terminal-manager-proc" data-proc="${escapeHtml(t.id)}" title="Memory and CPU of the claude process. Tools it runs are not counted. 100% CPU is one core.">${escapeHtml(formatTerminalProc(terminalProcStats[t.id]))}</span>
|
|
12046
12321
|
<span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
|
|
12047
12322
|
</div>
|
|
12048
12323
|
</div>
|
|
@@ -12222,12 +12497,14 @@ async function loadTerminals() {
|
|
|
12222
12497
|
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
12223
12498
|
const list = (await res.json()).sessions || [];
|
|
12224
12499
|
runningTerminals = new Set(list.map((t) => t.id));
|
|
12500
|
+
pushTerminalClaims();
|
|
12225
12501
|
renderActivityChip();
|
|
12226
12502
|
return list;
|
|
12227
12503
|
}
|
|
12228
12504
|
|
|
12229
12505
|
function setRunningTerminals(ids) {
|
|
12230
12506
|
runningTerminals = new Set(ids);
|
|
12507
|
+
pushTerminalClaims();
|
|
12231
12508
|
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
12232
12509
|
else renderSessionViews();
|
|
12233
12510
|
renderActivityChip();
|
|
@@ -12876,7 +13153,11 @@ const hub = ClaudeHub.connect({
|
|
|
12876
13153
|
}),
|
|
12877
13154
|
});
|
|
12878
13155
|
|
|
12879
|
-
hub.onActive((active) =>
|
|
13156
|
+
hub.onActive((active) => {
|
|
13157
|
+
onHubActive(active);
|
|
13158
|
+
pushTerminalClaims();
|
|
13159
|
+
});
|
|
13160
|
+
hub.onStatus(pushTerminalClaims);
|
|
12880
13161
|
|
|
12881
13162
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
12882
13163
|
let publishTimer = 0;
|