neoctl 0.1.7 → 0.1.9

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/dist/web/html.js CHANGED
@@ -29,8 +29,10 @@ export const WEB_HTML = String.raw `<!doctype html>
29
29
  body { background: radial-gradient(circle at top, #101522 0, var(--bg) 42rem); color: var(--text); font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
30
30
  #app { height: 100%; display: flex; flex-direction: column; }
31
31
  .topbar { height: 34px; display: flex; align-items: center; gap: 12px; padding: 0 var(--topbar-gutter); border-bottom: 1px solid var(--line); color: var(--muted); background: rgba(7, 8, 11, .75); backdrop-filter: blur(12px); }
32
- .brand { color: var(--cyan); font-weight: 700; letter-spacing: .08em; }
33
- .page-title { color: var(--muted); font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
+ .brand { color: var(--cyan); font-weight: 700; letter-spacing: .08em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; background: transparent; padding: 0; font: inherit; cursor: pointer; text-align: left; }
33
+ .brand:hover, .brand:focus-visible { color: #67e8f9; text-decoration: underline; text-underline-offset: 3px; outline: none; }
34
+ .brand.session-title { letter-spacing: 0; }
35
+ #connection { flex: 0 0 auto; color: var(--yellow); }
34
36
  #transcriptWrap { position: relative; flex: 1; min-height: 0; }
35
37
  #transcript { height: 100%; overflow: auto; padding: 22px var(--page-gutter) 10px; scroll-behavior: smooth; }
36
38
  .scroll-bottom-zone { position: absolute; left: 0; right: 0; bottom: 0; height: 22px; padding: 0 var(--page-gutter); display: flex; align-items: flex-end; opacity: 0; pointer-events: none; transition: opacity .14s ease; z-index: 2; }
@@ -93,29 +95,68 @@ export const WEB_HTML = String.raw `<!doctype html>
93
95
  .live .marker { animation: pulse 900ms ease-in-out infinite; }
94
96
  @keyframes pulse { 50% { opacity: .35; } }
95
97
  .ansi { color: #d1d5db; }
96
- #status { flex: 0 0 auto; min-height: 28px; padding: 4px var(--page-gutter); color: var(--muted); border-top: 1px solid var(--line); display: flex; align-items: center; gap: 0; overflow: hidden; white-space: nowrap; }
98
+ .diff { color: #d1d5db; }
99
+ .diff-line { display: block; }
100
+ .diff-add { color: var(--green); }
101
+ .diff-del { color: var(--red); }
102
+ .diff-hunk { color: var(--cyan); }
103
+ .diff-meta { color: var(--muted); }
104
+ #status { flex: 0 0 auto; min-height: 28px; padding: 4px var(--page-gutter); color: var(--muted); border-top: 1px solid var(--line); display: flex; flex-direction: column; align-items: stretch; gap: 2px; overflow: hidden; white-space: nowrap; }
105
+ .status-main, .status-bg-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
106
+ .status-bg-row { color: var(--yellow); font-size: 12px; }
97
107
  .phase { font-weight: 700; color: var(--green); }
98
108
  .phase.active { color: var(--cyan); text-shadow: 0 0 12px currentColor; animation: shimmer 1.35s linear infinite; }
99
109
  .phase.thinking { color: var(--purple); }
100
110
  .phase.tools { color: var(--gold); }
101
111
  .phase.stopped { color: var(--yellow); }
102
112
  .sep { color: var(--muted); padding: 0 7px; }
103
- .ctx-stat { word-spacing: .35em; }
104
- .ctx-stat span { word-spacing: normal; }
105
113
  .token-hot { font-weight: 700; }
106
114
  .token-input-hot { color: var(--green); }
107
115
  .token-output-hot { color: var(--cyan); }
108
116
  .token-error-hot { color: var(--red); }
109
117
  @keyframes shimmer { 0%, 100% { filter: brightness(.9); } 45% { filter: brightness(1.9); } }
110
118
  #queued { display: none; padding: 0 var(--page-gutter) 4px; color: var(--yellow); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
111
- #panel { display: none; flex: 0 0 auto; padding: 8px var(--page-gutter); border-top: 1px solid var(--line); background: rgba(7, 8, 11, .96); color: var(--muted); max-height: 34vh; overflow: auto; }
119
+ #panel { display: none; flex: 0 0 auto; padding: 12px var(--page-gutter); border-top: 1px solid var(--line); background: rgba(7, 8, 11, .97); color: var(--muted); max-height: min(58vh, 560px); overflow: auto; }
120
+ #app.sessions-page #transcriptWrap, #app.sessions-page #status, #app.sessions-page #queued, #app.sessions-page #composerWrap { display: none; }
121
+ #app.sessions-page #panel { flex: 1 1 auto; max-height: none; border-top: 0; padding-top: 18px; }
122
+ #app.sessions-page .topbar { display: none; }
112
123
  #panel.open { display: block; }
113
124
  .panel-title { color: var(--cyan); font-weight: 700; margin-bottom: 6px; }
114
- .panel-row { display: grid; grid-template-columns: 3ch minmax(10ch, 1fr) auto auto; gap: 10px; align-items: center; min-height: 24px; color: var(--text); }
115
- .panel-row.selected .panel-num { background: var(--cyan); color: #020617; }
125
+ .panel-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
126
+ .panel-subtitle { color: var(--muted); font-size: 12px; margin-top: 2px; }
127
+ .session-list { display: grid; gap: 8px; }
128
+ .session-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 10px 12px; border: 1px solid #1f2937; border-radius: 12px; background: linear-gradient(180deg, rgba(15, 23, 42, .72), rgba(11, 13, 18, .82)); color: var(--text); cursor: pointer; }
129
+ .session-card.selected { border-color: rgba(34, 211, 238, .78); box-shadow: 0 0 0 1px rgba(34, 211, 238, .18), 0 0 22px rgba(34, 211, 238, .12); }
130
+ .session-card.running { border-color: rgba(34, 197, 94, .58); }
131
+ .session-card.current { background: linear-gradient(180deg, rgba(8, 47, 73, .56), rgba(15, 23, 42, .82)); }
132
+ .session-main { min-width: 0; }
133
+ .session-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
134
+ .session-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135
+ .session-badges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
136
+ .session-badge { border: 1px solid #263043; border-radius: 999px; padding: 1px 7px; color: var(--muted); font-size: 11px; line-height: 17px; background: rgba(15, 23, 42, .72); }
137
+ .session-badge.running { color: #bbf7d0; border-color: rgba(34, 197, 94, .45); background: rgba(22, 101, 52, .22); }
138
+ .session-badge.current { color: #a5f3fc; border-color: rgba(34, 211, 238, .45); background: rgba(8, 145, 178, .16); }
139
+ .session-meta { margin-top: 7px; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
140
+ .session-actions { display: flex; align-items: center; gap: 6px; }
116
141
  .panel-muted { color: var(--muted); }
117
- .panel-actions button, .login-actions button { margin-left: 6px; border: 1px solid #263043; border-radius: 999px; background: rgba(15, 23, 42, .92); color: var(--muted); font: inherit; font-size: 11px; cursor: pointer; }
118
- .panel-actions button:hover, .login-actions button:hover { color: var(--cyan); border-color: #31556b; box-shadow: 0 0 14px rgba(34, 211, 238, .18); }
142
+ .panel-toolbar { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
143
+ .panel-actions button, .login-actions button, .panel-close, .panel-primary { border: 1px solid #263043; border-radius: 999px; background: rgba(15, 23, 42, .92); color: var(--muted); font: inherit; font-size: 11px; cursor: pointer; min-height: 24px; padding: 2px 9px; }
144
+ .panel-actions button:disabled, .login-actions button:disabled, .panel-close:disabled, .panel-primary:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
145
+ .panel-primary { width: 34px; height: 34px; min-height: 34px; padding: 0; border-radius: 50%; color: #020617; border-color: rgba(34, 211, 238, .72); background: linear-gradient(90deg, var(--cyan), #67e8f9); font-weight: 700; font-size: 22px; line-height: 30px; }
146
+ .panel-actions button:hover, .login-actions button:hover, .panel-close:hover, .panel-primary:hover { color: var(--cyan); border-color: #31556b; box-shadow: 0 0 14px rgba(34, 211, 238, .18); }
147
+ .panel-primary:hover { color: #020617; }
148
+ .panel-actions button.danger:hover { color: var(--red); border-color: rgba(239, 68, 68, .5); box-shadow: 0 0 14px rgba(239, 68, 68, .14); }
149
+ @media (max-width: 640px) {
150
+ :root { --page-gutter: 12px; --topbar-gutter: 12px; }
151
+ .topbar { height: 40px; }
152
+ #panel { max-height: 72vh; padding-top: 10px; }
153
+ .panel-header { align-items: center; }
154
+ .session-card { grid-template-columns: 1fr; padding: 12px; }
155
+ .session-actions { justify-content: stretch; }
156
+ .session-actions .panel-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; width: 100%; }
157
+ .session-actions button { margin: 0; min-height: 36px; }
158
+ .session-title-line { align-items: flex-start; }
159
+ }
119
160
  .login-grid { display: grid; grid-template-columns: minmax(12ch, 22ch) 1fr; gap: 6px 10px; align-items: center; }
120
161
  .login-grid label { color: var(--muted); }
121
162
  .login-grid input, .login-grid select { min-width: 0; border: 1px solid #263043; border-radius: 6px; padding: 4px 6px; background: #0b1020; color: var(--text); font: inherit; }
@@ -139,7 +180,7 @@ export const WEB_HTML = String.raw `<!doctype html>
139
180
  </head>
140
181
  <body>
141
182
  <div id="app">
142
- <div class="topbar"><span class="brand">neo web</span><span id="connection">connecting…</span><span id="pageTitle" class="page-title"></span></div>
183
+ <div class="topbar"><button id="brand" class="brand" type="button" title="Open sessions">neo web</button><span id="connection" hidden>connecting…</span></div>
143
184
  <div id="transcriptWrap"><div id="transcript"></div><div id="scrollBottomZone" class="scroll-bottom-zone"><button id="scrollBottom" type="button" aria-label="Scroll to bottom">bottom</button></div></div>
144
185
  <div id="status"></div>
145
186
  <div id="queued"></div>
@@ -159,7 +200,7 @@ const ANIMATED_NUMBER_INTERVAL_MS = 50;
159
200
  const ANIMATED_NUMBER_MIN_DURATION_MS = 180;
160
201
  const ANIMATED_NUMBER_MAX_DURATION_MS = 700;
161
202
  const ANIMATED_NUMBER_DURATION_SCALE_MS = 130;
162
- const state = { lines: [], status: { phase: 'ready', streamedOutputTokens: 0 }, busy: false, queuedInput: undefined, backgroundTaskCount: 0, session: undefined, catalog: { commands: [], modelIds: [], reasoning: [] }, interactive: {}, tips: [], tipIndex: 0, history: [], historyIndex: undefined, completionIndex: 0, expandedToolLines: new Set(), panel: undefined, panelSelection: 0, attachments: [], attachmentCounter: 0 };
203
+ const state = { lines: [], status: { phase: 'ready', streamedOutputTokens: 0 }, busy: false, queuedInput: undefined, backgroundTaskCount: 0, backgroundTasks: [], backgroundSessionRunCount: 0, runningSessionIds: [], session: undefined, catalog: { commands: [], modelIds: [], reasoning: [] }, interactive: {}, tips: [], tipIndex: 0, history: [], historyIndex: undefined, completionIndex: 0, expandedToolLines: new Set(), panel: undefined, panelSelection: 0, attachments: [], attachmentCounter: 0, view: location.pathname === '/sessions' ? 'sessions' : 'chat' };
163
204
  const animatedNumbers = { input: { target: undefined, display: undefined, timer: undefined }, output: { target: undefined, display: undefined, timer: undefined } };
164
205
  const renderedLineKeys = new Map();
165
206
  const statusNodes = {};
@@ -173,12 +214,20 @@ const queuedEl = document.getElementById('queued');
173
214
  const panelEl = document.getElementById('panel');
174
215
  const input = document.getElementById('input');
175
216
  const completionsEl = document.getElementById('completions');
217
+ const brand = document.getElementById('brand');
176
218
  const connection = document.getElementById('connection');
177
- const pageTitle = document.getElementById('pageTitle');
178
219
 
220
+ const sessionsPage = () => state.view === 'sessions';
221
+ const openSessionsOnLoad = state.view === 'sessions';
179
222
  const es = new EventSource('/events');
180
- es.addEventListener('open', () => connection.textContent = 'connected');
181
- es.addEventListener('error', () => connection.textContent = 'reconnecting…');
223
+ es.addEventListener('open', () => {
224
+ connection.hidden = true;
225
+ connection.textContent = '';
226
+ });
227
+ es.addEventListener('error', () => {
228
+ connection.hidden = false;
229
+ connection.textContent = 'reconnecting…';
230
+ });
182
231
  es.addEventListener('sync', (event) => {
183
232
  const payload = JSON.parse(event.data);
184
233
  state.lines = payload.lines || [];
@@ -186,6 +235,9 @@ es.addEventListener('sync', (event) => {
186
235
  state.busy = !!payload.busy;
187
236
  state.queuedInput = payload.queuedInput;
188
237
  state.backgroundTaskCount = payload.backgroundTaskCount || 0;
238
+ state.backgroundTasks = payload.backgroundTasks || [];
239
+ state.backgroundSessionRunCount = payload.backgroundSessionRunCount || 0;
240
+ state.runningSessionIds = payload.runningSessionIds || state.runningSessionIds || [];
189
241
  state.session = payload.session;
190
242
  if (payload.catalog) state.catalog = payload.catalog;
191
243
  if (payload.interactive) state.interactive = payload.interactive;
@@ -263,7 +315,7 @@ function markerForLine(line, kind) {
263
315
  return '●';
264
316
  }
265
317
  function shouldRenderMarkdown(line) {
266
- if (line.format === 'ansi' || line.format === 'plain') return false;
318
+ if (line.format === 'ansi' || line.format === 'plain' || line.format === 'diff') return false;
267
319
  return line.kind === 'assistant' || line.kind === 'thinking' || line.kind === 'system' || line.kind === 'tool';
268
320
  }
269
321
  function hasMoreThanLines(text, maxLines) {
@@ -276,9 +328,26 @@ function hasMoreThanLines(text, maxLines) {
276
328
  }
277
329
  function renderText(text, format, markdown) {
278
330
  if (format === 'ansi') return '<span class="ansi">' + esc(stripAnsi(text)) + '</span>';
331
+ if (format === 'diff') return renderDiffText(text);
279
332
  if (!markdown) return linkify(esc(text));
280
333
  return sanitizeMarkdownHtml(marked.parse(text || ''));
281
334
  }
335
+ function renderDiffText(text) {
336
+ const lines = String(text || '').split('\n');
337
+ return '<span class="diff">' + lines.map((line) => {
338
+ const cls = diffLineClass(line);
339
+ return '<span class="diff-line ' + cls + '">' + esc(line) + '</span>';
340
+ }).join('') + '</span>';
341
+ }
342
+ function diffLineClass(line) {
343
+ const pipeIndex = line.indexOf('│ ');
344
+ const diffMarker = pipeIndex >= 0 ? line.slice(pipeIndex + 2, pipeIndex + 3) : line.slice(0, 1);
345
+ if (line.startsWith('@@')) return 'diff-hunk';
346
+ if (line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('create ') || line.startsWith('edit ') || line.startsWith('write ') || line.startsWith('failed ') || line === 'no changes') return 'diff-meta';
347
+ if (diffMarker === '+') return 'diff-add';
348
+ if (diffMarker === '-') return 'diff-del';
349
+ return 'diff-meta';
350
+ }
282
351
  function renderStatus() {
283
352
  ensureStatusNodes();
284
353
  const s = state.status || {};
@@ -296,7 +365,6 @@ function renderStatus() {
296
365
  setText(statusNodes.ctxPercent, ctx.percent);
297
366
  const ctxColor = contextColor(s.metrics);
298
367
  if (statusNodes.ctxPercent.style.color !== ctxColor) statusNodes.ctxPercent.style.color = ctxColor;
299
- setText(statusNodes.ctxLimit, ctx.limit);
300
368
  const now = Date.now();
301
369
  const retryPending = retryCooldownActive(s, now);
302
370
  const inputArrowClass = retryPending ? 'token-hot token-error-hot' : tokenArrowHotClass(s.inputTokenUpdatedAt, now, 'token-input-hot');
@@ -305,14 +373,29 @@ function renderStatus() {
305
373
  const outputArrowClass = modelOutputPending(s, now) ? '' : tokenArrowHotClass(s.outputTokenUpdatedAt, now, 'token-output-hot');
306
374
  if (statusNodes.outputArrow.className !== outputArrowClass) statusNodes.outputArrow.className = outputArrowClass;
307
375
  setText(statusNodes.outputTokens, outputTokens);
308
- const tasks = state.backgroundTaskCount ? '◇'.repeat(Math.min(3, state.backgroundTaskCount)) + (state.backgroundTaskCount > 3 ? '×' + state.backgroundTaskCount : '') : '';
309
- const tasksDisplay = tasks ? '' : 'none';
310
- if (statusNodes.tasksWrap.style.display !== tasksDisplay) statusNodes.tasksWrap.style.display = tasksDisplay;
311
- setText(statusNodes.tasks, tasks);
376
+ renderBackgroundTasks();
377
+ }
378
+ function renderBackgroundTasks() {
379
+ const tasks = state.backgroundTasks || [];
380
+ const rows = statusNodes.backgroundRows;
381
+ if (!rows) return;
382
+ rows.innerHTML = '';
383
+ if (!tasks.length) { rows.style.display = 'none'; return; }
384
+ rows.style.display = '';
385
+ const summary = document.createElement('div');
386
+ summary.className = 'status-bg-row';
387
+ summary.textContent = '◇ background tools: ' + tasks.length + ' task' + (tasks.length === 1 ? '' : 's');
388
+ rows.appendChild(summary);
389
+ for (const task of tasks.slice(0, 2)) {
390
+ const row = document.createElement('div');
391
+ row.className = 'status-bg-row';
392
+ row.textContent = ' ' + task.type + ':' + truncateMiddle(task.description || task.agentId || task.taskId, Math.max(12, Math.floor(window.innerWidth / 18))) + ' · ' + task.status + ' · ' + formatElapsed(Date.now() - Date.parse(task.createdAt || new Date().toISOString()));
393
+ rows.appendChild(row);
394
+ }
312
395
  }
313
396
  function ensureStatusNodes() {
314
397
  if (statusNodes.phase) return;
315
- statusEl.innerHTML = '<span data-part="phase"></span><span class="sep">·</span><span data-part="model"></span><span class="sep">·</span><span class="ctx-stat">ctx <span data-part="ctxPercent"></span> of <span data-part="ctxLimit"></span></span><span class="sep">·</span><span data-part="inputArrow">↑</span> <span data-part="inputTokens"></span><span class="sep">·</span><span data-part="outputArrow">↓</span> <span data-part="outputTokens"></span><span data-part="tasksWrap"><span class="sep">·</span><span data-part="tasks" style="color:var(--yellow)"></span></span>';
398
+ statusEl.innerHTML = '<div class="status-main"><span data-part="phase"></span><span class="sep">·</span><span data-part="model"></span><span class="sep">·</span><span data-part="ctxPercent"></span><span class="sep">·</span><span data-part="inputArrow">↑</span> <span data-part="inputTokens"></span><span class="sep">·</span><span data-part="outputArrow">↓</span> <span data-part="outputTokens"></span></div><div data-part="backgroundRows"></div>';
316
399
  for (const node of statusEl.querySelectorAll('[data-part]')) statusNodes[node.getAttribute('data-part')] = node;
317
400
  }
318
401
  function minimumDisplayPhase(target) {
@@ -351,10 +434,13 @@ function renderTitle() {
351
434
  const prefix = isActivePhase((state.status || {}).phase) || state.backgroundTaskCount > 0 ? '● ' : '✓ ';
352
435
  if (title) {
353
436
  const value = prefix + title;
354
- setText(pageTitle, value);
437
+ setText(brand, value);
438
+ brand.classList.add('session-title');
355
439
  if (document.title !== value) document.title = value;
356
440
  } else {
357
- setText(pageTitle, '');
441
+ setText(brand, 'neo web');
442
+ brand.classList.remove('session-title');
443
+ if (document.title !== 'neo web') document.title = 'neo web';
358
444
  }
359
445
  }
360
446
  function sessionDisplayTitle(session) {
@@ -418,28 +504,65 @@ function renderQueued() {
418
504
  setText(queuedEl, 'queued next: ' + state.queuedInput.replace(/\s+/g, ' ').trim() + ' (Esc/Ctrl+C to clear)');
419
505
  }
420
506
  function renderPanel() {
507
+ document.getElementById('app').classList.toggle('sessions-page', sessionsPage());
421
508
  if (!state.panel) { panelEl.className = ''; panelEl.innerHTML = ''; return; }
422
509
  panelEl.className = 'open';
423
510
  if (state.panel === 'sessions') renderSessionsPanel();
424
511
  else if (state.panel === 'login') renderLoginPanel();
425
512
  }
426
513
  async function openSessionsPanel() {
514
+ state.view = 'sessions';
427
515
  state.panel = 'sessions';
428
516
  state.panelSelection = 0;
517
+ history.replaceState(null, '', '/sessions');
518
+ renderPanel();
429
519
  panelEl.className = 'open';
430
- panelEl.innerHTML = '<div class="panel-title">Saved sessions</div><div class="panel-muted">loading…</div>';
520
+ panelEl.innerHTML = '<div class="panel-title">Sessions</div><div class="panel-muted">loading…</div>';
431
521
  const res = await fetch('/api/sessions');
432
522
  const body = await res.json();
433
523
  state.sessions = body.sessions || [];
524
+ state.runningSessionIds = body.runningSessionIds || [];
434
525
  renderPanel();
435
526
  }
436
527
  function renderSessionsPanel() {
437
528
  const sessions = state.sessions || [];
438
529
  const selected = Math.max(0, Math.min(state.panelSelection, sessions.length - 1));
439
530
  state.panelSelection = selected;
440
- panelEl.innerHTML = '<div class="panel-title">Saved sessions</div>' + (sessions.length ? sessions.map((s, i) => '<div class="panel-row ' + (i === selected ? 'selected' : '') + '" data-session-index="' + i + '"><span class="panel-num">' + (i + 1) + '.</span><span>' + esc(s.title || '(untitled)') + ' <span class="panel-muted">' + esc(truncateMiddle(s.sessionId, 18)) + ' · ' + esc(s.messages) + ' messages · ' + esc(s.updatedAt || '') + '</span></span><span class="panel-actions"><button data-action="resume" data-session-id="' + esc(s.sessionId) + '">resume</button><button data-action="delete" data-session-id="' + esc(s.sessionId) + '">delete</button></span></div>').join('') : '<div class="panel-muted">No saved sessions found.</div>') + '<div class="panel-muted">↑/↓ select · Enter resume · Delete remove · Esc close</div>';
441
- }
531
+ const currentSessionId = state.session && state.session.sessionId;
532
+ const header = '<div class="panel-header"><div><div class="panel-title">Sessions</div><div class="panel-subtitle">Manage saved sessions.</div></div><div class="panel-toolbar"><button class="panel-primary" data-action="new-session" title="New session" aria-label="New session">+</button></div></div>';
533
+ const body = sessions.length ? '<div class="session-list">' + sessions.map((s, i) => renderSessionCard(s, i, selected, currentSessionId)).join('') + '</div>' : '<div class="panel-muted">No saved sessions found. Tap + to start a new session.</div>';
534
+ panelEl.innerHTML = header + body + '<div class="panel-muted" style="margin-top:8px">↑/↓ select · Enter enter · Delete remove</div>';
535
+ }
536
+ function renderSessionCard(s, i, selected, currentSessionId) {
537
+ const isCurrent = s.sessionId === currentSessionId;
538
+ const isRunning = (state.runningSessionIds || []).includes(s.sessionId) || (isCurrent && (state.busy || isActivePhase((state.status || {}).phase)));
539
+ const badges = [
540
+ isRunning ? '<span class="session-badge running">● running</span>' : '',
541
+ isCurrent ? '<span class="session-badge current">current</span>' : '',
542
+ '<span class="session-badge">' + esc(s.messages) + ' messages</span>',
543
+ ].filter(Boolean).join('');
544
+ const classes = ['session-card', i === selected ? 'selected' : '', isCurrent ? 'current' : '', isRunning ? 'running' : ''].filter(Boolean).join(' ');
545
+ return '<div class="' + classes + '" data-session-index="' + i + '"><div class="session-main"><div class="session-title-line"><span class="session-name">' + esc(s.title || '(untitled)') + '</span></div><div class="session-badges">' + badges + '</div><div class="session-meta">' + esc(truncateMiddle(s.sessionId, 28)) + ' · updated ' + esc(s.updatedAt || 'unknown') + '</div></div><div class="session-actions"><span class="panel-actions"><button data-action="enter" data-session-id="' + esc(s.sessionId) + '">enter</button><button class="danger" data-action="delete" data-session-id="' + esc(s.sessionId) + '">delete</button></span></div></div>';
546
+ }
547
+ function showChatView() {
548
+ state.view = 'chat';
549
+ state.panel = undefined;
550
+ history.replaceState(null, '', '/');
551
+ renderPanel();
552
+ input.focus();
553
+ }
554
+ async function enterSession(sessionId) {
555
+ const result = await postJson('/api/sessions/resume', { sessionId });
556
+ if (result.ok) showChatView();
557
+ }
558
+ async function createAndEnterSession() {
559
+ const result = await postJson('/api/sessions/new', {});
560
+ if (result.ok) showChatView();
561
+ }
442
562
  async function openSessionsPanelAfterDelete(sessionId) {
563
+ const session = (state.sessions || []).find(s => s.sessionId === sessionId);
564
+ const label = session ? (session.title || session.sessionId) : sessionId;
565
+ if (!confirm('Delete session "' + label + '"? This cannot be undone.')) return;
443
566
  await postJson('/api/sessions/delete', { sessionId });
444
567
  await openSessionsPanel();
445
568
  }
@@ -538,6 +661,15 @@ async function submit() {
538
661
  const text = input.value;
539
662
  if (text.trim() === '/sessions') { input.value = ''; autosize(); renderCompletions(); await openSessionsPanel(); return; }
540
663
  if (text.trim() === '/login') { input.value = ''; autosize(); renderCompletions(); await openLoginPanel(); return; }
664
+ if (text.trim() === '/new') {
665
+ input.value = '';
666
+ state.attachments = [];
667
+ autosize();
668
+ renderCompletions();
669
+ const result = await postJson('/api/sessions/new', {});
670
+ if (!result.ok && result.error) alert(result.error);
671
+ return;
672
+ }
541
673
  const attachments = attachmentsForText(text);
542
674
  if (!text.trim() && attachments.length === 0) return;
543
675
  state.history = [text].concat(state.history.filter(x => x !== text)).slice(0, 100);
@@ -552,12 +684,14 @@ async function submit() {
552
684
  }
553
685
  transcript.addEventListener('scroll', updateScrollBottomAffordance, { passive: true });
554
686
  scrollBottom.addEventListener('click', () => { transcript.scrollTo({ top: transcript.scrollHeight, behavior: 'smooth' }); updateScrollBottomAffordance(); });
687
+ brand.addEventListener('click', () => { void openSessionsPanel(); });
555
688
  panelEl.addEventListener('click', async (e) => {
556
689
  const button = e.target.closest('button');
557
690
  if (!button) return;
558
691
  const action = button.getAttribute('data-action');
559
692
  if (action === 'panel-close') { state.panel = undefined; renderPanel(); input.focus(); return; }
560
- if (action === 'resume') { await postJson('/api/sessions/resume', { sessionId: button.getAttribute('data-session-id') }); state.panel = undefined; renderPanel(); return; }
693
+ if (action === 'new-session') { await createAndEnterSession(); return; }
694
+ if (action === 'enter') { await enterSession(button.getAttribute('data-session-id')); return; }
561
695
  if (action === 'delete') { await openSessionsPanelAfterDelete(button.getAttribute('data-session-id')); return; }
562
696
  if (action === 'login-save') { await saveLoginPanel(); return; }
563
697
  });
@@ -582,16 +716,19 @@ transcript.addEventListener('click', (e) => {
582
716
  renderedLineKeys.set(String(id), lineRenderKey(line));
583
717
  }
584
718
  });
585
- input.addEventListener('keydown', (e) => {
586
- const count = completions().length;
587
- if (state.panel === 'sessions') {
588
- const countSessions = (state.sessions || []).length;
589
- if (e.key === 'Escape') { e.preventDefault(); state.panel = undefined; renderPanel(); return; }
590
- if (e.key === 'ArrowUp' && countSessions) { e.preventDefault(); state.panelSelection = (state.panelSelection + countSessions - 1) % countSessions; renderPanel(); return; }
591
- if (e.key === 'ArrowDown' && countSessions) { e.preventDefault(); state.panelSelection = (state.panelSelection + 1) % countSessions; renderPanel(); return; }
592
- if (e.key === 'Enter' && countSessions) { e.preventDefault(); const s = state.sessions[state.panelSelection]; if (s) { void postJson('/api/sessions/resume', { sessionId: s.sessionId }); state.panel = undefined; renderPanel(); } return; }
593
- if ((e.key === 'Delete' || e.key === 'Backspace') && countSessions) { e.preventDefault(); const s = state.sessions[state.panelSelection]; if (s) openSessionsPanelAfterDelete(s.sessionId); return; }
594
- }
719
+ function handleSessionsKey(e) {
720
+ if (state.panel !== 'sessions') return false;
721
+ const countSessions = (state.sessions || []).length;
722
+ if (e.key === 'Escape') { e.preventDefault(); showChatView(); return true; }
723
+ if (e.key === 'ArrowUp' && countSessions) { e.preventDefault(); state.panelSelection = (state.panelSelection + countSessions - 1) % countSessions; renderPanel(); return true; }
724
+ if (e.key === 'ArrowDown' && countSessions) { e.preventDefault(); state.panelSelection = (state.panelSelection + 1) % countSessions; renderPanel(); return true; }
725
+ if (e.key === 'Enter' && countSessions) { e.preventDefault(); const s = state.sessions[state.panelSelection]; if (s) void enterSession(s.sessionId); return true; }
726
+ if ((e.key === 'Delete' || e.key === 'Backspace') && countSessions) { e.preventDefault(); const s = state.sessions[state.panelSelection]; if (s) openSessionsPanelAfterDelete(s.sessionId); return true; }
727
+ return false;
728
+ }
729
+ input.addEventListener('keydown', (e) => {
730
+ const count = completions().length;
731
+ if (handleSessionsKey(e)) return;
595
732
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const c = selectedCompletion(); if (c && c.kind === 'command' && c.arguments !== 'none') { completeSelection(); input.value += ' '; input.selectionStart = input.selectionEnd = input.value.length; return; } submit(); return; }
596
733
  if (e.key === 'Tab') { if (completeSelection()) e.preventDefault(); else if (!input.value) { e.preventDefault(); advanceTip(); } return; }
597
734
  if (e.key === 'ArrowUp' && count) { e.preventDefault(); state.completionIndex = (state.completionIndex + count - 1) % count; renderCompletions(); return; }
@@ -605,14 +742,19 @@ input.addEventListener('keydown', (e) => {
605
742
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { if (input.value) { input.value = ''; autosize(); renderCompletions(); } else fetch('/api/interrupt', { method: 'POST' }); }
606
743
  if (e.key === 'Escape') { state.completionIndex = 0; if (state.queuedInput) fetch('/api/interrupt', { method: 'POST' }); else renderCompletions(); }
607
744
  });
745
+ document.addEventListener('keydown', (e) => {
746
+ if (e.target === input || e.target.closest('input, textarea, select')) return;
747
+ handleSessionsKey(e);
748
+ });
608
749
  input.addEventListener('input', () => { state.completionIndex = 0; state.attachments = attachmentsForText(input.value); advanceTip(); autosize(); renderCompletions(); });
609
750
  input.addEventListener('paste', (e) => { void handlePaste(e); });
610
751
  function autosize() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * .35) + 'px'; updateScrollBottomAffordance(); }
611
752
  function phaseLabel(phase) { if (phase === 'calling_model') return 'model'; if (phase === 'thinking') return 'think'; if (phase === 'running_tools') return 'tools'; if (phase === 'injecting_context') return 'context'; return phase || 'ready'; }
612
753
  function isActivePhase(phase) { return ['running', 'preparing', 'calling_model', 'thinking', 'running_tools', 'compacting', 'injecting_context'].includes(phase); }
613
- function contextParts(metrics) { if (!metrics) return { used: '?', limit: '?', percent: '?' }; return { used: compactNumber(metrics.estimatedInputTokens), limit: metrics.contextWindowTokens ? compactNumber(metrics.contextWindowTokens) : '?', percent: metrics.contextUsageRatio === undefined ? '?' : (metrics.contextUsageRatio * 100).toFixed(1) + '%' }; }
754
+ function contextParts(metrics) { if (!metrics) return { percent: '?' }; return { percent: metrics.contextUsageRatio === undefined ? '?' : (metrics.contextUsageRatio * 100).toFixed(1) + '%' }; }
614
755
  function contextColor(metrics) { const r = metrics && metrics.contextUsageRatio; if (r === undefined) return 'var(--muted)'; if (r >= .9) return 'var(--red)'; if (r >= .75) return 'var(--yellow)'; return 'var(--muted)'; }
615
756
  function compactNumber(value) { if (value === undefined || value === null) return '?'; const n = Math.max(0, Math.round(value)); if (n >= 1000000) return trimFixed(n / 1000000) + 'm'; if (n >= 10000) return Math.round(n / 1000) + 'k'; if (n >= 1000) return trimFixed(n / 1000) + 'k'; return String(n); }
757
+ function formatElapsed(ms) { const seconds = Math.max(0, Math.floor(ms / 1000)); if (seconds < 60) return seconds + 's'; const minutes = Math.floor(seconds / 60); const rem = String(seconds % 60).padStart(2, '0'); if (minutes < 60) return minutes + 'm' + rem + 's'; return Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm'; }
616
758
  function trimFixed(v) { return v >= 10 ? v.toFixed(0) : v.toFixed(1).replace(/\.0$/, ''); }
617
759
  function truncateMiddle(value, max) { value = String(value); if (value.length <= max) return value; if (max <= 3) return value.slice(0, max); const l = Math.ceil((max - 3) / 2), r = Math.floor((max - 3) / 2); return value.slice(0, l) + '...' + value.slice(value.length - r); }
618
760
  function stripAnsi(value) { return String(value).replace(/\x1b\[[0-9;]*m/g, ''); }
@@ -689,8 +831,10 @@ function safeHref(value) {
689
831
  }
690
832
  function esc(value) { return String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
691
833
  function linkify(value) { return value.replace(/(https?:\/\/[^\s<]+)/g, '<a style="color:var(--cyan)" target="_blank" href="$1">$1</a>'); }
834
+ document.getElementById('app').classList.toggle('sessions-page', sessionsPage());
692
835
  updateInputPlaceholder();
693
836
  autosize();
837
+ if (openSessionsOnLoad) void openSessionsPanel();
694
838
  </script>
695
839
  </body>
696
840
  </html>`;
@@ -1 +1 @@
1
- {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/web/html.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAurB1B,CAAC"}
1
+ {"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/web/html.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAu0B1B,CAAC"}