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

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