create-walle 0.9.0 → 0.9.3
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 +35 -31
- package/package.json +3 -3
- package/template/CLAUDE.md +23 -1
- package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
- package/template/claude-task-manager/db.js +38 -0
- package/template/claude-task-manager/public/css/walle.css +123 -0
- package/template/claude-task-manager/public/index.html +962 -69
- package/template/claude-task-manager/public/js/walle.js +374 -121
- package/template/claude-task-manager/public/prompts.html +84 -26
- package/template/claude-task-manager/public/walle-icon.svg +45 -0
- package/template/claude-task-manager/server.js +69 -4
- package/template/docs/openclaw-vs-walle-comparison.md +103 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +63 -3
- package/template/wall-e/api-walle.js +42 -0
- package/template/wall-e/brain.js +182 -5
- package/template/wall-e/channels/imessage-channel.js +4 -1
- package/template/wall-e/channels/slack-channel.js +3 -1
- package/template/wall-e/chat.js +106 -224
- package/template/wall-e/context/compactor.js +163 -0
- package/template/wall-e/context/context-builder.js +355 -0
- package/template/wall-e/context/state-snapshot.js +209 -0
- package/template/wall-e/context/token-counter.js +55 -0
- package/template/wall-e/context/topic-matcher.js +79 -0
- package/template/wall-e/core-tasks.js +24 -0
- package/template/wall-e/events/event-bus.js +23 -0
- package/template/wall-e/loops/ingest.js +4 -0
- package/template/wall-e/loops/initiative.js +316 -0
- package/template/wall-e/loops/tasks.js +55 -5
- package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
- package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
- package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
- package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
- package/template/wall-e/tests/brain.test.js +4 -4
- package/template/wall-e/tests/compactor.test.js +323 -0
- package/template/wall-e/tests/context-builder.test.js +215 -0
- package/template/wall-e/tests/event-bus.test.js +74 -0
- package/template/wall-e/tests/initiative.test.js +354 -0
- package/template/wall-e/tests/proactive-alerts.test.js +140 -0
- package/template/wall-e/tests/session-persistence.test.js +335 -0
|
@@ -251,10 +251,12 @@
|
|
|
251
251
|
.session-item .dot {
|
|
252
252
|
width: 7px; height: 7px;
|
|
253
253
|
border-radius: 50%;
|
|
254
|
-
background: var(--
|
|
254
|
+
background: var(--fg-dim);
|
|
255
255
|
flex-shrink: 0;
|
|
256
|
+
opacity: 0.4;
|
|
256
257
|
}
|
|
257
|
-
.session-item.
|
|
258
|
+
.session-item.running .dot { background: var(--green); opacity: 1; }
|
|
259
|
+
.session-item.stale .dot { background: var(--yellow, #e0af68); opacity: 0.7; }
|
|
258
260
|
.session-item .idle-hint {
|
|
259
261
|
font-size: 9px;
|
|
260
262
|
color: var(--yellow, #e0af68);
|
|
@@ -269,6 +271,52 @@
|
|
|
269
271
|
text-overflow: ellipsis;
|
|
270
272
|
white-space: nowrap;
|
|
271
273
|
}
|
|
274
|
+
.session-item .status-tag {
|
|
275
|
+
font-size: 9px;
|
|
276
|
+
padding: 1px 5px;
|
|
277
|
+
border-radius: 3px;
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
font-weight: 500;
|
|
280
|
+
letter-spacing: 0.02em;
|
|
281
|
+
}
|
|
282
|
+
.status-tag.running {
|
|
283
|
+
background: rgba(115, 218, 202, 0.15);
|
|
284
|
+
color: var(--green);
|
|
285
|
+
}
|
|
286
|
+
.session-item.active .status-tag.running {
|
|
287
|
+
background: rgba(26, 27, 38, 0.15);
|
|
288
|
+
color: #1a1b26;
|
|
289
|
+
}
|
|
290
|
+
.status-tag.running::before {
|
|
291
|
+
content: '';
|
|
292
|
+
display: inline-block;
|
|
293
|
+
width: 5px; height: 5px;
|
|
294
|
+
border-radius: 50%;
|
|
295
|
+
background: currentColor;
|
|
296
|
+
margin-right: 3px;
|
|
297
|
+
vertical-align: middle;
|
|
298
|
+
animation: pulse-dot 1.5s ease-in-out infinite;
|
|
299
|
+
}
|
|
300
|
+
@keyframes pulse-dot {
|
|
301
|
+
0%, 100% { opacity: 1; }
|
|
302
|
+
50% { opacity: 0.3; }
|
|
303
|
+
}
|
|
304
|
+
.status-tag.idle {
|
|
305
|
+
background: rgba(192, 202, 245, 0.08);
|
|
306
|
+
color: var(--fg-dim);
|
|
307
|
+
}
|
|
308
|
+
.session-item.active .status-tag.idle {
|
|
309
|
+
background: rgba(26, 27, 38, 0.1);
|
|
310
|
+
color: rgba(26, 27, 38, 0.5);
|
|
311
|
+
}
|
|
312
|
+
.status-tag.waiting {
|
|
313
|
+
background: rgba(224, 175, 104, 0.15);
|
|
314
|
+
color: var(--yellow);
|
|
315
|
+
}
|
|
316
|
+
.session-item.active .status-tag.waiting {
|
|
317
|
+
background: rgba(26, 27, 38, 0.15);
|
|
318
|
+
color: #1a1b26;
|
|
319
|
+
}
|
|
272
320
|
.session-item .close-btn {
|
|
273
321
|
opacity: 0;
|
|
274
322
|
background: none;
|
|
@@ -716,6 +764,37 @@
|
|
|
716
764
|
.scroll-bottom-btn:hover { transform: scale(1.1); }
|
|
717
765
|
.scroll-bottom-btn.visible { display: flex; }
|
|
718
766
|
|
|
767
|
+
/* Prompt navigation */
|
|
768
|
+
.prompt-nav {
|
|
769
|
+
display: inline-flex; align-items: center; gap: 2px; margin-left: auto;
|
|
770
|
+
}
|
|
771
|
+
.prompt-nav-btn {
|
|
772
|
+
background: none; border: 1px solid var(--border); color: var(--fg);
|
|
773
|
+
font-size: 10px; padding: 3px 6px; border-radius: 3px; cursor: pointer;
|
|
774
|
+
line-height: 1;
|
|
775
|
+
}
|
|
776
|
+
.prompt-nav-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
777
|
+
.prompt-nav-btn:disabled { opacity: 0.25; cursor: default; }
|
|
778
|
+
.prompt-nav-btn:disabled:hover { background: none; color: var(--fg); }
|
|
779
|
+
.prompt-nav-badge {
|
|
780
|
+
font-size: 11px; color: var(--fg); padding: 2px 6px; cursor: pointer;
|
|
781
|
+
border-radius: 3px; white-space: nowrap; user-select: none;
|
|
782
|
+
}
|
|
783
|
+
.prompt-nav-badge:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
784
|
+
.prompt-nav-list {
|
|
785
|
+
position: absolute; top: 100%; right: 0; z-index: 50;
|
|
786
|
+
background: var(--bg-light); border: 1px solid var(--border); border-radius: 6px;
|
|
787
|
+
max-height: 300px; overflow-y: auto; min-width: 260px; max-width: 400px;
|
|
788
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4); padding: 4px 0;
|
|
789
|
+
}
|
|
790
|
+
.prompt-nav-list-item {
|
|
791
|
+
padding: 5px 10px; font-size: 11px; color: var(--fg); cursor: pointer;
|
|
792
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
793
|
+
}
|
|
794
|
+
.prompt-nav-list-item:hover { background: rgba(255,255,255,0.06); }
|
|
795
|
+
.prompt-nav-list-item.current { color: var(--accent); font-weight: 600; }
|
|
796
|
+
.prompt-nav-list-item.not-in-buffer { color: var(--fg-dim); cursor: default; }
|
|
797
|
+
|
|
719
798
|
.review-msg {
|
|
720
799
|
margin-bottom: 12px;
|
|
721
800
|
border-radius: 8px;
|
|
@@ -1285,6 +1364,9 @@
|
|
|
1285
1364
|
0%, 100% { opacity: 1; }
|
|
1286
1365
|
50% { opacity: 0.3; }
|
|
1287
1366
|
}
|
|
1367
|
+
.tab[draggable="true"] { cursor: grab; }
|
|
1368
|
+
.tab[draggable="true"]:active { cursor: grabbing; }
|
|
1369
|
+
.tab.tab-drag-over { border-left: 2px solid var(--accent); }
|
|
1288
1370
|
.tab .tab-icon {
|
|
1289
1371
|
color: var(--green, #9ece6a);
|
|
1290
1372
|
font-size: 10px;
|
|
@@ -1327,7 +1409,7 @@
|
|
|
1327
1409
|
.term-container.active { display: flex; flex-direction: column; }
|
|
1328
1410
|
.term-container .xterm { flex: 1; height: 0; }
|
|
1329
1411
|
.session-toolbar {
|
|
1330
|
-
display: flex; gap: 6px; padding: 3px 8px;
|
|
1412
|
+
display: flex; align-items: center; gap: 6px; padding: 3px 8px;
|
|
1331
1413
|
background: rgba(255,255,255,0.03); border-bottom: 1px solid var(--border);
|
|
1332
1414
|
flex-shrink: 0;
|
|
1333
1415
|
}
|
|
@@ -2237,7 +2319,7 @@
|
|
|
2237
2319
|
<nav class="topbar-nav" id="topbar-nav">
|
|
2238
2320
|
<button class="nav-pill active" data-nav="sessions" onclick="navTo('sessions')" title="Terminal sessions">Sessions</button>
|
|
2239
2321
|
<button class="nav-pill" data-nav="prompts" onclick="navTo('prompts')" title="Prompt Editor">Prompts</button>
|
|
2240
|
-
<button class="nav-pill" data-nav="walle" onclick="navTo('walle')" title="WALL-E Agent">WALL-E</button>
|
|
2322
|
+
<button class="nav-pill" data-nav="walle" onclick="navTo('walle')" title="WALL-E Agent"><img src="/walle-icon.svg" width="14" height="14" style="vertical-align:middle;margin-right:3px;position:relative;top:-1px;">WALL-E</button>
|
|
2241
2323
|
<div style="position:relative;display:inline-block;" id="nav-more-wrap">
|
|
2242
2324
|
<button class="nav-pill" onclick="toggleNavMore()" title="More pages">More <span style="font-size:10px;">▾</span></button>
|
|
2243
2325
|
<div id="nav-more-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:999;background:var(--bg-light);border:1px solid var(--border);border-radius:6px;padding:4px 0;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,0.3);">
|
|
@@ -2317,7 +2399,7 @@
|
|
|
2317
2399
|
<div id="welcome">
|
|
2318
2400
|
<h2 style="font-size:24px;margin-bottom:4px;">Welcome to CTM</h2>
|
|
2319
2401
|
<p style="color:var(--fg-dim);margin-bottom:20px;">Manage Claude Code sessions, prompts, and your AI assistant Wall-E.</p>
|
|
2320
|
-
<button class="btn primary" onclick="
|
|
2402
|
+
<button class="btn primary" onclick="showNewSessionModal()" style="font-size:15px;padding:8px 24px;margin-bottom:28px;">New Claude Session</button>
|
|
2321
2403
|
<div style="display:flex;gap:16px;max-width:680px;">
|
|
2322
2404
|
<div onclick="navTo('sessions')" style="flex:1;padding:14px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:8px;cursor:pointer;">
|
|
2323
2405
|
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Sessions</div>
|
|
@@ -2328,7 +2410,7 @@
|
|
|
2328
2410
|
<div style="font-size:12px;color:var(--fg-dim);">Save, organize, and send prompts to Claude</div>
|
|
2329
2411
|
</div>
|
|
2330
2412
|
<div onclick="navTo('walle')" style="flex:1;padding:14px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:8px;cursor:pointer;">
|
|
2331
|
-
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">WALL-E</div>
|
|
2413
|
+
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);display:flex;align-items:center;gap:6px;"><img src="/walle-icon.svg" width="18" height="18"> WALL-E</div>
|
|
2332
2414
|
<div style="font-size:12px;color:var(--fg-dim);">Your personal AI assistant — chat, tasks, and insights</div>
|
|
2333
2415
|
</div>
|
|
2334
2416
|
</div>
|
|
@@ -2358,6 +2440,11 @@
|
|
|
2358
2440
|
<label style="font-size:11px;color:var(--fg-dim);display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap;user-select:none;">
|
|
2359
2441
|
<input type="checkbox" id="hide-tool-msgs" checked onchange="savePref('hide_tool_msgs', this.checked); toggleToolMsgs()"> Hide tool calls
|
|
2360
2442
|
</label>
|
|
2443
|
+
<div class="prompt-nav" id="review-prompt-nav" style="position:relative">
|
|
2444
|
+
<button class="prompt-nav-btn" onclick="reviewPromptNavGo(-1)" title="Previous prompt (Alt+↑)" disabled>▲</button>
|
|
2445
|
+
<span class="prompt-nav-badge" onclick="reviewPromptNavToggleList()" title="Click to see all prompts">0 prompts</span>
|
|
2446
|
+
<button class="prompt-nav-btn" onclick="reviewPromptNavGo(1)" title="Next prompt (Alt+↓)" disabled>▼</button>
|
|
2447
|
+
</div>
|
|
2361
2448
|
<div class="review-actions" id="review-actions"></div>
|
|
2362
2449
|
</div>
|
|
2363
2450
|
</div>
|
|
@@ -2391,6 +2478,7 @@
|
|
|
2391
2478
|
</div>
|
|
2392
2479
|
<div id="walle-panel">
|
|
2393
2480
|
<div class="walle-header">
|
|
2481
|
+
<img src="/walle-icon.svg" alt="WALL-E" width="22" height="22" style="vertical-align:middle;">
|
|
2394
2482
|
<span class="walle-header-title">WALL-E</span>
|
|
2395
2483
|
<div class="walle-subnav">
|
|
2396
2484
|
<button class="walle-subnav-btn active" data-view="chat" onclick="WE.showView('chat')">Chat</button>
|
|
@@ -3110,8 +3198,11 @@ function connect() {
|
|
|
3110
3198
|
setTimeout(() => {
|
|
3111
3199
|
if (state.ws?.readyState === 1) {
|
|
3112
3200
|
overlay.classList.remove('active');
|
|
3201
|
+
const wasRestarting = state._restarting;
|
|
3113
3202
|
state._restarting = false;
|
|
3114
|
-
if (attempts > showOverlayAfter) toast('Server reconnected', { type: 'success' });
|
|
3203
|
+
if (attempts > showOverlayAfter || wasRestarting) toast('Server reconnected', { type: 'success' });
|
|
3204
|
+
// Post-reconnect reinitialization — refresh all state that may have gone stale
|
|
3205
|
+
onReconnected();
|
|
3115
3206
|
} else {
|
|
3116
3207
|
setTimeout(tryReconnect, state._restarting ? 1000 : 2000);
|
|
3117
3208
|
}
|
|
@@ -3121,6 +3212,30 @@ function connect() {
|
|
|
3121
3212
|
};
|
|
3122
3213
|
}
|
|
3123
3214
|
|
|
3215
|
+
function onReconnected() {
|
|
3216
|
+
// Refresh sidebar data (recent sessions, prompt links, etc.)
|
|
3217
|
+
loadRecentSessions();
|
|
3218
|
+
refreshSessionPrompts();
|
|
3219
|
+
// Clear prompt scan cache so it re-fetches from API
|
|
3220
|
+
for (const key of Object.keys(_promptScanCache)) delete _promptScanCache[key];
|
|
3221
|
+
// Re-attach ALL sessions — after a restart, the server has a fresh WS client map
|
|
3222
|
+
// so existing sessions need to re-register their WS connection for input to work.
|
|
3223
|
+
for (const [id] of state.sessions) {
|
|
3224
|
+
send({ type: 'attach', id });
|
|
3225
|
+
}
|
|
3226
|
+
// Resize active session's PTY to match current terminal dimensions
|
|
3227
|
+
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
3228
|
+
const s = state.sessions.get(state.activeTab);
|
|
3229
|
+
if (s && s.term && s.fitAddon) {
|
|
3230
|
+
requestAnimationFrame(() => {
|
|
3231
|
+
s.fitAddon.fit();
|
|
3232
|
+
send({ type: 'resize', id: state.activeTab, cols: s.term.cols, rows: s.term.rows });
|
|
3233
|
+
s.term.focus();
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3124
3239
|
function send(msg) {
|
|
3125
3240
|
if (state.ws?.readyState === 1) {
|
|
3126
3241
|
state.ws.send(JSON.stringify(msg));
|
|
@@ -3212,7 +3327,7 @@ async function restartAll() {
|
|
|
3212
3327
|
await new Promise(r => setTimeout(r, 500));
|
|
3213
3328
|
state._restarting = true;
|
|
3214
3329
|
try {
|
|
3215
|
-
await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
|
|
3330
|
+
await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
|
|
3216
3331
|
} catch { /* expected — server dying */ }
|
|
3217
3332
|
}
|
|
3218
3333
|
|
|
@@ -3239,7 +3354,7 @@ async function svcAction(service, action) {
|
|
|
3239
3354
|
state._restarting = true;
|
|
3240
3355
|
hideAppMenu();
|
|
3241
3356
|
try {
|
|
3242
|
-
await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
|
|
3357
|
+
await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
|
|
3243
3358
|
} catch { /* expected — server dying */ }
|
|
3244
3359
|
}
|
|
3245
3360
|
}
|
|
@@ -3265,6 +3380,19 @@ function createTerminal(id) {
|
|
|
3265
3380
|
reviewBtn.textContent = '\u{1F50D} Review';
|
|
3266
3381
|
reviewBtn.onclick = function() { openSessionReview(id); };
|
|
3267
3382
|
toolbar.appendChild(reviewBtn);
|
|
3383
|
+
|
|
3384
|
+
// Prompt navigation — prev/next arrows + count badge (click for list)
|
|
3385
|
+
const promptNav = document.createElement('div');
|
|
3386
|
+
promptNav.className = 'prompt-nav';
|
|
3387
|
+
promptNav.style.position = 'relative';
|
|
3388
|
+
promptNav.innerHTML =
|
|
3389
|
+
'<button class="prompt-nav-btn" data-dir="prev" title="Previous prompt (Alt+\u2191)" disabled>▲</button>' +
|
|
3390
|
+
'<span class="prompt-nav-badge" title="Click to see all prompts">0/0</span>' +
|
|
3391
|
+
'<button class="prompt-nav-btn" data-dir="next" title="Next prompt (Alt+\u2193)" disabled>▼</button>';
|
|
3392
|
+
promptNav.querySelector('[data-dir="prev"]').onclick = function() { promptNavGo(id, -1); };
|
|
3393
|
+
promptNav.querySelector('[data-dir="next"]').onclick = function() { promptNavGo(id, 1); };
|
|
3394
|
+
promptNav.querySelector('.prompt-nav-badge').onclick = function() { promptNavToggleList(id); };
|
|
3395
|
+
toolbar.appendChild(promptNav);
|
|
3268
3396
|
container.appendChild(toolbar);
|
|
3269
3397
|
|
|
3270
3398
|
document.getElementById('terminal-area').appendChild(container);
|
|
@@ -3310,29 +3438,309 @@ function createTerminal(id) {
|
|
|
3310
3438
|
term.onData((data) => {
|
|
3311
3439
|
send({ type: 'input', id, data });
|
|
3312
3440
|
clearWaitingState(id);
|
|
3313
|
-
// User typed something — scroll to bottom
|
|
3441
|
+
// User typed something — scroll to bottom and re-enable follow mode
|
|
3314
3442
|
const s = state.sessions.get(id);
|
|
3315
|
-
if (s) s.term.scrollToBottom();
|
|
3443
|
+
if (s) { s.writer.followMode = true; s.term.scrollToBottom(); }
|
|
3316
3444
|
});
|
|
3317
3445
|
|
|
3318
3446
|
term.onResize(({ cols, rows }) => {
|
|
3447
|
+
const s = state.sessions.get(id);
|
|
3448
|
+
if (s && s._suppressResize) return; // Skip during font metric refresh
|
|
3319
3449
|
send({ type: 'resize', id, cols, rows });
|
|
3320
3450
|
});
|
|
3321
3451
|
|
|
3322
|
-
//
|
|
3323
|
-
//
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3452
|
+
// Alt screen buffer switch listener — when Claude Code's TUI (Ink) exits,
|
|
3453
|
+
// force fit + scroll immediately instead of waiting for the idle timer.
|
|
3454
|
+
term.buffer.onBufferChange((activeBuffer) => {
|
|
3455
|
+
if (activeBuffer.type === 'normal') {
|
|
3456
|
+
requestAnimationFrame(() => {
|
|
3457
|
+
const s = state.sessions.get(id);
|
|
3458
|
+
if (s) {
|
|
3459
|
+
s.fitAddon.fit();
|
|
3460
|
+
// Returning from alt screen — scroll to bottom and re-enable follow
|
|
3461
|
+
s.writer.followMode = true;
|
|
3462
|
+
s.term.scrollToBottom();
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3330
3466
|
});
|
|
3331
3467
|
|
|
3332
|
-
|
|
3468
|
+
// Momentum scroll clamping — macOS trackpad momentum can cause "rocket scroll"
|
|
3469
|
+
// where the viewport flies past the content. Detect momentum events (rapid,
|
|
3470
|
+
// decaying deltas) and clamp them after a threshold.
|
|
3471
|
+
const viewportEl = container.querySelector('.xterm-viewport');
|
|
3472
|
+
if (viewportEl) {
|
|
3473
|
+
let lastWheelTime = 0;
|
|
3474
|
+
let lastDeltaY = 0;
|
|
3475
|
+
let momentumCount = 0;
|
|
3476
|
+
viewportEl.addEventListener('wheel', (e) => {
|
|
3477
|
+
const now = Date.now();
|
|
3478
|
+
const elapsed = now - lastWheelTime;
|
|
3479
|
+
lastWheelTime = now;
|
|
3480
|
+
if (elapsed < 80 && Math.abs(e.deltaY) < Math.abs(lastDeltaY) * 0.9) {
|
|
3481
|
+
momentumCount++;
|
|
3482
|
+
} else if (elapsed > 120) {
|
|
3483
|
+
momentumCount = 0;
|
|
3484
|
+
}
|
|
3485
|
+
lastDeltaY = e.deltaY;
|
|
3486
|
+
if (momentumCount >= 4) {
|
|
3487
|
+
e.preventDefault();
|
|
3488
|
+
e.stopPropagation();
|
|
3489
|
+
}
|
|
3490
|
+
}, { capture: true, passive: false });
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
// RAF write batcher — coalesces rapid output into ~60fps frames to prevent
|
|
3494
|
+
// viewport desync during burst output (Claude Code thinking/streaming).
|
|
3495
|
+
const writer = {
|
|
3496
|
+
queue: '',
|
|
3497
|
+
scheduled: false,
|
|
3498
|
+
followMode: true, // tracks whether we should auto-scroll
|
|
3499
|
+
_suppressScroll: 0, // timestamp until which onScroll is suppressed
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
// Track user scroll to toggle follow mode:
|
|
3503
|
+
// - Scrolling up disables auto-scroll so the user can read history
|
|
3504
|
+
// - Scrolling back to the bottom re-enables it
|
|
3505
|
+
// - Suppressed briefly after fit() to prevent reflow from flipping followMode
|
|
3506
|
+
term.onScroll(() => {
|
|
3507
|
+
const buf = term.buffer.active;
|
|
3508
|
+
const atBottom = buf.viewportY >= buf.baseY;
|
|
3509
|
+
// During suppression (after fit/write), ignore ALL scroll events.
|
|
3510
|
+
// The RAF batcher and fitActiveTerminal handle followMode explicitly;
|
|
3511
|
+
// allowing onScroll to re-enable followMode here causes a race where
|
|
3512
|
+
// term.write()'s internal scroll-to-bottom flips followMode back to true
|
|
3513
|
+
// even when the user has scrolled up.
|
|
3514
|
+
if (Date.now() < writer._suppressScroll) return;
|
|
3515
|
+
writer.followMode = atBottom;
|
|
3516
|
+
});
|
|
3517
|
+
|
|
3518
|
+
state.sessions.set(id, { term, fitAddon, container, needsFontRefresh: true, writer, promptLines: [], promptNavIdx: -1 });
|
|
3333
3519
|
return { term, fitAddon, container };
|
|
3334
3520
|
}
|
|
3335
3521
|
|
|
3522
|
+
// --- Prompt Navigation (active terminal) ---
|
|
3523
|
+
|
|
3524
|
+
// Scan for user prompts using the structured JSONL conversation data (same source as the Review page).
|
|
3525
|
+
// Falls back to terminal regex if the API is unavailable.
|
|
3526
|
+
// API results are cached and only re-fetched every 30s to avoid input lag.
|
|
3527
|
+
const _promptScanCache = {}; // { [sessionId]: { ts, previews } }
|
|
3528
|
+
|
|
3529
|
+
function scanPromptLines(id) {
|
|
3530
|
+
const s = state.sessions.get(id);
|
|
3531
|
+
if (!s) return;
|
|
3532
|
+
const recent = allRecentSessions.find(r => r.sessionId === id);
|
|
3533
|
+
if (recent && recent.projectEntry) {
|
|
3534
|
+
const cache = _promptScanCache[id];
|
|
3535
|
+
if (cache && Date.now() - cache.ts < 30000) {
|
|
3536
|
+
// Cache is fresh — just update badge from cached count (no buffer search)
|
|
3537
|
+
if (!s.promptPreviews || s.promptPreviews.length !== cache.previews.length) {
|
|
3538
|
+
s.promptLines = cache.previews.map(() => -1);
|
|
3539
|
+
s.promptPreviews = cache.previews;
|
|
3540
|
+
s.promptNavIdx = -1;
|
|
3541
|
+
}
|
|
3542
|
+
promptNavUpdateBadge(id);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
_scanPromptLinesFromAPI(id, recent.projectEntry);
|
|
3546
|
+
} else {
|
|
3547
|
+
_scanPromptLinesFromTerminal(id);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
async function _scanPromptLinesFromAPI(id, projectEntry) {
|
|
3552
|
+
const s = state.sessions.get(id);
|
|
3553
|
+
if (!s) return;
|
|
3554
|
+
try {
|
|
3555
|
+
const res = await fetch(`/api/session/messages?id=${id}&project=${encodeURIComponent(projectEntry)}&token=${state.token}`);
|
|
3556
|
+
const messages = await res.json();
|
|
3557
|
+
if (messages.error || !Array.isArray(messages)) {
|
|
3558
|
+
_scanPromptLinesFromTerminal(id);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
// Match the review page: count all role:'user' messages
|
|
3562
|
+
const userMsgs = messages.filter(m => m.role === 'user');
|
|
3563
|
+
const previews = [];
|
|
3564
|
+
for (const msg of userMsgs) {
|
|
3565
|
+
const firstLine = msg.text.split('\n')[0].trim();
|
|
3566
|
+
if (!firstLine || firstLine.length < 3) continue;
|
|
3567
|
+
previews.push(firstLine.slice(0, 80));
|
|
3568
|
+
}
|
|
3569
|
+
_promptScanCache[id] = { ts: Date.now(), previews };
|
|
3570
|
+
if (s) s._promptLinesResolved = false;
|
|
3571
|
+
// Set prompt data without buffer search — positions resolved lazily on navigate/dropdown
|
|
3572
|
+
s.promptLines = previews.map(() => -1);
|
|
3573
|
+
s.promptPreviews = previews;
|
|
3574
|
+
s.promptNavIdx = -1;
|
|
3575
|
+
promptNavUpdateBadge(id);
|
|
3576
|
+
} catch {
|
|
3577
|
+
_scanPromptLinesFromTerminal(id);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
function _applyPromptCache(id) {
|
|
3582
|
+
const s = state.sessions.get(id);
|
|
3583
|
+
const cache = _promptScanCache[id];
|
|
3584
|
+
if (!s || !cache) return;
|
|
3585
|
+
// Build buffer text index once, then match prompts against it
|
|
3586
|
+
const buf = s.term.buffer.active;
|
|
3587
|
+
const totalLines = buf.baseY + buf.cursorY;
|
|
3588
|
+
const bufTexts = new Array(totalLines + 1);
|
|
3589
|
+
for (let i = 0; i <= totalLines; i++) {
|
|
3590
|
+
const line = buf.getLine(i);
|
|
3591
|
+
bufTexts[i] = line ? line.translateToString(true) : '';
|
|
3592
|
+
}
|
|
3593
|
+
const lines = [];
|
|
3594
|
+
let searchFrom = 0;
|
|
3595
|
+
for (const preview of cache.previews) {
|
|
3596
|
+
let found = -1;
|
|
3597
|
+
if (preview.length >= 10) {
|
|
3598
|
+
const needle = preview.slice(0, 30);
|
|
3599
|
+
for (let i = searchFrom; i <= totalLines; i++) {
|
|
3600
|
+
if (bufTexts[i].includes(needle)) {
|
|
3601
|
+
found = i;
|
|
3602
|
+
searchFrom = i + 1;
|
|
3603
|
+
break;
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
lines.push(found);
|
|
3608
|
+
}
|
|
3609
|
+
s.promptLines = lines;
|
|
3610
|
+
s.promptPreviews = cache.previews;
|
|
3611
|
+
s.promptNavIdx = -1;
|
|
3612
|
+
promptNavUpdateBadge(id);
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
function _scanPromptLinesFromTerminal(id) {
|
|
3616
|
+
const s = state.sessions.get(id);
|
|
3617
|
+
if (!s) return;
|
|
3618
|
+
const buf = s.term.buffer.active;
|
|
3619
|
+
const lines = [];
|
|
3620
|
+
let prevText = '';
|
|
3621
|
+
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
|
|
3622
|
+
const line = buf.getLine(i);
|
|
3623
|
+
if (!line) continue;
|
|
3624
|
+
const text = line.translateToString(true);
|
|
3625
|
+
// Match Claude Code prompt: ❯ at column 0, followed by space + non-space.
|
|
3626
|
+
if (/^❯[ \u00a0]\S/.test(text)) {
|
|
3627
|
+
if (text !== prevText) {
|
|
3628
|
+
lines.push(i);
|
|
3629
|
+
prevText = text;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
s.promptLines = lines;
|
|
3634
|
+
s.promptPreviews = null;
|
|
3635
|
+
s.promptNavIdx = -1;
|
|
3636
|
+
promptNavUpdateBadge(id);
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
function _ensurePromptBufferPositions(id) {
|
|
3640
|
+
const s = state.sessions.get(id);
|
|
3641
|
+
if (!s || !s.promptPreviews) return;
|
|
3642
|
+
// Skip if already resolved (has at least one non-negative line)
|
|
3643
|
+
if (s._promptLinesResolved) return;
|
|
3644
|
+
s._promptLinesResolved = true;
|
|
3645
|
+
_applyPromptCache(id);
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
function promptNavGo(id, dir) {
|
|
3649
|
+
const s = state.sessions.get(id);
|
|
3650
|
+
if (!s || s.promptLines.length === 0) return;
|
|
3651
|
+
_ensurePromptBufferPositions(id);
|
|
3652
|
+
|
|
3653
|
+
// Find the next navigable prompt (one with a valid buffer line)
|
|
3654
|
+
let newIdx = s.promptNavIdx;
|
|
3655
|
+
while (true) {
|
|
3656
|
+
newIdx += (s.promptNavIdx < 0 ? (dir < 0 ? 0 : 1) : dir);
|
|
3657
|
+
if (s.promptNavIdx < 0 && dir < 0) { newIdx = s.promptLines.length - 1; }
|
|
3658
|
+
if (newIdx < 0 || newIdx >= s.promptLines.length) return;
|
|
3659
|
+
if (s.promptLines[newIdx] >= 0) break; // Found a navigable prompt
|
|
3660
|
+
if (dir === 0) return;
|
|
3661
|
+
// Skip prompts not in the terminal buffer
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
s.promptNavIdx = newIdx;
|
|
3665
|
+
const targetLine = s.promptLines[newIdx];
|
|
3666
|
+
s.writer.followMode = false;
|
|
3667
|
+
s.term.scrollToLine(Math.max(0, targetLine - 1));
|
|
3668
|
+
promptNavUpdateBadge(id);
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
function promptNavUpdateBadge(id) {
|
|
3672
|
+
const s = state.sessions.get(id);
|
|
3673
|
+
if (!s) return;
|
|
3674
|
+
const nav = s.container.querySelector('.prompt-nav');
|
|
3675
|
+
if (!nav) return;
|
|
3676
|
+
const badge = nav.querySelector('.prompt-nav-badge');
|
|
3677
|
+
const prevBtn = nav.querySelector('[data-dir="prev"]');
|
|
3678
|
+
const nextBtn = nav.querySelector('[data-dir="next"]');
|
|
3679
|
+
const total = s.promptLines.length;
|
|
3680
|
+
const idx = s.promptNavIdx;
|
|
3681
|
+
badge.textContent = total === 0 ? '0 prompts' : (idx >= 0 ? (idx + 1) + '/' + total : total + (total === 1 ? ' prompt' : ' prompts'));
|
|
3682
|
+
prevBtn.disabled = total === 0 || idx <= 0;
|
|
3683
|
+
nextBtn.disabled = total === 0 || idx >= total - 1;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
function promptNavToggleList(id) {
|
|
3687
|
+
const s = state.sessions.get(id);
|
|
3688
|
+
if (!s) return;
|
|
3689
|
+
_ensurePromptBufferPositions(id);
|
|
3690
|
+
const nav = s.container.querySelector('.prompt-nav');
|
|
3691
|
+
if (!nav) return;
|
|
3692
|
+
// Close existing list
|
|
3693
|
+
const existing = nav.querySelector('.prompt-nav-list');
|
|
3694
|
+
if (existing) { existing.remove(); return; }
|
|
3695
|
+
if (s.promptLines.length === 0) return;
|
|
3696
|
+
|
|
3697
|
+
// Build list showing preview text for each prompt
|
|
3698
|
+
const list = document.createElement('div');
|
|
3699
|
+
list.className = 'prompt-nav-list';
|
|
3700
|
+
const buf = s.term.buffer.active;
|
|
3701
|
+
// Show most recent prompts first
|
|
3702
|
+
for (let i = s.promptLines.length - 1; i >= 0; i--) {
|
|
3703
|
+
let text;
|
|
3704
|
+
if (s.promptPreviews && s.promptPreviews[i]) {
|
|
3705
|
+
text = s.promptPreviews[i];
|
|
3706
|
+
} else if (s.promptLines[i] >= 0) {
|
|
3707
|
+
const line = buf.getLine(s.promptLines[i]);
|
|
3708
|
+
if (!line) continue;
|
|
3709
|
+
text = line.translateToString(true).trim();
|
|
3710
|
+
} else {
|
|
3711
|
+
continue; // No preview and not in buffer — skip
|
|
3712
|
+
}
|
|
3713
|
+
if (text.length > 80) text = text.slice(0, 80) + '\u2026';
|
|
3714
|
+
const inBuffer = s.promptLines[i] >= 0;
|
|
3715
|
+
const item = document.createElement('div');
|
|
3716
|
+
item.className = 'prompt-nav-list-item' + (i === s.promptNavIdx ? ' current' : '') + (inBuffer ? '' : ' not-in-buffer');
|
|
3717
|
+
item.textContent = text;
|
|
3718
|
+
if (inBuffer) {
|
|
3719
|
+
item.onclick = (function(idx) { return function() {
|
|
3720
|
+
s.promptNavIdx = idx;
|
|
3721
|
+
s.writer.followMode = false;
|
|
3722
|
+
s.term.scrollToLine(Math.max(0, s.promptLines[idx] - 1));
|
|
3723
|
+
promptNavUpdateBadge(id);
|
|
3724
|
+
list.remove();
|
|
3725
|
+
}; })(i);
|
|
3726
|
+
}
|
|
3727
|
+
list.appendChild(item);
|
|
3728
|
+
}
|
|
3729
|
+
nav.appendChild(list);
|
|
3730
|
+
// Close on outside click (mousedown catches terminal canvas clicks that don't bubble as click)
|
|
3731
|
+
setTimeout(() => {
|
|
3732
|
+
function close(e) {
|
|
3733
|
+
if (!list.contains(e.target)) {
|
|
3734
|
+
list.remove();
|
|
3735
|
+
document.removeEventListener('click', close);
|
|
3736
|
+
document.removeEventListener('mousedown', close);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
document.addEventListener('click', close);
|
|
3740
|
+
document.addEventListener('mousedown', close);
|
|
3741
|
+
}, 0);
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3336
3744
|
function activateTab(id) {
|
|
3337
3745
|
const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
|
|
3338
3746
|
const isPanel = specialPanels.includes(id);
|
|
@@ -3388,20 +3796,24 @@ function activateTab(id) {
|
|
|
3388
3796
|
} else if (state.sessions.has(id)) {
|
|
3389
3797
|
const s = state.sessions.get(id);
|
|
3390
3798
|
s.container.classList.add('active');
|
|
3391
|
-
// Force xterm.js to re-measure font metrics if terminal was opened while hidden
|
|
3392
|
-
// (must happen synchronously after container becomes visible, before scrollback arrives)
|
|
3393
|
-
if (s.needsFontRefresh) {
|
|
3394
|
-
s.needsFontRefresh = false;
|
|
3395
|
-
const size = s.term.options.fontSize;
|
|
3396
|
-
s.term.options.fontSize = size + 1;
|
|
3397
|
-
s.term.options.fontSize = size;
|
|
3398
|
-
}
|
|
3399
3799
|
// Update hash so refresh re-attaches to this session
|
|
3400
3800
|
history.replaceState(null, '', location.pathname + location.search + '#session=' + id);
|
|
3401
3801
|
savePref('active_session', id);
|
|
3402
3802
|
requestAnimationFrame(() => {
|
|
3803
|
+
// Force xterm.js to re-measure font metrics if terminal was opened while hidden.
|
|
3804
|
+
// Must happen AFTER container is visible and in rAF so layout is computed.
|
|
3805
|
+
// Suppress onResize during the font toggle to avoid sending wrong PTY dimensions.
|
|
3806
|
+
if (s.needsFontRefresh) {
|
|
3807
|
+
s.needsFontRefresh = false;
|
|
3808
|
+
s._suppressResize = true;
|
|
3809
|
+
const size = s.term.options.fontSize;
|
|
3810
|
+
s.term.options.fontSize = size + 1;
|
|
3811
|
+
s.term.options.fontSize = size;
|
|
3812
|
+
s._suppressResize = false;
|
|
3813
|
+
}
|
|
3403
3814
|
s.fitAddon.fit();
|
|
3404
3815
|
send({ type: 'resize', id, cols: s.term.cols, rows: s.term.rows });
|
|
3816
|
+
s.writer.followMode = true;
|
|
3405
3817
|
s.term.scrollToBottom();
|
|
3406
3818
|
// If this session needs to attach (reconnect), do it after fit so PTY gets correct size
|
|
3407
3819
|
if (s.needsAttach) {
|
|
@@ -3800,6 +4212,7 @@ function onCreated(msg) {
|
|
|
3800
4212
|
|
|
3801
4213
|
if (!state.tabOrder.includes(id)) {
|
|
3802
4214
|
state.tabOrder.push(id);
|
|
4215
|
+
saveTabOrder();
|
|
3803
4216
|
}
|
|
3804
4217
|
activateTab(id);
|
|
3805
4218
|
|
|
@@ -3820,39 +4233,91 @@ function onCreated(msg) {
|
|
|
3820
4233
|
if (typeof onSkillSessionCreated === 'function') {
|
|
3821
4234
|
onSkillSessionCreated(msg);
|
|
3822
4235
|
}
|
|
4236
|
+
|
|
4237
|
+
// Refresh recent sessions so the new session appears in the sidebar list
|
|
4238
|
+
// (delay to let Claude Code write the .jsonl file)
|
|
4239
|
+
setTimeout(loadRecentSessions, 2000);
|
|
3823
4240
|
}
|
|
3824
4241
|
|
|
3825
4242
|
function onOutput(msg) {
|
|
3826
4243
|
const s = state.sessions.get(msg.id);
|
|
3827
4244
|
if (!s) return;
|
|
4245
|
+
s._lastOutputAt = Date.now();
|
|
4246
|
+
s._waitingForInput = false;
|
|
3828
4247
|
// Only strip \e[3J (Erase Scrollback) — preserves scroll history.
|
|
3829
4248
|
// Do NOT strip \e[2J or \e[?1049h/l — needed for Claude Code's TUI.
|
|
3830
4249
|
const data = msg.data.replace(/\x1b\[3J/g, '');
|
|
3831
|
-
|
|
3832
|
-
//
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
4250
|
+
|
|
4251
|
+
// followMode is maintained by the term.onScroll listener — no manual check needed here.
|
|
4252
|
+
|
|
4253
|
+
// RAF write batcher — coalesces rapid output chunks into ~60fps frames.
|
|
4254
|
+
// This prevents viewport desync caused by hundreds of tiny writes/sec
|
|
4255
|
+
// during Claude Code streaming output.
|
|
4256
|
+
const sid = msg.id;
|
|
4257
|
+
s.writer.queue += data;
|
|
4258
|
+
if (!s.writer.scheduled) {
|
|
4259
|
+
s.writer.scheduled = true;
|
|
4260
|
+
requestAnimationFrame(() => {
|
|
4261
|
+
const batch = s.writer.queue;
|
|
4262
|
+
const follow = s.writer.followMode;
|
|
4263
|
+
s.writer.queue = '';
|
|
4264
|
+
s.writer.scheduled = false;
|
|
4265
|
+
// Re-check active tab at render time (may have changed since queued)
|
|
4266
|
+
if (state.activeTab === sid && follow) {
|
|
4267
|
+
s.term.write(batch, () => {
|
|
4268
|
+
s.term.scrollToBottom();
|
|
4269
|
+
});
|
|
4270
|
+
} else {
|
|
4271
|
+
const savedLine = s.term.buffer.active.viewportY;
|
|
4272
|
+
// Suppress during write (large window covers slow writes), then
|
|
4273
|
+
// tighten to a short window in the callback to cover scrollToLine's
|
|
4274
|
+
// own scroll event before expiring naturally. No setTimeout needed.
|
|
4275
|
+
s.writer._suppressScroll = Date.now() + 2000;
|
|
4276
|
+
s.term.write(batch, () => {
|
|
4277
|
+
s.writer._suppressScroll = Date.now() + 100;
|
|
4278
|
+
s.term.scrollToLine(savedLine);
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
3839
4281
|
});
|
|
3840
|
-
} else {
|
|
3841
|
-
s.term.write(data);
|
|
3842
4282
|
}
|
|
3843
|
-
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
// Without this, the viewport may become unscrollable after output finishes.
|
|
4283
|
+
|
|
4284
|
+
// After output stops for 300ms, ensure scroll position is correct
|
|
4285
|
+
// and auto-fix dimension drift (PTY size ≠ container size).
|
|
3847
4286
|
clearTimeout(s._outputIdleTimer);
|
|
3848
4287
|
s._outputIdleTimer = setTimeout(() => {
|
|
3849
4288
|
if (state.activeTab === msg.id) {
|
|
3850
|
-
|
|
4289
|
+
// Auto-heal dimension mismatch — if PTY was running at different
|
|
4290
|
+
// cols/rows than the container requires, fit + resize now.
|
|
4291
|
+
try {
|
|
4292
|
+
const dims = s.fitAddon.proposeDimensions();
|
|
4293
|
+
if (dims && (dims.cols !== s.term.cols || dims.rows !== s.term.rows)) {
|
|
4294
|
+
const buf = s.term.buffer.active;
|
|
4295
|
+
const wasAtBottom = buf.viewportY >= buf.baseY;
|
|
4296
|
+
const oldCols = s.term.cols;
|
|
4297
|
+
const savedLine = buf.viewportY;
|
|
4298
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
4299
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4300
|
+
s.fitAddon.fit();
|
|
4301
|
+
send({ type: 'resize', id: msg.id, cols: s.term.cols, rows: s.term.rows });
|
|
4302
|
+
if (wasAtBottom) {
|
|
4303
|
+
s.term.scrollToBottom();
|
|
4304
|
+
} else if (s.term.cols !== oldCols) {
|
|
4305
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
4306
|
+
s._promptLinesResolved = false;
|
|
4307
|
+
} else {
|
|
4308
|
+
s.term.scrollToLine(savedLine);
|
|
4309
|
+
}
|
|
4310
|
+
return; // fit handles scroll position
|
|
4311
|
+
}
|
|
4312
|
+
} catch (_) { /* proposeDimensions may fail if container hidden */ }
|
|
4313
|
+
if (s.writer.followMode) {
|
|
4314
|
+
s.term.scrollToBottom();
|
|
4315
|
+
}
|
|
3851
4316
|
}
|
|
3852
|
-
},
|
|
4317
|
+
}, 300);
|
|
3853
4318
|
// Remove compact banner on new activity
|
|
3854
4319
|
const banner = s.container.querySelector('.compact-banner');
|
|
3855
|
-
if (banner) { banner.remove(); requestAnimationFrame(() => s.fitAddon.fit()); }
|
|
4320
|
+
if (banner) { banner.remove(); requestAnimationFrame(() => { const ln = s.term.buffer.active.viewportY; const ab = s.writer.followMode; s.writer._suppressScroll = Date.now() + 200; s.fitAddon.fit(); if (!ab) s.term.scrollToLine(ln); }); }
|
|
3856
4321
|
// Keep focus on the active session's terminal, but don't steal focus from
|
|
3857
4322
|
// input elements (e.g. queue panel textarea, search boxes)
|
|
3858
4323
|
if (state.activeTab === msg.id && document.activeElement !== s.term.textarea) {
|
|
@@ -3886,6 +4351,8 @@ function onScrollback(msg) {
|
|
|
3886
4351
|
s.term.clear();
|
|
3887
4352
|
s.term.write(msg.data, () => {
|
|
3888
4353
|
s.term.scrollToBottom();
|
|
4354
|
+
// Scan for prompt lines after scrollback is loaded
|
|
4355
|
+
scanPromptLines(msg.id);
|
|
3889
4356
|
});
|
|
3890
4357
|
|
|
3891
4358
|
// Send resize to ensure PTY matches this client's dimensions
|
|
@@ -3902,7 +4369,10 @@ function onExit(msg) {
|
|
|
3902
4369
|
}
|
|
3903
4370
|
}
|
|
3904
4371
|
|
|
3905
|
-
function onSessionsList(msg) {
|
|
4372
|
+
async function onSessionsList(msg) {
|
|
4373
|
+
// Wait for prefs to load before applying (avoids race with tab_order restore)
|
|
4374
|
+
if (state._prefsLoaded) await state._prefsLoaded;
|
|
4375
|
+
|
|
3906
4376
|
// Update sidebar - merge with existing data
|
|
3907
4377
|
const serverIds = new Set(msg.sessions.map(s => s.id));
|
|
3908
4378
|
|
|
@@ -3937,6 +4407,23 @@ function onSessionsList(msg) {
|
|
|
3937
4407
|
if (existing) existing.meta = sess;
|
|
3938
4408
|
}
|
|
3939
4409
|
|
|
4410
|
+
// Restore saved tab order (from prefs) on first session list after reconnect
|
|
4411
|
+
if (state._savedTabOrder) {
|
|
4412
|
+
const saved = state._savedTabOrder;
|
|
4413
|
+
delete state._savedTabOrder;
|
|
4414
|
+
// Reorder: put saved IDs first (if they still exist), then any new ones
|
|
4415
|
+
const sessionIds = state.tabOrder.filter(id => state.sessions.has(id));
|
|
4416
|
+
const nonSessionIds = state.tabOrder.filter(id => !state.sessions.has(id));
|
|
4417
|
+
const ordered = [];
|
|
4418
|
+
for (const id of saved) {
|
|
4419
|
+
if (sessionIds.includes(id)) ordered.push(id);
|
|
4420
|
+
}
|
|
4421
|
+
for (const id of sessionIds) {
|
|
4422
|
+
if (!ordered.includes(id)) ordered.push(id);
|
|
4423
|
+
}
|
|
4424
|
+
state.tabOrder = [...nonSessionIds, ...ordered];
|
|
4425
|
+
}
|
|
4426
|
+
|
|
3940
4427
|
renderSessionList();
|
|
3941
4428
|
renderTabs();
|
|
3942
4429
|
|
|
@@ -3971,6 +4458,8 @@ function onSessionsList(msg) {
|
|
|
3971
4458
|
// --- UI Rendering ---
|
|
3972
4459
|
function renderSessionList() {
|
|
3973
4460
|
const list = document.getElementById('session-list');
|
|
4461
|
+
// Skip re-render if user is actively renaming a session (input would be destroyed)
|
|
4462
|
+
if (list.querySelector('input')) return;
|
|
3974
4463
|
const sessions = Array.from(state.sessions.entries()).filter(([id, s]) => s.meta);
|
|
3975
4464
|
|
|
3976
4465
|
if (sessions.length === 0) {
|
|
@@ -3985,19 +4474,89 @@ function renderSessionList() {
|
|
|
3985
4474
|
const idleMs = Date.now() - lastAct;
|
|
3986
4475
|
const isStale = idleMs > 24 * 60 * 60 * 1000;
|
|
3987
4476
|
const idleHint = isStale ? `<span class="idle-hint">${formatIdleTime(idleMs)}</span>` : '';
|
|
4477
|
+
const sStatus = getSessionStatus(s);
|
|
4478
|
+
const statusTag = isStale ? '' : `<span class="status-tag ${sStatus.cls}">${sStatus.text}</span>`;
|
|
3988
4479
|
const promptBadges = (s.linkedPrompts || []).map(p =>
|
|
3989
4480
|
`<span class="prompt-badge" onclick="event.stopPropagation();openPromptInEditor(${p.prompt_id})" title="${escHtml(p.title || 'Prompt')}">${escHtml((p.title || 'Prompt').slice(0, 20))}</span>`
|
|
3990
4481
|
).join('');
|
|
3991
|
-
return `<div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''}" onclick="
|
|
4482
|
+
return `<div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''} ${sStatus.cls === 'running' ? 'running' : ''}" data-session-id="${id}" onclick="sessionItemClick('${id}', event)" ondblclick="sessionItemDblClick('${id}', event)">
|
|
3992
4483
|
<span class="dot"></span>
|
|
3993
4484
|
<span class="label" title="${escHtml(label)}">${escHtml(label)}</span>
|
|
4485
|
+
${statusTag}
|
|
3994
4486
|
${idleHint}
|
|
3995
4487
|
<span class="close-btn" onclick="event.stopPropagation();killSession('${id}')">×</span>
|
|
3996
4488
|
</div>${promptBadges ? `<div class="session-prompts">${promptBadges}</div>` : ''}`;
|
|
3997
4489
|
}).join('');
|
|
3998
4490
|
}
|
|
3999
4491
|
|
|
4492
|
+
// Determine session status: running (actively producing output), waiting (at prompt), idle (quiet)
|
|
4493
|
+
function getSessionStatus(s) {
|
|
4494
|
+
const now = Date.now();
|
|
4495
|
+
const recentOutput = s._lastOutputAt && (now - s._lastOutputAt) < 8000;
|
|
4496
|
+
const recentInput = s._lastInputAt && (now - s._lastInputAt) < 8000;
|
|
4497
|
+
if (s._waitingForInput) return { cls: 'waiting', text: 'Waiting' };
|
|
4498
|
+
if (recentOutput || recentInput) return { cls: 'running', text: 'Running' };
|
|
4499
|
+
return { cls: 'idle', text: 'Idle' };
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
// Lightweight status tag updater — avoids full re-render to preserve rename inputs
|
|
4503
|
+
setInterval(function() {
|
|
4504
|
+
const list = document.getElementById('session-list');
|
|
4505
|
+
if (!list || list.querySelector('input')) return;
|
|
4506
|
+
list.querySelectorAll('.session-item[data-session-id]').forEach(el => {
|
|
4507
|
+
const id = el.dataset.sessionId;
|
|
4508
|
+
const s = state.sessions.get(id);
|
|
4509
|
+
if (!s) return;
|
|
4510
|
+
const tag = el.querySelector('.status-tag');
|
|
4511
|
+
if (!tag) return;
|
|
4512
|
+
const st = getSessionStatus(s);
|
|
4513
|
+
if (!tag.classList.contains(st.cls)) {
|
|
4514
|
+
tag.className = 'status-tag ' + st.cls;
|
|
4515
|
+
tag.textContent = st.text;
|
|
4516
|
+
el.classList.toggle('running', st.cls === 'running');
|
|
4517
|
+
}
|
|
4518
|
+
});
|
|
4519
|
+
}, 3000);
|
|
4520
|
+
|
|
4521
|
+
var _sessionClickTimer = null;
|
|
4522
|
+
function sessionItemClick(id, event) {
|
|
4523
|
+
if (_sessionClickTimer) clearTimeout(_sessionClickTimer);
|
|
4524
|
+
_sessionClickTimer = setTimeout(function() { _sessionClickTimer = null; activateTab(id); }, 250);
|
|
4525
|
+
}
|
|
4526
|
+
function sessionItemDblClick(id, event) {
|
|
4527
|
+
event.preventDefault();
|
|
4528
|
+
event.stopPropagation();
|
|
4529
|
+
if (_sessionClickTimer) { clearTimeout(_sessionClickTimer); _sessionClickTimer = null; }
|
|
4530
|
+
setTimeout(function() {
|
|
4531
|
+
var item = document.querySelector('#session-list .session-item[data-session-id="' + id + '"]');
|
|
4532
|
+
if (item) {
|
|
4533
|
+
var labelEl = item.querySelector('.label');
|
|
4534
|
+
if (labelEl) startRenameSession(id, labelEl);
|
|
4535
|
+
}
|
|
4536
|
+
}, 10);
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
var _recentClickTimer = null;
|
|
4540
|
+
function recentItemClick(id, event) {
|
|
4541
|
+
if (_recentClickTimer) clearTimeout(_recentClickTimer);
|
|
4542
|
+
_recentClickTimer = setTimeout(function() { _recentClickTimer = null; openSessionReview(id); }, 250);
|
|
4543
|
+
}
|
|
4544
|
+
function recentItemDblClick(id, event) {
|
|
4545
|
+
event.preventDefault();
|
|
4546
|
+
event.stopPropagation();
|
|
4547
|
+
if (_recentClickTimer) { clearTimeout(_recentClickTimer); _recentClickTimer = null; }
|
|
4548
|
+
setTimeout(function() {
|
|
4549
|
+
var item = document.querySelector('.recent-item[data-session-id="' + id + '"]');
|
|
4550
|
+
if (item) {
|
|
4551
|
+
var labelEl = item.querySelector('.recent-msg-text');
|
|
4552
|
+
if (labelEl) startRenameRecentSession(id, labelEl);
|
|
4553
|
+
}
|
|
4554
|
+
}, 10);
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4000
4557
|
function startRenameSession(sessionId, labelEl) {
|
|
4558
|
+
// Guard: already editing
|
|
4559
|
+
if (labelEl.querySelector('input')) return;
|
|
4001
4560
|
const currentText = labelEl.textContent.trim();
|
|
4002
4561
|
const input = document.createElement('input');
|
|
4003
4562
|
input.type = 'text';
|
|
@@ -4008,18 +4567,35 @@ function startRenameSession(sessionId, labelEl) {
|
|
|
4008
4567
|
input.focus();
|
|
4009
4568
|
input.select();
|
|
4010
4569
|
|
|
4570
|
+
let done = false;
|
|
4011
4571
|
function finish() {
|
|
4572
|
+
if (done) return;
|
|
4573
|
+
done = true;
|
|
4012
4574
|
const newName = input.value.trim();
|
|
4013
4575
|
if (newName && newName !== currentText) {
|
|
4014
|
-
//
|
|
4015
|
-
|
|
4016
|
-
|
|
4576
|
+
// Persist to DB via REST API
|
|
4577
|
+
fetch(`/api/sessions/rename?token=${state.token}`, {
|
|
4578
|
+
method: 'POST',
|
|
4579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4580
|
+
body: JSON.stringify({ sessionId, title: newName }),
|
|
4581
|
+
});
|
|
4582
|
+
// Update active session label
|
|
4583
|
+
const active = state.sessions.get(sessionId);
|
|
4584
|
+
if (active && active.meta) active.meta.label = newName;
|
|
4585
|
+
// Update recent session list
|
|
4586
|
+
const recent = allRecentSessions.find(x => x.sessionId === sessionId);
|
|
4587
|
+
if (recent) { recent.aiTitle = newName; recent.userRenamed = true; }
|
|
4588
|
+
// Update review title if this session is being reviewed
|
|
4589
|
+
if (state.reviewingSessionId === sessionId) {
|
|
4590
|
+
const reviewTitleEl = document.getElementById('review-title');
|
|
4591
|
+
if (reviewTitleEl) reviewTitleEl.textContent = newName;
|
|
4017
4592
|
}
|
|
4018
|
-
// Update local state immediately
|
|
4019
|
-
const s = state.sessions.get(sessionId);
|
|
4020
|
-
if (s && s.meta) s.meta.label = newName;
|
|
4021
4593
|
}
|
|
4594
|
+
// Remove input before re-rendering so the input-guard doesn't block
|
|
4595
|
+
input.remove();
|
|
4022
4596
|
renderSessionList();
|
|
4597
|
+
renderFilteredSessions();
|
|
4598
|
+
renderTabs();
|
|
4023
4599
|
}
|
|
4024
4600
|
|
|
4025
4601
|
input.addEventListener('blur', finish);
|
|
@@ -4100,11 +4676,18 @@ function startRenameRecentSession(sessionId, spanEl) {
|
|
|
4100
4676
|
});
|
|
4101
4677
|
// Update local data immediately
|
|
4102
4678
|
const s = allRecentSessions.find(x => x.sessionId === sessionId);
|
|
4103
|
-
if (s) s.aiTitle = newName;
|
|
4679
|
+
if (s) { s.aiTitle = newName; s.userRenamed = true; }
|
|
4104
4680
|
// Also update active session if applicable
|
|
4105
4681
|
const active = state.sessions.get(sessionId);
|
|
4106
4682
|
if (active && active.meta) active.meta.label = newName;
|
|
4683
|
+
// Update review title if this session is being reviewed
|
|
4684
|
+
if (state.reviewingSessionId === sessionId) {
|
|
4685
|
+
const reviewTitleEl = document.getElementById('review-title');
|
|
4686
|
+
if (reviewTitleEl) reviewTitleEl.textContent = newName;
|
|
4687
|
+
}
|
|
4107
4688
|
}
|
|
4689
|
+
// Remove input before re-rendering so the input-guard doesn't block
|
|
4690
|
+
input.remove();
|
|
4108
4691
|
renderFilteredSessions();
|
|
4109
4692
|
renderSessionList();
|
|
4110
4693
|
renderTabs();
|
|
@@ -4161,8 +4744,14 @@ function showCompactBannerIfStale(id, s) {
|
|
|
4161
4744
|
<button class="compact-dismiss" onclick="dismissCompactBanner('${id}')">×</button>
|
|
4162
4745
|
`;
|
|
4163
4746
|
s.container.prepend(banner);
|
|
4164
|
-
// Refit terminal since banner takes space
|
|
4165
|
-
requestAnimationFrame(() =>
|
|
4747
|
+
// Refit terminal since banner takes space — preserve scroll position
|
|
4748
|
+
requestAnimationFrame(() => {
|
|
4749
|
+
const ln = s.term.buffer.active.viewportY;
|
|
4750
|
+
const atBot = s.writer.followMode;
|
|
4751
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4752
|
+
s.fitAddon.fit();
|
|
4753
|
+
if (!atBot) s.term.scrollToLine(ln);
|
|
4754
|
+
});
|
|
4166
4755
|
}
|
|
4167
4756
|
|
|
4168
4757
|
function compactSession(id) {
|
|
@@ -4177,13 +4766,21 @@ function dismissCompactBanner(id) {
|
|
|
4177
4766
|
const banner = s.container.querySelector('.compact-banner');
|
|
4178
4767
|
if (banner) {
|
|
4179
4768
|
banner.remove();
|
|
4180
|
-
requestAnimationFrame(() =>
|
|
4769
|
+
requestAnimationFrame(() => {
|
|
4770
|
+
const ln = s.term.buffer.active.viewportY;
|
|
4771
|
+
const atBot = s.writer.followMode;
|
|
4772
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4773
|
+
s.fitAddon.fit();
|
|
4774
|
+
if (!atBot) s.term.scrollToLine(ln);
|
|
4775
|
+
});
|
|
4181
4776
|
}
|
|
4182
4777
|
}
|
|
4183
4778
|
}
|
|
4184
4779
|
|
|
4185
4780
|
function renderTabs() {
|
|
4186
4781
|
const tabbar = document.getElementById('tabbar');
|
|
4782
|
+
// Skip re-render if user is actively renaming a tab
|
|
4783
|
+
if (tabbar.querySelector('input')) return;
|
|
4187
4784
|
const addBtn = tabbar.querySelector('.tab-add');
|
|
4188
4785
|
|
|
4189
4786
|
// Remove old tabs
|
|
@@ -4238,20 +4835,70 @@ function renderTabs() {
|
|
|
4238
4835
|
|
|
4239
4836
|
const tab = document.createElement('div');
|
|
4240
4837
|
tab.className = `tab ${state.activeTab === id ? 'active' : ''}`;
|
|
4241
|
-
tab.
|
|
4242
|
-
tab.
|
|
4838
|
+
tab.dataset.sessionId = id;
|
|
4839
|
+
tab.draggable = true;
|
|
4840
|
+
tab.innerHTML = `<span class="tab-icon">▸</span><span class="tab-label">${escHtml(label)}</span><span class="close-tab" onclick="event.stopPropagation();killSession('${escHtml(id)}')">×</span>`;
|
|
4841
|
+
tab.onclick = function(e) { sessionItemClick(id, e); };
|
|
4842
|
+
tab.ondblclick = function(e) {
|
|
4843
|
+
e.preventDefault();
|
|
4844
|
+
e.stopPropagation();
|
|
4845
|
+
if (_sessionClickTimer) { clearTimeout(_sessionClickTimer); _sessionClickTimer = null; }
|
|
4846
|
+
setTimeout(function() {
|
|
4847
|
+
var t = document.querySelector('#tabbar .tab[data-session-id="' + id + '"] .tab-label');
|
|
4848
|
+
if (t) startRenameSession(id, t);
|
|
4849
|
+
}, 10);
|
|
4850
|
+
};
|
|
4851
|
+
tab.ondragstart = function(e) { _tabDragId = id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); tab.style.opacity = '0.4'; };
|
|
4852
|
+
tab.ondragend = function() { tab.style.opacity = ''; };
|
|
4853
|
+
tab.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; tab.classList.add('tab-drag-over'); };
|
|
4854
|
+
tab.ondragleave = function() { tab.classList.remove('tab-drag-over'); };
|
|
4855
|
+
tab.ondrop = function(e) {
|
|
4856
|
+
e.preventDefault();
|
|
4857
|
+
tab.classList.remove('tab-drag-over');
|
|
4858
|
+
if (!_tabDragId || _tabDragId === id) return;
|
|
4859
|
+
const from = state.tabOrder.indexOf(_tabDragId);
|
|
4860
|
+
const to = state.tabOrder.indexOf(id);
|
|
4861
|
+
if (from === -1 || to === -1) return;
|
|
4862
|
+
state.tabOrder.splice(from, 1);
|
|
4863
|
+
state.tabOrder.splice(to, 0, _tabDragId);
|
|
4864
|
+
_tabDragId = null;
|
|
4865
|
+
saveTabOrder();
|
|
4866
|
+
renderTabs();
|
|
4867
|
+
};
|
|
4243
4868
|
tabbar.insertBefore(tab, addBtn);
|
|
4244
4869
|
}
|
|
4245
4870
|
}
|
|
4246
4871
|
|
|
4872
|
+
// --- Tab drag-and-drop reorder ---
|
|
4873
|
+
let _tabDragId = null;
|
|
4874
|
+
function saveTabOrder() {
|
|
4875
|
+
// Save only session IDs (not panel IDs like 'rules', 'review', etc.)
|
|
4876
|
+
const sessionOrder = state.tabOrder.filter(id => state.sessions.has(id));
|
|
4877
|
+
savePref('tab_order', sessionOrder);
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4247
4880
|
// --- Actions ---
|
|
4881
|
+
function getLastSessionCwd() {
|
|
4882
|
+
// Try most recently active session's cwd
|
|
4883
|
+
let latest = null;
|
|
4884
|
+
for (const [id, s] of state.sessions) {
|
|
4885
|
+
if (s.meta?.cwd && (!latest || (s.meta.lastActivity || 0) > (latest.lastActivity || 0))) {
|
|
4886
|
+
latest = s.meta;
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
if (latest?.cwd) return latest.cwd;
|
|
4890
|
+
// Fall back to most recent session from filesystem
|
|
4891
|
+
if (allRecentSessions.length > 0) return allRecentSessions[0].cwd || allRecentSessions[0].project || '';
|
|
4892
|
+
return '';
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4248
4895
|
function showNewSessionModal() {
|
|
4249
4896
|
document.getElementById('new-session-modal').classList.remove('hidden');
|
|
4250
|
-
document.getElementById('ns-cwd').value =
|
|
4897
|
+
document.getElementById('ns-cwd').value = getLastSessionCwd();
|
|
4251
4898
|
document.getElementById('ns-label').value = '';
|
|
4252
4899
|
document.getElementById('ns-type').value = 'claude';
|
|
4253
4900
|
onSessionTypeChange();
|
|
4254
|
-
document.getElementById('ns-
|
|
4901
|
+
document.getElementById('ns-label').focus();
|
|
4255
4902
|
}
|
|
4256
4903
|
|
|
4257
4904
|
function closeModal(id) {
|
|
@@ -4297,6 +4944,7 @@ function killSession(id) {
|
|
|
4297
4944
|
state.sessions.delete(id);
|
|
4298
4945
|
}
|
|
4299
4946
|
state.tabOrder = state.tabOrder.filter(t => t !== id);
|
|
4947
|
+
saveTabOrder();
|
|
4300
4948
|
|
|
4301
4949
|
if (state.activeTab === id) {
|
|
4302
4950
|
const next = state.tabOrder[state.tabOrder.length - 1];
|
|
@@ -4315,9 +4963,27 @@ function toggleSidebar() {
|
|
|
4315
4963
|
state.sidebarManuallyHidden = state.sidebarCollapsed;
|
|
4316
4964
|
document.getElementById('sidebar').classList.toggle('collapsed', state.sidebarCollapsed);
|
|
4317
4965
|
document.getElementById('sidebar-resize').style.display = state.sidebarCollapsed ? 'none' : '';
|
|
4318
|
-
// Refit active terminal
|
|
4966
|
+
// Refit active terminal — restore scroll position if user scrolled up
|
|
4319
4967
|
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
4320
|
-
setTimeout(() =>
|
|
4968
|
+
setTimeout(() => {
|
|
4969
|
+
const s = state.sessions.get(state.activeTab);
|
|
4970
|
+
if (!s) return;
|
|
4971
|
+
const wasAtBottom = s.writer.followMode;
|
|
4972
|
+
const savedLine = s.term.buffer.active.viewportY;
|
|
4973
|
+
const oldCols = s.term.cols;
|
|
4974
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
4975
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4976
|
+
s.fitAddon.fit();
|
|
4977
|
+
if (!wasAtBottom) {
|
|
4978
|
+
if (s.term.cols !== oldCols) {
|
|
4979
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
4980
|
+
// Invalidate prompt line positions — reflow shifts them
|
|
4981
|
+
s._promptLinesResolved = false;
|
|
4982
|
+
} else {
|
|
4983
|
+
s.term.scrollToLine(savedLine);
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
}, 100);
|
|
4321
4987
|
}
|
|
4322
4988
|
}
|
|
4323
4989
|
|
|
@@ -4495,6 +5161,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
4495
5161
|
swapNav();
|
|
4496
5162
|
}
|
|
4497
5163
|
}
|
|
5164
|
+
// Alt+Up/Down: navigate prompts in active terminal or review panel
|
|
5165
|
+
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
5166
|
+
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
|
5167
|
+
if (state.activeTab === 'review') {
|
|
5168
|
+
e.preventDefault();
|
|
5169
|
+
reviewPromptNavGo(dir);
|
|
5170
|
+
} else {
|
|
5171
|
+
const id = state.activeTab;
|
|
5172
|
+
if (id && state.sessions.has(id)) {
|
|
5173
|
+
e.preventDefault();
|
|
5174
|
+
promptNavGo(id, dir);
|
|
5175
|
+
}
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
4498
5178
|
// Escape: close modal
|
|
4499
5179
|
if (e.key === 'Escape') {
|
|
4500
5180
|
document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
|
|
@@ -4537,11 +5217,70 @@ function fitActiveTerminal() {
|
|
|
4537
5217
|
_fitDebounce = null;
|
|
4538
5218
|
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
4539
5219
|
const s = state.sessions.get(state.activeTab);
|
|
5220
|
+
const buf = s.term.buffer.active;
|
|
5221
|
+
const wasAtBottom = buf.viewportY >= buf.baseY;
|
|
5222
|
+
const savedLine = buf.viewportY;
|
|
5223
|
+
const oldCols = s.term.cols;
|
|
5224
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
5225
|
+
// Suppress onScroll so reflow doesn't flip followMode
|
|
5226
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4540
5227
|
s.fitAddon.fit();
|
|
4541
5228
|
send({ type: 'resize', id: state.activeTab, cols: s.term.cols, rows: s.term.rows });
|
|
5229
|
+
if (wasAtBottom) {
|
|
5230
|
+
s.term.scrollToBottom();
|
|
5231
|
+
} else if (s.term.cols !== oldCols) {
|
|
5232
|
+
// Cols changed — reflow shifted content, use anchor search
|
|
5233
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
5234
|
+
// Invalidate prompt line positions — reflow shifts them
|
|
5235
|
+
s._promptLinesResolved = false;
|
|
5236
|
+
} else {
|
|
5237
|
+
// Same cols — just restore line position
|
|
5238
|
+
s.term.scrollToLine(savedLine);
|
|
5239
|
+
}
|
|
4542
5240
|
}
|
|
4543
5241
|
});
|
|
4544
5242
|
}
|
|
5243
|
+
|
|
5244
|
+
// Content-based scroll anchor: saves text at viewport position so we can find
|
|
5245
|
+
// the same content after reflow (column change causes line wrapping shifts).
|
|
5246
|
+
function _getScrollAnchor(term) {
|
|
5247
|
+
const buf = term.buffer.active;
|
|
5248
|
+
const viewportY = buf.viewportY;
|
|
5249
|
+
// Grab a few non-empty lines near the viewport top as anchor text
|
|
5250
|
+
const anchors = [];
|
|
5251
|
+
for (let i = 0; i < 10 && anchors.length < 3; i++) {
|
|
5252
|
+
const line = buf.getLine(viewportY + i);
|
|
5253
|
+
if (line) {
|
|
5254
|
+
const text = line.translateToString(true).trim();
|
|
5255
|
+
if (text.length > 8) anchors.push({ offset: i, text });
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
5258
|
+
return { viewportY, baseY: buf.baseY, anchors };
|
|
5259
|
+
}
|
|
5260
|
+
|
|
5261
|
+
function _restoreScrollAnchor(term, anchor) {
|
|
5262
|
+
const buf = term.buffer.active;
|
|
5263
|
+
const newBaseY = buf.baseY;
|
|
5264
|
+
if (!anchor.anchors.length) {
|
|
5265
|
+
term.scrollToLine(Math.min(anchor.viewportY, newBaseY));
|
|
5266
|
+
return;
|
|
5267
|
+
}
|
|
5268
|
+
const searchText = anchor.anchors[0].text.substring(0, 30);
|
|
5269
|
+
// Full forward scan from line 0 — reflow can shift content anywhere when
|
|
5270
|
+
// the scrollback buffer is at its cap and line wrapping changes.
|
|
5271
|
+
for (let i = 0; i <= newBaseY; i++) {
|
|
5272
|
+
const line = buf.getLine(i);
|
|
5273
|
+
if (line) {
|
|
5274
|
+
const text = line.translateToString(true).trim();
|
|
5275
|
+
if (text.includes(searchText)) {
|
|
5276
|
+
term.scrollToLine(Math.max(0, i - anchor.anchors[0].offset));
|
|
5277
|
+
return;
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
// Fallback: keep same viewportY
|
|
5282
|
+
term.scrollToLine(Math.min(anchor.viewportY, newBaseY));
|
|
5283
|
+
}
|
|
4545
5284
|
new ResizeObserver(fitActiveTerminal).observe(document.getElementById('terminal-area'));
|
|
4546
5285
|
window.addEventListener('resize', fitActiveTerminal);
|
|
4547
5286
|
|
|
@@ -4625,6 +5364,11 @@ async function loadPrefs() {
|
|
|
4625
5364
|
state._savedActiveSession = prefs.active_session;
|
|
4626
5365
|
}
|
|
4627
5366
|
|
|
5367
|
+
// Restore tab order
|
|
5368
|
+
if (prefs.tab_order && Array.isArray(prefs.tab_order)) {
|
|
5369
|
+
state._savedTabOrder = prefs.tab_order;
|
|
5370
|
+
}
|
|
5371
|
+
|
|
4628
5372
|
// Restore code review tree width
|
|
4629
5373
|
if (prefs.cr_tree_width) {
|
|
4630
5374
|
state._savedCrTreeWidth = prefs.cr_tree_width;
|
|
@@ -4736,6 +5480,11 @@ async function loadRecentSessions() {
|
|
|
4736
5480
|
reviewSession(s.sessionId, s.projectEntry, sessionDisplayText(s), s);
|
|
4737
5481
|
}
|
|
4738
5482
|
}
|
|
5483
|
+
|
|
5484
|
+
// Re-scan prompts for active sessions now that we have projectEntry data
|
|
5485
|
+
for (const [id, s] of state.sessions) {
|
|
5486
|
+
if (s.term) scanPromptLines(id);
|
|
5487
|
+
}
|
|
4739
5488
|
}
|
|
4740
5489
|
|
|
4741
5490
|
const CTM_SESSION_PATTERNS = [
|
|
@@ -4779,20 +5528,51 @@ function getFilteredSessions() {
|
|
|
4779
5528
|
let titleGenInProgress = false;
|
|
4780
5529
|
|
|
4781
5530
|
function renderFilteredSessions() {
|
|
5531
|
+
// Skip re-render if user is actively renaming a session in the recent list
|
|
5532
|
+
const recentList = document.getElementById('recent-list');
|
|
5533
|
+
if (recentList && recentList.querySelector('input[type="text"]')) return;
|
|
4782
5534
|
const q = document.getElementById('recent-search').value.toLowerCase();
|
|
4783
5535
|
let sessions = getFilteredSessions();
|
|
4784
5536
|
if (q && !aiSearchMode) {
|
|
4785
5537
|
// First filter local metadata
|
|
4786
5538
|
const metaMatches = new Set();
|
|
5539
|
+
const recentIds = new Set(sessions.map(s => s.sessionId));
|
|
4787
5540
|
sessions = sessions.filter(s => {
|
|
5541
|
+
// Also check active session label (tab name)
|
|
5542
|
+
const activeLabel = state.sessions.get(s.sessionId)?.meta?.label || '';
|
|
4788
5543
|
const match = (s.firstMessage || '').toLowerCase().includes(q) ||
|
|
4789
5544
|
(s.aiTitle || '').toLowerCase().includes(q) ||
|
|
4790
5545
|
s.project.toLowerCase().includes(q) ||
|
|
4791
5546
|
s.sessionId.toLowerCase().includes(q) ||
|
|
4792
|
-
(s.gitBranch || '').toLowerCase().includes(q)
|
|
5547
|
+
(s.gitBranch || '').toLowerCase().includes(q) ||
|
|
5548
|
+
activeLabel.toLowerCase().includes(q);
|
|
4793
5549
|
if (match) metaMatches.add(s.sessionId);
|
|
4794
5550
|
return match;
|
|
4795
5551
|
});
|
|
5552
|
+
// Include active sessions matching by label that aren't yet in allRecentSessions
|
|
5553
|
+
for (const [id, s] of state.sessions) {
|
|
5554
|
+
if (recentIds.has(id) || metaMatches.has(id)) continue;
|
|
5555
|
+
const label = s.meta?.label || '';
|
|
5556
|
+
if (label.toLowerCase().includes(q)) {
|
|
5557
|
+
sessions.push({
|
|
5558
|
+
sessionId: id,
|
|
5559
|
+
project: s.meta?.cwd || '',
|
|
5560
|
+
projectEntry: '',
|
|
5561
|
+
cwd: s.meta?.cwd || '',
|
|
5562
|
+
firstMessage: '',
|
|
5563
|
+
title: label,
|
|
5564
|
+
aiTitle: label,
|
|
5565
|
+
isEmpty: true,
|
|
5566
|
+
userMsgCount: 0,
|
|
5567
|
+
modifiedAt: new Date(s.meta?.createdAt || Date.now()).toISOString(),
|
|
5568
|
+
timestamp: new Date(s.meta?.createdAt || Date.now()).toISOString(),
|
|
5569
|
+
version: '',
|
|
5570
|
+
gitBranch: '',
|
|
5571
|
+
fileSize: 0,
|
|
5572
|
+
});
|
|
5573
|
+
metaMatches.add(id);
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
4796
5576
|
// Also search imported conversations in DB (async, will re-render when results arrive)
|
|
4797
5577
|
if (sessions.length === 0 && q.length >= 3 && !_convSearchPending) {
|
|
4798
5578
|
_convSearchPending = true;
|
|
@@ -4902,7 +5682,7 @@ function sessionItemHtml(s) {
|
|
|
4902
5682
|
const isPinned = pinnedSessionIds.includes(s.sessionId);
|
|
4903
5683
|
const pinCls = isPinned ? ' pinned' : '';
|
|
4904
5684
|
const dragAttrs = isPinned ? ` draggable="true" ondragstart="pinDragStart(event)" ondragover="pinDragOver(event)" ondragleave="pinDragLeave(event)" ondrop="pinDrop(event)" ondragend="pinDragEnd(event)"` : '';
|
|
4905
|
-
return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="
|
|
5685
|
+
return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="recentItemClick('${s.sessionId}', event)" ondblclick="recentItemDblClick('${s.sessionId}', event)" oncontextmenu="event.preventDefault();togglePinSession('${s.sessionId}')">
|
|
4906
5686
|
<div class="recent-msg"><span class="pin-icon" style="${isPinned ? 'cursor:grab;' : ''}">★</span><span class="recent-msg-text">${escHtml(displayText)}</span>${emptyTag}${deviceTag}</div>
|
|
4907
5687
|
<div class="recent-meta">
|
|
4908
5688
|
<span class="project">${escHtml(project)}</span>
|
|
@@ -5226,6 +6006,9 @@ async function reviewSession(sessionId, projectEntry, title, sessionData) {
|
|
|
5226
6006
|
// Apply tool-only visibility
|
|
5227
6007
|
toggleToolMsgs();
|
|
5228
6008
|
|
|
6009
|
+
// Update review prompt navigation
|
|
6010
|
+
reviewPromptNavReset();
|
|
6011
|
+
|
|
5229
6012
|
renderTabs();
|
|
5230
6013
|
} catch (e) {
|
|
5231
6014
|
document.getElementById('review-messages').innerHTML = `<div style="padding:20px;color:var(--red)">Failed to load: ${escHtml(e.message)}</div>`;
|
|
@@ -5659,6 +6442,99 @@ function scrollReviewToBottom() {
|
|
|
5659
6442
|
if (el) el.scrollTop = el.scrollHeight;
|
|
5660
6443
|
}
|
|
5661
6444
|
|
|
6445
|
+
// --- Review Prompt Navigation ---
|
|
6446
|
+
let _reviewPromptEls = [];
|
|
6447
|
+
let _reviewPromptIdx = -1;
|
|
6448
|
+
|
|
6449
|
+
function reviewPromptNavReset() {
|
|
6450
|
+
const container = document.getElementById('review-messages');
|
|
6451
|
+
_reviewPromptEls = container ? Array.from(container.querySelectorAll('.review-msg.user')) : [];
|
|
6452
|
+
_reviewPromptIdx = -1;
|
|
6453
|
+
reviewPromptNavUpdateBadge();
|
|
6454
|
+
}
|
|
6455
|
+
|
|
6456
|
+
function reviewPromptNavGo(dir) {
|
|
6457
|
+
if (_reviewPromptEls.length === 0) return;
|
|
6458
|
+
const container = document.getElementById('review-messages');
|
|
6459
|
+
if (!container) return;
|
|
6460
|
+
|
|
6461
|
+
let newIdx;
|
|
6462
|
+
if (_reviewPromptIdx < 0) {
|
|
6463
|
+
// First navigation — find prompt in the direction the user wants
|
|
6464
|
+
const scrollMid = container.scrollTop + container.clientHeight / 2;
|
|
6465
|
+
if (dir < 0) {
|
|
6466
|
+
newIdx = 0;
|
|
6467
|
+
for (let i = _reviewPromptEls.length - 1; i >= 0; i--) {
|
|
6468
|
+
if (_reviewPromptEls[i].offsetTop <= scrollMid) { newIdx = i; break; }
|
|
6469
|
+
}
|
|
6470
|
+
} else {
|
|
6471
|
+
newIdx = _reviewPromptEls.length - 1;
|
|
6472
|
+
for (let i = 0; i < _reviewPromptEls.length; i++) {
|
|
6473
|
+
if (_reviewPromptEls[i].offsetTop >= scrollMid) { newIdx = i; break; }
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
} else {
|
|
6477
|
+
newIdx = _reviewPromptIdx + dir;
|
|
6478
|
+
}
|
|
6479
|
+
if (newIdx < 0 || newIdx >= _reviewPromptEls.length) return;
|
|
6480
|
+
_reviewPromptIdx = newIdx;
|
|
6481
|
+
|
|
6482
|
+
// Scroll the message into view
|
|
6483
|
+
_reviewPromptEls[newIdx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
6484
|
+
|
|
6485
|
+
// Brief highlight
|
|
6486
|
+
_reviewPromptEls[newIdx].style.outline = '2px solid var(--accent)';
|
|
6487
|
+
setTimeout(() => { if (_reviewPromptEls[newIdx]) _reviewPromptEls[newIdx].style.outline = ''; }, 1200);
|
|
6488
|
+
|
|
6489
|
+
reviewPromptNavUpdateBadge();
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
function reviewPromptNavUpdateBadge() {
|
|
6493
|
+
const nav = document.getElementById('review-prompt-nav');
|
|
6494
|
+
if (!nav) return;
|
|
6495
|
+
const badge = nav.querySelector('.prompt-nav-badge');
|
|
6496
|
+
const btns = nav.querySelectorAll('.prompt-nav-btn');
|
|
6497
|
+
const total = _reviewPromptEls.length;
|
|
6498
|
+
const idx = _reviewPromptIdx;
|
|
6499
|
+
badge.textContent = total === 0 ? '0 prompts' : (idx >= 0 ? (idx + 1) + '/' + total : total + (total === 1 ? ' prompt' : ' prompts'));
|
|
6500
|
+
btns[0].disabled = total === 0 || idx <= 0;
|
|
6501
|
+
btns[1].disabled = total === 0 || idx >= total - 1;
|
|
6502
|
+
}
|
|
6503
|
+
|
|
6504
|
+
function reviewPromptNavToggleList() {
|
|
6505
|
+
const nav = document.getElementById('review-prompt-nav');
|
|
6506
|
+
if (!nav) return;
|
|
6507
|
+
const existing = nav.querySelector('.prompt-nav-list');
|
|
6508
|
+
if (existing) { existing.remove(); return; }
|
|
6509
|
+
if (_reviewPromptEls.length === 0) return;
|
|
6510
|
+
|
|
6511
|
+
const list = document.createElement('div');
|
|
6512
|
+
list.className = 'prompt-nav-list';
|
|
6513
|
+
// Show most recent first
|
|
6514
|
+
for (let i = _reviewPromptEls.length - 1; i >= 0; i--) {
|
|
6515
|
+
const msgText = _reviewPromptEls[i].querySelector('.msg-text');
|
|
6516
|
+
let text = msgText ? msgText.textContent.trim() : '(empty)';
|
|
6517
|
+
if (text.length > 80) text = text.slice(0, 80) + '\u2026';
|
|
6518
|
+
const item = document.createElement('div');
|
|
6519
|
+
item.className = 'prompt-nav-list-item' + (i === _reviewPromptIdx ? ' current' : '');
|
|
6520
|
+
item.textContent = text;
|
|
6521
|
+
item.onclick = (function(idx) { return function() {
|
|
6522
|
+
_reviewPromptIdx = idx;
|
|
6523
|
+
_reviewPromptEls[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
6524
|
+
_reviewPromptEls[idx].style.outline = '2px solid var(--accent)';
|
|
6525
|
+
setTimeout(() => { if (_reviewPromptEls[idx]) _reviewPromptEls[idx].style.outline = ''; }, 1200);
|
|
6526
|
+
reviewPromptNavUpdateBadge();
|
|
6527
|
+
list.remove();
|
|
6528
|
+
}; })(i);
|
|
6529
|
+
list.appendChild(item);
|
|
6530
|
+
}
|
|
6531
|
+
nav.appendChild(list);
|
|
6532
|
+
setTimeout(() => {
|
|
6533
|
+
function close(e) { if (!list.contains(e.target)) { list.remove(); document.removeEventListener('click', close); } }
|
|
6534
|
+
document.addEventListener('click', close);
|
|
6535
|
+
}, 0);
|
|
6536
|
+
}
|
|
6537
|
+
|
|
5662
6538
|
(function() {
|
|
5663
6539
|
const el = document.getElementById('review-messages');
|
|
5664
6540
|
const btn = document.getElementById('review-scroll-bottom');
|
|
@@ -7219,6 +8095,8 @@ let _titleFlashStop = null;
|
|
|
7219
8095
|
|
|
7220
8096
|
// Clear waiting state for a session (tab indicator + title flash)
|
|
7221
8097
|
function clearWaitingState(sessionId) {
|
|
8098
|
+
const s = state.sessions.get(sessionId);
|
|
8099
|
+
if (s) { s._waitingForInput = false; s._lastInputAt = Date.now(); }
|
|
7222
8100
|
// Remove pulsing tab indicator
|
|
7223
8101
|
const tabs = document.querySelectorAll('#tabbar .tab');
|
|
7224
8102
|
const tabOrder = state.tabOrder;
|
|
@@ -7269,9 +8147,13 @@ function playNotificationSound(type) {
|
|
|
7269
8147
|
} catch (e) { /* Audio API may not be available */ }
|
|
7270
8148
|
}
|
|
7271
8149
|
|
|
8150
|
+
// Dedup tracking: { sessionId -> { reason, timestamp } }
|
|
8151
|
+
const _lastNotif = {};
|
|
8152
|
+
|
|
7272
8153
|
function onWaitingForInput(msg) {
|
|
7273
8154
|
const sessionId = msg.id;
|
|
7274
8155
|
const session = state.sessions.get(sessionId);
|
|
8156
|
+
if (session) session._waitingForInput = true;
|
|
7275
8157
|
const label = msg.label || session?.meta?.label || sessionId.slice(0, 8);
|
|
7276
8158
|
const reason = msg.reason || 'input';
|
|
7277
8159
|
const reasonText = reason === 'approval' ? 'Needs approval'
|
|
@@ -7294,7 +8176,17 @@ function onWaitingForInput(msg) {
|
|
|
7294
8176
|
}
|
|
7295
8177
|
}
|
|
7296
8178
|
|
|
7297
|
-
if (userIsViewing)
|
|
8179
|
+
if (userIsViewing) {
|
|
8180
|
+
// Clear dedup state when user views the session so next event fires fresh
|
|
8181
|
+
delete _lastNotif[sessionId];
|
|
8182
|
+
return;
|
|
8183
|
+
}
|
|
8184
|
+
|
|
8185
|
+
// Dedup: skip if same session+reason notified within 30s
|
|
8186
|
+
const now = Date.now();
|
|
8187
|
+
const prev = _lastNotif[sessionId];
|
|
8188
|
+
if (prev && prev.reason === reason && (now - prev.ts) < 30000) return;
|
|
8189
|
+
_lastNotif[sessionId] = { reason, ts: now };
|
|
7298
8190
|
|
|
7299
8191
|
// 2. Play notification sound
|
|
7300
8192
|
playNotificationSound(reason === 'approval' ? 'alert' : 'chime');
|
|
@@ -7412,6 +8304,7 @@ window.addEventListener('message', (e) => {
|
|
|
7412
8304
|
const activeSession = state.activeTab && state.sessions.get(state.activeTab);
|
|
7413
8305
|
if (activeSession) {
|
|
7414
8306
|
send({ type: 'input', id: state.activeTab, data: e.data.text + '\n' });
|
|
8307
|
+
clearWaitingState(state.activeTab);
|
|
7415
8308
|
}
|
|
7416
8309
|
}
|
|
7417
8310
|
});
|
|
@@ -7536,7 +8429,7 @@ window.addEventListener('hashchange', handleHashRoute);
|
|
|
7536
8429
|
|
|
7537
8430
|
// --- Init ---
|
|
7538
8431
|
connect();
|
|
7539
|
-
loadPrefs().then(() => {
|
|
8432
|
+
state._prefsLoaded = loadPrefs().then(() => {
|
|
7540
8433
|
loadRecentSessions();
|
|
7541
8434
|
// Restore hash from saved nav if no hash present
|
|
7542
8435
|
handleHashRoute();
|
|
@@ -7551,10 +8444,10 @@ function onQueueState(msg) {
|
|
|
7551
8444
|
// msg has: sessionId, mode, status, currentIndex, items, idleTimeoutMs
|
|
7552
8445
|
queueState = msg;
|
|
7553
8446
|
renderQueueBar();
|
|
7554
|
-
// Scroll terminal to bottom when queue is active
|
|
8447
|
+
// Scroll terminal to bottom when queue is active (only if user hasn't scrolled up)
|
|
7555
8448
|
if (msg.sessionId) {
|
|
7556
8449
|
const s = state.sessions.get(msg.sessionId);
|
|
7557
|
-
if (s) s.term.scrollToBottom();
|
|
8450
|
+
if (s && s.writer.followMode) s.term.scrollToBottom();
|
|
7558
8451
|
}
|
|
7559
8452
|
}
|
|
7560
8453
|
|