@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.30

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.
Files changed (58) hide show
  1. package/README.md +39 -6
  2. package/dist/bin/ai.js +142 -383
  3. package/dist/src/agent-mode.js +1 -6
  4. package/dist/src/api/auth.js +6 -4
  5. package/dist/src/api/browser-login.js +152 -37
  6. package/dist/src/api/chat.js +258 -38
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/default-host.js +1 -0
  9. package/dist/src/api/http.js +69 -7
  10. package/dist/src/api/models.js +19 -10
  11. package/dist/src/background-jobs.js +2 -2
  12. package/dist/src/cli-args.js +19 -5
  13. package/dist/src/core/clipboard.js +7 -13
  14. package/dist/src/core/image-limits.js +56 -0
  15. package/dist/src/core/image-path-extractor.js +70 -3
  16. package/dist/src/core/session-image-store.js +199 -0
  17. package/dist/src/executor.js +25 -3
  18. package/dist/src/help-text.js +63 -16
  19. package/dist/src/permissions.js +243 -0
  20. package/dist/src/session-safety.js +0 -12
  21. package/dist/src/session-store.js +121 -20
  22. package/dist/src/session.js +14 -3
  23. package/dist/src/signin.js +58 -0
  24. package/dist/src/tool-executor.js +11 -46
  25. package/dist/src/tools/delete-file.js +15 -3
  26. package/dist/src/tools/index.js +13 -10
  27. package/dist/src/tools/patch-file.js +12 -26
  28. package/dist/src/tools/read-image-file.js +85 -0
  29. package/dist/src/tools/replace-document-text.js +28 -18
  30. package/dist/src/tools/restore-checkpoint.js +0 -1
  31. package/dist/src/tools/run-command.js +14 -71
  32. package/dist/src/tools/run-node-script.js +12 -81
  33. package/dist/src/tools/save-generated-image.js +120 -0
  34. package/dist/src/tools/str-replace.js +12 -26
  35. package/dist/src/tools/undo-edit.js +1 -6
  36. package/dist/src/tools/write-file.js +67 -11
  37. package/dist/src/turn-failure-marker.js +11 -0
  38. package/dist/src/ui/prompt-history-store.js +1 -1
  39. package/dist/src/ui/repl.js +610 -164
  40. package/dist/src/ui/tui/bridge.js +10 -0
  41. package/dist/src/ui/tui/build-frame.js +452 -115
  42. package/dist/src/ui/tui/markdown-render.js +81 -73
  43. package/dist/src/ui/tui/shell-input.js +206 -63
  44. package/dist/src/ui/tui/terminal-theme.js +28 -0
  45. package/dist/src/ui/tui/terminal-title.js +3 -0
  46. package/dist/src/ui/tui/terminal-writes.js +48 -0
  47. package/dist/src/ui/tui/text.js +158 -4
  48. package/dist/src/ui/tui/user-input.js +568 -0
  49. package/dist/src/utils.js +9 -0
  50. package/package.json +29 -6
  51. package/dist/src/markdown-renderer.js +0 -112
  52. package/dist/src/project-index.js +0 -221
  53. package/dist/src/tools/code-intel.js +0 -472
  54. package/dist/src/tools/find-symbol.js +0 -70
  55. package/dist/src/tools/hover-symbol.js +0 -95
  56. package/dist/src/tools/list-symbols.js +0 -55
  57. package/dist/src/tools/search-code.js +0 -37
  58. package/dist/src/tools/signature-help.js +0 -118
@@ -1,8 +1,10 @@
1
1
  import { agentModeLabel } from '../../agent-mode.js';
2
- import { truncate } from '../../utils.js';
2
+ import { singleLinePreview, truncate } from '../../utils.js';
3
3
  import { formatClientTokenUsage } from '../repl.js';
4
4
  import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdown-render.js';
5
- import { line, plainLine, span, wrapText } from './text.js';
5
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
+ import { isDarkTerminalBackground, mutedColor, mutedStyle } from './terminal-theme.js';
7
+ import { buildUserInputOverlayLines, } from './user-input.js';
6
8
  const WORKING_CLOCK_ICON = '◷';
7
9
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
10
  const TODO_PANEL_MAX_ROWS = 12;
@@ -10,14 +12,29 @@ const TODO_IN_PROGRESS_COLOR = 'ansi256(214)';
10
12
  const COMMAND_PREVIEW_LINES = 10;
11
13
  const WORKING_TOOL_PREVIEW_ROWS = 3;
12
14
  const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
15
+ const APPROVAL_PREVIEW_MAX_ROWS = 20;
16
+ const APPROVAL_PREVIEW_MIN_ROWS = 4;
17
+ const APPROVAL_OVERLAY_CHROME_ROWS = 11;
18
+ const APPROVAL_PADDING_CHROME_ROWS = 4;
19
+ const APPROVAL_PADDING_MIN_ROWS = 30;
20
+ const APPROVAL_SCROLLBAR_COLUMNS = 2;
21
+ const APPROVAL_ACCENT_COLOR = 'cyan';
22
+ const SUDO_PASSWORD_ASSURANCE = 'The model never sees this. Your password goes straight to sudo on this ' +
23
+ 'computer, then is discarded — never sent to our servers, saved, or logged.';
24
+ const SUDO_ASSURANCE_COLOR = 'ansi256(248)';
25
+ const APPROVAL_SCROLL_STATUS_COLOR = 'ansi256(248)';
26
+ const APPROVAL_SCROLLBAR_THUMB = '█';
27
+ const APPROVAL_SCROLLBAR_TRACK = '░';
13
28
  const THINKING_NOTE_PREVIEW_ROWS = 3;
14
29
  const COMPOSER_INPUT_MAX_ROWS = 6;
15
30
  const AGENT_MODE_LABEL_WIDTH = 16;
16
31
  const OVERLAY_PANEL_MAX_WIDTH = 86;
17
32
  const OVERLAY_PANEL_MARGIN_LINES = 2;
33
+ const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
18
34
  const OVERLAY_BORDER_COLOR = 'yellow';
35
+ const USER_INPUT_BORDER_COLOR = 'cyan';
19
36
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
20
- const MODEL_PICKER_PANEL_MAX_WIDTH = 120;
37
+ const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
21
38
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
22
39
  const MODEL_PICKER_BORDER_COLOR = 'cyan';
23
40
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
@@ -71,7 +88,7 @@ function splitComposerInput(input, cursor, width) {
71
88
  function buildComposerInputLines(input, cursor, promptLabel, placeholder, width) {
72
89
  if (!input) {
73
90
  return [
74
- line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: 'gray' })),
91
+ line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: mutedColor() })),
75
92
  ];
76
93
  }
77
94
  const labelWidth = promptLabel.length;
@@ -133,14 +150,14 @@ function diffLinePrefix(kind) {
133
150
  return ' ';
134
151
  }
135
152
  }
136
- function fitLine(content, maxWidth) {
153
+ export function fitLine(content, maxWidth) {
137
154
  if (maxWidth <= 0)
138
155
  return '';
139
- if (content.length <= maxWidth)
156
+ if (displayWidth(content) <= maxWidth)
140
157
  return content;
141
158
  if (maxWidth === 1)
142
159
  return '…';
143
- return `${content.slice(0, maxWidth - 1)}…`;
160
+ return `${sliceToWidth(content, maxWidth - 1)}…`;
144
161
  }
145
162
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
146
163
  const trimmed = String(projectRoot ?? '').trim();
@@ -163,7 +180,8 @@ const CLIENT_SLASH_COMMANDS = [
163
180
  { command: '/model', description: 'Switch the active model' },
164
181
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
165
182
  { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
166
- { command: '/clear', description: 'Clear conversation history' },
183
+ { command: '/new', description: 'start a new conversation; this session remains saved' },
184
+ { command: '/logout', description: 'Sign out and quit' },
167
185
  { command: '/exit', description: 'Quit the current session' },
168
186
  ];
169
187
  function buildModelPickerOptions(currentModelId, serverModels) {
@@ -212,6 +230,9 @@ export function getSlashCommandSuggestions(input) {
212
230
  function formatModelLabel(modelId, serverModels) {
213
231
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
214
232
  }
233
+ function pickerModelLabel(modelId, serverModels) {
234
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
235
+ }
215
236
  function filterResumeSessions(sessions, filter, serverModels) {
216
237
  const q = filter.trim().toLowerCase();
217
238
  if (!q)
@@ -235,26 +256,90 @@ function formatRelativeTime(isoDate) {
235
256
  return `${hours}h ago`;
236
257
  return `${Math.floor(hours / 24)}d ago`;
237
258
  }
238
- function todoItemLine(item, width) {
259
+ const TODO_IN_PROGRESS_FRAMES = [
260
+ '\u25CB',
261
+ '\u25D4',
262
+ '\u25D1',
263
+ '\u25D5',
264
+ '\u25CF',
265
+ ];
266
+ const TODO_IN_PROGRESS_STATIC_GLYPH = '\u25D1';
267
+ export const TODO_IN_PROGRESS_STEP_MS = 1_500;
268
+ export function todoProgressTick(nowMs) {
269
+ return Math.floor(nowMs / TODO_IN_PROGRESS_STEP_MS);
270
+ }
271
+ function todoInProgressGlyph(progressTick) {
272
+ if (progressTick === null)
273
+ return TODO_IN_PROGRESS_STATIC_GLYPH;
274
+ const index = ((progressTick % TODO_IN_PROGRESS_FRAMES.length) +
275
+ TODO_IN_PROGRESS_FRAMES.length) %
276
+ TODO_IN_PROGRESS_FRAMES.length;
277
+ return TODO_IN_PROGRESS_FRAMES[index];
278
+ }
279
+ function todoItemLine(item, width, progressTick = null) {
239
280
  const text = fitLine(item.text, Math.max(8, width - 5));
240
281
  if (item.status === 'completed') {
241
- return line(span(' ✔ ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
282
+ return line(span(' ✔ ', { color: 'green' }), span(text, mutedStyle()));
242
283
  }
243
284
  if (item.status === 'in_progress') {
244
- return line(span(' ◐ ', { color: TODO_IN_PROGRESS_COLOR }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
285
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
286
+ color: TODO_IN_PROGRESS_COLOR,
287
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
288
+ }
289
+ return line(span(' ○ ', { color: mutedColor() }), isDarkTerminalBackground()
290
+ ? span(text, { color: mutedColor() })
291
+ : span(text));
292
+ }
293
+ const TURN_MESSAGE_PANEL_MAX_ROWS = 6;
294
+ const TURN_MESSAGE_LABEL = {
295
+ queued: 'queued',
296
+ sending: 'Processing . . .',
297
+ delivered: 'delivered',
298
+ };
299
+ function turnMessageLine(message, width, progressTick) {
300
+ const DECORATION = 6;
301
+ const budget = Math.max(1, width - DECORATION);
302
+ const preferredLabel = Math.max(...Object.values(TURN_MESSAGE_LABEL).map((value) => displayWidth(value)));
303
+ const labelWidth = Math.min(Math.max(1, Math.floor(budget / 2)), Math.max(preferredLabel, displayWidth(message.note ?? '')));
304
+ const label = fitLine(message.note ?? TURN_MESSAGE_LABEL[message.state], labelWidth);
305
+ const textWidth = Math.max(1, budget - labelWidth);
306
+ const text = padToWidth(fitLine(`"${message.text}"`, textWidth), textWidth);
307
+ if (message.state === 'delivered') {
308
+ return line(span(' ● ', { color: 'green' }), span(text, { color: 'gray', dim: true }), span(` ${label}`, { color: 'green' }));
309
+ }
310
+ if (message.state === 'sending') {
311
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
312
+ color: TODO_IN_PROGRESS_COLOR,
313
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }), span(` ${label}`, { color: TODO_IN_PROGRESS_COLOR }));
245
314
  }
246
- return line(span(' ○ ', { color: 'gray' }), span(text));
315
+ return line(span(' ○ ', { color: 'cyan' }), span(text, { color: 'cyan' }), span(` ${label}`, { color: 'gray' }));
316
+ }
317
+ export function buildTurnMessageLines(messages, width, progressTick = null) {
318
+ if (messages.length === 0)
319
+ return [];
320
+ const lines = [
321
+ line(span('Your messages', { color: 'cyan', bold: true })),
322
+ ];
323
+ const visible = messages.slice(-(TURN_MESSAGE_PANEL_MAX_ROWS - 1));
324
+ const hidden = messages.length - visible.length;
325
+ if (hidden > 0) {
326
+ lines.push(line(span(` ● ${hidden} earlier`, { color: 'gray', dim: true })));
327
+ }
328
+ for (const message of visible) {
329
+ lines.push(turnMessageLine(message, width, progressTick));
330
+ }
331
+ return lines;
247
332
  }
248
333
  export function formatTodoProgress(items) {
249
334
  const done = items.filter((item) => item.status === 'completed').length;
250
335
  return `${done}/${items.length} done`;
251
336
  }
252
- export function renderTodoListLines(items, width, { header = false } = {}) {
337
+ export function renderTodoListLines(items, width, { header = false, progressTick = null } = {}) {
253
338
  if (items.length === 0)
254
339
  return [];
255
340
  const lines = [];
256
341
  if (header) {
257
- lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: 'gray' })));
342
+ lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: mutedColor() })));
258
343
  }
259
344
  const itemRowBudget = TODO_PANEL_MAX_ROWS - (header ? 1 : 0);
260
345
  let collapsedDone = 0;
@@ -268,19 +353,16 @@ export function renderTodoListLines(items, width, { header = false } = {}) {
268
353
  collapsedDone = Math.min(over, leadingDone);
269
354
  }
270
355
  if (collapsedDone > 0) {
271
- lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, { color: 'gray', dim: true })));
356
+ lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, mutedStyle())));
272
357
  }
273
358
  const remaining = items.slice(collapsedDone);
274
359
  const budget = itemRowBudget - (collapsedDone > 0 ? 1 : 0);
275
360
  const visible = remaining.length > budget ? remaining.slice(0, budget - 1) : remaining;
276
361
  for (const item of visible) {
277
- lines.push(todoItemLine(item, width));
362
+ lines.push(todoItemLine(item, width, progressTick));
278
363
  }
279
364
  if (remaining.length > visible.length) {
280
- lines.push(plainLine(` … +${remaining.length - visible.length} more`, {
281
- color: 'gray',
282
- dim: true,
283
- }));
365
+ lines.push(plainLine(` … +${remaining.length - visible.length} more`, mutedStyle()));
284
366
  }
285
367
  return lines;
286
368
  }
@@ -293,20 +375,45 @@ export function renderTranscriptEntryLines(entry, width) {
293
375
  ? renderPreformattedBodyLines(entry.body, width, entry.kind)
294
376
  : renderFormattedBodyLines(entry.body, width, entry.kind)));
295
377
  if (entry.diffPreview) {
296
- lines.push(plainLine(` Added ${entry.diffPreview.added} line${entry.diffPreview.added === 1 ? '' : 's'}, removed ${entry.diffPreview.removed} line${entry.diffPreview.removed === 1 ? '' : 's'}`, { color: 'gray' }));
297
- for (const diffLine of entry.diffPreview.lines.slice(0, TRANSCRIPT_DIFF_PREVIEW_LINES)) {
298
- lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitLine(diffLine.content || ' ', width - 4), {
299
- color: diffLineColor(diffLine.kind),
300
- })));
301
- }
378
+ lines.push(...renderDiffPreviewLines(entry.diffPreview, width));
302
379
  }
303
380
  if (entry.todoList && entry.todoList.length > 0) {
304
381
  lines.push(...renderTodoListLines(entry.todoList, width));
305
382
  }
306
383
  return lines;
307
384
  }
385
+ const transcriptEntryLineCache = new WeakMap();
386
+ const MAX_CACHED_TRANSCRIPT_WIDTHS = 2;
387
+ function cachedTranscriptEntryLines(entry, width) {
388
+ let byWidth = transcriptEntryLineCache.get(entry);
389
+ if (!byWidth) {
390
+ byWidth = new Map();
391
+ transcriptEntryLineCache.set(entry, byWidth);
392
+ }
393
+ const cached = byWidth.get(width);
394
+ if (cached)
395
+ return cached;
396
+ const lines = renderTranscriptEntryLines(entry, width);
397
+ if (byWidth.size >= MAX_CACHED_TRANSCRIPT_WIDTHS) {
398
+ const oldest = byWidth.keys().next().value;
399
+ if (oldest !== undefined)
400
+ byWidth.delete(oldest);
401
+ }
402
+ byWidth.set(width, lines);
403
+ return lines;
404
+ }
405
+ function renderDiffPreviewLines(preview, width, maxDiffLines = TRANSCRIPT_DIFF_PREVIEW_LINES) {
406
+ return [
407
+ plainLine(` Added ${preview.added} line${preview.added === 1 ? '' : 's'}, removed ${preview.removed} line${preview.removed === 1 ? '' : 's'}`, { color: 'gray' }),
408
+ ...preview.lines.slice(0, maxDiffLines).map((diffLine) => line(span(`${diffLinePrefix(diffLine.kind)} `, {
409
+ color: diffLineColor(diffLine.kind),
410
+ }), span(fitLine(diffLine.content || ' ', width - 4), {
411
+ color: diffLineColor(diffLine.kind),
412
+ }))),
413
+ ];
414
+ }
308
415
  function tokenUsageLines(usage) {
309
- const match = usage.match(/^Session tokens • in ([^•]+) • out ([^•]+)(?: • think ([^•]+))? • cache ([^•]+)(?: • write ([^•]+))?(?: • index ([^•]+))?$/);
416
+ const match = usage.match(/^Session tokens • in ([^•]+) • out ([^•]+)(?: • think ([^•]+))? • cache ([^•]+)(?: • write ([^•]+))?$/);
310
417
  if (!match) {
311
418
  return [plainLine(usage, { color: 'cyan' })];
312
419
  }
@@ -324,9 +431,6 @@ function tokenUsageLines(usage) {
324
431
  if (match[5]) {
325
432
  spans.push(span(' Write ', { color: 'gray' }), span(match[5].trim(), { color: 'yellow' }));
326
433
  }
327
- if (match[6]) {
328
- spans.push(span(' Index ', { color: 'gray' }), span(match[6].trim(), { color: 'blue' }));
329
- }
330
434
  return [line(...spans)];
331
435
  }
332
436
  function footerTransientStatus(status) {
@@ -405,20 +509,26 @@ function composerFooterLines(state) {
405
509
  }));
406
510
  return lines;
407
511
  }
408
- const helperText = state.busy
409
- ? state.queuedMessage
410
- ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
411
- : 'Enter queues • Esc / Ctrl+C cancel turn'
412
- : process.platform === 'win32'
413
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
414
- : process.platform === 'darwin'
415
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
416
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
512
+ const busyHelperText = state.queuedMessage
513
+ ? 'Enter sends it to the agent • ↑ edit • Esc / Ctrl+C discard'
514
+ : state.input
515
+ ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
516
+ : 'Enter queues • Esc / Ctrl+C cancel turn';
517
+ const helperText = state.userInputPrompt
518
+ ? 'Answering agent questions • Ctrl+C cancels turn'
519
+ : state.busy
520
+ ? busyHelperText
521
+ : process.platform === 'win32'
522
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
523
+ : process.platform === 'darwin'
524
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
525
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
417
526
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
418
527
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
528
+ const footerMuted = mutedStyle();
419
529
  const footerSpans = [
420
- span(helperText, { color: 'gray', dim: true }),
421
- span(' '.repeat(Math.max(1, 8)), { color: 'gray', dim: true }),
530
+ span(helperText, footerMuted),
531
+ span(' '.repeat(Math.max(1, 8)), footerMuted),
422
532
  span(agentLabel, {
423
533
  color: state.agentMode === 'plan' ? 'yellow' : 'cyan',
424
534
  }),
@@ -506,17 +616,48 @@ function buildJobsPickerLines(state, width, nowMs) {
506
616
  }));
507
617
  return lines;
508
618
  }
619
+ export const THINKING_FALLBACK_PHRASES = [
620
+ 'Working out where to start...',
621
+ 'Deciding what comes first...',
622
+ 'Lining up the pieces...',
623
+ 'Untangling the details...',
624
+ 'Deciding what not to touch...',
625
+ 'Checking what this would break...',
626
+ 'Choosing the smaller change...',
627
+ 'Picking the least clever option...',
628
+ 'Resisting the obvious answer...',
629
+ 'Trying the boring explanation first...',
630
+ 'Checking whether the assumption holds...',
631
+ 'Asking what would have to be true...',
632
+ 'Reading it the way the machine would...',
633
+ 'Testing the story against the code...',
634
+ 'Working out what actually changed...',
635
+ 'Finding the smallest thing that explains it...',
636
+ 'Looking for the part that is not settled yet...',
637
+ 'Making sure this is the simple version...',
638
+ ];
639
+ export function pickThinkingFallbackPhrase() {
640
+ const index = Math.floor(Math.random() * THINKING_FALLBACK_PHRASES.length);
641
+ return THINKING_FALLBACK_PHRASES[index];
642
+ }
643
+ function withProgressEllipsis(title) {
644
+ const text = title.trim();
645
+ if (!text)
646
+ return text;
647
+ return /(?:\.\.\.|…)$/.test(text) ? text : `${text}...`;
648
+ }
509
649
  function thinkingHeaderLine(spinnerFrame, title, width) {
510
650
  const spinner = `${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `;
511
- const fittedTitle = title
512
- ? fitLine(title, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
651
+ const decorated = withProgressEllipsis(title);
652
+ const fittedTitle = decorated
653
+ ? fitLine(decorated, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
513
654
  : '';
514
655
  return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
515
656
  }
516
657
  function thinkingNoteLine(note, width) {
517
658
  return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
518
659
  }
519
- function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
660
+ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
520
661
  if (!state.busy)
521
662
  return [];
522
663
  const lines = [plainLine('')];
@@ -546,11 +687,19 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
546
687
  }
547
688
  lines.push(plainLine(''));
548
689
  }
549
- lines.push(plainLine(`${WORKING_CLOCK_ICON} Working · ${elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`}`, { color: 'yellow' }));
690
+ lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
691
+ const turnMessages = state.turnMessages ?? [];
692
+ if (turnMessages.length > 0) {
693
+ lines.push(plainLine(''));
694
+ lines.push(...buildTurnMessageLines(turnMessages, width, todoProgressTick(nowMs)));
695
+ }
550
696
  const todos = state.todos ?? [];
551
697
  if (todos.length > 0) {
552
698
  lines.push(plainLine(''));
553
- lines.push(...renderTodoListLines(todos, width, { header: true }));
699
+ lines.push(...renderTodoListLines(todos, width, {
700
+ header: true,
701
+ progressTick: todoProgressTick(nowMs),
702
+ }));
554
703
  }
555
704
  const visibleTitle = state.thinkingTitle.trim();
556
705
  const visibleNotes = state.thinkingNotes.filter(Boolean);
@@ -560,11 +709,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
560
709
  const minimalText = visibleTitle && visibleTitle !== 'Thinking'
561
710
  ? visibleTitle
562
711
  : (visibleNotes[visibleNotes.length - 1] ?? '');
563
- lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
712
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText || THINKING_FALLBACK_PHRASES[0], width));
564
713
  }
565
714
  else {
566
- lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
567
- for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
715
+ const headerTitle = visibleTitle && visibleTitle !== 'Thinking' ? visibleTitle : '';
716
+ lines.push(thinkingHeaderLine(spinnerFrame, headerTitle || THINKING_FALLBACK_PHRASES[0], width));
717
+ for (const note of visibleNotes.slice(0, THINKING_NOTE_PREVIEW_ROWS)) {
568
718
  lines.push(thinkingNoteLine(note, width));
569
719
  }
570
720
  }
@@ -573,16 +723,30 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
573
723
  return lines;
574
724
  }
575
725
  function lineCharCount(row) {
576
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
726
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
577
727
  }
578
728
  function overlayPanelLine(row, width, color) {
579
729
  const padding = Math.max(0, width - lineCharCount(row));
580
730
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
581
731
  }
582
- function buildOverlayPanel(rows, width, color) {
732
+ function overlayPanelMarginLineCount(height) {
733
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
734
+ ? 0
735
+ : OVERLAY_PANEL_MARGIN_LINES;
736
+ }
737
+ function overlayPanelContentBudget(height) {
738
+ if (!Number.isFinite(height)) {
739
+ return Number.POSITIVE_INFINITY;
740
+ }
741
+ const margins = overlayPanelMarginLineCount(height) * 2;
742
+ return Math.max(1, Math.floor(height) - margins - 2);
743
+ }
744
+ function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY) {
583
745
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
584
746
  const innerWidth = Math.max(1, panelWidth - 4);
585
- const margin = Array.from({ length: OVERLAY_PANEL_MARGIN_LINES }, () => plainLine(''));
747
+ const margin = Array.from({
748
+ length: overlayPanelMarginLineCount(height),
749
+ }, () => plainLine(''));
586
750
  return [
587
751
  ...margin,
588
752
  plainLine(`╭${'─'.repeat(panelWidth - 2)}╮`, { color }),
@@ -603,7 +767,6 @@ function modelPickerPanelSideLine(content, innerWidth) {
603
767
  }
604
768
  const MODEL_PICKER_COST_WIDTH = 6;
605
769
  const MODEL_PICKER_MODEL_WIDTH = 42;
606
- const MODEL_PICKER_NUMBER_WIDTH = 4;
607
770
  const MODEL_PICKER_SEPARATOR = ' │ ';
608
771
  const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
609
772
  function modelPickerTopBorder(panelWidth) {
@@ -630,7 +793,6 @@ function modelPickerCostText(rating) {
630
793
  }
631
794
  function modelPickerNotesWidth(innerWidth) {
632
795
  return Math.max(18, innerWidth -
633
- MODEL_PICKER_NUMBER_WIDTH -
634
796
  MODEL_PICKER_MODEL_WIDTH -
635
797
  MODEL_PICKER_COST_WIDTH -
636
798
  MODEL_PICKER_SEPARATOR.length * 2);
@@ -653,12 +815,10 @@ function modelPickerItemLines(option, selected, innerWidth) {
653
815
  : selected
654
816
  ? { color: 'cyan', bold: true }
655
817
  : {};
656
- const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
657
818
  const cost = modelPickerCostText(option.costRating);
658
819
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
659
- const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
820
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_COST_WIDTH - 2);
660
821
  const row = [
661
- numberCell,
662
822
  ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
663
823
  span(' ', selectedStyle),
664
824
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -668,7 +828,6 @@ function modelPickerItemLines(option, selected, innerWidth) {
668
828
  ];
669
829
  }
670
830
  const row = [
671
- numberCell,
672
831
  ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
673
832
  span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
674
833
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -685,14 +844,19 @@ function modelPickerItemLines(option, selected, innerWidth) {
685
844
  function modelPickerHeaderLine(innerWidth) {
686
845
  const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
687
846
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
688
- return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', Math.max(1, innerWidth - MODEL_PICKER_NUMBER_WIDTH), heading));
847
+ return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
689
848
  }
690
- return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', MODEL_PICKER_MODEL_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Cost', MODEL_PICKER_COST_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Notes', modelPickerNotesWidth(innerWidth), heading));
849
+ return line(modelPickerCell('Model', MODEL_PICKER_MODEL_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Cost', MODEL_PICKER_COST_WIDTH, heading), span(MODEL_PICKER_SEPARATOR, { color: 'gray' }), modelPickerCell('Notes', modelPickerNotesWidth(innerWidth), heading));
691
850
  }
692
- function buildModelPickerPanel(options, selectedIndex, width) {
851
+ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
693
852
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
694
853
  const innerWidth = Math.max(1, panelWidth - 4);
695
- const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
854
+ const compactBodyLineCount = options.length + 6;
855
+ const spacerLineCount = Math.max(0, options.length - 1);
856
+ const useRowSpacing = compactBodyLineCount + spacerLineCount <= availableHeight;
857
+ const bodyLineCount = compactBodyLineCount + (useRowSpacing ? spacerLineCount : 0);
858
+ const marginLineCount = Math.max(0, Math.min(MODEL_PICKER_PANEL_MARGIN_LINES, Math.floor((availableHeight - bodyLineCount) / 2)));
859
+ const margin = Array.from({ length: marginLineCount }, () => plainLine(''));
696
860
  const body = [
697
861
  modelPickerTopBorder(panelWidth),
698
862
  modelPickerPanelSideLine(modelPickerHeaderLine(innerWidth), innerWidth),
@@ -700,6 +864,9 @@ function buildModelPickerPanel(options, selectedIndex, width) {
700
864
  ];
701
865
  options.forEach((option, index) => {
702
866
  body.push(...modelPickerItemLines(option, index === selectedIndex, innerWidth));
867
+ if (useRowSpacing && index < options.length - 1) {
868
+ body.push(modelPickerPanelSideLine(plainLine(''), innerWidth));
869
+ }
703
870
  });
704
871
  const fullHint = '↑/↓ navigate • Enter select • Esc cancel';
705
872
  const compactHint = '↑/↓ • enter • esc';
@@ -774,10 +941,105 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
774
941
  body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Tab or Enter accept • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
775
942
  return [...margin, ...body, ...margin];
776
943
  }
777
- function buildOverlayLines(state, width, nowMs) {
944
+ export function formatElapsedClock(elapsedSeconds) {
945
+ if (elapsedSeconds < 60)
946
+ return `${elapsedSeconds}s`;
947
+ return `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`;
948
+ }
949
+ export function buildWorkingClockLine(state, elapsedSeconds) {
950
+ const elapsed = formatElapsedClock(elapsedSeconds);
951
+ if (state.busyPausedAt != null) {
952
+ return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
953
+ }
954
+ const label = state.generatingImage
955
+ ? 'Generating image'
956
+ : state.analyzingImages > 0
957
+ ? state.analyzingImages > 1
958
+ ? 'Analyzing images'
959
+ : 'Analyzing image'
960
+ : 'Working';
961
+ return `${WORKING_CLOCK_ICON} ${label} · ${elapsed}`;
962
+ }
963
+ export function approvalPanelInnerWidth(width) {
964
+ return Math.max(1, Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH)) - 4);
965
+ }
966
+ export function approvalPaddingEnabled(height) {
967
+ return height >= APPROVAL_PADDING_MIN_ROWS;
968
+ }
969
+ export function approvalPreviewBudget(height) {
970
+ const chrome = APPROVAL_OVERLAY_CHROME_ROWS +
971
+ (approvalPaddingEnabled(height) ? APPROVAL_PADDING_CHROME_ROWS : 0);
972
+ return Math.max(APPROVAL_PREVIEW_MIN_ROWS, Math.min(APPROVAL_PREVIEW_MAX_ROWS, Math.floor(height / 2) - chrome));
973
+ }
974
+ export function approvalPreviewRows(prompt, innerWidth) {
975
+ const previewWidth = Math.max(8, innerWidth - APPROVAL_SCROLLBAR_COLUMNS);
976
+ if (prompt.diffPreview) {
977
+ return renderDiffPreviewLines(prompt.diffPreview, previewWidth, prompt.diffPreview.lines.length);
978
+ }
979
+ return wrapText(prompt.body, previewWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR }));
980
+ }
981
+ export function approvalScrollLimit(prompt, width, height) {
982
+ if (!prompt)
983
+ return 0;
984
+ return Math.max(0, approvalPreviewRows(prompt, approvalPanelInnerWidth(width)).length -
985
+ approvalPreviewBudget(height));
986
+ }
987
+ function approvalScrollbarGlyphs(totalRows, visibleRows, offset) {
988
+ const thumbRows = Math.max(1, Math.min(visibleRows, Math.round((visibleRows * visibleRows) / totalRows)));
989
+ const maxOffset = totalRows - visibleRows;
990
+ const thumbTop = maxOffset <= 0
991
+ ? 0
992
+ : Math.round((offset / maxOffset) * (visibleRows - thumbRows));
993
+ return Array.from({ length: visibleRows }, (_, index) => index >= thumbTop && index < thumbTop + thumbRows
994
+ ? APPROVAL_SCROLLBAR_THUMB
995
+ : APPROVAL_SCROLLBAR_TRACK);
996
+ }
997
+ function withScrollbarGlyph(target, glyph, column) {
998
+ const padding = Math.max(1, column - lineCharCount(target));
999
+ return {
1000
+ spans: [
1001
+ ...target.spans,
1002
+ span(' '.repeat(padding)),
1003
+ span(glyph, {
1004
+ color: 'gray',
1005
+ dim: glyph === APPROVAL_SCROLLBAR_TRACK,
1006
+ }),
1007
+ ],
1008
+ };
1009
+ }
1010
+ export function buildApprovalPreviewWindow(prompt, innerWidth, height, requestedOffset) {
1011
+ const rows = approvalPreviewRows(prompt, innerWidth);
1012
+ const budget = approvalPreviewBudget(height);
1013
+ if (rows.length <= budget) {
1014
+ return {
1015
+ firstVisibleRow: rows.length === 0 ? 0 : 1,
1016
+ lastVisibleRow: rows.length,
1017
+ lines: rows,
1018
+ offset: 0,
1019
+ totalRows: rows.length,
1020
+ };
1021
+ }
1022
+ const offset = Math.max(0, Math.min(requestedOffset, rows.length - budget));
1023
+ const visible = rows.slice(offset, offset + budget);
1024
+ const glyphs = approvalScrollbarGlyphs(rows.length, budget, offset);
1025
+ return {
1026
+ firstVisibleRow: offset + 1,
1027
+ lastVisibleRow: offset + visible.length,
1028
+ lines: visible.map((target, index) => withScrollbarGlyph(target, glyphs[index], innerWidth - 1)),
1029
+ offset,
1030
+ totalRows: rows.length,
1031
+ };
1032
+ }
1033
+ export function approvalScrollStatusLine(window) {
1034
+ return `lines ${window.firstVisibleRow}–${window.lastVisibleRow} of ${window.totalRows} · PgUp/PgDn scrolls`;
1035
+ }
1036
+ export function rightAlignedLine(text, width, style = {}) {
1037
+ return plainLine(`${' '.repeat(Math.max(0, width - displayWidth(text)))}${text}`, style);
1038
+ }
1039
+ function buildOverlayLines(state, width, height, nowMs) {
778
1040
  const lines = [];
779
1041
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
780
- const innerWidth = Math.max(1, panelWidth - 4);
1042
+ const innerWidth = approvalPanelInnerWidth(width);
781
1043
  if (state.sudoPrompt) {
782
1044
  const prompt = state.sudoPrompt;
783
1045
  const passwordWidth = Math.max(0, innerWidth - 11);
@@ -797,75 +1059,117 @@ function buildOverlayLines(state, width, nowMs) {
797
1059
  : line(span(' '), span(text, { color: OVERLAY_BORDER_COLOR })));
798
1060
  });
799
1061
  lines.push(plainLine(''));
800
- if (prompt.prompt.trim()) {
801
- lines.push(plainLine(prompt.prompt.trim(), { color: 'gray' }));
802
- }
1062
+ lines.push(...wrapText(SUDO_PASSWORD_ASSURANCE, innerWidth).map((text) => plainLine(text, { color: SUDO_ASSURANCE_COLOR })));
1063
+ lines.push(plainLine(''));
803
1064
  lines.push(line(span('Password: ', { color: 'cyan', bold: true }), span('•'.repeat(Math.min(prompt.passwordLength, passwordWidth)), {
804
1065
  color: 'cyan',
805
1066
  }), span(' ', { inverse: true })));
806
1067
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
807
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1068
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1069
+ }
1070
+ if (state.userInputPrompt) {
1071
+ if (height < 3) {
1072
+ return [];
1073
+ }
1074
+ return buildOverlayPanel(buildUserInputOverlayLines(state.userInputPrompt, innerWidth, overlayPanelContentBudget(height)), width, USER_INPUT_BORDER_COLOR, height);
808
1075
  }
809
1076
  if (state.approvalPrompt) {
810
1077
  const prompt = state.approvalPrompt;
1078
+ const padded = approvalPaddingEnabled(height);
811
1079
  lines.push(plainLine(prompt.title, {
812
- color: OVERLAY_BORDER_COLOR,
1080
+ color: APPROVAL_ACCENT_COLOR,
813
1081
  bold: true,
814
1082
  }));
1083
+ if (padded)
1084
+ lines.push(plainLine(''));
815
1085
  if (prompt.diffPreview && prompt.filePath) {
816
1086
  lines.push(line(span('● ', { color: 'green' }), span('Update(', { bold: true }), span(prompt.filePath, { color: 'cyan', bold: true }), span(')', { bold: true })));
817
- lines.push(...renderTranscriptEntryLines({
818
- body: '',
819
- diffPreview: prompt.diffPreview,
820
- kind: 'diff',
821
- title: '',
822
- }, innerWidth));
823
1087
  }
824
- else {
825
- lines.push(...wrapText(prompt.body, innerWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR })));
1088
+ const previewWindow = buildApprovalPreviewWindow(prompt, innerWidth, height, state.approvalScrollOffset ?? 0);
1089
+ lines.push(...previewWindow.lines);
1090
+ if (padded)
1091
+ lines.push(plainLine(''));
1092
+ if (previewWindow.totalRows > previewWindow.lines.length) {
1093
+ lines.push(rightAlignedLine(approvalScrollStatusLine(previewWindow), innerWidth, {
1094
+ color: APPROVAL_SCROLL_STATUS_COLOR,
1095
+ }));
1096
+ if (padded)
1097
+ lines.push(plainLine(''));
826
1098
  }
827
- const options = [
828
- { value: 'y', label: 'Approve once', color: 'green' },
829
- { value: 'a', label: 'Approve all remaining actions', color: 'cyan' },
830
- { value: 'n', label: 'Deny', color: 'red' },
831
- ];
832
- options.forEach((option, index) => {
1099
+ const options = (prompt.options ?? []).map((option) => option.label);
1100
+ options.forEach((label, index) => {
833
1101
  const selected = index === state.approvalCursor;
834
1102
  lines.push(line(span(selected ? '› ' : ' ', {
835
- color: selected ? OVERLAY_BORDER_COLOR : 'gray',
836
- }), span(option.value, {
837
- color: selected ? OVERLAY_BORDER_COLOR : option.color,
838
- bold: true,
839
- }), span(` ${option.label}`, {
840
- color: selected ? OVERLAY_BORDER_COLOR : undefined,
1103
+ color: selected ? APPROVAL_ACCENT_COLOR : 'gray',
1104
+ }), span(label, {
1105
+ color: selected ? APPROVAL_ACCENT_COLOR : undefined,
1106
+ bold: selected,
841
1107
  })));
842
1108
  });
843
- lines.push(plainLine('Press y, a, or n • ↑/↓ moves • Enter confirms', {
1109
+ if (padded)
1110
+ lines.push(plainLine(''));
1111
+ lines.push(plainLine('↑/↓ moves • Enter confirms • Esc denies', {
844
1112
  color: 'gray',
845
1113
  }));
846
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1114
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
847
1115
  }
848
1116
  if (state.modelPickerOpen) {
849
1117
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
850
- lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
1118
+ lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
851
1119
  }
852
1120
  if (state.jobsPickerOpen) {
853
1121
  lines.push(...buildJobsPickerLines(state, width, nowMs));
854
1122
  }
855
1123
  if (state.resumePickerOpen) {
856
1124
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
1125
+ const pickerWidth = Math.max(20, width - 2);
1126
+ const divider = () => plainLine('─'.repeat(pickerWidth), { color: 'gray', dim: true });
857
1127
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
858
1128
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
859
- for (const [index, session] of filtered.entries()) {
1129
+ lines.push(divider());
1130
+ const maxCards = Math.max(2, Math.min(filtered.length, Math.floor((height - 10) / 3)));
1131
+ let start = 0;
1132
+ if (filtered.length > maxCards) {
1133
+ start = Math.min(Math.max(0, state.resumePickerIndex - Math.floor(maxCards / 2)), filtered.length - maxCards);
1134
+ }
1135
+ const visible = filtered.slice(start, start + maxCards);
1136
+ if (start > 0) {
1137
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
1138
+ }
1139
+ for (const [offset, session] of visible.entries()) {
1140
+ const index = start + offset;
860
1141
  const selected = index === state.resumePickerIndex;
861
- const color = selected ? 'cyan' : undefined;
862
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
863
- lines.push(plainLine(`${selected ? '› ' : ' '}${formatRelativeTime(session.updatedAt).padEnd(11)} ${(session.branch ?? '(no branch)').slice(0, 13).padEnd(14)} ${model.padEnd(26)} ${(session.lastUserMessage.slice(0, 40) || '(empty)')}`, { color, bold: selected }));
1142
+ const prompt = singleLinePreview(session.lastUserMessage, pickerWidth) ||
1143
+ session.name ||
1144
+ `Session ${session.id}`;
1145
+ lines.push(line(span(selected ? '› ' : ' ', { color: 'cyan' }), span(fitLine(prompt, Math.max(10, pickerWidth - 2)), selected
1146
+ ? { color: 'cyan', bold: true }
1147
+ : session.lastUserMessage
1148
+ ? {}
1149
+ : { color: 'gray', dim: true })));
1150
+ const meta = [
1151
+ formatRelativeTime(session.updatedAt),
1152
+ pickerModelLabel(session.modelId, state.serverModels),
1153
+ session.branch ?? null,
1154
+ ]
1155
+ .filter(Boolean)
1156
+ .join(' · ');
1157
+ lines.push(line(span(' '), span(fitLine(meta, Math.max(10, pickerWidth - 4)), {
1158
+ color: selected ? 'cyan' : 'gray',
1159
+ dim: !selected,
1160
+ })));
1161
+ if (offset < visible.length - 1)
1162
+ lines.push(plainLine(''));
1163
+ }
1164
+ const remaining = filtered.length - (start + visible.length);
1165
+ if (remaining > 0) {
1166
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
864
1167
  }
865
1168
  if (filtered.length === 0) {
866
1169
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
867
1170
  }
868
- lines.push(plainLine('↑/↓ move enter resume esc start new ctrl+c quit', {
1171
+ lines.push(divider());
1172
+ lines.push(plainLine('↑/↓ move · enter resume · esc start new · ctrl+c quit', {
869
1173
  color: 'gray',
870
1174
  }));
871
1175
  }
@@ -884,6 +1188,15 @@ function buildOverlayLines(state, width, nowMs) {
884
1188
  function countSectionLines(sections) {
885
1189
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
886
1190
  }
1191
+ export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1192
+ const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1193
+ const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1194
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1195
+ return {
1196
+ width: approvalPanelInnerWidth(contentWidth),
1197
+ maxRows: overlayPanelContentBudget(overlayHeight),
1198
+ };
1199
+ }
887
1200
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
888
1201
  if (maxLines <= 0 || lines.length <= maxLines) {
889
1202
  return lines;
@@ -896,28 +1209,34 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
896
1209
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
897
1210
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
898
1211
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
899
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
900
- const transcriptLines = [];
901
- transcriptBlocks.forEach((block, index) => {
902
- if (index > 0) {
903
- transcriptLines.push(plainLine(''));
904
- }
905
- transcriptLines.push(...block);
906
- });
1212
+ const buildTranscriptLines = (wrapWidth) => {
1213
+ const blocks = state.transcript.map((entry) => cachedTranscriptEntryLines(entry, wrapWidth));
1214
+ const lines = [];
1215
+ blocks.forEach((block, index) => {
1216
+ if (index > 0) {
1217
+ lines.push(plainLine(''));
1218
+ }
1219
+ lines.push(...block);
1220
+ });
1221
+ return lines;
1222
+ };
1223
+ let transcriptLines = buildTranscriptLines(contentWidth);
907
1224
  const sections = [];
908
- const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds);
1225
+ const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
909
1226
  if (liveLines.length > 0) {
910
1227
  sections.push({ kind: 'live', lines: liveLines });
911
1228
  }
912
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
1229
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
913
1230
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
914
1231
  const composerLines = [];
915
- if (state.queuedMessage) {
916
- const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
1232
+ if (state.busy && state.status === 'Starting a new conversation...') {
1233
+ composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
1234
+ }
1235
+ else if (state.queuedMessage) {
917
1236
  const imageCount = state.queuedMessage.imageAttachments.length;
918
- composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
919
- ? [span(` +${imageCount} img`, { color: 'gray', dim: true })]
920
- : []), span(' edit · esc cancel', { color: 'gray', dim: true })));
1237
+ composerLines.push(line(span('Queued', { color: 'cyan', bold: true }), span(' Enter', { color: 'cyan', bold: true }), span(imageCount > 0
1238
+ ? ' sends it with the next prompt'
1239
+ : ' sends it to the agent now', { color: 'gray' }), span(' · ', { color: 'cyan', bold: true }), span(' edit', { color: 'gray' }), span(' · Esc', { color: 'cyan', bold: true }), span(' discard', { color: 'gray' })));
921
1240
  }
922
1241
  else {
923
1242
  const promptLabel = state.busy ? 'queue> ' : '❯ ';
@@ -931,14 +1250,32 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
931
1250
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
932
1251
  });
933
1252
  }
934
- const overlayLines = buildOverlayLines(state, contentWidth, nowMs);
1253
+ if (state.userInputPrompt) {
1254
+ sections.push({
1255
+ kind: 'busyFooter',
1256
+ lines: composerFooterLines(state),
1257
+ });
1258
+ }
1259
+ const overlayHeight = state.userInputPrompt
1260
+ ? Math.max(0, rows - countSectionLines(sections))
1261
+ : rows;
1262
+ const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
935
1263
  if (overlayLines.length > 0) {
936
1264
  sections.push({ kind: 'overlay', lines: overlayLines });
937
1265
  }
938
1266
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
939
- const composerReserve = state.resumePickerOpen ? 0 : 4;
1267
+ const composerReserve = state.resumePickerOpen ||
1268
+ state.approvalPrompt ||
1269
+ state.sudoPrompt ||
1270
+ state.userInputPrompt
1271
+ ? 0
1272
+ : 4;
940
1273
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
941
- const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1274
+ let transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1275
+ if (transcriptScrollLimit > 0 && contentWidth > 2) {
1276
+ transcriptLines = buildTranscriptLines(contentWidth - 2);
1277
+ transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1278
+ }
942
1279
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
943
1280
  sections.unshift({
944
1281
  kind: 'transcript',