@thegitai/cli 1.0.0-preview.2 → 1.0.0-preview.21

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 (44) hide show
  1. package/README.md +32 -4
  2. package/dist/bin/ai.js +57 -291
  3. package/dist/src/agent-mode.js +1 -1
  4. package/dist/src/api/auth.js +2 -2
  5. package/dist/src/api/browser-login.js +72 -3
  6. package/dist/src/api/chat.js +236 -33
  7. package/dist/src/api/contracts.js +55 -1
  8. package/dist/src/api/http.js +16 -3
  9. package/dist/src/api/models.js +9 -4
  10. package/dist/src/core/clipboard.js +7 -13
  11. package/dist/src/core/image-limits.js +56 -0
  12. package/dist/src/core/image-path-extractor.js +70 -3
  13. package/dist/src/core/session-image-store.js +199 -0
  14. package/dist/src/executor.js +1 -1
  15. package/dist/src/help-text.js +51 -11
  16. package/dist/src/permissions.js +243 -0
  17. package/dist/src/project-index.js +13 -1
  18. package/dist/src/session-store.js +119 -20
  19. package/dist/src/session.js +14 -3
  20. package/dist/src/tool-executor.js +2 -2
  21. package/dist/src/tools/delete-file.js +14 -0
  22. package/dist/src/tools/index.js +2 -0
  23. package/dist/src/tools/patch-file.js +12 -16
  24. package/dist/src/tools/read-image-file.js +85 -0
  25. package/dist/src/tools/replace-document-text.js +28 -18
  26. package/dist/src/tools/run-command.js +13 -27
  27. package/dist/src/tools/run-node-script.js +11 -26
  28. package/dist/src/tools/str-replace.js +12 -16
  29. package/dist/src/tools/write-file.js +66 -0
  30. package/dist/src/turn-failure-marker.js +11 -0
  31. package/dist/src/ui/prompt-history-store.js +1 -1
  32. package/dist/src/ui/repl.js +579 -154
  33. package/dist/src/ui/tui/bridge.js +10 -0
  34. package/dist/src/ui/tui/build-frame.js +535 -159
  35. package/dist/src/ui/tui/markdown-render.js +81 -73
  36. package/dist/src/ui/tui/shell-input.js +206 -63
  37. package/dist/src/ui/tui/terminal-theme.js +28 -0
  38. package/dist/src/ui/tui/terminal-title.js +3 -0
  39. package/dist/src/ui/tui/terminal-writes.js +48 -0
  40. package/dist/src/ui/tui/text.js +158 -4
  41. package/dist/src/ui/tui/user-input.js +568 -0
  42. package/dist/src/utils.js +9 -0
  43. package/package.json +18 -6
  44. package/dist/src/markdown-renderer.js +0 -112
@@ -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,16 +12,31 @@ 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 = 86;
37
+ const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
21
38
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
22
- const MODEL_PICKER_BORDER_COLOR = 'gray';
39
+ const MODEL_PICKER_BORDER_COLOR = 'cyan';
23
40
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
24
41
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
25
42
  const MODEL_PICKER_META_INDENT = ' ';
@@ -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,15 +180,18 @@ 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' },
167
184
  { command: '/exit', description: 'Quit the current session' },
168
185
  ];
169
186
  function buildModelPickerOptions(currentModelId, serverModels) {
170
187
  return serverModels.map((model) => ({
171
188
  id: model.id,
172
189
  label: model.label,
173
- meta: model.id === currentModelId ? 'current' : '',
190
+ publicId: model.id,
191
+ costRating: model.costRating,
192
+ current: model.id === currentModelId,
174
193
  disabled: false,
194
+ note: model.description,
175
195
  }));
176
196
  }
177
197
  function getInputCommandToken(input) {
@@ -209,6 +229,9 @@ export function getSlashCommandSuggestions(input) {
209
229
  function formatModelLabel(modelId, serverModels) {
210
230
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
211
231
  }
232
+ function pickerModelLabel(modelId, serverModels) {
233
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
234
+ }
212
235
  function filterResumeSessions(sessions, filter, serverModels) {
213
236
  const q = filter.trim().toLowerCase();
214
237
  if (!q)
@@ -232,26 +255,90 @@ function formatRelativeTime(isoDate) {
232
255
  return `${hours}h ago`;
233
256
  return `${Math.floor(hours / 24)}d ago`;
234
257
  }
235
- function todoItemLine(item, width) {
258
+ const TODO_IN_PROGRESS_FRAMES = [
259
+ '\u25CB',
260
+ '\u25D4',
261
+ '\u25D1',
262
+ '\u25D5',
263
+ '\u25CF',
264
+ ];
265
+ const TODO_IN_PROGRESS_STATIC_GLYPH = '\u25D1';
266
+ export const TODO_IN_PROGRESS_STEP_MS = 1_500;
267
+ export function todoProgressTick(nowMs) {
268
+ return Math.floor(nowMs / TODO_IN_PROGRESS_STEP_MS);
269
+ }
270
+ function todoInProgressGlyph(progressTick) {
271
+ if (progressTick === null)
272
+ return TODO_IN_PROGRESS_STATIC_GLYPH;
273
+ const index = ((progressTick % TODO_IN_PROGRESS_FRAMES.length) +
274
+ TODO_IN_PROGRESS_FRAMES.length) %
275
+ TODO_IN_PROGRESS_FRAMES.length;
276
+ return TODO_IN_PROGRESS_FRAMES[index];
277
+ }
278
+ function todoItemLine(item, width, progressTick = null) {
236
279
  const text = fitLine(item.text, Math.max(8, width - 5));
237
280
  if (item.status === 'completed') {
238
- return line(span(' ✔ ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
281
+ return line(span(' ✔ ', { color: 'green' }), span(text, mutedStyle()));
239
282
  }
240
283
  if (item.status === 'in_progress') {
241
- return line(span(' ◐ ', { color: TODO_IN_PROGRESS_COLOR }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
284
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
285
+ color: TODO_IN_PROGRESS_COLOR,
286
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
287
+ }
288
+ return line(span(' ○ ', { color: mutedColor() }), isDarkTerminalBackground()
289
+ ? span(text, { color: mutedColor() })
290
+ : span(text));
291
+ }
292
+ const TURN_MESSAGE_PANEL_MAX_ROWS = 6;
293
+ const TURN_MESSAGE_LABEL = {
294
+ queued: 'queued',
295
+ sending: 'Processing . . .',
296
+ delivered: 'delivered',
297
+ };
298
+ function turnMessageLine(message, width, progressTick) {
299
+ const DECORATION = 6;
300
+ const budget = Math.max(1, width - DECORATION);
301
+ const preferredLabel = Math.max(...Object.values(TURN_MESSAGE_LABEL).map((value) => displayWidth(value)));
302
+ const labelWidth = Math.min(Math.max(1, Math.floor(budget / 2)), Math.max(preferredLabel, displayWidth(message.note ?? '')));
303
+ const label = fitLine(message.note ?? TURN_MESSAGE_LABEL[message.state], labelWidth);
304
+ const textWidth = Math.max(1, budget - labelWidth);
305
+ const text = padToWidth(fitLine(`"${message.text}"`, textWidth), textWidth);
306
+ if (message.state === 'delivered') {
307
+ return line(span(' ● ', { color: 'green' }), span(text, { color: 'gray', dim: true }), span(` ${label}`, { color: 'green' }));
308
+ }
309
+ if (message.state === 'sending') {
310
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
311
+ color: TODO_IN_PROGRESS_COLOR,
312
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }), span(` ${label}`, { color: TODO_IN_PROGRESS_COLOR }));
242
313
  }
243
- return line(span(' ○ ', { color: 'gray' }), span(text));
314
+ return line(span(' ○ ', { color: 'cyan' }), span(text, { color: 'cyan' }), span(` ${label}`, { color: 'gray' }));
315
+ }
316
+ export function buildTurnMessageLines(messages, width, progressTick = null) {
317
+ if (messages.length === 0)
318
+ return [];
319
+ const lines = [
320
+ line(span('Your messages', { color: 'cyan', bold: true })),
321
+ ];
322
+ const visible = messages.slice(-(TURN_MESSAGE_PANEL_MAX_ROWS - 1));
323
+ const hidden = messages.length - visible.length;
324
+ if (hidden > 0) {
325
+ lines.push(line(span(` ● ${hidden} earlier`, { color: 'gray', dim: true })));
326
+ }
327
+ for (const message of visible) {
328
+ lines.push(turnMessageLine(message, width, progressTick));
329
+ }
330
+ return lines;
244
331
  }
245
332
  export function formatTodoProgress(items) {
246
333
  const done = items.filter((item) => item.status === 'completed').length;
247
334
  return `${done}/${items.length} done`;
248
335
  }
249
- export function renderTodoListLines(items, width, { header = false } = {}) {
336
+ export function renderTodoListLines(items, width, { header = false, progressTick = null } = {}) {
250
337
  if (items.length === 0)
251
338
  return [];
252
339
  const lines = [];
253
340
  if (header) {
254
- lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: 'gray' })));
341
+ lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: mutedColor() })));
255
342
  }
256
343
  const itemRowBudget = TODO_PANEL_MAX_ROWS - (header ? 1 : 0);
257
344
  let collapsedDone = 0;
@@ -265,19 +352,16 @@ export function renderTodoListLines(items, width, { header = false } = {}) {
265
352
  collapsedDone = Math.min(over, leadingDone);
266
353
  }
267
354
  if (collapsedDone > 0) {
268
- lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, { color: 'gray', dim: true })));
355
+ lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, mutedStyle())));
269
356
  }
270
357
  const remaining = items.slice(collapsedDone);
271
358
  const budget = itemRowBudget - (collapsedDone > 0 ? 1 : 0);
272
359
  const visible = remaining.length > budget ? remaining.slice(0, budget - 1) : remaining;
273
360
  for (const item of visible) {
274
- lines.push(todoItemLine(item, width));
361
+ lines.push(todoItemLine(item, width, progressTick));
275
362
  }
276
363
  if (remaining.length > visible.length) {
277
- lines.push(plainLine(` … +${remaining.length - visible.length} more`, {
278
- color: 'gray',
279
- dim: true,
280
- }));
364
+ lines.push(plainLine(` … +${remaining.length - visible.length} more`, mutedStyle()));
281
365
  }
282
366
  return lines;
283
367
  }
@@ -290,18 +374,43 @@ export function renderTranscriptEntryLines(entry, width) {
290
374
  ? renderPreformattedBodyLines(entry.body, width, entry.kind)
291
375
  : renderFormattedBodyLines(entry.body, width, entry.kind)));
292
376
  if (entry.diffPreview) {
293
- 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' }));
294
- for (const diffLine of entry.diffPreview.lines.slice(0, TRANSCRIPT_DIFF_PREVIEW_LINES)) {
295
- lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitLine(diffLine.content || ' ', width - 4), {
296
- color: diffLineColor(diffLine.kind),
297
- })));
298
- }
377
+ lines.push(...renderDiffPreviewLines(entry.diffPreview, width));
299
378
  }
300
379
  if (entry.todoList && entry.todoList.length > 0) {
301
380
  lines.push(...renderTodoListLines(entry.todoList, width));
302
381
  }
303
382
  return lines;
304
383
  }
384
+ const transcriptEntryLineCache = new WeakMap();
385
+ const MAX_CACHED_TRANSCRIPT_WIDTHS = 2;
386
+ function cachedTranscriptEntryLines(entry, width) {
387
+ let byWidth = transcriptEntryLineCache.get(entry);
388
+ if (!byWidth) {
389
+ byWidth = new Map();
390
+ transcriptEntryLineCache.set(entry, byWidth);
391
+ }
392
+ const cached = byWidth.get(width);
393
+ if (cached)
394
+ return cached;
395
+ const lines = renderTranscriptEntryLines(entry, width);
396
+ if (byWidth.size >= MAX_CACHED_TRANSCRIPT_WIDTHS) {
397
+ const oldest = byWidth.keys().next().value;
398
+ if (oldest !== undefined)
399
+ byWidth.delete(oldest);
400
+ }
401
+ byWidth.set(width, lines);
402
+ return lines;
403
+ }
404
+ function renderDiffPreviewLines(preview, width, maxDiffLines = TRANSCRIPT_DIFF_PREVIEW_LINES) {
405
+ return [
406
+ plainLine(` Added ${preview.added} line${preview.added === 1 ? '' : 's'}, removed ${preview.removed} line${preview.removed === 1 ? '' : 's'}`, { color: 'gray' }),
407
+ ...preview.lines.slice(0, maxDiffLines).map((diffLine) => line(span(`${diffLinePrefix(diffLine.kind)} `, {
408
+ color: diffLineColor(diffLine.kind),
409
+ }), span(fitLine(diffLine.content || ' ', width - 4), {
410
+ color: diffLineColor(diffLine.kind),
411
+ }))),
412
+ ];
413
+ }
305
414
  function tokenUsageLines(usage) {
306
415
  const match = usage.match(/^Session tokens • in ([^•]+) • out ([^•]+)(?: • think ([^•]+))? • cache ([^•]+)(?: • write ([^•]+))?(?: • index ([^•]+))?$/);
307
416
  if (!match) {
@@ -402,20 +511,26 @@ function composerFooterLines(state) {
402
511
  }));
403
512
  return lines;
404
513
  }
405
- const helperText = state.busy
406
- ? state.queuedMessage
407
- ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
408
- : 'Enter queues • Esc / Ctrl+C cancel turn'
409
- : process.platform === 'win32'
410
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
411
- : process.platform === 'darwin'
412
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
413
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
514
+ const busyHelperText = state.queuedMessage
515
+ ? 'Enter sends it to the agent • ↑ edit • Esc / Ctrl+C discard'
516
+ : state.input
517
+ ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
518
+ : 'Enter queues • Esc / Ctrl+C cancel turn';
519
+ const helperText = state.userInputPrompt
520
+ ? 'Answering agent questions • Ctrl+C cancels turn'
521
+ : state.busy
522
+ ? busyHelperText
523
+ : process.platform === 'win32'
524
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
525
+ : process.platform === 'darwin'
526
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
527
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
414
528
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
415
529
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
530
+ const footerMuted = mutedStyle();
416
531
  const footerSpans = [
417
- span(helperText, { color: 'gray', dim: true }),
418
- span(' '.repeat(Math.max(1, 8)), { color: 'gray', dim: true }),
532
+ span(helperText, footerMuted),
533
+ span(' '.repeat(Math.max(1, 8)), footerMuted),
419
534
  span(agentLabel, {
420
535
  color: state.agentMode === 'plan' ? 'yellow' : 'cyan',
421
536
  }),
@@ -503,17 +618,48 @@ function buildJobsPickerLines(state, width, nowMs) {
503
618
  }));
504
619
  return lines;
505
620
  }
621
+ export const THINKING_FALLBACK_PHRASES = [
622
+ 'Working out where to start...',
623
+ 'Deciding what comes first...',
624
+ 'Lining up the pieces...',
625
+ 'Untangling the details...',
626
+ 'Deciding what not to touch...',
627
+ 'Checking what this would break...',
628
+ 'Choosing the smaller change...',
629
+ 'Picking the least clever option...',
630
+ 'Resisting the obvious answer...',
631
+ 'Trying the boring explanation first...',
632
+ 'Checking whether the assumption holds...',
633
+ 'Asking what would have to be true...',
634
+ 'Reading it the way the machine would...',
635
+ 'Testing the story against the code...',
636
+ 'Working out what actually changed...',
637
+ 'Finding the smallest thing that explains it...',
638
+ 'Looking for the part that is not settled yet...',
639
+ 'Making sure this is the simple version...',
640
+ ];
641
+ export function pickThinkingFallbackPhrase() {
642
+ const index = Math.floor(Math.random() * THINKING_FALLBACK_PHRASES.length);
643
+ return THINKING_FALLBACK_PHRASES[index];
644
+ }
645
+ function withProgressEllipsis(title) {
646
+ const text = title.trim();
647
+ if (!text)
648
+ return text;
649
+ return /(?:\.\.\.|…)$/.test(text) ? text : `${text}...`;
650
+ }
506
651
  function thinkingHeaderLine(spinnerFrame, title, width) {
507
652
  const spinner = `${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `;
508
- const fittedTitle = title
509
- ? fitLine(title, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
653
+ const decorated = withProgressEllipsis(title);
654
+ const fittedTitle = decorated
655
+ ? fitLine(decorated, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
510
656
  : '';
511
657
  return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
512
658
  }
513
659
  function thinkingNoteLine(note, width) {
514
660
  return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
515
661
  }
516
- function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
662
+ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
517
663
  if (!state.busy)
518
664
  return [];
519
665
  const lines = [plainLine('')];
@@ -543,11 +689,19 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
543
689
  }
544
690
  lines.push(plainLine(''));
545
691
  }
546
- 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' }));
692
+ lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
693
+ const turnMessages = state.turnMessages ?? [];
694
+ if (turnMessages.length > 0) {
695
+ lines.push(plainLine(''));
696
+ lines.push(...buildTurnMessageLines(turnMessages, width, todoProgressTick(nowMs)));
697
+ }
547
698
  const todos = state.todos ?? [];
548
699
  if (todos.length > 0) {
549
700
  lines.push(plainLine(''));
550
- lines.push(...renderTodoListLines(todos, width, { header: true }));
701
+ lines.push(...renderTodoListLines(todos, width, {
702
+ header: true,
703
+ progressTick: todoProgressTick(nowMs),
704
+ }));
551
705
  }
552
706
  const visibleTitle = state.thinkingTitle.trim();
553
707
  const visibleNotes = state.thinkingNotes.filter(Boolean);
@@ -557,11 +711,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
557
711
  const minimalText = visibleTitle && visibleTitle !== 'Thinking'
558
712
  ? visibleTitle
559
713
  : (visibleNotes[visibleNotes.length - 1] ?? '');
560
- lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
714
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText || THINKING_FALLBACK_PHRASES[0], width));
561
715
  }
562
716
  else {
563
- lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
564
- for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
717
+ const headerTitle = visibleTitle && visibleTitle !== 'Thinking' ? visibleTitle : '';
718
+ lines.push(thinkingHeaderLine(spinnerFrame, headerTitle || THINKING_FALLBACK_PHRASES[0], width));
719
+ for (const note of visibleNotes.slice(0, THINKING_NOTE_PREVIEW_ROWS)) {
565
720
  lines.push(thinkingNoteLine(note, width));
566
721
  }
567
722
  }
@@ -570,16 +725,30 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
570
725
  return lines;
571
726
  }
572
727
  function lineCharCount(row) {
573
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
728
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
574
729
  }
575
730
  function overlayPanelLine(row, width, color) {
576
731
  const padding = Math.max(0, width - lineCharCount(row));
577
732
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
578
733
  }
579
- function buildOverlayPanel(rows, width, color) {
734
+ function overlayPanelMarginLineCount(height) {
735
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
736
+ ? 0
737
+ : OVERLAY_PANEL_MARGIN_LINES;
738
+ }
739
+ function overlayPanelContentBudget(height) {
740
+ if (!Number.isFinite(height)) {
741
+ return Number.POSITIVE_INFINITY;
742
+ }
743
+ const margins = overlayPanelMarginLineCount(height) * 2;
744
+ return Math.max(1, Math.floor(height) - margins - 2);
745
+ }
746
+ function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY) {
580
747
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
581
748
  const innerWidth = Math.max(1, panelWidth - 4);
582
- const margin = Array.from({ length: OVERLAY_PANEL_MARGIN_LINES }, () => plainLine(''));
749
+ const margin = Array.from({
750
+ length: overlayPanelMarginLineCount(height),
751
+ }, () => plainLine(''));
583
752
  return [
584
753
  ...margin,
585
754
  plainLine(`╭${'─'.repeat(panelWidth - 2)}╮`, { color }),
@@ -598,77 +767,113 @@ function padSpansToInnerWidth(spans, innerWidth, fill) {
598
767
  function modelPickerPanelSideLine(content, innerWidth) {
599
768
  return overlayPanelLine(content, innerWidth, MODEL_PICKER_BORDER_COLOR);
600
769
  }
770
+ const MODEL_PICKER_COST_WIDTH = 6;
771
+ const MODEL_PICKER_MODEL_WIDTH = 42;
772
+ const MODEL_PICKER_SEPARATOR = ' │ ';
773
+ const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
601
774
  function modelPickerTopBorder(panelWidth) {
602
- const prefix = '╭─ Models ';
603
- const suffix = '';
604
- const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
605
- return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Models', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
775
+ const fullTitle = ' TheGitAI - Model Selection ';
776
+ const compactTitle = ' Model Selection ';
777
+ const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
778
+ const available = Math.max(0, panelWidth - 2 - [...title].length);
779
+ const left = Math.floor(available / 2);
780
+ const right = available - left;
781
+ return line(span(`╭${'─'.repeat(left)}`, { color: MODEL_PICKER_BORDER_COLOR }), span(title, { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(`${'─'.repeat(right)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
782
+ }
783
+ function modelPickerDivider(panelWidth) {
784
+ return plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
785
+ color: MODEL_PICKER_BORDER_COLOR,
786
+ });
787
+ }
788
+ function modelPickerCell(text, width, style = {}) {
789
+ const fitted = fitLine(text, width);
790
+ return span(`${fitted}${' '.repeat(Math.max(0, width - [...fitted].length))}`, style);
791
+ }
792
+ function modelPickerCostText(rating) {
793
+ const steps = Math.max(1, Math.min(3, Math.round(rating)));
794
+ return '$'.repeat(steps);
795
+ }
796
+ function modelPickerNotesWidth(innerWidth) {
797
+ return Math.max(18, innerWidth -
798
+ MODEL_PICKER_MODEL_WIDTH -
799
+ MODEL_PICKER_COST_WIDTH -
800
+ MODEL_PICKER_SEPARATOR.length * 2);
801
+ }
802
+ function modelPickerModelSpans(option, selected, width, showCurrentTag, labelStyle, selectedStyle) {
803
+ const tag = option.current && showCurrentTag ? ' (current)' : '';
804
+ const labelWidth = Math.max(1, width - [...tag].length);
805
+ const label = fitLine(`${selected ? '▶ ' : ' '}${option.label}`, labelWidth);
806
+ const used = [...label].length + [...tag].length;
807
+ return [
808
+ span(label, { ...labelStyle, ...selectedStyle }),
809
+ span(tag, { color: 'gray', ...selectedStyle }),
810
+ span(' '.repeat(Math.max(0, width - used)), selectedStyle),
811
+ ];
606
812
  }
607
813
  function modelPickerItemLines(option, selected, innerWidth) {
608
- const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
609
- if (selected) {
610
- const titleSpans = padSpansToInnerWidth([
611
- span('▌', {
612
- color: MODEL_PICKER_ACCENT_COLOR,
613
- bold: true,
614
- ...highlight,
615
- }),
616
- span('▶ ', {
617
- color: MODEL_PICKER_ACCENT_COLOR,
618
- bold: true,
619
- ...highlight,
620
- }),
621
- span('o ', { color: MODEL_PICKER_ACCENT_COLOR, ...highlight }),
622
- span(option.label, {
623
- color: MODEL_PICKER_ACCENT_COLOR,
624
- bold: true,
625
- ...highlight,
626
- }),
627
- ], innerWidth, highlight);
628
- const lines = [modelPickerPanelSideLine(line(...titleSpans), innerWidth)];
629
- if (option.meta) {
630
- const metaSpans = padSpansToInnerWidth([
631
- span(`${MODEL_PICKER_META_INDENT}${option.meta}`, {
632
- color: 'gray',
633
- ...highlight,
634
- }),
635
- ], innerWidth, highlight);
636
- lines.push(modelPickerPanelSideLine(line(...metaSpans), innerWidth));
637
- }
638
- return lines;
814
+ const selectedStyle = selected ? { bgColor: MODEL_PICKER_HIGHLIGHT_BG } : {};
815
+ const labelStyle = option.disabled
816
+ ? { color: 'gray' }
817
+ : selected
818
+ ? { color: 'cyan', bold: true }
819
+ : {};
820
+ const cost = modelPickerCostText(option.costRating);
821
+ if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
822
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_COST_WIDTH - 2);
823
+ const row = [
824
+ ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
825
+ span(' ', selectedStyle),
826
+ modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
827
+ ];
828
+ return [
829
+ modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
830
+ ];
639
831
  }
640
- const labelColor = option.disabled ? 'gray' : MODEL_PICKER_ACCENT_COLOR;
641
- const lines = [
642
- modelPickerPanelSideLine(line(span(' o ', { color: labelColor }), span(option.label, { color: labelColor, bold: !option.disabled })), innerWidth),
832
+ const row = [
833
+ ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
834
+ span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
835
+ modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
836
+ span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
837
+ modelPickerCell(option.note, modelPickerNotesWidth(innerWidth), {
838
+ color: 'gray',
839
+ ...selectedStyle,
840
+ }),
841
+ ];
842
+ return [
843
+ modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
643
844
  ];
644
- if (option.meta) {
645
- lines.push(modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.meta}`, { color: 'gray' })), innerWidth));
646
- }
647
- return lines;
648
845
  }
649
- function modelPickerSeparatorLine(innerWidth) {
650
- return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
846
+ function modelPickerHeaderLine(innerWidth) {
847
+ const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
848
+ if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
849
+ return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
850
+ }
851
+ 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));
651
852
  }
652
- function buildModelPickerPanel(options, selectedIndex, width) {
853
+ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
653
854
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
654
855
  const innerWidth = Math.max(1, panelWidth - 4);
655
- const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
856
+ const compactBodyLineCount = options.length + 6;
857
+ const spacerLineCount = Math.max(0, options.length - 1);
858
+ const useRowSpacing = compactBodyLineCount + spacerLineCount <= availableHeight;
859
+ const bodyLineCount = compactBodyLineCount + (useRowSpacing ? spacerLineCount : 0);
860
+ const marginLineCount = Math.max(0, Math.min(MODEL_PICKER_PANEL_MARGIN_LINES, Math.floor((availableHeight - bodyLineCount) / 2)));
861
+ const margin = Array.from({ length: marginLineCount }, () => plainLine(''));
656
862
  const body = [
657
- plainLine('TheGitAI - Model Selection', {
658
- color: MODEL_PICKER_ACCENT_COLOR,
659
- bold: true,
660
- }),
661
- plainLine(''),
662
863
  modelPickerTopBorder(panelWidth),
663
- modelPickerPanelSideLine(plainLine(''), innerWidth),
864
+ modelPickerPanelSideLine(modelPickerHeaderLine(innerWidth), innerWidth),
865
+ modelPickerDivider(panelWidth),
664
866
  ];
665
867
  options.forEach((option, index) => {
666
868
  body.push(...modelPickerItemLines(option, index === selectedIndex, innerWidth));
667
- if (index < options.length - 1) {
668
- body.push(modelPickerSeparatorLine(innerWidth));
869
+ if (useRowSpacing && index < options.length - 1) {
870
+ body.push(modelPickerPanelSideLine(plainLine(''), innerWidth));
669
871
  }
670
872
  });
671
- body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Enter select • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
873
+ const fullHint = '↑/↓ navigate • Enter select • Esc cancel';
874
+ const compactHint = '↑/↓ • enter • esc';
875
+ const hint = [...fullHint].length <= innerWidth ? fullHint : compactHint;
876
+ body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
672
877
  return [...margin, ...body, ...margin];
673
878
  }
674
879
  function commandPaletteTopBorder(panelWidth) {
@@ -677,6 +882,9 @@ function commandPaletteTopBorder(panelWidth) {
677
882
  const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
678
883
  return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Commands', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
679
884
  }
885
+ function commandPaletteSeparatorLine(innerWidth) {
886
+ return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
887
+ }
680
888
  function commandPaletteItemLines(option, selected, innerWidth) {
681
889
  const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
682
890
  if (selected) {
@@ -729,16 +937,109 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
729
937
  suggestions.forEach((suggestion, index) => {
730
938
  body.push(...commandPaletteItemLines(suggestion, index === selectedIndex, innerWidth));
731
939
  if (index < suggestions.length - 1) {
732
- body.push(modelPickerSeparatorLine(innerWidth));
940
+ body.push(commandPaletteSeparatorLine(innerWidth));
733
941
  }
734
942
  });
735
943
  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 }));
736
944
  return [...margin, ...body, ...margin];
737
945
  }
738
- function buildOverlayLines(state, width, nowMs) {
946
+ export function formatElapsedClock(elapsedSeconds) {
947
+ if (elapsedSeconds < 60)
948
+ return `${elapsedSeconds}s`;
949
+ return `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`;
950
+ }
951
+ export function buildWorkingClockLine(state, elapsedSeconds) {
952
+ const elapsed = formatElapsedClock(elapsedSeconds);
953
+ if (state.busyPausedAt != null) {
954
+ return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
955
+ }
956
+ const label = 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) {
739
1040
  const lines = [];
740
1041
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
741
- const innerWidth = Math.max(1, panelWidth - 4);
1042
+ const innerWidth = approvalPanelInnerWidth(width);
742
1043
  if (state.sudoPrompt) {
743
1044
  const prompt = state.sudoPrompt;
744
1045
  const passwordWidth = Math.max(0, innerWidth - 11);
@@ -758,75 +1059,117 @@ function buildOverlayLines(state, width, nowMs) {
758
1059
  : line(span(' '), span(text, { color: OVERLAY_BORDER_COLOR })));
759
1060
  });
760
1061
  lines.push(plainLine(''));
761
- if (prompt.prompt.trim()) {
762
- lines.push(plainLine(prompt.prompt.trim(), { color: 'gray' }));
763
- }
1062
+ lines.push(...wrapText(SUDO_PASSWORD_ASSURANCE, innerWidth).map((text) => plainLine(text, { color: SUDO_ASSURANCE_COLOR })));
1063
+ lines.push(plainLine(''));
764
1064
  lines.push(line(span('Password: ', { color: 'cyan', bold: true }), span('•'.repeat(Math.min(prompt.passwordLength, passwordWidth)), {
765
1065
  color: 'cyan',
766
1066
  }), span(' ', { inverse: true })));
767
1067
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
768
- 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);
769
1075
  }
770
1076
  if (state.approvalPrompt) {
771
1077
  const prompt = state.approvalPrompt;
1078
+ const padded = approvalPaddingEnabled(height);
772
1079
  lines.push(plainLine(prompt.title, {
773
- color: OVERLAY_BORDER_COLOR,
1080
+ color: APPROVAL_ACCENT_COLOR,
774
1081
  bold: true,
775
1082
  }));
1083
+ if (padded)
1084
+ lines.push(plainLine(''));
776
1085
  if (prompt.diffPreview && prompt.filePath) {
777
1086
  lines.push(line(span('● ', { color: 'green' }), span('Update(', { bold: true }), span(prompt.filePath, { color: 'cyan', bold: true }), span(')', { bold: true })));
778
- lines.push(...renderTranscriptEntryLines({
779
- body: '',
780
- diffPreview: prompt.diffPreview,
781
- kind: 'diff',
782
- title: '',
783
- }, innerWidth));
784
1087
  }
785
- else {
786
- 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(''));
787
1098
  }
788
- const options = [
789
- { value: 'y', label: 'Approve once', color: 'green' },
790
- { value: 'a', label: 'Approve all remaining actions', color: 'cyan' },
791
- { value: 'n', label: 'Deny', color: 'red' },
792
- ];
793
- options.forEach((option, index) => {
1099
+ const options = (prompt.options ?? []).map((option) => option.label);
1100
+ options.forEach((label, index) => {
794
1101
  const selected = index === state.approvalCursor;
795
1102
  lines.push(line(span(selected ? '› ' : ' ', {
796
- color: selected ? OVERLAY_BORDER_COLOR : 'gray',
797
- }), span(option.value, {
798
- color: selected ? OVERLAY_BORDER_COLOR : option.color,
799
- bold: true,
800
- }), span(` ${option.label}`, {
801
- 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,
802
1107
  })));
803
1108
  });
804
- 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', {
805
1112
  color: 'gray',
806
1113
  }));
807
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1114
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
808
1115
  }
809
1116
  if (state.modelPickerOpen) {
810
1117
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
811
- lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
1118
+ lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
812
1119
  }
813
1120
  if (state.jobsPickerOpen) {
814
1121
  lines.push(...buildJobsPickerLines(state, width, nowMs));
815
1122
  }
816
1123
  if (state.resumePickerOpen) {
817
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 });
818
1127
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
819
1128
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
820
- 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;
821
1141
  const selected = index === state.resumePickerIndex;
822
- const color = selected ? 'cyan' : undefined;
823
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
824
- 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 }));
825
1167
  }
826
1168
  if (filtered.length === 0) {
827
1169
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
828
1170
  }
829
- 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', {
830
1173
  color: 'gray',
831
1174
  }));
832
1175
  }
@@ -845,6 +1188,15 @@ function buildOverlayLines(state, width, nowMs) {
845
1188
  function countSectionLines(sections) {
846
1189
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
847
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
+ }
848
1200
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
849
1201
  if (maxLines <= 0 || lines.length <= maxLines) {
850
1202
  return lines;
@@ -857,28 +1209,34 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
857
1209
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
858
1210
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
859
1211
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
860
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
861
- const transcriptLines = [];
862
- transcriptBlocks.forEach((block, index) => {
863
- if (index > 0) {
864
- transcriptLines.push(plainLine(''));
865
- }
866
- transcriptLines.push(...block);
867
- });
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);
868
1224
  const sections = [];
869
- const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds);
1225
+ const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
870
1226
  if (liveLines.length > 0) {
871
1227
  sections.push({ kind: 'live', lines: liveLines });
872
1228
  }
873
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
1229
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
874
1230
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
875
1231
  const composerLines = [];
876
- if (state.queuedMessage) {
877
- 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) {
878
1236
  const imageCount = state.queuedMessage.imageAttachments.length;
879
- composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
880
- ? [span(` +${imageCount} img`, { color: 'gray', dim: true })]
881
- : []), 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' })));
882
1240
  }
883
1241
  else {
884
1242
  const promptLabel = state.busy ? 'queue> ' : '❯ ';
@@ -892,14 +1250,32 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
892
1250
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
893
1251
  });
894
1252
  }
895
- 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);
896
1263
  if (overlayLines.length > 0) {
897
1264
  sections.push({ kind: 'overlay', lines: overlayLines });
898
1265
  }
899
1266
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
900
- const composerReserve = state.resumePickerOpen ? 0 : 4;
1267
+ const composerReserve = state.resumePickerOpen ||
1268
+ state.approvalPrompt ||
1269
+ state.sudoPrompt ||
1270
+ state.userInputPrompt
1271
+ ? 0
1272
+ : 4;
901
1273
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
902
- 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
+ }
903
1279
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
904
1280
  sections.unshift({
905
1281
  kind: 'transcript',