claude-code-kanban 4.27.0 → 4.28.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 +72 -44
- package/cli.js +235 -7
- package/lib/claude-dir.js +1 -1
- package/lib/dispatch-groups.js +93 -0
- package/lib/dispatch.js +171 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/net-guard.js +20 -3
- package/lib/session-events.js +22 -2
- package/lib/terminal.js +555 -0
- package/package.json +14 -3
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +6 -0
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +34 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +7 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban-dispatch/SKILL.md +23 -0
- package/public/app.js +1761 -138
- package/public/index.html +150 -14
- package/public/style.css +772 -53
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +240 -18
- package/skill-guides/dispatch.md +72 -0
package/public/app.js
CHANGED
|
@@ -24,6 +24,8 @@ const FILTER_DEFAULTS = { project: '__recent__', session: 'active', limit: '20'
|
|
|
24
24
|
|
|
25
25
|
let sessions = [];
|
|
26
26
|
let currentSessionId = null;
|
|
27
|
+
let lastSessionId = null;
|
|
28
|
+
let previousSessionId = null;
|
|
27
29
|
let currentTasks = [];
|
|
28
30
|
let viewMode = 'session';
|
|
29
31
|
let sessionFilter = FILTER_DEFAULTS.session;
|
|
@@ -45,6 +47,8 @@ const SECTION_PROJECTS = '__section_projects__';
|
|
|
45
47
|
const SECTION_SESSIONS = '__section_sessions__';
|
|
46
48
|
let stableGroupOrder = []; // cached project path order to prevent jumping
|
|
47
49
|
let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
|
|
50
|
+
// Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
|
|
51
|
+
let sgReleased = new Set();
|
|
48
52
|
let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
|
|
49
53
|
let searchQuery = ''; // Search query for fuzzy search
|
|
50
54
|
let allTasksCache = []; // Cache all tasks for search
|
|
@@ -58,12 +62,24 @@ let lastMessagesHash = '';
|
|
|
58
62
|
let currentMessages = [];
|
|
59
63
|
let agentDurationInterval = null;
|
|
60
64
|
let agentPollInterval = null;
|
|
65
|
+
// Inside the hub an inactive app is a display:none iframe whose document.hidden stays false,
|
|
66
|
+
// so the hub tells us via hub:active. Standalone, or if that message is lost, cck stays active.
|
|
67
|
+
let hubActive = true;
|
|
68
|
+
let missedWhileHidden = false;
|
|
69
|
+
const isOnScreen = () => hubActive && !document.hidden;
|
|
70
|
+
function skipOffScreen() {
|
|
71
|
+
if (isOnScreen()) return false;
|
|
72
|
+
missedWhileHidden = true;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const METADATA_MAX_WAIT_MS = 5000;
|
|
61
76
|
let selectedTaskId = null;
|
|
62
77
|
let selectedSessionId = null;
|
|
63
78
|
// Task stays selected (keyboard nav) but its white highlight is dimmed once the detail panel closes.
|
|
64
79
|
let taskHighlightDimmed = false;
|
|
65
80
|
let focusZone = 'board'; // 'board' | 'sidebar'
|
|
66
81
|
let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
|
|
82
|
+
let runningTerminals = new Set();
|
|
67
83
|
let selectedSessionIdx = -1;
|
|
68
84
|
let selectedSessionKbId = null;
|
|
69
85
|
let sessionJustSelected = false;
|
|
@@ -117,6 +133,7 @@ function updateUrl() {
|
|
|
117
133
|
const url = qs ? `?${qs}` : window.location.pathname;
|
|
118
134
|
history.replaceState(null, '', url);
|
|
119
135
|
persistLastView();
|
|
136
|
+
syncTerminal();
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
const LAST_VIEW_KEY = 'lastView';
|
|
@@ -230,7 +247,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
230
247
|
}
|
|
231
248
|
lastSessionsHash = sessionsHash;
|
|
232
249
|
|
|
233
|
-
sessions = newSessions;
|
|
250
|
+
sessions = mergePlaceholders(newSessions);
|
|
234
251
|
renderSessions();
|
|
235
252
|
renderActivityChip();
|
|
236
253
|
} catch (error) {
|
|
@@ -448,6 +465,10 @@ async function fetchTasks(sessionId) {
|
|
|
448
465
|
revealedStorageSessionId = null;
|
|
449
466
|
}
|
|
450
467
|
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
468
|
+
if (lastSessionId !== sessionId) {
|
|
469
|
+
previousSessionId = lastSessionId;
|
|
470
|
+
lastSessionId = sessionId;
|
|
471
|
+
}
|
|
451
472
|
currentSessionId = sessionId;
|
|
452
473
|
currentPins = loadPins(sessionId);
|
|
453
474
|
ownerFilter = '';
|
|
@@ -614,6 +635,7 @@ async function refreshProjectAgents() {
|
|
|
614
635
|
function toggleMessagePanel() {
|
|
615
636
|
const panel = document.getElementById('message-panel');
|
|
616
637
|
messagePanelOpen = !messagePanelOpen;
|
|
638
|
+
termState.panelHidden = false;
|
|
617
639
|
store.setItem('message-panel-open', messagePanelOpen);
|
|
618
640
|
panel.classList.toggle('visible', messagePanelOpen);
|
|
619
641
|
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
@@ -1503,6 +1525,8 @@ const ICON_AGENT_WAITING =
|
|
|
1503
1525
|
'<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="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
|
1504
1526
|
const ICON_AGENT_ACTIVE =
|
|
1505
1527
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
|
|
1528
|
+
const ICON_TERMINAL =
|
|
1529
|
+
'<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>';
|
|
1506
1530
|
const ICON_CHAT =
|
|
1507
1531
|
'<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
|
1508
1532
|
const TOOL_ICONS = {
|
|
@@ -2558,6 +2582,17 @@ function sanitizeOutputHtml(text) {
|
|
|
2558
2582
|
return typeof text === 'string' ? ansiToHtml(stripLineNumbers(text)) : '';
|
|
2559
2583
|
}
|
|
2560
2584
|
|
|
2585
|
+
function toolOutputHtml(text) {
|
|
2586
|
+
if (typeof text === 'string' && text.length <= HLJS_MAX_CHARS && /^\s*[[{]/.test(text)) {
|
|
2587
|
+
try {
|
|
2588
|
+
const pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
2589
|
+
if (typeof hljs === 'undefined' || !hljs.getLanguage('json')) return escapeHtml(pretty);
|
|
2590
|
+
return `<code class="hljs language-json">${hljs.highlight(pretty, { language: 'json' }).value}</code>`;
|
|
2591
|
+
} catch (_) {}
|
|
2592
|
+
}
|
|
2593
|
+
return sanitizeOutputHtml(text);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2561
2596
|
function highlightBash(escaped) {
|
|
2562
2597
|
return escaped
|
|
2563
2598
|
.replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
|
|
@@ -2623,11 +2658,11 @@ function autoSizeModal(modal, body) {
|
|
|
2623
2658
|
|
|
2624
2659
|
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
2625
2660
|
if (!toolResult) return '';
|
|
2626
|
-
const escaped =
|
|
2661
|
+
const escaped = toolOutputHtml(toolResult);
|
|
2627
2662
|
let truncLabel = '',
|
|
2628
2663
|
fullBlock = '';
|
|
2629
2664
|
if (isTruncated && fullResult) {
|
|
2630
|
-
const toggle = makeExpandToggle(escaped,
|
|
2665
|
+
const toggle = makeExpandToggle(escaped, toolOutputHtml(fullResult));
|
|
2631
2666
|
truncLabel = toggle.btn;
|
|
2632
2667
|
fullBlock = toggle.full;
|
|
2633
2668
|
} else if (isTruncated && toolUseId) {
|
|
@@ -2669,7 +2704,7 @@ async function _toggleToolResultExpand(btn) {
|
|
|
2669
2704
|
);
|
|
2670
2705
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
2671
2706
|
const { content } = await r.json();
|
|
2672
|
-
f.innerHTML =
|
|
2707
|
+
f.innerHTML = toolOutputHtml(content);
|
|
2673
2708
|
btn.dataset.loaded = '1';
|
|
2674
2709
|
} catch (_e) {
|
|
2675
2710
|
btn.textContent = 'Show more';
|
|
@@ -2860,9 +2895,10 @@ function renderAgentFooter() {
|
|
|
2860
2895
|
|
|
2861
2896
|
clearInterval(agentDurationInterval);
|
|
2862
2897
|
if (visible.some((a) => a.status === 'active' || a.status === 'idle')) {
|
|
2863
|
-
agentDurationInterval = setInterval(() => renderAgentFooter(), 1000);
|
|
2898
|
+
agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 1000);
|
|
2864
2899
|
if (!agentPollInterval) {
|
|
2865
2900
|
agentPollInterval = setInterval(() => {
|
|
2901
|
+
if (skipOffScreen()) return;
|
|
2866
2902
|
if (viewMode === 'project' && currentProjectPath) {
|
|
2867
2903
|
refreshProjectAgents();
|
|
2868
2904
|
} else if (currentSessionId) {
|
|
@@ -2871,7 +2907,7 @@ function renderAgentFooter() {
|
|
|
2871
2907
|
}, 3000);
|
|
2872
2908
|
}
|
|
2873
2909
|
} else {
|
|
2874
|
-
agentDurationInterval = setInterval(() => renderAgentFooter(), 10000);
|
|
2910
|
+
agentDurationInterval = setInterval(() => isOnScreen() && renderAgentFooter(), 10000);
|
|
2875
2911
|
clearInterval(agentPollInterval);
|
|
2876
2912
|
agentPollInterval = null;
|
|
2877
2913
|
}
|
|
@@ -3130,11 +3166,12 @@ function getFilteredSessions() {
|
|
|
3130
3166
|
filteredSessions = filteredSessions.filter((s) => {
|
|
3131
3167
|
if (dismissedSessionIds.has(s.id)) return false;
|
|
3132
3168
|
const isActive =
|
|
3133
|
-
s.
|
|
3134
|
-
(
|
|
3135
|
-
s.
|
|
3136
|
-
|
|
3137
|
-
|
|
3169
|
+
runningTerminals.has(s.id) ||
|
|
3170
|
+
(s.hasMessages &&
|
|
3171
|
+
((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
|
|
3172
|
+
s.hasActiveAgents ||
|
|
3173
|
+
s.hasWaitingForUser ||
|
|
3174
|
+
s.hasRecentActivity));
|
|
3138
3175
|
if (isActive) activeSessionIds.add(s.id);
|
|
3139
3176
|
return isActive;
|
|
3140
3177
|
});
|
|
@@ -3262,6 +3299,7 @@ function renderSessions() {
|
|
|
3262
3299
|
|
|
3263
3300
|
// Helper to render a single session card
|
|
3264
3301
|
const renderSessionCard = (session) => {
|
|
3302
|
+
if (session.placeholder) return renderPlaceholderCard(session);
|
|
3265
3303
|
const total = session.taskCount;
|
|
3266
3304
|
const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
|
|
3267
3305
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
@@ -3290,11 +3328,11 @@ function renderSessions() {
|
|
|
3290
3328
|
const showCtx = !!session.contextStatus && !zenMode;
|
|
3291
3329
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3292
3330
|
const bookmarksCount = loadPins(session.id).length;
|
|
3293
|
-
const hasScratchpad =
|
|
3331
|
+
const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
|
|
3294
3332
|
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
3295
3333
|
const sid = escAttrJs(session.id);
|
|
3296
3334
|
return `
|
|
3297
|
-
<button onclick="
|
|
3335
|
+
<button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3298
3336
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3299
3337
|
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3300
3338
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
@@ -3313,6 +3351,7 @@ function renderSessions() {
|
|
|
3313
3351
|
${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
|
|
3314
3352
|
${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
|
|
3315
3353
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
3354
|
+
${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="Running in a terminal here">${ICON_TERMINAL}</span>` : ''}
|
|
3316
3355
|
${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
|
|
3317
3356
|
${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
|
|
3318
3357
|
${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
|
|
@@ -3331,7 +3370,7 @@ function renderSessions() {
|
|
|
3331
3370
|
if (zenMode) {
|
|
3332
3371
|
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3333
3372
|
<div class="zen-panel">
|
|
3334
|
-
${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3373
|
+
${renderContextDetail(zenSession.contextStatus, { tokens: false }) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3335
3374
|
${renderScratchpadRow(zenSession)}
|
|
3336
3375
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3337
3376
|
${renderArtifactsHtml(zenSession.id)}
|
|
@@ -3411,10 +3450,12 @@ function renderSessions() {
|
|
|
3411
3450
|
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}${nestedCls}" draggable="true" data-group-path="${escapedPath}" data-project-path="${escapedPath}">
|
|
3412
3451
|
${groupChevronSvg()}
|
|
3413
3452
|
<span class="group-name">${escapeHtml(folderName)}</span>
|
|
3414
|
-
${
|
|
3453
|
+
${terminalAvailable() ? `<span class="project-new-btn" data-project-path="${escapedPath}" title="New session in ${escapeHtml(folderName)}">${PLUS_SVG}</span>` : ''}
|
|
3454
|
+
${headerPadBtnHtml(_projectScratchpadKey(projectPath), folderName)}
|
|
3415
3455
|
<span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
|
|
3416
3456
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
3417
3457
|
</span>
|
|
3458
|
+
${countHtml(projectSessions)}
|
|
3418
3459
|
</div>
|
|
3419
3460
|
<div class="project-group-breadcrumb${nestedCls}" data-full-path="${escapedPath}" title="Click to copy path">${breadcrumbHtml}</div>
|
|
3420
3461
|
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}${nestedCls}" data-project-path="${escapedPath}">
|
|
@@ -3426,28 +3467,27 @@ function renderSessions() {
|
|
|
3426
3467
|
// Named groups are a pure partition of the already-filtered list: whatever they don't claim
|
|
3427
3468
|
// falls through to the project blocks below, which look exactly as they did before groups.
|
|
3428
3469
|
const sgBuckets = new Map();
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
} else {
|
|
3438
|
-
sgRest.push(session);
|
|
3439
|
-
}
|
|
3470
|
+
const sgRest = [];
|
|
3471
|
+
for (const session of filteredSessions) {
|
|
3472
|
+
const group = sgGroupForSession(session);
|
|
3473
|
+
if (group) {
|
|
3474
|
+
if (!sgBuckets.has(group.id)) sgBuckets.set(group.id, []);
|
|
3475
|
+
sgBuckets.get(group.id).push(session);
|
|
3476
|
+
} else {
|
|
3477
|
+
sgRest.push(session);
|
|
3440
3478
|
}
|
|
3441
3479
|
}
|
|
3480
|
+
for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
|
|
3442
3481
|
// A group emptied by a filter hides its header; a group the user just created (no filters)
|
|
3443
3482
|
// shows its drop-here state instead of vanishing.
|
|
3444
3483
|
const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
|
|
3445
3484
|
|
|
3446
3485
|
// flat = the "All sessions" view, which has no project sub-blocks.
|
|
3447
3486
|
const sgSectionHtml = (flat) => {
|
|
3448
|
-
|
|
3487
|
+
const transient = [...transientGroups.values()];
|
|
3488
|
+
if (sessionGroups.length === 0 && transient.length === 0) return '';
|
|
3449
3489
|
let out = '';
|
|
3450
|
-
for (const group of sessionGroups) {
|
|
3490
|
+
for (const group of [...sessionGroups, ...transient]) {
|
|
3451
3491
|
const bucket = sgBuckets.get(group.id) || [];
|
|
3452
3492
|
if (bucket.length === 0 && sgFiltering) continue;
|
|
3453
3493
|
if (!groupPinned) bucket.sort(pinSort);
|
|
@@ -3455,6 +3495,17 @@ function renderSessions() {
|
|
|
3455
3495
|
let body = '';
|
|
3456
3496
|
if (flat) {
|
|
3457
3497
|
body = bucket.map(renderSessionCard).join('');
|
|
3498
|
+
} else if (group.transient) {
|
|
3499
|
+
// Each session under its own project block, so a worktree session reads as its repo's.
|
|
3500
|
+
const byProject = new Map();
|
|
3501
|
+
for (const s of bucket) {
|
|
3502
|
+
const key = s.project || '';
|
|
3503
|
+
if (!byProject.has(key)) byProject.set(key, []);
|
|
3504
|
+
byProject.get(key).push(s);
|
|
3505
|
+
}
|
|
3506
|
+
for (const [p, arr] of byProject) {
|
|
3507
|
+
body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
|
|
3508
|
+
}
|
|
3458
3509
|
} else {
|
|
3459
3510
|
const loose = [];
|
|
3460
3511
|
const byProject = new Map();
|
|
@@ -3486,8 +3537,9 @@ function renderSessions() {
|
|
|
3486
3537
|
}
|
|
3487
3538
|
}
|
|
3488
3539
|
if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
|
|
3540
|
+
const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
|
|
3489
3541
|
out += sgHeaderHtml(group, countHtml(bucket));
|
|
3490
|
-
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"
|
|
3542
|
+
out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}</div>`;
|
|
3491
3543
|
}
|
|
3492
3544
|
if (!out) return '';
|
|
3493
3545
|
return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
|
|
@@ -3541,7 +3593,7 @@ function renderSessions() {
|
|
|
3541
3593
|
// The section header is rendered even when nothing is left under it: it is the drop target
|
|
3542
3594
|
// for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
|
|
3543
3595
|
// the whole project list, re-parsed on every live refresh.
|
|
3544
|
-
const sectioned = sessionGroups.length > 0;
|
|
3596
|
+
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3545
3597
|
let projectsHtml = '';
|
|
3546
3598
|
if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
|
|
3547
3599
|
for (const [projectPath, projectSessions] of sortedGroups) {
|
|
@@ -3569,7 +3621,7 @@ function renderSessions() {
|
|
|
3569
3621
|
|
|
3570
3622
|
sessionsList.innerHTML = html;
|
|
3571
3623
|
} else {
|
|
3572
|
-
const sectioned = sessionGroups.length > 0;
|
|
3624
|
+
const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
|
|
3573
3625
|
const restHtml =
|
|
3574
3626
|
sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
|
|
3575
3627
|
const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
|
|
@@ -4000,7 +4052,7 @@ async function onColumnDrop(e) {
|
|
|
4000
4052
|
// windows the list), so membership is never garbage-collected on load.
|
|
4001
4053
|
const SESSION_GROUPS_KEY = 'sessionGroups';
|
|
4002
4054
|
const SG_ACTION_SELECTOR =
|
|
4003
|
-
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
4055
|
+
'.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
|
|
4004
4056
|
|
|
4005
4057
|
// Collapse state shares the existing `collapsedGroups` key; this namespace keeps it from
|
|
4006
4058
|
// colliding with a project path or the __ungrouped__ / __pinned_* sentinels.
|
|
@@ -4035,14 +4087,17 @@ function loadSessionGroups() {
|
|
|
4035
4087
|
return out;
|
|
4036
4088
|
}),
|
|
4037
4089
|
}));
|
|
4090
|
+
const released = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.released;
|
|
4091
|
+
sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
|
|
4038
4092
|
} catch (_) {
|
|
4039
4093
|
sessionGroups = [];
|
|
4094
|
+
sgReleased = new Set();
|
|
4040
4095
|
}
|
|
4041
4096
|
}
|
|
4042
4097
|
|
|
4043
4098
|
function persistSessionGroups() {
|
|
4044
4099
|
try {
|
|
4045
|
-
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups }));
|
|
4100
|
+
store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
|
|
4046
4101
|
} catch (_) {}
|
|
4047
4102
|
}
|
|
4048
4103
|
|
|
@@ -4056,9 +4111,51 @@ function sgGroupOf(type, ref) {
|
|
|
4056
4111
|
|
|
4057
4112
|
// An individually placed session wins over the placement of its project.
|
|
4058
4113
|
function sgGroupForSession(session) {
|
|
4114
|
+
return sgUserGroupFor(session) || sgDispatchGroupFor(session);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
function sgUserGroupFor(session) {
|
|
4059
4118
|
return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
|
|
4060
4119
|
}
|
|
4061
4120
|
|
|
4121
|
+
// Transient groups come from the server (`dispatch start --group`) and go when their sessions
|
|
4122
|
+
// end. They are never written to localStorage; the objects are cached only so collapse state,
|
|
4123
|
+
// keyed by id, survives a re-render.
|
|
4124
|
+
const transientGroups = new Map();
|
|
4125
|
+
|
|
4126
|
+
function sgTransientGroup(name) {
|
|
4127
|
+
let group = transientGroups.get(name);
|
|
4128
|
+
if (!group) {
|
|
4129
|
+
group = { id: `t_${name}`, name, transient: true };
|
|
4130
|
+
transientGroups.set(name, group);
|
|
4131
|
+
}
|
|
4132
|
+
return group;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
// A user group with the same name takes the session in, so Keep does not split a group in two.
|
|
4136
|
+
// With no group of its own, a started session follows its starter into the starter's named group.
|
|
4137
|
+
function sgDispatchGroupFor(session) {
|
|
4138
|
+
if (sgReleased.has(session.id)) return null;
|
|
4139
|
+
const name = session.dispatchGroup;
|
|
4140
|
+
if (name) return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
|
|
4141
|
+
const starter = session.startedBy && sessions.find((s) => s.id === session.startedBy);
|
|
4142
|
+
return starter ? sgUserGroupFor(starter) : null;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
function sgKeepTransient(name) {
|
|
4146
|
+
const group = sgTransientGroup(name);
|
|
4147
|
+
const members = sessions.filter((s) => sgGroupForSession(s) === group);
|
|
4148
|
+
const kept = sgCreateGroup(name);
|
|
4149
|
+
for (const s of members) kept.members.push({ type: 'session', ref: s.id });
|
|
4150
|
+
persistSessionGroups();
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
function sgDeleteTransient(name) {
|
|
4154
|
+
const group = sgTransientGroup(name);
|
|
4155
|
+
for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
|
|
4156
|
+
persistSessionGroups();
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4062
4159
|
function sgDetach(type, ref) {
|
|
4063
4160
|
for (const g of sessionGroups) {
|
|
4064
4161
|
g.members = g.members.filter((m) => !(m.type === type && m.ref === ref));
|
|
@@ -4155,19 +4252,35 @@ function sgSearchMatchIds(query) {
|
|
|
4155
4252
|
return ids;
|
|
4156
4253
|
}
|
|
4157
4254
|
|
|
4255
|
+
const PAD_SVG =
|
|
4256
|
+
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>';
|
|
4257
|
+
|
|
4258
|
+
function headerPadBtnHtml(key, label) {
|
|
4259
|
+
const has = _hasScratchpad(key);
|
|
4260
|
+
return `<span class="header-pad-btn${has ? ' has-pad' : ''}" onclick="event.stopPropagation(); showScratchpad('${escAttrJs(key)}')" title="${has ? 'Open' : 'New'} scratchpad for ${escapeHtml(label)} (N)">${PAD_SVG}</span>`;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4158
4263
|
function sgHeaderHtml(group, countHtml) {
|
|
4159
4264
|
const collapsed = collapsedProjectGroups.has(sgKey(group.id));
|
|
4160
4265
|
const editing = sgEditingId === group.id;
|
|
4161
4266
|
const nameHtml = editing
|
|
4162
4267
|
? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
|
|
4163
4268
|
: `<span class="group-name">${escapeHtml(group.name)}</span>`;
|
|
4269
|
+
const attrs = group.transient
|
|
4270
|
+
? `class="session-group-header sg-transient${collapsed ? ' collapsed' : ''}" data-transient-group="${escapeHtml(group.name)}" title="${escapeHtml(group.name)} — goes when its sessions end"`
|
|
4271
|
+
: `class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here"`;
|
|
4272
|
+
const actions = group.transient
|
|
4273
|
+
? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
|
|
4274
|
+
<span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">×</span>`
|
|
4275
|
+
: `${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}
|
|
4276
|
+
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
|
|
4277
|
+
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>`;
|
|
4164
4278
|
return `
|
|
4165
|
-
<div
|
|
4279
|
+
<div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
|
|
4166
4280
|
${groupChevronSvg()}
|
|
4167
4281
|
${nameHtml}
|
|
4282
|
+
${actions}
|
|
4168
4283
|
${countHtml}
|
|
4169
|
-
<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span>
|
|
4170
|
-
<span class="sg-action sg-delete" title="Delete group (members return to Projects)">×</span>
|
|
4171
4284
|
</div>`;
|
|
4172
4285
|
}
|
|
4173
4286
|
|
|
@@ -4244,8 +4357,9 @@ function sgDropZone(target) {
|
|
|
4244
4357
|
? { zone, groupId: zone.dataset.groupId }
|
|
4245
4358
|
: null;
|
|
4246
4359
|
}
|
|
4247
|
-
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
|
|
4248
4360
|
const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
|
|
4361
|
+
const releasable = !!dragSession && !sgUserGroupFor(dragSession) && !!sgDispatchGroupFor(dragSession);
|
|
4362
|
+
if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
|
|
4249
4363
|
const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
|
|
4250
4364
|
const fromGroupId = fromGroup?.id || null;
|
|
4251
4365
|
// A drop that lands on the host the session already has changes nothing, and highlighting it
|
|
@@ -4264,7 +4378,8 @@ function sgDropZone(target) {
|
|
|
4264
4378
|
const targetSession = sessions.find((s) => s.id === targetSessionId);
|
|
4265
4379
|
const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
|
|
4266
4380
|
const targetGroupId = targetGroup?.id || null;
|
|
4267
|
-
|
|
4381
|
+
// A transient group holds no members to reorder or place under; a drop there pairs instead.
|
|
4382
|
+
if (targetGroup && !targetGroup.transient) {
|
|
4268
4383
|
// Stacking onto a card adopts that card's placement, project block included.
|
|
4269
4384
|
const targetHost = sgHostOf(targetGroup, targetSession);
|
|
4270
4385
|
if (targetHost) {
|
|
@@ -4295,7 +4410,7 @@ function sgDropZone(target) {
|
|
|
4295
4410
|
return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
|
|
4296
4411
|
}
|
|
4297
4412
|
// Stacking a session onto its own project block would only fold it back where it sits today.
|
|
4298
|
-
if (dragSession?.project === path) return null;
|
|
4413
|
+
if (dragSession?.project === path) return releasable ? { zone } : null;
|
|
4299
4414
|
return { zone, pairWith: path };
|
|
4300
4415
|
}
|
|
4301
4416
|
|
|
@@ -4356,6 +4471,7 @@ function sgOnDrop(e) {
|
|
|
4356
4471
|
else sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4357
4472
|
} else {
|
|
4358
4473
|
sgDetach(drag.kind, drag.ref);
|
|
4474
|
+
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
4359
4475
|
persistSessionGroups();
|
|
4360
4476
|
}
|
|
4361
4477
|
sgFinishDrag();
|
|
@@ -5258,6 +5374,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5258
5374
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5259
5375
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5260
5376
|
{ keys: ['J', 'K'], label: 'Previous / next message in detail' },
|
|
5377
|
+
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5261
5378
|
],
|
|
5262
5379
|
},
|
|
5263
5380
|
],
|
|
@@ -5267,7 +5384,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5267
5384
|
rows: [
|
|
5268
5385
|
{ keys: ['Enter'], label: 'Toggle task detail panel' },
|
|
5269
5386
|
{ keys: ['D'], label: 'Delete selected task' },
|
|
5270
|
-
{ keys: ['N'], label: 'Toggle scratchpad' },
|
|
5387
|
+
{ keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
|
|
5271
5388
|
{ keys: ['R'], label: 'Refresh data' },
|
|
5272
5389
|
{ keys: ['Esc'], label: 'Close panel / clear selection' },
|
|
5273
5390
|
],
|
|
@@ -5307,6 +5424,25 @@ const SHORTCUT_PAIRS = [
|
|
|
5307
5424
|
],
|
|
5308
5425
|
},
|
|
5309
5426
|
],
|
|
5427
|
+
[
|
|
5428
|
+
{
|
|
5429
|
+
title: 'Terminal',
|
|
5430
|
+
rows: [
|
|
5431
|
+
{ keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
|
|
5432
|
+
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5433
|
+
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5434
|
+
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5435
|
+
],
|
|
5436
|
+
},
|
|
5437
|
+
{
|
|
5438
|
+
title: 'Sessions',
|
|
5439
|
+
rows: [
|
|
5440
|
+
{ keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
|
|
5441
|
+
{ keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
|
|
5442
|
+
{ keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
|
|
5443
|
+
],
|
|
5444
|
+
},
|
|
5445
|
+
],
|
|
5310
5446
|
];
|
|
5311
5447
|
|
|
5312
5448
|
const EMPTY_GROUP = { title: '', rows: [] };
|
|
@@ -5375,18 +5511,52 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
|
|
|
5375
5511
|
|
|
5376
5512
|
let _scratchpadKeyOverride = null;
|
|
5377
5513
|
|
|
5514
|
+
const SESSION_PAD_PREFIX = 'scratchpad-';
|
|
5515
|
+
const PROJECT_PAD_PREFIX = `${SESSION_PAD_PREFIX}project:`;
|
|
5516
|
+
const GROUP_PAD_PREFIX = `${SESSION_PAD_PREFIX}group:`;
|
|
5517
|
+
|
|
5378
5518
|
function _sessionScratchpadKey(sessionId) {
|
|
5379
|
-
return
|
|
5519
|
+
return SESSION_PAD_PREFIX + sessionId;
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
function _projectScratchpadKey(projectPath) {
|
|
5523
|
+
return PROJECT_PAD_PREFIX + projectPath;
|
|
5380
5524
|
}
|
|
5381
5525
|
|
|
5382
|
-
function
|
|
5383
|
-
return
|
|
5526
|
+
function _groupScratchpadKey(groupId) {
|
|
5527
|
+
return GROUP_PAD_PREFIX + groupId;
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
// Every kind shares the session prefix, so the longer prefixes are tested first.
|
|
5531
|
+
function _parsePadKey(key) {
|
|
5532
|
+
for (const [kind, prefix] of [
|
|
5533
|
+
['group', GROUP_PAD_PREFIX],
|
|
5534
|
+
['project', PROJECT_PAD_PREFIX],
|
|
5535
|
+
['session', SESSION_PAD_PREFIX],
|
|
5536
|
+
]) {
|
|
5537
|
+
if (key.startsWith(prefix)) return { kind, id: key.slice(prefix.length) };
|
|
5538
|
+
}
|
|
5539
|
+
return null;
|
|
5540
|
+
}
|
|
5541
|
+
|
|
5542
|
+
function _hasScratchpad(key) {
|
|
5543
|
+
return !!(store.getItem(key) || '').trim();
|
|
5384
5544
|
}
|
|
5385
5545
|
|
|
5386
5546
|
function _scratchpadKey() {
|
|
5387
5547
|
if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
|
|
5388
5548
|
if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
|
|
5389
|
-
if (currentProjectPath) return
|
|
5549
|
+
if (currentProjectPath) return _projectScratchpadKey(currentProjectPath);
|
|
5550
|
+
return null;
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
function _scratchpadKeyOf(el) {
|
|
5554
|
+
if (!el) return null;
|
|
5555
|
+
if (el.dataset.sessionId) return _sessionScratchpadKey(el.dataset.sessionId);
|
|
5556
|
+
if (el.classList.contains('session-group-header')) return _groupScratchpadKey(el.dataset.groupId);
|
|
5557
|
+
if (el.classList.contains('project-group-header') && el.dataset.projectPath) {
|
|
5558
|
+
return _projectScratchpadKey(el.dataset.projectPath);
|
|
5559
|
+
}
|
|
5390
5560
|
return null;
|
|
5391
5561
|
}
|
|
5392
5562
|
|
|
@@ -5427,14 +5597,14 @@ function saveScratchpad() {
|
|
|
5427
5597
|
const key = _scratchpadKey();
|
|
5428
5598
|
if (!key) return;
|
|
5429
5599
|
const val = _scratchpadTextarea.value;
|
|
5430
|
-
const had =
|
|
5600
|
+
const had = _hasScratchpad(key);
|
|
5431
5601
|
const has = !!val.trim();
|
|
5432
5602
|
if (has) {
|
|
5433
5603
|
store.setItem(key, val);
|
|
5434
5604
|
} else {
|
|
5435
5605
|
store.removeItem(key);
|
|
5436
5606
|
}
|
|
5437
|
-
if (had !== has
|
|
5607
|
+
if (had !== has) {
|
|
5438
5608
|
renderSessions();
|
|
5439
5609
|
}
|
|
5440
5610
|
}
|
|
@@ -5448,6 +5618,30 @@ _scratchpadTextarea.addEventListener('input', () => {
|
|
|
5448
5618
|
}, 500);
|
|
5449
5619
|
});
|
|
5450
5620
|
|
|
5621
|
+
// Bound on the textarea: the global handler returns early on TEXTAREA targets.
|
|
5622
|
+
_scratchpadTextarea.addEventListener('keydown', (e) => {
|
|
5623
|
+
if (e.key === 'Escape') closeScratchpad();
|
|
5624
|
+
});
|
|
5625
|
+
|
|
5626
|
+
// Vimium eats Escape inside a text field and only blurs it, so the page never sees the key.
|
|
5627
|
+
// A blur that no click in the modal caused, while the window keeps focus, is that Escape.
|
|
5628
|
+
let _scratchpadPointerDown = false;
|
|
5629
|
+
_scratchpadModal.addEventListener('mousedown', () => {
|
|
5630
|
+
_scratchpadPointerDown = true;
|
|
5631
|
+
});
|
|
5632
|
+
document.addEventListener('mouseup', () => {
|
|
5633
|
+
_scratchpadPointerDown = false;
|
|
5634
|
+
});
|
|
5635
|
+
_scratchpadTextarea.addEventListener('blur', (e) => {
|
|
5636
|
+
if (
|
|
5637
|
+
!e.relatedTarget &&
|
|
5638
|
+
!_scratchpadPointerDown &&
|
|
5639
|
+
_scratchpadModal.classList.contains('visible') &&
|
|
5640
|
+
document.hasFocus()
|
|
5641
|
+
)
|
|
5642
|
+
closeScratchpad();
|
|
5643
|
+
});
|
|
5644
|
+
|
|
5451
5645
|
//#endregion
|
|
5452
5646
|
|
|
5453
5647
|
//#region STORAGE_MANAGER
|
|
@@ -5664,30 +5858,38 @@ function _storageUnpinMessage(sessionId, pinId) {
|
|
|
5664
5858
|
function _renderStorageScratchpads() {
|
|
5665
5859
|
const allItems = [];
|
|
5666
5860
|
for (const key of store.keys()) {
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
const id = isProject ? key.slice('scratchpad-project:'.length) : key.slice('scratchpad-'.length);
|
|
5671
|
-
allItems.push({ key, id, isProject, chars: val.length });
|
|
5861
|
+
const parsed = _parsePadKey(key);
|
|
5862
|
+
if (!parsed) continue;
|
|
5863
|
+
allItems.push({ key, ...parsed, chars: (store.getItem(key) || '').length });
|
|
5672
5864
|
}
|
|
5673
5865
|
if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
|
|
5674
5866
|
|
|
5675
|
-
const
|
|
5676
|
-
const
|
|
5867
|
+
const groupItems = allItems.filter((i) => i.kind === 'group');
|
|
5868
|
+
const projectItems = allItems.filter((i) => i.kind === 'project');
|
|
5869
|
+
const sessionItems = allItems.filter((i) => i.kind === 'session');
|
|
5677
5870
|
const sessionIds = sessionItems.map((i) => i.id);
|
|
5678
5871
|
const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
|
|
5679
5872
|
const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
|
|
5680
5873
|
|
|
5681
5874
|
function renderScratchItem(item) {
|
|
5682
|
-
const session = !item.isProject ? sessions.find((s) => s.id === item.id) : null;
|
|
5683
|
-
const typeBadge = item.isProject
|
|
5684
|
-
? '<span class="storage-item-badge">project</span>'
|
|
5685
|
-
: '<span class="storage-item-badge">session</span>';
|
|
5686
5875
|
const jsKey = escAttrJs(item.key);
|
|
5687
|
-
|
|
5876
|
+
let label;
|
|
5877
|
+
switch (item.kind) {
|
|
5878
|
+
case 'group':
|
|
5879
|
+
label = escapeHtml(sgGroupById(item.id)?.name ?? `${item.id} (deleted)`);
|
|
5880
|
+
break;
|
|
5881
|
+
case 'project':
|
|
5882
|
+
label = escapeHtml(_projectLabel(item.id));
|
|
5883
|
+
break;
|
|
5884
|
+
default:
|
|
5885
|
+
label = _sessionLabel(
|
|
5886
|
+
sessions.find((s) => s.id === item.id),
|
|
5887
|
+
item.id,
|
|
5888
|
+
);
|
|
5889
|
+
}
|
|
5688
5890
|
return `<div class="storage-item">
|
|
5689
5891
|
<span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
|
|
5690
|
-
|
|
5892
|
+
<span class="storage-item-badge">${item.kind}</span>
|
|
5691
5893
|
<span class="storage-item-meta">${item.chars} chars</span>
|
|
5692
5894
|
<div class="storage-item-actions">
|
|
5693
5895
|
<button onclick="_storagePreviewScratchpad('${jsKey}')">View</button>
|
|
@@ -5698,6 +5900,14 @@ function _renderStorageScratchpads() {
|
|
|
5698
5900
|
|
|
5699
5901
|
let html = '';
|
|
5700
5902
|
|
|
5903
|
+
if (groupItems.length) {
|
|
5904
|
+
html += _renderProjectGroup(
|
|
5905
|
+
'Group Scratchpads',
|
|
5906
|
+
`${groupItems.length}`,
|
|
5907
|
+
groupItems.map(renderScratchItem).join(''),
|
|
5908
|
+
);
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5701
5911
|
if (projectItems.length) {
|
|
5702
5912
|
html += _renderProjectGroup(
|
|
5703
5913
|
'Project Scratchpads',
|
|
@@ -5831,10 +6041,13 @@ function _findOrphanedKeys() {
|
|
|
5831
6041
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
5832
6042
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
5833
6043
|
for (const key of store.keys()) {
|
|
5834
|
-
|
|
6044
|
+
const pad = _parsePadKey(key);
|
|
6045
|
+
if (pad?.kind === 'group') {
|
|
6046
|
+
if (!sgGroupById(pad.id)) orphaned.push(key);
|
|
6047
|
+
} else if (key.startsWith('pinned-messages-')) {
|
|
5835
6048
|
if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
|
|
5836
|
-
} else if (
|
|
5837
|
-
if (!known.has(
|
|
6049
|
+
} else if (pad?.kind === 'session') {
|
|
6050
|
+
if (!known.has(pad.id)) orphaned.push(key);
|
|
5838
6051
|
} else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
|
|
5839
6052
|
if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
|
|
5840
6053
|
} else if (key.startsWith(PAD_LINKED_PREFIX)) {
|
|
@@ -5909,6 +6122,8 @@ const MODAL_CLOSERS = {
|
|
|
5909
6122
|
'agent-modal': () => closeAgentModal(),
|
|
5910
6123
|
'help-modal': () => closeHelpModal(),
|
|
5911
6124
|
'session-picker-modal': () => closeSessionPicker(),
|
|
6125
|
+
'terminal-manager-modal': () => closeTerminalManager(),
|
|
6126
|
+
'new-session-modal': () => closeNewSession(),
|
|
5912
6127
|
};
|
|
5913
6128
|
|
|
5914
6129
|
document.addEventListener('keydown', (e) => {
|
|
@@ -5924,6 +6139,35 @@ document.addEventListener('keydown', (e) => {
|
|
|
5924
6139
|
}
|
|
5925
6140
|
}
|
|
5926
6141
|
|
|
6142
|
+
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
6143
|
+
const terminalPrompt = document.getElementById('terminal-prompt');
|
|
6144
|
+
if (terminalPrompt.contains(e.target)) {
|
|
6145
|
+
if (e.key === 'Escape') {
|
|
6146
|
+
e.preventDefault();
|
|
6147
|
+
toggleTerminal();
|
|
6148
|
+
return;
|
|
6149
|
+
}
|
|
6150
|
+
if (e.key === 'Enter' || e.key === ' ') return;
|
|
6151
|
+
const step = matchKey(e, 'ArrowLeft', 'ArrowUp', 'KeyH', 'KeyK')
|
|
6152
|
+
? -1
|
|
6153
|
+
: matchKey(e, 'ArrowRight', 'ArrowDown', 'KeyL', 'KeyJ')
|
|
6154
|
+
? 1
|
|
6155
|
+
: 0;
|
|
6156
|
+
if (step) {
|
|
6157
|
+
e.preventDefault();
|
|
6158
|
+
const buttons = [...terminalPrompt.querySelectorAll('button')];
|
|
6159
|
+
const i = buttons.indexOf(e.target);
|
|
6160
|
+
buttons[(i + step + buttons.length) % buttons.length]?.focus();
|
|
6161
|
+
return;
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
const terminalAction = terminalShortcut(e);
|
|
6165
|
+
if (terminalAction) {
|
|
6166
|
+
e.preventDefault();
|
|
6167
|
+
terminalAction();
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
|
|
5927
6171
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
5928
6172
|
return;
|
|
5929
6173
|
}
|
|
@@ -5953,7 +6197,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
5953
6197
|
e.preventDefault();
|
|
5954
6198
|
closeMsgDetailModal();
|
|
5955
6199
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
5956
|
-
if (
|
|
6200
|
+
if (
|
|
6201
|
+
e.key === 'Enter' &&
|
|
6202
|
+
(e.ctrlKey || e.metaKey) &&
|
|
6203
|
+
currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX &&
|
|
6204
|
+
isWaitingAnswerable() &&
|
|
6205
|
+
currentWaiting.kind !== 'question'
|
|
6206
|
+
) {
|
|
6207
|
+
e.preventDefault();
|
|
6208
|
+
respondWaiting({ behavior: 'allow' });
|
|
6209
|
+
} else if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
5957
6210
|
e.preventDefault();
|
|
5958
6211
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
5959
6212
|
msgDetailFollowLatest = true;
|
|
@@ -6037,6 +6290,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
6037
6290
|
// Tab toggles focus zone
|
|
6038
6291
|
if (e.key === 'Tab') {
|
|
6039
6292
|
e.preventDefault();
|
|
6293
|
+
// The terminal replaces the board, so it takes the board's place in the Tab cycle.
|
|
6294
|
+
if (wantsTerminal()) {
|
|
6295
|
+
if (focusZone === 'sidebar') focusTerminalPane();
|
|
6296
|
+
else leaveTerminalPane();
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6040
6299
|
if (focusZone === 'sidebar') {
|
|
6041
6300
|
const hasCards = document.querySelector('.task-card');
|
|
6042
6301
|
if (!hasCards) return;
|
|
@@ -6135,10 +6394,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
6135
6394
|
}
|
|
6136
6395
|
|
|
6137
6396
|
// Shared actions — work in both sidebar and board
|
|
6397
|
+
const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
|
|
6138
6398
|
const contextSid =
|
|
6139
|
-
focusZone === 'sidebar'
|
|
6140
|
-
? sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId
|
|
6141
|
-
: selectedSessionId || currentSessionId;
|
|
6399
|
+
focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
|
|
6142
6400
|
if (matchKey(e, 'KeyP') && !e.shiftKey) {
|
|
6143
6401
|
e.preventDefault();
|
|
6144
6402
|
if (contextSid) openPlanForSession(contextSid);
|
|
@@ -6151,7 +6409,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
6151
6409
|
}
|
|
6152
6410
|
if (matchKey(e, 'KeyN') && !e.shiftKey) {
|
|
6153
6411
|
e.preventDefault();
|
|
6154
|
-
|
|
6412
|
+
const cursorKey = _scratchpadKeyOf(cursorEl);
|
|
6413
|
+
if (cursorKey) showScratchpad(cursorKey);
|
|
6414
|
+
else toggleScratchpad();
|
|
6155
6415
|
return;
|
|
6156
6416
|
}
|
|
6157
6417
|
if (e.key === '$' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
@@ -7057,6 +7317,24 @@ function setupEventSource() {
|
|
|
7057
7317
|
failCount = 0;
|
|
7058
7318
|
}
|
|
7059
7319
|
|
|
7320
|
+
let taskRefreshTimer = null;
|
|
7321
|
+
let metadataRefreshTimer = null;
|
|
7322
|
+
let metadataDeadline = 0;
|
|
7323
|
+
let agentRefreshTimer = null;
|
|
7324
|
+
const pendingTaskSessionIds = new Set();
|
|
7325
|
+
const pendingAgentSessionIds = new Set();
|
|
7326
|
+
|
|
7327
|
+
function refreshAllView() {
|
|
7328
|
+
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
7329
|
+
renderAllTasks();
|
|
7330
|
+
renderActivityChip();
|
|
7331
|
+
}
|
|
7332
|
+
|
|
7333
|
+
async function refreshSessionDetail(sessionId) {
|
|
7334
|
+
await fetchAgents(sessionId);
|
|
7335
|
+
if (!agentLogMode) fetchMessages(sessionId);
|
|
7336
|
+
}
|
|
7337
|
+
|
|
7060
7338
|
function connect() {
|
|
7061
7339
|
eventSource = new EventSource('/api/events');
|
|
7062
7340
|
|
|
@@ -7065,6 +7343,10 @@ function setupEventSource() {
|
|
|
7065
7343
|
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
7066
7344
|
fetchSessions().catch(() => {});
|
|
7067
7345
|
if (currentSessionId) fetchTasks(currentSessionId);
|
|
7346
|
+
if (terminalAvailable())
|
|
7347
|
+
loadTerminals()
|
|
7348
|
+
.then(renderSessions)
|
|
7349
|
+
.catch(() => {});
|
|
7068
7350
|
}
|
|
7069
7351
|
wasConnected = true;
|
|
7070
7352
|
retryDelay = 1000;
|
|
@@ -7080,31 +7362,29 @@ function setupEventSource() {
|
|
|
7080
7362
|
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
7081
7363
|
};
|
|
7082
7364
|
|
|
7083
|
-
let taskRefreshTimer = null;
|
|
7084
|
-
let metadataRefreshTimer = null;
|
|
7085
|
-
let agentRefreshTimer = null;
|
|
7086
|
-
const pendingTaskSessionIds = new Set();
|
|
7087
|
-
const pendingAgentSessionIds = new Set();
|
|
7088
|
-
|
|
7089
7365
|
function debouncedRefresh(sessionId, isMetadata) {
|
|
7090
7366
|
if (isMetadata) {
|
|
7367
|
+
// Busy sessions emit events faster than the 2 s quiet period, so a pure debounce would
|
|
7368
|
+
// never fire; the deadline caps how long a burst can hold the refresh back.
|
|
7369
|
+
metadataDeadline ||= Date.now() + METADATA_MAX_WAIT_MS;
|
|
7091
7370
|
clearTimeout(metadataRefreshTimer);
|
|
7092
|
-
metadataRefreshTimer = setTimeout(
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7371
|
+
metadataRefreshTimer = setTimeout(
|
|
7372
|
+
() => {
|
|
7373
|
+
metadataDeadline = 0;
|
|
7374
|
+
if (skipOffScreen()) return;
|
|
7375
|
+
fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7376
|
+
if (currentSessionId) refreshSessionDetail(currentSessionId);
|
|
7377
|
+
},
|
|
7378
|
+
Math.min(2000, metadataDeadline - Date.now()),
|
|
7379
|
+
);
|
|
7099
7380
|
} else {
|
|
7100
7381
|
pendingTaskSessionIds.add(sessionId);
|
|
7101
7382
|
clearTimeout(taskRefreshTimer);
|
|
7102
7383
|
taskRefreshTimer = setTimeout(async () => {
|
|
7384
|
+
if (skipOffScreen()) return;
|
|
7103
7385
|
await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7104
7386
|
if (viewMode === 'all') {
|
|
7105
|
-
|
|
7106
|
-
renderAllTasks();
|
|
7107
|
-
renderActivityChip();
|
|
7387
|
+
refreshAllView();
|
|
7108
7388
|
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7109
7389
|
const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
|
|
7110
7390
|
if (hasUpdate) fetchProjectView(currentProjectPath);
|
|
@@ -7124,23 +7404,27 @@ function setupEventSource() {
|
|
|
7124
7404
|
}
|
|
7125
7405
|
|
|
7126
7406
|
if (data.type === 'plan-update') {
|
|
7127
|
-
refreshOpenPlan();
|
|
7407
|
+
if (!skipOffScreen()) refreshOpenPlan();
|
|
7128
7408
|
}
|
|
7129
7409
|
|
|
7410
|
+
if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
|
|
7411
|
+
|
|
7412
|
+
if (data.type === 'dispatch-update') onDispatchUpdate();
|
|
7413
|
+
|
|
7130
7414
|
if (data.type === 'agent-update') {
|
|
7131
7415
|
pendingAgentSessionIds.add(data.sessionId);
|
|
7132
7416
|
clearTimeout(agentRefreshTimer);
|
|
7133
7417
|
agentRefreshTimer = setTimeout(() => {
|
|
7418
|
+
if (skipOffScreen()) return;
|
|
7134
7419
|
fetchSessions(false).catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7135
7420
|
if (viewMode === 'project' && currentProjectSessionIds.some((id) => pendingAgentSessionIds.has(id))) {
|
|
7136
7421
|
refreshProjectAgents();
|
|
7137
7422
|
} else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
|
|
7138
|
-
fetchAgents(currentSessionId);
|
|
7139
7423
|
// An agent starting/stopping adds its Agent tool_use + completion rows to
|
|
7140
7424
|
// the transcript. Refresh the message log on the same signal so they appear
|
|
7141
7425
|
// at start, not only when an unrelated metadata/context refresh coincides
|
|
7142
7426
|
// with completion.
|
|
7143
|
-
|
|
7427
|
+
refreshSessionDetail(currentSessionId);
|
|
7144
7428
|
}
|
|
7145
7429
|
pendingAgentSessionIds.clear();
|
|
7146
7430
|
}, 500);
|
|
@@ -7148,7 +7432,7 @@ function setupEventSource() {
|
|
|
7148
7432
|
|
|
7149
7433
|
if (data.type === 'context-update') {
|
|
7150
7434
|
debouncedRefresh(data.sessionId, true);
|
|
7151
|
-
refreshRateLimits();
|
|
7435
|
+
if (!skipOffScreen()) refreshRateLimits();
|
|
7152
7436
|
}
|
|
7153
7437
|
|
|
7154
7438
|
if (data.type === 'preview:open') {
|
|
@@ -7178,20 +7462,42 @@ function setupEventSource() {
|
|
|
7178
7462
|
};
|
|
7179
7463
|
}
|
|
7180
7464
|
|
|
7181
|
-
// When
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7465
|
+
// When cck comes back on screen (tab visible or hub:active), catch up immediately
|
|
7466
|
+
async function catchUp() {
|
|
7467
|
+
if (!isOnScreen() || !missedWhileHidden) return;
|
|
7468
|
+
missedWhileHidden = false;
|
|
7469
|
+
// The full refresh below covers whatever the pending debounces would have fetched.
|
|
7470
|
+
for (const t of [taskRefreshTimer, metadataRefreshTimer, agentRefreshTimer]) clearTimeout(t);
|
|
7471
|
+
metadataDeadline = 0;
|
|
7472
|
+
pendingTaskSessionIds.clear();
|
|
7473
|
+
pendingAgentSessionIds.clear();
|
|
7474
|
+
const sessionsLoaded = fetchSessions().catch(() => {});
|
|
7475
|
+
if (viewMode === 'all') {
|
|
7476
|
+
await sessionsLoaded;
|
|
7477
|
+
refreshAllView();
|
|
7478
|
+
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7479
|
+
await sessionsLoaded;
|
|
7480
|
+
fetchProjectView(currentProjectPath);
|
|
7481
|
+
} else if (currentSessionId) {
|
|
7482
|
+
fetchTasks(currentSessionId).catch(() => {});
|
|
7483
|
+
await refreshSessionDetail(currentSessionId);
|
|
7484
|
+
}
|
|
7485
|
+
refreshOpenPlan();
|
|
7486
|
+
refreshRateLimits();
|
|
7487
|
+
renderAgentFooter();
|
|
7488
|
+
}
|
|
7489
|
+
document.addEventListener('visibilitychange', catchUp);
|
|
7490
|
+
window.addEventListener('message', (e) => {
|
|
7491
|
+
if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:active') return;
|
|
7492
|
+
hubActive = !!e.data.active;
|
|
7493
|
+
catchUp();
|
|
7494
|
+
if (hubActive && wantsTerminal()) onTerminalShown();
|
|
7189
7495
|
});
|
|
7190
7496
|
|
|
7191
|
-
// Fallback poll every 30s in case SSE silently drops; skip when
|
|
7497
|
+
// Fallback poll every 30s in case SSE silently drops; skip when not on screen
|
|
7192
7498
|
setInterval(() => {
|
|
7193
|
-
if (
|
|
7194
|
-
|
|
7499
|
+
if (!isOnScreen()) {
|
|
7500
|
+
if (eventSource?.readyState !== EventSource.OPEN) missedWhileHidden = true;
|
|
7195
7501
|
return;
|
|
7196
7502
|
}
|
|
7197
7503
|
fetchSessions().catch(() => {});
|
|
@@ -7301,7 +7607,7 @@ function formatCost(usd) {
|
|
|
7301
7607
|
return `$${usd.toFixed(2)}`;
|
|
7302
7608
|
}
|
|
7303
7609
|
|
|
7304
|
-
function renderContextDetail(raw) {
|
|
7610
|
+
function renderContextDetail(raw, { tokens = true } = {}) {
|
|
7305
7611
|
const ctx = getCtx(raw);
|
|
7306
7612
|
if (!ctx) return '';
|
|
7307
7613
|
const totalK = ctx.size / 1000;
|
|
@@ -7310,6 +7616,16 @@ function renderContextDetail(raw) {
|
|
|
7310
7616
|
const cw = raw.context_window || {};
|
|
7311
7617
|
const usage = cw.current_usage || {};
|
|
7312
7618
|
const cost = raw.cost || {};
|
|
7619
|
+
const tokenRows = tokens
|
|
7620
|
+
? `<div class="stat-item"><span class="stat-label">Cache read</span><span class="stat-value">${formatTokens((usage.cache_read_input_tokens || 0) / 1000)}</span></div>
|
|
7621
|
+
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
|
|
7622
|
+
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
|
|
7623
|
+
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
7624
|
+
<div class="stat-divider"></div>
|
|
7625
|
+
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
7626
|
+
<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
|
|
7627
|
+
<div class="stat-divider"></div>`
|
|
7628
|
+
: '';
|
|
7313
7629
|
|
|
7314
7630
|
return `
|
|
7315
7631
|
<div class="detail-context">
|
|
@@ -7325,14 +7641,7 @@ function renderContextDetail(raw) {
|
|
|
7325
7641
|
<span>${formatTokens((ctx.pct / 100) * totalK)} / ${formatTokens(totalK)}</span>
|
|
7326
7642
|
</div>
|
|
7327
7643
|
<div class="detail-context-stats">
|
|
7328
|
-
|
|
7329
|
-
<div class="stat-item"><span class="stat-label">Cache write</span><span class="stat-value">${formatTokens((usage.cache_creation_input_tokens || 0) / 1000)}</span></div>
|
|
7330
|
-
<div class="stat-item"><span class="stat-label">Current input</span><span class="stat-value">${formatTokens((usage.input_tokens || 0) / 1000)}</span></div>
|
|
7331
|
-
<div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
|
|
7332
|
-
<div class="stat-divider"></div>
|
|
7333
|
-
<div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
|
|
7334
|
-
<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
|
|
7335
|
-
<div class="stat-divider"></div>
|
|
7644
|
+
${tokenRows}
|
|
7336
7645
|
<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>
|
|
7337
7646
|
<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
|
|
7338
7647
|
<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
|
|
@@ -7365,6 +7674,8 @@ let waitingDetailAutoOpened = false;
|
|
|
7365
7674
|
function maybeAutoOpenWaiting() {
|
|
7366
7675
|
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7367
7676
|
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7677
|
+
// The terminal on screen shows the same ask; hiding it lets the next poll open the modal.
|
|
7678
|
+
if (termState.shown) return;
|
|
7368
7679
|
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7369
7680
|
msgDetailFollowLatest = true;
|
|
7370
7681
|
waitingDetailAutoOpened = true;
|
|
@@ -7762,6 +8073,10 @@ function stripTeammateWrapper(text) {
|
|
|
7762
8073
|
// escaping is explicit here.
|
|
7763
8074
|
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`' };
|
|
7764
8075
|
|
|
8076
|
+
function pickRandom(items) {
|
|
8077
|
+
return items[Math.floor(Math.random() * items.length)];
|
|
8078
|
+
}
|
|
8079
|
+
|
|
7765
8080
|
function escapeHtml(text) {
|
|
7766
8081
|
if (text == null) return '';
|
|
7767
8082
|
return String(text).replace(/[&<>"'`]/g, (c) => HTML_ESCAPES[c]);
|
|
@@ -8122,6 +8437,13 @@ document.addEventListener('click', (e) => {
|
|
|
8122
8437
|
return;
|
|
8123
8438
|
}
|
|
8124
8439
|
|
|
8440
|
+
const newBtn = e.target.closest('.project-new-btn');
|
|
8441
|
+
if (newBtn) {
|
|
8442
|
+
e.stopPropagation();
|
|
8443
|
+
openNewSession(newBtn.dataset.projectPath);
|
|
8444
|
+
return;
|
|
8445
|
+
}
|
|
8446
|
+
|
|
8125
8447
|
const projectBtn = e.target.closest('.project-view-btn');
|
|
8126
8448
|
if (projectBtn) {
|
|
8127
8449
|
e.stopPropagation();
|
|
@@ -8147,20 +8469,34 @@ document.addEventListener('click', (e) => {
|
|
|
8147
8469
|
if (sgHeader) {
|
|
8148
8470
|
if (e.target.closest('.sg-name-input')) return;
|
|
8149
8471
|
e.stopPropagation();
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
if (e.target.closest('.sg-rename')) {
|
|
8154
|
-
sgBeginRename(groupId);
|
|
8472
|
+
if (e.target.closest('.sg-keep')) {
|
|
8473
|
+
sgKeepTransient(sgHeader.dataset.transientGroup);
|
|
8474
|
+
renderSessions();
|
|
8155
8475
|
return;
|
|
8156
8476
|
}
|
|
8157
|
-
if (e.target.closest('.sg-delete')) {
|
|
8158
|
-
|
|
8159
|
-
|
|
8477
|
+
if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
|
|
8478
|
+
const name = sgHeader.dataset.transientGroup;
|
|
8479
|
+
if (confirm(`Delete group “${name}”? Its sessions go back to Projects.`)) {
|
|
8480
|
+
sgDeleteTransient(name);
|
|
8160
8481
|
renderSessions();
|
|
8161
8482
|
}
|
|
8162
8483
|
return;
|
|
8163
8484
|
}
|
|
8485
|
+
const groupId = sgHeader.dataset.groupId;
|
|
8486
|
+
const group = sgGroupById(groupId);
|
|
8487
|
+
if (group) {
|
|
8488
|
+
if (e.target.closest('.sg-rename')) {
|
|
8489
|
+
sgBeginRename(groupId);
|
|
8490
|
+
return;
|
|
8491
|
+
}
|
|
8492
|
+
if (e.target.closest('.sg-delete')) {
|
|
8493
|
+
if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
|
|
8494
|
+
sgDeleteGroup(groupId);
|
|
8495
|
+
renderSessions();
|
|
8496
|
+
}
|
|
8497
|
+
return;
|
|
8498
|
+
}
|
|
8499
|
+
} else if (!sgHeader.dataset.transientGroup) return;
|
|
8164
8500
|
}
|
|
8165
8501
|
|
|
8166
8502
|
const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
|
|
@@ -8180,14 +8516,8 @@ function filterByProject(project) {
|
|
|
8180
8516
|
|
|
8181
8517
|
let projectsCache = null;
|
|
8182
8518
|
|
|
8183
|
-
async function
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
if (!projectsCacheDirty && projectsCache) {
|
|
8187
|
-
renderProjectDropdown(dropdown, projectsCache);
|
|
8188
|
-
return;
|
|
8189
|
-
}
|
|
8190
|
-
|
|
8519
|
+
async function loadProjects() {
|
|
8520
|
+
if (!projectsCacheDirty && projectsCache) return projectsCache;
|
|
8191
8521
|
let projects;
|
|
8192
8522
|
try {
|
|
8193
8523
|
const res = await fetch('/api/projects');
|
|
@@ -8197,10 +8527,20 @@ async function updateProjectDropdown() {
|
|
|
8197
8527
|
.sort()
|
|
8198
8528
|
.map((p) => ({ path: p, modifiedAt: null }));
|
|
8199
8529
|
}
|
|
8200
|
-
|
|
8201
8530
|
projectsCache = projects;
|
|
8202
8531
|
projectsCacheDirty = false;
|
|
8532
|
+
return projects;
|
|
8533
|
+
}
|
|
8534
|
+
|
|
8535
|
+
async function updateProjectDropdown() {
|
|
8536
|
+
const dropdown = document.getElementById('project-filter');
|
|
8203
8537
|
|
|
8538
|
+
if (!projectsCacheDirty && projectsCache) {
|
|
8539
|
+
renderProjectDropdown(dropdown, projectsCache);
|
|
8540
|
+
return;
|
|
8541
|
+
}
|
|
8542
|
+
|
|
8543
|
+
const projects = await loadProjects();
|
|
8204
8544
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
8205
8545
|
const prevRecent = recentProjects;
|
|
8206
8546
|
recentProjects = new Set(
|
|
@@ -9593,6 +9933,1274 @@ function filterByOwner(value) {
|
|
|
9593
9933
|
|
|
9594
9934
|
//#endregion
|
|
9595
9935
|
|
|
9936
|
+
//#region TERMINAL
|
|
9937
|
+
// The kanban and the terminal share one slot: terminal mode hides #main-content and shows
|
|
9938
|
+
// the pane. The PTY lives on the server (lib/terminal.js) and outlives the pane, so
|
|
9939
|
+
// leaving the session view or switching sessions drops only the socket; coming back
|
|
9940
|
+
// reattaches and the server replays the screen.
|
|
9941
|
+
const TERMINAL_TOKEN_KEY = 'terminal-token';
|
|
9942
|
+
const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
9943
|
+
// Ctrl+Alt letters cck keeps instead of forwarding to the hub: New session, Resume session, and
|
|
9944
|
+
// Swap to the previous session.
|
|
9945
|
+
const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
|
|
9946
|
+
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
9947
|
+
const ACK_BATCH_BYTES = 32 * 1024;
|
|
9948
|
+
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
9949
|
+
const TERMINAL_STABLE_MS = 5000;
|
|
9950
|
+
const STALE_TOKEN_MSG = 'The terminal token is out of date. Reload the hub window.';
|
|
9951
|
+
// Read at load, before the first updateUrl() rewrites the URL without the fragment.
|
|
9952
|
+
let terminalToken = readTerminalToken();
|
|
9953
|
+
const termState = {
|
|
9954
|
+
loaded: null,
|
|
9955
|
+
term: null,
|
|
9956
|
+
fit: null,
|
|
9957
|
+
webgl: null,
|
|
9958
|
+
ws: null,
|
|
9959
|
+
sessionId: null,
|
|
9960
|
+
syncedView: null,
|
|
9961
|
+
panelHidden: false,
|
|
9962
|
+
shown: false,
|
|
9963
|
+
attached: false,
|
|
9964
|
+
closeGuard: false,
|
|
9965
|
+
leaving: false,
|
|
9966
|
+
focusNext: false,
|
|
9967
|
+
openedByUser: null,
|
|
9968
|
+
ackPending: 0,
|
|
9969
|
+
ackTimer: null,
|
|
9970
|
+
retryTimer: null,
|
|
9971
|
+
};
|
|
9972
|
+
|
|
9973
|
+
// The hub passes the token in the fragment, which is never sent to a server or logged.
|
|
9974
|
+
function readTerminalToken() {
|
|
9975
|
+
const m = /[#&]t=([^&]*)/.exec(location.hash);
|
|
9976
|
+
if (m && TERMINAL_TOKEN_RE.test(m[1])) {
|
|
9977
|
+
storeTerminalToken(m[1]);
|
|
9978
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
9979
|
+
return m[1];
|
|
9980
|
+
}
|
|
9981
|
+
try {
|
|
9982
|
+
return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
|
|
9983
|
+
} catch (_) {
|
|
9984
|
+
return null;
|
|
9985
|
+
}
|
|
9986
|
+
}
|
|
9987
|
+
|
|
9988
|
+
function storeTerminalToken(token) {
|
|
9989
|
+
try {
|
|
9990
|
+
sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
|
|
9991
|
+
} catch (_) {}
|
|
9992
|
+
}
|
|
9993
|
+
|
|
9994
|
+
let tokenRefresh = null;
|
|
9995
|
+
|
|
9996
|
+
// The hub mints a new token each run, and this page can outlive a hub restart. Resolves true only
|
|
9997
|
+
// for a token that differs from the one that just failed, so a caller retries at most once.
|
|
9998
|
+
function refreshTerminalToken() {
|
|
9999
|
+
if (!window.__HUB__?.enabled) return Promise.resolve(false);
|
|
10000
|
+
tokenRefresh ??= requestTerminalToken().finally(() => {
|
|
10001
|
+
tokenRefresh = null;
|
|
10002
|
+
});
|
|
10003
|
+
return tokenRefresh;
|
|
10004
|
+
}
|
|
10005
|
+
|
|
10006
|
+
function requestTerminalToken() {
|
|
10007
|
+
return new Promise((resolve) => {
|
|
10008
|
+
const done = (ok) => {
|
|
10009
|
+
window.removeEventListener('message', onMessage);
|
|
10010
|
+
clearTimeout(timer);
|
|
10011
|
+
resolve(ok);
|
|
10012
|
+
};
|
|
10013
|
+
const onMessage = (e) => {
|
|
10014
|
+
if (e.source !== window.parent || e.origin !== hubOrigin() || e.data?.type !== 'hub:terminalToken') return;
|
|
10015
|
+
const token = e.data.token;
|
|
10016
|
+
if (typeof token !== 'string' || !TERMINAL_TOKEN_RE.test(token) || token === terminalToken) return done(false);
|
|
10017
|
+
terminalToken = token;
|
|
10018
|
+
storeTerminalToken(token);
|
|
10019
|
+
done(true);
|
|
10020
|
+
};
|
|
10021
|
+
const timer = setTimeout(() => done(false), 3000);
|
|
10022
|
+
window.addEventListener('message', onMessage);
|
|
10023
|
+
hubPost({ type: 'hub:terminalToken' });
|
|
10024
|
+
});
|
|
10025
|
+
}
|
|
10026
|
+
|
|
10027
|
+
function terminalAvailable() {
|
|
10028
|
+
return !!appConfig.terminal?.available && !!terminalToken;
|
|
10029
|
+
}
|
|
10030
|
+
|
|
10031
|
+
function terminalModes() {
|
|
10032
|
+
return new Set(readStoredList(TERMINAL_MODES_KEY));
|
|
10033
|
+
}
|
|
10034
|
+
|
|
10035
|
+
function setTerminalMode(sessionId, on) {
|
|
10036
|
+
const modes = terminalModes();
|
|
10037
|
+
if (on) modes.add(sessionId);
|
|
10038
|
+
else modes.delete(sessionId);
|
|
10039
|
+
try {
|
|
10040
|
+
store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
|
|
10041
|
+
} catch (_) {}
|
|
10042
|
+
}
|
|
10043
|
+
|
|
10044
|
+
function wantsTerminal() {
|
|
10045
|
+
return terminalAvailable() && viewMode === 'session' && !!currentSessionId && terminalModes().has(currentSessionId);
|
|
10046
|
+
}
|
|
10047
|
+
|
|
10048
|
+
function toggleTerminal() {
|
|
10049
|
+
if (!terminalAvailable() || viewMode !== 'session' || !currentSessionId) return;
|
|
10050
|
+
setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
|
|
10051
|
+
termState.focusNext = true;
|
|
10052
|
+
syncTerminal();
|
|
10053
|
+
}
|
|
10054
|
+
|
|
10055
|
+
function terminalPaneFocused() {
|
|
10056
|
+
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
10057
|
+
}
|
|
10058
|
+
|
|
10059
|
+
function terminalShortcut(e) {
|
|
10060
|
+
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10061
|
+
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10062
|
+
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10063
|
+
return () => openNewSession(null, e.code === 'KeyR');
|
|
10064
|
+
}
|
|
10065
|
+
if (e.code !== 'Backquote' || e.metaKey) return null;
|
|
10066
|
+
if (e.ctrlKey && !e.altKey) return e.shiftKey ? openTerminalManager : toggleTerminal;
|
|
10067
|
+
if (e.altKey && !e.ctrlKey && !e.shiftKey) return toggleTerminalFocus;
|
|
10068
|
+
if (!e.altKey || !e.shiftKey || e.ctrlKey) return null;
|
|
10069
|
+
if (termState.attached && wantsTerminal()) return () => closeTerminalSession();
|
|
10070
|
+
if (viewMode === 'session' && runningTerminals.has(currentSessionId)) {
|
|
10071
|
+
return () => closeTerminalSession(currentSessionId);
|
|
10072
|
+
}
|
|
10073
|
+
return null;
|
|
10074
|
+
}
|
|
10075
|
+
|
|
10076
|
+
function swapToPreviousSession() {
|
|
10077
|
+
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
10078
|
+
if (!target || target === currentSessionId) return;
|
|
10079
|
+
if (terminalPaneFocused()) termState.focusNext = true;
|
|
10080
|
+
openSession(target);
|
|
10081
|
+
}
|
|
10082
|
+
|
|
10083
|
+
// Esc belongs to Claude, so leaving the terminal without hiding it needs its own key.
|
|
10084
|
+
function toggleTerminalFocus() {
|
|
10085
|
+
if (!wantsTerminal()) {
|
|
10086
|
+
toggleTerminal();
|
|
10087
|
+
return;
|
|
10088
|
+
}
|
|
10089
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10090
|
+
else focusTerminalPane();
|
|
10091
|
+
}
|
|
10092
|
+
|
|
10093
|
+
// Idempotent: runs on every view change, so whichever path changed the view lands here.
|
|
10094
|
+
function syncTerminal() {
|
|
10095
|
+
const btn = document.getElementById('terminal-toggle');
|
|
10096
|
+
const on = wantsTerminal();
|
|
10097
|
+
const arrived = viewMode === 'session' ? currentSessionId : null;
|
|
10098
|
+
const view = `${arrived}:${on}`;
|
|
10099
|
+
// Only on a change of session or terminal state, so a panel opened by hand over the terminal stays.
|
|
10100
|
+
if (view !== termState.syncedView) {
|
|
10101
|
+
termState.syncedView = view;
|
|
10102
|
+
// The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
|
|
10103
|
+
if (on && messagePanelOpen) {
|
|
10104
|
+
toggleMessagePanel();
|
|
10105
|
+
termState.panelHidden = true;
|
|
10106
|
+
} else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
|
|
10107
|
+
}
|
|
10108
|
+
if (btn) {
|
|
10109
|
+
btn.style.display = terminalAvailable() && viewMode === 'session' && currentSessionId ? '' : 'none';
|
|
10110
|
+
btn.classList.toggle('active', on);
|
|
10111
|
+
}
|
|
10112
|
+
sessionView.classList.toggle('terminal-mode', on);
|
|
10113
|
+
if (!on) {
|
|
10114
|
+
termState.shown = false;
|
|
10115
|
+
termState.focusNext = false;
|
|
10116
|
+
termState.openedByUser = null;
|
|
10117
|
+
syncCloseGuard();
|
|
10118
|
+
// Hiding keeps the socket so Ctrl+` back is instant.
|
|
10119
|
+
const keepSocket = termState.sessionId === currentSessionId && viewMode === 'session';
|
|
10120
|
+
if (termState.sessionId && !keepSocket) detachTerminal();
|
|
10121
|
+
return;
|
|
10122
|
+
}
|
|
10123
|
+
const wasShown = termState.shown;
|
|
10124
|
+
termState.shown = true;
|
|
10125
|
+
syncCloseGuard();
|
|
10126
|
+
if (termState.sessionId !== currentSessionId) openTerminal(currentSessionId, terminalOpenMode(currentSessionId));
|
|
10127
|
+
else if (!wasShown) onTerminalShown(takeTerminalFocus(currentSessionId));
|
|
10128
|
+
}
|
|
10129
|
+
|
|
10130
|
+
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
10131
|
+
function openSession(sessionId) {
|
|
10132
|
+
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10133
|
+
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10134
|
+
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
10135
|
+
return fetchTasks(sessionId);
|
|
10136
|
+
}
|
|
10137
|
+
|
|
10138
|
+
// Selecting a session only shows its terminal; focus follows an explicit toggle, a new session, or
|
|
10139
|
+
// the user opening a session that waits on a prompt, since answering it is why they opened it.
|
|
10140
|
+
function takeTerminalFocus(sessionId) {
|
|
10141
|
+
const opened = termState.openedByUser === sessionId;
|
|
10142
|
+
termState.openedByUser = null;
|
|
10143
|
+
const focus = termState.focusNext || newSpecs.has(sessionId) || (opened && promptAwaitsUser(sessionId));
|
|
10144
|
+
termState.focusNext = false;
|
|
10145
|
+
return focus;
|
|
10146
|
+
}
|
|
10147
|
+
|
|
10148
|
+
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
10149
|
+
function promptAwaitsUser(sessionId) {
|
|
10150
|
+
if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
|
|
10151
|
+
return !document.activeElement?.matches('input, textarea, select, [contenteditable]');
|
|
10152
|
+
}
|
|
10153
|
+
|
|
10154
|
+
function onTerminalShown(focus = true) {
|
|
10155
|
+
requestAnimationFrame(() => {
|
|
10156
|
+
fitTerminal();
|
|
10157
|
+
repaintTerminal();
|
|
10158
|
+
if (focus) focusTerminalPane();
|
|
10159
|
+
});
|
|
10160
|
+
}
|
|
10161
|
+
|
|
10162
|
+
function focusTerminalPane() {
|
|
10163
|
+
const prompt = document.getElementById('terminal-prompt');
|
|
10164
|
+
if (prompt.classList.contains('visible')) prompt.querySelector('button')?.focus();
|
|
10165
|
+
else termState.term?.focus();
|
|
10166
|
+
}
|
|
10167
|
+
|
|
10168
|
+
function leaveTerminalPane() {
|
|
10169
|
+
termState.leaving = true;
|
|
10170
|
+
document.activeElement.blur();
|
|
10171
|
+
termState.leaving = false;
|
|
10172
|
+
setFocusZone('sidebar');
|
|
10173
|
+
}
|
|
10174
|
+
|
|
10175
|
+
function loadXterm() {
|
|
10176
|
+
if (termState.loaded) return termState.loaded;
|
|
10177
|
+
const css = document.createElement('link');
|
|
10178
|
+
css.rel = 'stylesheet';
|
|
10179
|
+
css.href = '/vendor/xterm/xterm.css';
|
|
10180
|
+
document.head.appendChild(css);
|
|
10181
|
+
const script = (src) =>
|
|
10182
|
+
new Promise((resolve, reject) => {
|
|
10183
|
+
const s = document.createElement('script');
|
|
10184
|
+
s.src = src;
|
|
10185
|
+
s.onload = resolve;
|
|
10186
|
+
s.onerror = () => reject(new Error(`failed to load ${src}`));
|
|
10187
|
+
document.head.appendChild(s);
|
|
10188
|
+
});
|
|
10189
|
+
termState.loaded = script('/vendor/xterm/xterm.js').then(() =>
|
|
10190
|
+
Promise.all(['addon-fit', 'addon-webgl', 'addon-unicode11'].map((n) => script(`/vendor/xterm/${n}.js`))),
|
|
10191
|
+
);
|
|
10192
|
+
termState.loaded.catch(() => {
|
|
10193
|
+
termState.loaded = null;
|
|
10194
|
+
});
|
|
10195
|
+
return termState.loaded;
|
|
10196
|
+
}
|
|
10197
|
+
|
|
10198
|
+
function terminalTheme() {
|
|
10199
|
+
const css = getComputedStyle(document.body);
|
|
10200
|
+
const v = (name) => css.getPropertyValue(name).trim();
|
|
10201
|
+
const theme = {
|
|
10202
|
+
background: v('--bg-deep'),
|
|
10203
|
+
foreground: v('--text-primary'),
|
|
10204
|
+
cursor: v('--accent'),
|
|
10205
|
+
cursorAccent: v('--bg-deep'),
|
|
10206
|
+
selectionBackground: v('--accent-dim'),
|
|
10207
|
+
};
|
|
10208
|
+
for (const name of ANSI_COLOR_NAMES) {
|
|
10209
|
+
theme[name] = v(`--ansi-${name}`);
|
|
10210
|
+
theme[`bright${name[0].toUpperCase()}${name.slice(1)}`] = v(`--ansi-bright-${name}`);
|
|
10211
|
+
}
|
|
10212
|
+
return theme;
|
|
10213
|
+
}
|
|
10214
|
+
|
|
10215
|
+
function terminalThemeOptions() {
|
|
10216
|
+
return {
|
|
10217
|
+
theme: terminalTheme(),
|
|
10218
|
+
// Claude Code on a dark theme (pinned, or output from before it saw the OSC 11 reply) draws
|
|
10219
|
+
// fixed 256/truecolor values no palette reaches; 3 keeps them readable and still blue.
|
|
10220
|
+
minimumContrastRatio: isLightTheme() ? 3 : 1,
|
|
10221
|
+
};
|
|
10222
|
+
}
|
|
10223
|
+
|
|
10224
|
+
// Returning false hands the key back to the page: xterm skips it and the document
|
|
10225
|
+
// listeners (the toggle, hub forwarding, native paste) see it instead.
|
|
10226
|
+
function terminalKeyFilter(e) {
|
|
10227
|
+
if (e.type !== 'keydown') return true;
|
|
10228
|
+
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
10229
|
+
if (terminalShortcut(e)) return false;
|
|
10230
|
+
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || termState.term.hasSelection())) {
|
|
10231
|
+
e.preventDefault();
|
|
10232
|
+
navigator.clipboard?.writeText(termState.term.getSelection()).catch(() => {});
|
|
10233
|
+
termState.term.clearSelection();
|
|
10234
|
+
return false;
|
|
10235
|
+
}
|
|
10236
|
+
if (ctrlOnly && e.code === 'KeyV') return false;
|
|
10237
|
+
return !isHubKey(e);
|
|
10238
|
+
}
|
|
10239
|
+
|
|
10240
|
+
function oscColor(hex) {
|
|
10241
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
10242
|
+
if (!m) return null;
|
|
10243
|
+
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
10244
|
+
}
|
|
10245
|
+
|
|
10246
|
+
function ensureTerm() {
|
|
10247
|
+
if (termState.term) return termState.term;
|
|
10248
|
+
const cfg = appConfig.terminal;
|
|
10249
|
+
const host = document.getElementById('terminal-host');
|
|
10250
|
+
const fontFamily =
|
|
10251
|
+
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
10252
|
+
const term = new window.Terminal({
|
|
10253
|
+
fontFamily,
|
|
10254
|
+
fontSize: cfg.fontSize,
|
|
10255
|
+
scrollback: cfg.scrollback,
|
|
10256
|
+
cursorBlink: true,
|
|
10257
|
+
allowProposedApi: true,
|
|
10258
|
+
...terminalThemeOptions(),
|
|
10259
|
+
});
|
|
10260
|
+
termState.fit = new window.FitAddon.FitAddon();
|
|
10261
|
+
term.loadAddon(termState.fit);
|
|
10262
|
+
term.loadAddon(new window.Unicode11Addon.Unicode11Addon());
|
|
10263
|
+
term.unicode.activeVersion = '11';
|
|
10264
|
+
term.open(host);
|
|
10265
|
+
try {
|
|
10266
|
+
const gl = new window.WebglAddon.WebglAddon();
|
|
10267
|
+
gl.onContextLoss(() => {
|
|
10268
|
+
gl.dispose();
|
|
10269
|
+
termState.webgl = null;
|
|
10270
|
+
repaintTerminal();
|
|
10271
|
+
});
|
|
10272
|
+
term.loadAddon(gl);
|
|
10273
|
+
termState.webgl = gl;
|
|
10274
|
+
} catch (_) {
|
|
10275
|
+
// No WebGL: xterm falls back to its DOM renderer.
|
|
10276
|
+
}
|
|
10277
|
+
document.fonts
|
|
10278
|
+
?.load(`${cfg.fontSize}px ${fontFamily}`)
|
|
10279
|
+
.then(repaintTerminal)
|
|
10280
|
+
.catch(() => {});
|
|
10281
|
+
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
10282
|
+
// xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
|
|
10283
|
+
// background (Claude Code's Auto theme) assume dark even on a light theme.
|
|
10284
|
+
for (const [code, key] of [
|
|
10285
|
+
[10, 'foreground'],
|
|
10286
|
+
[11, 'background'],
|
|
10287
|
+
]) {
|
|
10288
|
+
term.parser.registerOscHandler(code, (data) => {
|
|
10289
|
+
if (data !== '?') return false;
|
|
10290
|
+
const rgb = oscColor(term.options.theme[key]);
|
|
10291
|
+
if (rgb) terminalSend({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
|
|
10292
|
+
return true;
|
|
10293
|
+
});
|
|
10294
|
+
}
|
|
10295
|
+
term.onData((d) => terminalSend({ t: 'in', d }));
|
|
10296
|
+
term.onResize(({ cols, rows }) => terminalSend({ t: 'resize', cols, rows }));
|
|
10297
|
+
// One fit per frame while a drag resizes the host. The atlas is rebuilt only when the host comes
|
|
10298
|
+
// back from 0×0, which is also how a hidden hub iframe or pane shows again.
|
|
10299
|
+
let frame = 0;
|
|
10300
|
+
let visible = false;
|
|
10301
|
+
new ResizeObserver(() => {
|
|
10302
|
+
if (frame) return;
|
|
10303
|
+
frame = requestAnimationFrame(() => {
|
|
10304
|
+
frame = 0;
|
|
10305
|
+
const nowVisible = host.offsetWidth > 0;
|
|
10306
|
+
fitTerminal();
|
|
10307
|
+
if (nowVisible && !visible) repaintTerminal();
|
|
10308
|
+
visible = nowVisible;
|
|
10309
|
+
});
|
|
10310
|
+
}).observe(host);
|
|
10311
|
+
const themeKey = () => `${isLightTheme()}|${document.body.dataset.colorTheme || ''}`;
|
|
10312
|
+
let appliedTheme = themeKey();
|
|
10313
|
+
new MutationObserver(() => {
|
|
10314
|
+
if (themeKey() === appliedTheme) return;
|
|
10315
|
+
appliedTheme = themeKey();
|
|
10316
|
+
Object.assign(term.options, terminalThemeOptions());
|
|
10317
|
+
repaintTerminal();
|
|
10318
|
+
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'] });
|
|
10319
|
+
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
|
|
10320
|
+
// A blur no click caused, while the pane stays shown and the window keeps focus, is that Escape.
|
|
10321
|
+
let pointerDown = false;
|
|
10322
|
+
document.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
10323
|
+
document.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
10324
|
+
term.textarea.addEventListener('blur', () => {
|
|
10325
|
+
if (pointerDown || termState.leaving) return;
|
|
10326
|
+
requestAnimationFrame(() => {
|
|
10327
|
+
if (!termState.attached || !host.offsetWidth) return;
|
|
10328
|
+
if (!document.hasFocus() || document.activeElement !== document.body) return;
|
|
10329
|
+
terminalSend({ t: 'in', d: '\x1b' });
|
|
10330
|
+
term.focus();
|
|
10331
|
+
});
|
|
10332
|
+
});
|
|
10333
|
+
termState.term = term;
|
|
10334
|
+
return term;
|
|
10335
|
+
}
|
|
10336
|
+
|
|
10337
|
+
// The WebGL renderer keeps its last frame and glyph atlas while the pane (or the hub's iframe) is
|
|
10338
|
+
// display:none, and comes back showing stale cells or backgrounds with no text. xterm does not
|
|
10339
|
+
// repaint on its own when the canvas is shown again.
|
|
10340
|
+
function repaintTerminal() {
|
|
10341
|
+
const term = termState.term;
|
|
10342
|
+
if (!term) return;
|
|
10343
|
+
try {
|
|
10344
|
+
termState.webgl?.clearTextureAtlas();
|
|
10345
|
+
} catch (_) {}
|
|
10346
|
+
term.refresh(0, term.rows - 1);
|
|
10347
|
+
}
|
|
10348
|
+
|
|
10349
|
+
// The fit addon measures 0×0 while the pane (or the hub's iframe) is display:none.
|
|
10350
|
+
function fitTerminal() {
|
|
10351
|
+
const host = document.getElementById('terminal-host');
|
|
10352
|
+
if (!termState.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
10353
|
+
try {
|
|
10354
|
+
termState.fit.fit();
|
|
10355
|
+
} catch (_) {}
|
|
10356
|
+
}
|
|
10357
|
+
|
|
10358
|
+
function terminalSend(msg) {
|
|
10359
|
+
if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
|
|
10360
|
+
}
|
|
10361
|
+
|
|
10362
|
+
function ackTerminal(ws, n) {
|
|
10363
|
+
if (termState.ws !== ws) return;
|
|
10364
|
+
termState.ackPending += n;
|
|
10365
|
+
const flush = () => {
|
|
10366
|
+
clearTimeout(termState.ackTimer);
|
|
10367
|
+
termState.ackTimer = null;
|
|
10368
|
+
if (termState.ws === ws && termState.ackPending) terminalSend({ t: 'ack', n: termState.ackPending });
|
|
10369
|
+
termState.ackPending = 0;
|
|
10370
|
+
};
|
|
10371
|
+
if (termState.ackPending >= ACK_BATCH_BYTES) flush();
|
|
10372
|
+
else if (!termState.ackTimer) termState.ackTimer = setTimeout(flush, 50);
|
|
10373
|
+
}
|
|
10374
|
+
|
|
10375
|
+
function setTerminalStatus(text) {
|
|
10376
|
+
document.getElementById('terminal-status').textContent = text;
|
|
10377
|
+
}
|
|
10378
|
+
|
|
10379
|
+
function hideTerminalPrompt() {
|
|
10380
|
+
document.getElementById('terminal-prompt').classList.remove('visible');
|
|
10381
|
+
}
|
|
10382
|
+
|
|
10383
|
+
const TERMINAL_BOOT_VERBS = ['Waking Claude…', 'Clawding…', 'Warming up the terminal…', 'Summoning the session…'];
|
|
10384
|
+
const TERMINAL_BOOT_MAX_MS = 15000;
|
|
10385
|
+
let terminalBootTimer = null;
|
|
10386
|
+
|
|
10387
|
+
// A new PTY shows only a blinking cursor until Claude draws its first frame.
|
|
10388
|
+
function showTerminalBoot() {
|
|
10389
|
+
document.getElementById('terminal-boot-verb').textContent = pickRandom(TERMINAL_BOOT_VERBS);
|
|
10390
|
+
document.getElementById('terminal-boot').classList.add('visible');
|
|
10391
|
+
clearTimeout(terminalBootTimer);
|
|
10392
|
+
terminalBootTimer = setTimeout(hideTerminalBoot, TERMINAL_BOOT_MAX_MS);
|
|
10393
|
+
}
|
|
10394
|
+
|
|
10395
|
+
function hideTerminalBoot() {
|
|
10396
|
+
clearTimeout(terminalBootTimer);
|
|
10397
|
+
terminalBootTimer = null;
|
|
10398
|
+
document.getElementById('terminal-boot').classList.remove('visible');
|
|
10399
|
+
}
|
|
10400
|
+
|
|
10401
|
+
function showTerminalPrompt(sessionId, title, detail, choices, output = '') {
|
|
10402
|
+
const el = document.getElementById('terminal-prompt');
|
|
10403
|
+
const tail = output ? `<pre class="${TINTED_PRE_CLASS} terminal-prompt-output">${escapeHtml(output)}</pre>` : '';
|
|
10404
|
+
el.innerHTML = `<div class="terminal-prompt-text"><div class="terminal-prompt-title">${escapeHtml(title)}</div><div>${escapeHtml(detail)}</div></div>${tail}<div class="terminal-prompt-actions">${choices
|
|
10405
|
+
.map(
|
|
10406
|
+
([mode, label]) =>
|
|
10407
|
+
`<button type="button" class="btn btn-secondary" data-mode="${escapeHtml(mode)}">${escapeHtml(label)}</button>`,
|
|
10408
|
+
)
|
|
10409
|
+
.join('')}</div><div class="sp-hint"><kbd>Esc</kbd> hides the terminal</div>`;
|
|
10410
|
+
hideTerminalBoot();
|
|
10411
|
+
el.querySelectorAll('button[data-mode]').forEach((b) => {
|
|
10412
|
+
b.onclick = () => openTerminal(sessionId, b.dataset.mode);
|
|
10413
|
+
});
|
|
10414
|
+
el.classList.add('visible');
|
|
10415
|
+
el.querySelector('button')?.focus();
|
|
10416
|
+
}
|
|
10417
|
+
|
|
10418
|
+
function terminalTail(count) {
|
|
10419
|
+
const buf = termState.term?.buffer.active;
|
|
10420
|
+
if (!buf) return '';
|
|
10421
|
+
const lines = [];
|
|
10422
|
+
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
10423
|
+
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
10424
|
+
if (text.trim() || lines.length) lines.unshift(text);
|
|
10425
|
+
}
|
|
10426
|
+
return lines.join('\n');
|
|
10427
|
+
}
|
|
10428
|
+
|
|
10429
|
+
// Closing the page leaves the PTY running, but Ctrl+W meant for the prompt closes the tab, so ask first.
|
|
10430
|
+
// Under the hub the top frame asks: browsers do not reliably show the dialog for a frame.
|
|
10431
|
+
function setTerminalAttached(on) {
|
|
10432
|
+
termState.attached = on;
|
|
10433
|
+
syncCloseGuard();
|
|
10434
|
+
}
|
|
10435
|
+
|
|
10436
|
+
// A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
|
|
10437
|
+
function syncCloseGuard() {
|
|
10438
|
+
const on = termState.attached && termState.shown;
|
|
10439
|
+
if (termState.closeGuard === on) return;
|
|
10440
|
+
termState.closeGuard = on;
|
|
10441
|
+
hubPost({ type: 'hub:closeGuard', on });
|
|
10442
|
+
}
|
|
10443
|
+
|
|
10444
|
+
window.addEventListener('beforeunload', (e) => {
|
|
10445
|
+
if (termState.closeGuard && !window.__HUB__?.enabled) e.preventDefault();
|
|
10446
|
+
});
|
|
10447
|
+
|
|
10448
|
+
// The terminal stands in for the board, so it takes the board zone however focus arrives
|
|
10449
|
+
// (Tab, Alt+`, a click); leaveTerminalPane hands it back to the sidebar.
|
|
10450
|
+
document.getElementById('terminal-pane').addEventListener('focusin', () => {
|
|
10451
|
+
if (focusZone !== 'sidebar') return;
|
|
10452
|
+
clearKbSelection();
|
|
10453
|
+
focusZone = 'board';
|
|
10454
|
+
});
|
|
10455
|
+
|
|
10456
|
+
function detachTerminal() {
|
|
10457
|
+
const ws = termState.ws;
|
|
10458
|
+
termState.ws = null;
|
|
10459
|
+
termState.sessionId = null;
|
|
10460
|
+
clearTimeout(termState.ackTimer);
|
|
10461
|
+
termState.ackTimer = null;
|
|
10462
|
+
termState.ackPending = 0;
|
|
10463
|
+
clearTimeout(termState.retryTimer);
|
|
10464
|
+
termState.retryTimer = null;
|
|
10465
|
+
setTerminalAttached(false);
|
|
10466
|
+
if (ws) {
|
|
10467
|
+
ws.onclose = null;
|
|
10468
|
+
ws.close();
|
|
10469
|
+
}
|
|
10470
|
+
hideTerminalPrompt();
|
|
10471
|
+
hideTerminalBoot();
|
|
10472
|
+
}
|
|
10473
|
+
|
|
10474
|
+
async function openTerminal(sessionId, mode, attempt = 0) {
|
|
10475
|
+
detachTerminal();
|
|
10476
|
+
termState.sessionId = sessionId;
|
|
10477
|
+
if (!attempt) setTerminalStatus('');
|
|
10478
|
+
try {
|
|
10479
|
+
await loadXterm();
|
|
10480
|
+
} catch (e) {
|
|
10481
|
+
setTerminalStatus(e.message);
|
|
10482
|
+
return;
|
|
10483
|
+
}
|
|
10484
|
+
if (termState.sessionId !== sessionId) return;
|
|
10485
|
+
const term = ensureTerm();
|
|
10486
|
+
if (!attempt) showTerminalBoot();
|
|
10487
|
+
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
10488
|
+
// session had already queued would still be drawn after it. A retry keeps the last screen
|
|
10489
|
+
// until the replay arrives.
|
|
10490
|
+
const reset = () => term.write('\x1bc', repaintTerminal);
|
|
10491
|
+
if (!attempt) reset();
|
|
10492
|
+
fitTerminal();
|
|
10493
|
+
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/terminal/ws`);
|
|
10494
|
+
ws.binaryType = 'arraybuffer';
|
|
10495
|
+
termState.ws = ws;
|
|
10496
|
+
const spec = newSpecs.get(sessionId);
|
|
10497
|
+
let readyAt = 0;
|
|
10498
|
+
let refused = false;
|
|
10499
|
+
ws.onopen = () =>
|
|
10500
|
+
ws.send(
|
|
10501
|
+
JSON.stringify({
|
|
10502
|
+
t: 'hello',
|
|
10503
|
+
token: terminalToken,
|
|
10504
|
+
id: sessionId,
|
|
10505
|
+
mode,
|
|
10506
|
+
cols: term.cols,
|
|
10507
|
+
rows: term.rows,
|
|
10508
|
+
...(spec?.mode === mode && {
|
|
10509
|
+
cwd: spec.cwd,
|
|
10510
|
+
name: spec.name,
|
|
10511
|
+
worktree: spec.worktree,
|
|
10512
|
+
model: spec.model,
|
|
10513
|
+
prompt: spec.prompt,
|
|
10514
|
+
}),
|
|
10515
|
+
}),
|
|
10516
|
+
);
|
|
10517
|
+
ws.onmessage = (ev) => {
|
|
10518
|
+
if (termState.ws !== ws) return;
|
|
10519
|
+
if (typeof ev.data !== 'string') {
|
|
10520
|
+
const bytes = new Uint8Array(ev.data);
|
|
10521
|
+
term.write(bytes, () => {
|
|
10522
|
+
ackTerminal(ws, bytes.length);
|
|
10523
|
+
if (terminalBootTimer && termState.ws === ws && terminalTail(1)) hideTerminalBoot();
|
|
10524
|
+
});
|
|
10525
|
+
return;
|
|
10526
|
+
}
|
|
10527
|
+
let msg;
|
|
10528
|
+
try {
|
|
10529
|
+
msg = JSON.parse(ev.data);
|
|
10530
|
+
} catch (_) {
|
|
10531
|
+
return;
|
|
10532
|
+
}
|
|
10533
|
+
if (msg.t === 'ready') {
|
|
10534
|
+
readyAt = Date.now();
|
|
10535
|
+
if (attempt) reset();
|
|
10536
|
+
} else if (msg.t === 'error') refused = true;
|
|
10537
|
+
onTerminalMessage(sessionId, msg);
|
|
10538
|
+
};
|
|
10539
|
+
ws.onclose = (ev) => {
|
|
10540
|
+
if (termState.ws !== ws) return;
|
|
10541
|
+
termState.ws = null;
|
|
10542
|
+
setTerminalAttached(false);
|
|
10543
|
+
// A refusal already shows the server's reason, and retrying would only repeat it, unless the
|
|
10544
|
+
// reason was a token that the hub has since replaced (4001).
|
|
10545
|
+
if (refused && ev.code === 4001) {
|
|
10546
|
+
refreshTerminalToken().then((ok) => {
|
|
10547
|
+
if (ok && termState.sessionId === sessionId) openTerminal(sessionId, mode, attempt + 1);
|
|
10548
|
+
});
|
|
10549
|
+
return;
|
|
10550
|
+
}
|
|
10551
|
+
if (refused || document.getElementById('terminal-prompt').classList.contains('visible')) return;
|
|
10552
|
+
const stable = readyAt && Date.now() - readyAt > TERMINAL_STABLE_MS;
|
|
10553
|
+
retryTerminal(sessionId, stable ? 0 : attempt);
|
|
10554
|
+
};
|
|
10555
|
+
}
|
|
10556
|
+
|
|
10557
|
+
function terminalOpenMode(sessionId) {
|
|
10558
|
+
return newSpecs.get(sessionId)?.mode ?? 'auto';
|
|
10559
|
+
}
|
|
10560
|
+
|
|
10561
|
+
// The server moved the PTY to the session picked in `claude --resume`; reattaching under that id
|
|
10562
|
+
// replays the same screen, and the placeholder gives way to the real card.
|
|
10563
|
+
function adoptPickedSession(oldId, id) {
|
|
10564
|
+
const focused = terminalPaneFocused();
|
|
10565
|
+
if (lastSessionId === oldId) lastSessionId = id;
|
|
10566
|
+
if (previousSessionId === oldId) previousSessionId = id;
|
|
10567
|
+
setTerminalMode(id, true);
|
|
10568
|
+
forgetPlaceholder(oldId);
|
|
10569
|
+
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
10570
|
+
termState.focusNext = focused;
|
|
10571
|
+
fetchTasks(id);
|
|
10572
|
+
}
|
|
10573
|
+
|
|
10574
|
+
// The PTY outlives the socket, so a dropped connection (cck restart, sleep, proxy hiccup) reattaches
|
|
10575
|
+
// to the same screen. Only a socket that stayed up resets the backoff, so a server that accepts and
|
|
10576
|
+
// then drops every socket still runs out of attempts.
|
|
10577
|
+
function retryTerminal(sessionId, attempt) {
|
|
10578
|
+
const delay = TERMINAL_RETRY_MS[attempt];
|
|
10579
|
+
if (delay === undefined) {
|
|
10580
|
+
setTerminalStatus('');
|
|
10581
|
+
showTerminalPrompt(sessionId, 'Disconnected', 'The connection to the terminal was lost.', [
|
|
10582
|
+
[terminalOpenMode(sessionId), 'Reconnect'],
|
|
10583
|
+
]);
|
|
10584
|
+
return;
|
|
10585
|
+
}
|
|
10586
|
+
setTerminalStatus('Reconnecting…');
|
|
10587
|
+
termState.retryTimer = setTimeout(() => {
|
|
10588
|
+
termState.retryTimer = null;
|
|
10589
|
+
if (termState.sessionId === sessionId) openTerminal(sessionId, terminalOpenMode(sessionId), attempt + 1);
|
|
10590
|
+
}, delay);
|
|
10591
|
+
}
|
|
10592
|
+
|
|
10593
|
+
function onTerminalMessage(sessionId, msg) {
|
|
10594
|
+
if (msg.t === 'ready') {
|
|
10595
|
+
// The server has the prompt now; a later start of the same placeholder must not send it again.
|
|
10596
|
+
const spec = newSpecs.get(sessionId);
|
|
10597
|
+
if (spec) spec.prompt = null;
|
|
10598
|
+
hideTerminalPrompt();
|
|
10599
|
+
setTerminalStatus('');
|
|
10600
|
+
setTerminalAttached(true);
|
|
10601
|
+
terminalSend({ t: 'resize', cols: termState.term.cols, rows: termState.term.rows });
|
|
10602
|
+
if (takeTerminalFocus(sessionId)) termState.term.focus();
|
|
10603
|
+
} else if (msg.t === 'rekey' && typeof msg.id === 'string') {
|
|
10604
|
+
adoptPickedSession(sessionId, msg.id);
|
|
10605
|
+
if (msg.duplicate) showToast('That session is already open here, so this terminal switched to it', 'info');
|
|
10606
|
+
else if (msg.elsewhere)
|
|
10607
|
+
showToast('That session also runs in another terminal; both write the same transcript', 'error');
|
|
10608
|
+
} else if (msg.t === 'live') {
|
|
10609
|
+
showTerminalPrompt(
|
|
10610
|
+
sessionId,
|
|
10611
|
+
'Running in another terminal',
|
|
10612
|
+
'Resuming it here too makes both processes write to the same transcript.',
|
|
10613
|
+
[
|
|
10614
|
+
['resume', 'Resume anyway'],
|
|
10615
|
+
['fork', 'Fork'],
|
|
10616
|
+
['shell', 'Shell only'],
|
|
10617
|
+
],
|
|
10618
|
+
);
|
|
10619
|
+
} else if (msg.t === 'exit') {
|
|
10620
|
+
setTerminalAttached(false);
|
|
10621
|
+
// A failed exit shows its last lines in the prompt, so the cause stays readable.
|
|
10622
|
+
const clean = msg.code === 0 || msg.ended;
|
|
10623
|
+
// With no first message there is no transcript to resume, only the same new session to start again.
|
|
10624
|
+
const unsent = newSpecs.has(sessionId);
|
|
10625
|
+
if (unsent && clean) {
|
|
10626
|
+
dropPlaceholder(sessionId);
|
|
10627
|
+
return;
|
|
10628
|
+
}
|
|
10629
|
+
showTerminalPrompt(
|
|
10630
|
+
sessionId,
|
|
10631
|
+
msg.ended ? 'The terminal was ended' : 'The shell exited',
|
|
10632
|
+
clean ? 'Choose how to start again.' : `Exit code ${msg.code}.`,
|
|
10633
|
+
unsent
|
|
10634
|
+
? [
|
|
10635
|
+
[terminalOpenMode(sessionId), 'Start again'],
|
|
10636
|
+
['shell', 'Shell'],
|
|
10637
|
+
]
|
|
10638
|
+
: [
|
|
10639
|
+
['resume', 'Resume'],
|
|
10640
|
+
['fork', 'Fork'],
|
|
10641
|
+
['shell', 'Shell'],
|
|
10642
|
+
],
|
|
10643
|
+
clean ? '' : terminalTail(6),
|
|
10644
|
+
);
|
|
10645
|
+
} else if (msg.t === 'error') {
|
|
10646
|
+
hideTerminalBoot();
|
|
10647
|
+
setTerminalStatus(msg.msg || 'Terminal error');
|
|
10648
|
+
}
|
|
10649
|
+
}
|
|
10650
|
+
|
|
10651
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10652
|
+
function endTerminalSession() {
|
|
10653
|
+
terminalSend({ t: 'kill' });
|
|
10654
|
+
}
|
|
10655
|
+
|
|
10656
|
+
// Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
|
|
10657
|
+
function closeTerminalSession(id = termState.sessionId) {
|
|
10658
|
+
if (id === termState.sessionId) {
|
|
10659
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10660
|
+
detachTerminal();
|
|
10661
|
+
}
|
|
10662
|
+
setTerminalMode(id, false);
|
|
10663
|
+
syncTerminal();
|
|
10664
|
+
return endTerminal(id);
|
|
10665
|
+
}
|
|
10666
|
+
|
|
10667
|
+
function openTerminalManager() {
|
|
10668
|
+
if (!terminalAvailable()) return;
|
|
10669
|
+
// Esc typed in xterm goes to Claude, so the modal would not close on it.
|
|
10670
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10671
|
+
document.getElementById('terminal-manager-modal').classList.add('visible');
|
|
10672
|
+
renderTerminalManager();
|
|
10673
|
+
}
|
|
10674
|
+
|
|
10675
|
+
function closeTerminalManager() {
|
|
10676
|
+
hideModalOverlay('terminal-manager-modal');
|
|
10677
|
+
}
|
|
10678
|
+
|
|
10679
|
+
async function renderTerminalManager() {
|
|
10680
|
+
const body = document.getElementById('terminal-manager-body');
|
|
10681
|
+
let list = [];
|
|
10682
|
+
try {
|
|
10683
|
+
list = await loadTerminals();
|
|
10684
|
+
} catch (e) {
|
|
10685
|
+
body.innerHTML = `<div class="terminal-manager-empty">${escapeHtml(e.message)}</div>`;
|
|
10686
|
+
return;
|
|
10687
|
+
}
|
|
10688
|
+
const max = appConfig.terminal?.maxSessions;
|
|
10689
|
+
document.getElementById('terminal-manager-count').textContent = max
|
|
10690
|
+
? `${list.length} of ${max} running`
|
|
10691
|
+
: `${list.length} running`;
|
|
10692
|
+
document.getElementById('terminal-manager-end-all').hidden = list.length < 2;
|
|
10693
|
+
if (!list.length) {
|
|
10694
|
+
body.innerHTML = '<div class="terminal-manager-empty">No terminals running</div>';
|
|
10695
|
+
return;
|
|
10696
|
+
}
|
|
10697
|
+
list.sort((a, b) => b.startedAt - a.startedAt);
|
|
10698
|
+
body.innerHTML = list
|
|
10699
|
+
.map((t) => {
|
|
10700
|
+
const session = sessions.find((s) => s.id === t.id);
|
|
10701
|
+
const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
|
|
10702
|
+
const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
|
|
10703
|
+
const attached = t.clients ? `${t.clients} attached` : 'detached';
|
|
10704
|
+
return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
|
|
10705
|
+
<span class="terminal-manager-dot"></span>
|
|
10706
|
+
<div class="terminal-manager-info">
|
|
10707
|
+
<div class="terminal-manager-name"><span class="terminal-manager-title">${escapeHtml(name)}</span>${here}</div>
|
|
10708
|
+
<div class="terminal-manager-meta">
|
|
10709
|
+
<span class="terminal-manager-mode">${escapeHtml(t.mode)}</span>
|
|
10710
|
+
<span title="${escapeHtml(`pid ${t.pid}`)}">up ${formatDuration(Date.now() - t.startedAt)}</span>
|
|
10711
|
+
<span>${attached}</span>
|
|
10712
|
+
<span class="terminal-manager-cwd" title="${escapeHtml(t.cwd)}">${escapeHtml(pathBasename(t.cwd))}</span>
|
|
10713
|
+
</div>
|
|
10714
|
+
</div>
|
|
10715
|
+
<div class="terminal-manager-actions">
|
|
10716
|
+
<button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}">Open</button>
|
|
10717
|
+
<button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}">End</button>
|
|
10718
|
+
</div>
|
|
10719
|
+
</div>`;
|
|
10720
|
+
})
|
|
10721
|
+
.join('');
|
|
10722
|
+
body.querySelectorAll('[data-open]').forEach((b) => {
|
|
10723
|
+
b.onclick = () => {
|
|
10724
|
+
closeTerminalManager();
|
|
10725
|
+
showSessionTerminal(b.dataset.open);
|
|
10726
|
+
};
|
|
10727
|
+
});
|
|
10728
|
+
body.querySelectorAll('[data-end]').forEach((b) => {
|
|
10729
|
+
b.onclick = async () => {
|
|
10730
|
+
b.disabled = true;
|
|
10731
|
+
await endTerminal(b.dataset.end);
|
|
10732
|
+
renderTerminalManager();
|
|
10733
|
+
};
|
|
10734
|
+
});
|
|
10735
|
+
}
|
|
10736
|
+
|
|
10737
|
+
async function terminalFetch(url, method, body) {
|
|
10738
|
+
const send = () =>
|
|
10739
|
+
fetch(url, {
|
|
10740
|
+
method,
|
|
10741
|
+
headers: {
|
|
10742
|
+
'X-Terminal-Token': terminalToken || '',
|
|
10743
|
+
...(body && { 'Content-Type': 'application/json' }),
|
|
10744
|
+
},
|
|
10745
|
+
...(body && { body: JSON.stringify(body) }),
|
|
10746
|
+
});
|
|
10747
|
+
const res = await send();
|
|
10748
|
+
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
10749
|
+
}
|
|
10750
|
+
|
|
10751
|
+
async function endTerminal(id) {
|
|
10752
|
+
await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
|
|
10753
|
+
dropPlaceholder(id);
|
|
10754
|
+
}
|
|
10755
|
+
|
|
10756
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
10757
|
+
async function endAllTerminals() {
|
|
10758
|
+
const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
|
|
10759
|
+
await Promise.all([...buttons].map((b) => endTerminal(b.dataset.end)));
|
|
10760
|
+
renderTerminalManager();
|
|
10761
|
+
}
|
|
10762
|
+
|
|
10763
|
+
//#endregion
|
|
10764
|
+
|
|
10765
|
+
//#region NEW_SESSION
|
|
10766
|
+
// claude writes no transcript until the first message, so until then the sidebar shows a
|
|
10767
|
+
// placeholder built from the form. newSpecs holds one entry per such session; the entry
|
|
10768
|
+
// goes when the real session shows up in /api/sessions.
|
|
10769
|
+
const NEW_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
10770
|
+
const NEW_WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
10771
|
+
const NS_MAX_MATCHES = 8;
|
|
10772
|
+
const PLUS_SVG =
|
|
10773
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
|
10774
|
+
const newSpecs = new Map();
|
|
10775
|
+
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
10776
|
+
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
10777
|
+
const dispatchSpecs = new Map();
|
|
10778
|
+
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', browsing: false, resume: false };
|
|
10779
|
+
|
|
10780
|
+
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
10781
|
+
const PLACEHOLDER_HINTS = {
|
|
10782
|
+
pick: 'pick a session to resume',
|
|
10783
|
+
dispatch: 'starting',
|
|
10784
|
+
new: 'waiting for your first message',
|
|
10785
|
+
};
|
|
10786
|
+
|
|
10787
|
+
function placeholderSession(id, spec) {
|
|
10788
|
+
return {
|
|
10789
|
+
id,
|
|
10790
|
+
placeholder: true,
|
|
10791
|
+
mode: spec.mode,
|
|
10792
|
+
name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
|
|
10793
|
+
dispatchGroup: spec.group || undefined,
|
|
10794
|
+
startedBy: spec.startedBy || undefined,
|
|
10795
|
+
project: spec.cwd,
|
|
10796
|
+
worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
|
|
10797
|
+
modifiedAt: new Date(spec.startedAt).toISOString(),
|
|
10798
|
+
hasMessages: true,
|
|
10799
|
+
hasRecentActivity: true,
|
|
10800
|
+
taskCount: 0,
|
|
10801
|
+
completed: 0,
|
|
10802
|
+
};
|
|
10803
|
+
}
|
|
10804
|
+
|
|
10805
|
+
function mergePlaceholders(list) {
|
|
10806
|
+
if (!newSpecs.size && !dispatchSpecs.size) return list;
|
|
10807
|
+
const ids = new Set(list.map((s) => s.id));
|
|
10808
|
+
for (const specs of [newSpecs, dispatchSpecs]) {
|
|
10809
|
+
for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
|
|
10810
|
+
}
|
|
10811
|
+
const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
|
|
10812
|
+
return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
|
|
10813
|
+
}
|
|
10814
|
+
|
|
10815
|
+
async function loadDispatches() {
|
|
10816
|
+
try {
|
|
10817
|
+
const res = await fetch('/api/dispatch', { cache: 'no-store' });
|
|
10818
|
+
const { running = [] } = await res.json();
|
|
10819
|
+
dispatchSpecs.clear();
|
|
10820
|
+
for (const r of running) {
|
|
10821
|
+
if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
|
|
10822
|
+
dispatchSpecs.set(r.session, {
|
|
10823
|
+
mode: 'dispatch',
|
|
10824
|
+
cwd: r.cwd,
|
|
10825
|
+
name: r.name,
|
|
10826
|
+
worktree: r.worktree,
|
|
10827
|
+
group: r.group,
|
|
10828
|
+
startedBy: r.parent,
|
|
10829
|
+
startedAt: r.startedAt,
|
|
10830
|
+
});
|
|
10831
|
+
}
|
|
10832
|
+
} catch (_) {}
|
|
10833
|
+
}
|
|
10834
|
+
|
|
10835
|
+
async function onDispatchUpdate() {
|
|
10836
|
+
await loadDispatches();
|
|
10837
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
10838
|
+
renderSessions();
|
|
10839
|
+
fetchSessions(false).catch(() => {});
|
|
10840
|
+
}
|
|
10841
|
+
|
|
10842
|
+
// With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
|
|
10843
|
+
function dropPlaceholder(id) {
|
|
10844
|
+
if (!forgetPlaceholder(id)) return;
|
|
10845
|
+
if (currentSessionId === id && viewMode === 'session') showAllTasks();
|
|
10846
|
+
else renderSessions();
|
|
10847
|
+
}
|
|
10848
|
+
|
|
10849
|
+
function forgetPlaceholder(id) {
|
|
10850
|
+
if (!newSpecs.delete(id)) return false;
|
|
10851
|
+
setTerminalMode(id, false);
|
|
10852
|
+
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
10853
|
+
if (termState.sessionId === id) detachTerminal();
|
|
10854
|
+
sessions = sessions.filter((s) => s.id !== id);
|
|
10855
|
+
if (previousSessionId === id) previousSessionId = null;
|
|
10856
|
+
if (lastSessionId === id) lastSessionId = null;
|
|
10857
|
+
return true;
|
|
10858
|
+
}
|
|
10859
|
+
|
|
10860
|
+
function renderPlaceholderCard(session) {
|
|
10861
|
+
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
10862
|
+
const projectHtml = renderProjectIdentity(session);
|
|
10863
|
+
return `
|
|
10864
|
+
<button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
|
|
10865
|
+
<div class="session-name">${escapeHtml(session.name)}</div>
|
|
10866
|
+
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
10867
|
+
<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
|
|
10868
|
+
</button>
|
|
10869
|
+
`;
|
|
10870
|
+
}
|
|
10871
|
+
|
|
10872
|
+
// After a reload the form is gone, but the PTY still runs; its hello options come back from the server.
|
|
10873
|
+
async function restorePendingSessions() {
|
|
10874
|
+
document.getElementById('new-session-btn').hidden = !terminalAvailable();
|
|
10875
|
+
const terminals = terminalAvailable() ? loadTerminals() : null;
|
|
10876
|
+
await loadDispatches();
|
|
10877
|
+
if (!terminals) return;
|
|
10878
|
+
try {
|
|
10879
|
+
for (const t of await terminals) {
|
|
10880
|
+
if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
|
|
10881
|
+
newSpecs.set(t.id, { cwd: t.cwd, name: t.name, worktree: t.worktree, mode: t.mode, startedAt: t.startedAt });
|
|
10882
|
+
}
|
|
10883
|
+
} catch (_) {}
|
|
10884
|
+
}
|
|
10885
|
+
|
|
10886
|
+
async function loadTerminals() {
|
|
10887
|
+
const res = await fetch('/api/terminals', { cache: 'no-store' });
|
|
10888
|
+
const list = (await res.json()).sessions || [];
|
|
10889
|
+
runningTerminals = new Set(list.map((t) => t.id));
|
|
10890
|
+
return list;
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10893
|
+
function setRunningTerminals(ids) {
|
|
10894
|
+
runningTerminals = new Set(ids);
|
|
10895
|
+
if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
|
|
10896
|
+
else renderSessions();
|
|
10897
|
+
}
|
|
10898
|
+
|
|
10899
|
+
function showSessionTerminal(sessionId) {
|
|
10900
|
+
setTerminalMode(sessionId, true);
|
|
10901
|
+
if (currentSessionId === sessionId && viewMode === 'session') syncTerminal();
|
|
10902
|
+
else fetchTasks(sessionId);
|
|
10903
|
+
}
|
|
10904
|
+
|
|
10905
|
+
function openNewSession(folder, resume = false) {
|
|
10906
|
+
if (!terminalAvailable() || document.getElementById('new-session-modal').classList.contains('visible')) return;
|
|
10907
|
+
if (terminalPaneFocused()) leaveTerminalPane();
|
|
10908
|
+
ns.resume = resume;
|
|
10909
|
+
document.querySelector('.new-session-modal').classList.toggle('ns-resume', resume);
|
|
10910
|
+
document.getElementById('ns-title').textContent = resume ? 'Resume session' : 'New session';
|
|
10911
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
10912
|
+
document.getElementById('ns-wt').checked = false;
|
|
10913
|
+
document.getElementById('ns-model').value = '';
|
|
10914
|
+
setNewSessionError('');
|
|
10915
|
+
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
10916
|
+
setNewSessionFolder(folder || current || '');
|
|
10917
|
+
document.getElementById('new-session-modal').classList.add('visible');
|
|
10918
|
+
const input = document.getElementById('ns-folder');
|
|
10919
|
+
input.focus();
|
|
10920
|
+
input.select();
|
|
10921
|
+
loadProjects().then((list) => {
|
|
10922
|
+
ns.projects = list
|
|
10923
|
+
.filter((p) => !p.temp)
|
|
10924
|
+
.sort((a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || ''))
|
|
10925
|
+
.map((p) => p.path);
|
|
10926
|
+
if (!ns.folder) setNewSessionFolder(input.value, true);
|
|
10927
|
+
if (folderListOpen()) renderFolderList();
|
|
10928
|
+
});
|
|
10929
|
+
}
|
|
10930
|
+
|
|
10931
|
+
function closeNewSession() {
|
|
10932
|
+
hideModalOverlay('new-session-modal');
|
|
10933
|
+
closeFolderList();
|
|
10934
|
+
promptVoice.rec?.abort();
|
|
10935
|
+
}
|
|
10936
|
+
|
|
10937
|
+
function knownFolders() {
|
|
10938
|
+
return [...new Set([...ns.picked, ...ns.projects])];
|
|
10939
|
+
}
|
|
10940
|
+
|
|
10941
|
+
// The field holds free text; a folder counts as chosen only when the text is a known project
|
|
10942
|
+
// or a folder the dialog returned, because those are the only ones the server accepts.
|
|
10943
|
+
// The sidebar's projects count too, so a prefilled folder is usable before /api/projects answers.
|
|
10944
|
+
function setNewSessionFolder(value, keepList) {
|
|
10945
|
+
const input = document.getElementById('ns-folder');
|
|
10946
|
+
input.value = value;
|
|
10947
|
+
const known = knownFolders().includes(value) || sessions.some((s) => !s.placeholder && s.project === value);
|
|
10948
|
+
ns.folder = value && known ? value : '';
|
|
10949
|
+
if (!keepList) closeFolderList();
|
|
10950
|
+
renderNewSessionForm();
|
|
10951
|
+
}
|
|
10952
|
+
|
|
10953
|
+
function folderRank(path, q) {
|
|
10954
|
+
const name = pathBasename(path).toLowerCase();
|
|
10955
|
+
if (name === q) return 0;
|
|
10956
|
+
if (name.startsWith(q)) return 1;
|
|
10957
|
+
if (name.includes(q)) return 2;
|
|
10958
|
+
return path.toLowerCase().includes(q) ? 3 : 4;
|
|
10959
|
+
}
|
|
10960
|
+
|
|
10961
|
+
function rankFolders(paths, query) {
|
|
10962
|
+
const q = query.toLowerCase();
|
|
10963
|
+
return paths
|
|
10964
|
+
.filter((p) => fuzzyMatch(p, query))
|
|
10965
|
+
.map((p) => ({ p, r: folderRank(p, q) }))
|
|
10966
|
+
.sort((a, b) => a.r - b.r)
|
|
10967
|
+
.map((x) => x.p);
|
|
10968
|
+
}
|
|
10969
|
+
|
|
10970
|
+
function renderFolderList() {
|
|
10971
|
+
const input = document.getElementById('ns-folder');
|
|
10972
|
+
const list = document.getElementById('ns-folder-list');
|
|
10973
|
+
const query = input.value.trim();
|
|
10974
|
+
const all = knownFolders();
|
|
10975
|
+
ns.matches = (query && query !== ns.folder ? rankFolders(all, query) : all).slice(0, NS_MAX_MATCHES);
|
|
10976
|
+
if (ns.idx >= ns.matches.length) ns.idx = ns.matches.length - 1;
|
|
10977
|
+
list.innerHTML = ns.matches.length
|
|
10978
|
+
? ns.matches
|
|
10979
|
+
.map(
|
|
10980
|
+
(p, i) =>
|
|
10981
|
+
`<div class="ns-option${i === ns.idx ? ' active' : ''}" role="option" data-idx="${i}" aria-selected="${i === ns.idx}"><span class="ns-option-name">${escapeHtml(pathBasename(p))}</span><span class="ns-option-path">${escapeHtml(p)}</span></div>`,
|
|
10982
|
+
)
|
|
10983
|
+
.join('')
|
|
10984
|
+
: '<div class="ns-empty"><span>No project matches</span><button type="button" class="btn btn-secondary ns-browse" data-browse>Browse…</button></div>';
|
|
10985
|
+
list.hidden = false;
|
|
10986
|
+
input.setAttribute('aria-expanded', 'true');
|
|
10987
|
+
}
|
|
10988
|
+
|
|
10989
|
+
function moveFolderHighlight(step) {
|
|
10990
|
+
const n = ns.matches.length;
|
|
10991
|
+
if (!n) return;
|
|
10992
|
+
ns.idx = (ns.idx + step + n) % n;
|
|
10993
|
+
for (const row of document.querySelectorAll('#ns-folder-list .ns-option')) {
|
|
10994
|
+
const on = Number(row.dataset.idx) === ns.idx;
|
|
10995
|
+
row.classList.toggle('active', on);
|
|
10996
|
+
row.setAttribute('aria-selected', on);
|
|
10997
|
+
}
|
|
10998
|
+
}
|
|
10999
|
+
|
|
11000
|
+
function closeFolderList() {
|
|
11001
|
+
document.getElementById('ns-folder-list').hidden = true;
|
|
11002
|
+
document.getElementById('ns-folder').setAttribute('aria-expanded', 'false');
|
|
11003
|
+
ns.idx = -1;
|
|
11004
|
+
}
|
|
11005
|
+
|
|
11006
|
+
function folderListOpen() {
|
|
11007
|
+
return !document.getElementById('ns-folder-list').hidden;
|
|
11008
|
+
}
|
|
11009
|
+
|
|
11010
|
+
function newSessionValues() {
|
|
11011
|
+
const wtOn = document.getElementById('ns-wt').checked;
|
|
11012
|
+
const wtName = document.getElementById('ns-wt-name').value.trim();
|
|
11013
|
+
return {
|
|
11014
|
+
cwd: ns.folder,
|
|
11015
|
+
name: document.getElementById('ns-name').value.trim() || null,
|
|
11016
|
+
worktree: wtOn ? wtName || true : false,
|
|
11017
|
+
model: document.getElementById('ns-model').value || null,
|
|
11018
|
+
prompt: document.getElementById('ns-prompt').value.trim() || null,
|
|
11019
|
+
};
|
|
11020
|
+
}
|
|
11021
|
+
|
|
11022
|
+
function newSessionProblem(v) {
|
|
11023
|
+
if (v.name && !NEW_NAME_RE.test(v.name))
|
|
11024
|
+
return 'Name: use letters, digits, spaces and . _ -, starting with a letter or digit.';
|
|
11025
|
+
if (typeof v.worktree === 'string' && !NEW_WORKTREE_RE.test(v.worktree))
|
|
11026
|
+
return 'Worktree name: use letters, digits and . _ -, starting with a letter or digit.';
|
|
11027
|
+
return '';
|
|
11028
|
+
}
|
|
11029
|
+
|
|
11030
|
+
function setNewSessionError(text) {
|
|
11031
|
+
const el = document.getElementById('ns-error');
|
|
11032
|
+
el.textContent = text;
|
|
11033
|
+
el.hidden = !text;
|
|
11034
|
+
}
|
|
11035
|
+
|
|
11036
|
+
function renderNewSessionForm() {
|
|
11037
|
+
const v = newSessionValues();
|
|
11038
|
+
const wtOn = v.worktree !== false;
|
|
11039
|
+
const wtInput = document.getElementById('ns-wt-name');
|
|
11040
|
+
wtInput.disabled = !wtOn;
|
|
11041
|
+
const hint = document.getElementById('ns-wt-hint');
|
|
11042
|
+
hint.hidden = !wtOn;
|
|
11043
|
+
hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
|
|
11044
|
+
const problem = newSessionProblem(v);
|
|
11045
|
+
setNewSessionError(problem);
|
|
11046
|
+
const start = document.getElementById('ns-start');
|
|
11047
|
+
start.disabled = !v.cwd || !!problem || ns.browsing;
|
|
11048
|
+
const verb = ns.resume ? 'Resume' : 'Start';
|
|
11049
|
+
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
11050
|
+
}
|
|
11051
|
+
|
|
11052
|
+
async function browseNewSessionFolder() {
|
|
11053
|
+
if (ns.browsing) return;
|
|
11054
|
+
ns.browsing = true;
|
|
11055
|
+
closeFolderList();
|
|
11056
|
+
const btn = document.getElementById('ns-browse');
|
|
11057
|
+
btn.textContent = 'Opening…';
|
|
11058
|
+
renderNewSessionForm();
|
|
11059
|
+
try {
|
|
11060
|
+
const start = ns.folder || ns.picked[0] || ns.projects[0];
|
|
11061
|
+
const res = await terminalFetch('/api/terminal/pick-folder', 'POST', start ? { start } : null);
|
|
11062
|
+
const body = await res.json().catch(() => ({}));
|
|
11063
|
+
if (res.status === 401) setNewSessionError(STALE_TOKEN_MSG);
|
|
11064
|
+
else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
|
|
11065
|
+
else if (body.path) {
|
|
11066
|
+
ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
|
|
11067
|
+
setNewSessionFolder(body.path);
|
|
11068
|
+
}
|
|
11069
|
+
} catch (e) {
|
|
11070
|
+
setNewSessionError(e.message);
|
|
11071
|
+
} finally {
|
|
11072
|
+
ns.browsing = false;
|
|
11073
|
+
btn.textContent = 'Browse…';
|
|
11074
|
+
renderNewSessionForm();
|
|
11075
|
+
document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
|
|
11076
|
+
}
|
|
11077
|
+
}
|
|
11078
|
+
|
|
11079
|
+
function fieldAfterFolder() {
|
|
11080
|
+
return ns.resume ? 'ns-start' : 'ns-name';
|
|
11081
|
+
}
|
|
11082
|
+
|
|
11083
|
+
function startNewSession() {
|
|
11084
|
+
const v = newSessionValues();
|
|
11085
|
+
if (!v.cwd || newSessionProblem(v) || ns.browsing) return;
|
|
11086
|
+
const id = crypto.randomUUID();
|
|
11087
|
+
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
11088
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
11089
|
+
setTerminalMode(id, true);
|
|
11090
|
+
closeNewSession();
|
|
11091
|
+
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
11092
|
+
}
|
|
11093
|
+
|
|
11094
|
+
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
11095
|
+
const promptVoice = { rec: null };
|
|
11096
|
+
|
|
11097
|
+
// Replaces the selection the field had when dictation began, so interim results rewrite
|
|
11098
|
+
// themselves in place instead of piling up.
|
|
11099
|
+
function togglePromptVoice() {
|
|
11100
|
+
if (promptVoice.rec) return promptVoice.rec.stop();
|
|
11101
|
+
const field = document.getElementById('ns-prompt');
|
|
11102
|
+
const btn = document.getElementById('ns-mic');
|
|
11103
|
+
const before = field.value.slice(0, field.selectionStart);
|
|
11104
|
+
const after = field.value.slice(field.selectionEnd);
|
|
11105
|
+
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
11106
|
+
const rec = new SpeechRecognitionApi();
|
|
11107
|
+
rec.lang = navigator.language;
|
|
11108
|
+
rec.interimResults = true;
|
|
11109
|
+
rec.onresult = (e) => {
|
|
11110
|
+
const text = Array.from(e.results, (r) => r[0].transcript).join('');
|
|
11111
|
+
field.value = before + lead + text + after;
|
|
11112
|
+
const caret = before.length + lead.length + text.length;
|
|
11113
|
+
field.setSelectionRange(caret, caret);
|
|
11114
|
+
};
|
|
11115
|
+
rec.onerror = (e) => {
|
|
11116
|
+
if (e.error === 'not-allowed' || e.error === 'service-not-allowed')
|
|
11117
|
+
setNewSessionError('Microphone access is blocked for this page.');
|
|
11118
|
+
else if (e.error !== 'no-speech' && e.error !== 'aborted') setNewSessionError(`Dictation failed: ${e.error}`);
|
|
11119
|
+
};
|
|
11120
|
+
rec.onend = () => {
|
|
11121
|
+
promptVoice.rec = null;
|
|
11122
|
+
btn.setAttribute('aria-pressed', 'false');
|
|
11123
|
+
};
|
|
11124
|
+
promptVoice.rec = rec;
|
|
11125
|
+
btn.setAttribute('aria-pressed', 'true');
|
|
11126
|
+
field.focus();
|
|
11127
|
+
rec.start();
|
|
11128
|
+
}
|
|
11129
|
+
|
|
11130
|
+
function pickFolderOption(i) {
|
|
11131
|
+
const path = ns.matches[i];
|
|
11132
|
+
if (!path) return;
|
|
11133
|
+
setNewSessionFolder(path);
|
|
11134
|
+
document.getElementById(fieldAfterFolder()).focus();
|
|
11135
|
+
}
|
|
11136
|
+
|
|
11137
|
+
function initNewSession() {
|
|
11138
|
+
const modal = document.getElementById('new-session-modal');
|
|
11139
|
+
const input = document.getElementById('ns-folder');
|
|
11140
|
+
const list = document.getElementById('ns-folder-list');
|
|
11141
|
+
input.addEventListener('input', () => {
|
|
11142
|
+
setNewSessionFolder(input.value, true);
|
|
11143
|
+
if (ns.folder) return closeFolderList();
|
|
11144
|
+
ns.idx = 0;
|
|
11145
|
+
renderFolderList();
|
|
11146
|
+
});
|
|
11147
|
+
input.addEventListener('focus', () => {
|
|
11148
|
+
if (!ns.folder) renderFolderList();
|
|
11149
|
+
});
|
|
11150
|
+
input.addEventListener('click', () => {
|
|
11151
|
+
if (!folderListOpen()) renderFolderList();
|
|
11152
|
+
});
|
|
11153
|
+
input.addEventListener('blur', () =>
|
|
11154
|
+
setTimeout(() => {
|
|
11155
|
+
if (!list.contains(document.activeElement)) closeFolderList();
|
|
11156
|
+
}, 0),
|
|
11157
|
+
);
|
|
11158
|
+
// mousedown, not click: the input's blur would close the list before a click lands.
|
|
11159
|
+
list.addEventListener('mousedown', (e) => {
|
|
11160
|
+
e.preventDefault();
|
|
11161
|
+
if (e.target.closest('[data-browse]')) return browseNewSessionFolder();
|
|
11162
|
+
const row = e.target.closest('.ns-option');
|
|
11163
|
+
if (row) pickFolderOption(Number(row.dataset.idx));
|
|
11164
|
+
});
|
|
11165
|
+
document.getElementById('ns-wt').addEventListener('change', (e) => {
|
|
11166
|
+
renderNewSessionForm();
|
|
11167
|
+
if (e.target.checked) document.getElementById('ns-wt-name').focus();
|
|
11168
|
+
});
|
|
11169
|
+
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
11170
|
+
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
11171
|
+
}
|
|
11172
|
+
if (SpeechRecognitionApi) {
|
|
11173
|
+
const mic = document.getElementById('ns-mic');
|
|
11174
|
+
mic.hidden = false;
|
|
11175
|
+
// mousedown would blur the textarea and lose the caret the dictation inserts at.
|
|
11176
|
+
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
11177
|
+
mic.addEventListener('click', togglePromptVoice);
|
|
11178
|
+
}
|
|
11179
|
+
// Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
|
|
11180
|
+
modal.addEventListener('keydown', (e) => {
|
|
11181
|
+
const inFolder = e.target === input;
|
|
11182
|
+
if (e.key === 'Escape') {
|
|
11183
|
+
if (folderListOpen()) closeFolderList();
|
|
11184
|
+
else closeNewSession();
|
|
11185
|
+
} else if (inFolder && matchKey(e, 'ArrowDown')) {
|
|
11186
|
+
if (!folderListOpen()) renderFolderList();
|
|
11187
|
+
else moveFolderHighlight(1);
|
|
11188
|
+
} else if (inFolder && matchKey(e, 'ArrowUp') && folderListOpen()) {
|
|
11189
|
+
moveFolderHighlight(-1);
|
|
11190
|
+
} else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
|
|
11191
|
+
pickFolderOption(ns.idx);
|
|
11192
|
+
} else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
|
|
11193
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
11194
|
+
startNewSession();
|
|
11195
|
+
} else return;
|
|
11196
|
+
e.preventDefault();
|
|
11197
|
+
e.stopPropagation();
|
|
11198
|
+
});
|
|
11199
|
+
}
|
|
11200
|
+
|
|
11201
|
+
initNewSession();
|
|
11202
|
+
//#endregion
|
|
11203
|
+
|
|
9596
11204
|
//#region PWA
|
|
9597
11205
|
if ('serviceWorker' in navigator) {
|
|
9598
11206
|
navigator.serviceWorker.register('/sw.js');
|
|
@@ -9801,6 +11409,11 @@ if (urlState.search) {
|
|
|
9801
11409
|
document.getElementById('search-clear-btn').classList.add('visible');
|
|
9802
11410
|
}
|
|
9803
11411
|
|
|
11412
|
+
{
|
|
11413
|
+
const verbs = ['Clawding…', 'Shuffling cards…', 'Herding tasks…', 'Waking sessions…', 'Sorting the board…'];
|
|
11414
|
+
document.getElementById('boot-verb').textContent = pickRandom(verbs);
|
|
11415
|
+
}
|
|
11416
|
+
|
|
9804
11417
|
Promise.all([
|
|
9805
11418
|
fetch('/hub-config')
|
|
9806
11419
|
.then((r) => r.json())
|
|
@@ -9816,6 +11429,7 @@ Promise.all([
|
|
|
9816
11429
|
})
|
|
9817
11430
|
.catch(() => {}),
|
|
9818
11431
|
])
|
|
11432
|
+
.then(restorePendingSessions)
|
|
9819
11433
|
.then(() => fetchSessions())
|
|
9820
11434
|
.then(async () => {
|
|
9821
11435
|
if (urlState.projectView) {
|
|
@@ -9849,6 +11463,10 @@ Promise.all([
|
|
|
9849
11463
|
if (currentMessages.length) renderMessages(currentMessages);
|
|
9850
11464
|
});
|
|
9851
11465
|
}
|
|
11466
|
+
})
|
|
11467
|
+
.finally(() => {
|
|
11468
|
+
document.body.classList.remove('booting');
|
|
11469
|
+
sessionsList.removeAttribute('aria-busy');
|
|
9852
11470
|
});
|
|
9853
11471
|
|
|
9854
11472
|
window.addEventListener('popstate', () => {
|
|
@@ -9878,23 +11496,28 @@ window.addEventListener('popstate', () => {
|
|
|
9878
11496
|
// e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
|
|
9879
11497
|
// 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
|
|
9880
11498
|
// these tests only decide whether a press is the hub's to handle.
|
|
9881
|
-
|
|
9882
|
-
if (!window.__HUB__?.enabled) return;
|
|
9883
|
-
|
|
9884
|
-
e.preventDefault();
|
|
9885
|
-
hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
9886
|
-
};
|
|
9887
|
-
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
9888
|
-
fwd();
|
|
9889
|
-
}
|
|
11499
|
+
function isHubKey(e) {
|
|
11500
|
+
if (!window.__HUB__?.enabled) return false;
|
|
11501
|
+
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
|
|
9890
11502
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9891
11503
|
// keymap and ignores unbound letters.
|
|
9892
|
-
if (
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
11504
|
+
if (
|
|
11505
|
+
e.ctrlKey &&
|
|
11506
|
+
e.altKey &&
|
|
11507
|
+
!e.shiftKey &&
|
|
11508
|
+
!e.metaKey &&
|
|
11509
|
+
!CCK_CTRL_ALT_KEYS.has(e.code) &&
|
|
11510
|
+
(/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))
|
|
11511
|
+
) {
|
|
11512
|
+
return true;
|
|
9897
11513
|
}
|
|
11514
|
+
return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
|
|
11515
|
+
}
|
|
11516
|
+
|
|
11517
|
+
document.addEventListener('keydown', (e) => {
|
|
11518
|
+
if (!isHubKey(e)) return;
|
|
11519
|
+
e.preventDefault();
|
|
11520
|
+
hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
9898
11521
|
});
|
|
9899
11522
|
|
|
9900
11523
|
document.addEventListener('click', (e) => {
|