@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.41

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 (68) hide show
  1. package/README.md +49 -4
  2. package/dist/bin/ai.js +119 -111
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +11 -6
  5. package/dist/src/api/auth.js +6 -4
  6. package/dist/src/api/browser-login.js +152 -37
  7. package/dist/src/api/chat.js +286 -44
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +127 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/background-jobs.js +4 -2
  13. package/dist/src/browser/bridge.js +232 -0
  14. package/dist/src/browser/framing.js +42 -0
  15. package/dist/src/browser/native-host.js +209 -0
  16. package/dist/src/browser/protocol.js +46 -0
  17. package/dist/src/browser/session-bridge.js +104 -0
  18. package/dist/src/cli-args.js +19 -5
  19. package/dist/src/client-environment.js +2 -0
  20. package/dist/src/core/clipboard.js +7 -13
  21. package/dist/src/core/image-limits.js +56 -0
  22. package/dist/src/core/image-path-extractor.js +70 -3
  23. package/dist/src/core/session-image-store.js +246 -0
  24. package/dist/src/edit-journal.js +33 -2
  25. package/dist/src/executor.js +25 -3
  26. package/dist/src/help-text.js +58 -17
  27. package/dist/src/patcher.js +43 -4
  28. package/dist/src/permissions.js +288 -0
  29. package/dist/src/session-safety.js +0 -12
  30. package/dist/src/session-store.js +121 -20
  31. package/dist/src/session.js +19 -4
  32. package/dist/src/signin.js +58 -0
  33. package/dist/src/tool-executor.js +12 -46
  34. package/dist/src/tools/browser.js +628 -0
  35. package/dist/src/tools/delete-file.js +20 -4
  36. package/dist/src/tools/index.js +32 -10
  37. package/dist/src/tools/patch-file.js +12 -26
  38. package/dist/src/tools/read-image-file.js +85 -0
  39. package/dist/src/tools/replace-document-text.js +28 -18
  40. package/dist/src/tools/restore-checkpoint.js +0 -1
  41. package/dist/src/tools/run-command.js +14 -71
  42. package/dist/src/tools/run-node-script.js +12 -81
  43. package/dist/src/tools/save-generated-image.js +120 -0
  44. package/dist/src/tools/str-replace.js +12 -26
  45. package/dist/src/tools/undo-edit.js +1 -6
  46. package/dist/src/tools/write-file.js +67 -11
  47. package/dist/src/ui/prompt-history-store.js +1 -1
  48. package/dist/src/ui/repl.js +802 -195
  49. package/dist/src/ui/tui/bridge.js +10 -0
  50. package/dist/src/ui/tui/build-frame.js +517 -165
  51. package/dist/src/ui/tui/composer-layout.js +76 -0
  52. package/dist/src/ui/tui/markdown-render.js +81 -73
  53. package/dist/src/ui/tui/shell-input.js +227 -67
  54. package/dist/src/ui/tui/terminal-theme.js +28 -0
  55. package/dist/src/ui/tui/terminal-title.js +3 -0
  56. package/dist/src/ui/tui/terminal-writes.js +48 -0
  57. package/dist/src/ui/tui/text.js +158 -4
  58. package/dist/src/ui/tui/user-input.js +568 -0
  59. package/dist/src/utils.js +9 -0
  60. package/package.json +20 -8
  61. package/dist/src/markdown-renderer.js +0 -112
  62. package/dist/src/project-index.js +0 -221
  63. package/dist/src/tools/code-intel.js +0 -472
  64. package/dist/src/tools/find-symbol.js +0 -70
  65. package/dist/src/tools/hover-symbol.js +0 -95
  66. package/dist/src/tools/list-symbols.js +0 -55
  67. package/dist/src/tools/search-code.js +0 -37
  68. package/dist/src/tools/signature-help.js +0 -118
@@ -1,8 +1,11 @@
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';
8
+ import { composerPromptLabel, frameContentWidth, splitComposerInput, } from './composer-layout.js';
6
9
  const WORKING_CLOCK_ICON = '◷';
7
10
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
11
  const TODO_PANEL_MAX_ROWS = 12;
@@ -10,12 +13,27 @@ const TODO_IN_PROGRESS_COLOR = 'ansi256(214)';
10
13
  const COMMAND_PREVIEW_LINES = 10;
11
14
  const WORKING_TOOL_PREVIEW_ROWS = 3;
12
15
  const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
16
+ const APPROVAL_PREVIEW_MAX_ROWS = 20;
17
+ const APPROVAL_PREVIEW_MIN_ROWS = 4;
18
+ const APPROVAL_OVERLAY_CHROME_ROWS = 11;
19
+ const APPROVAL_PADDING_CHROME_ROWS = 4;
20
+ const APPROVAL_PADDING_MIN_ROWS = 30;
21
+ const APPROVAL_SCROLLBAR_COLUMNS = 2;
22
+ const APPROVAL_ACCENT_COLOR = 'cyan';
23
+ const SUDO_PASSWORD_ASSURANCE = 'The model never sees this. Your password goes straight to sudo on this ' +
24
+ 'computer, then is discarded — never sent to our servers, saved, or logged.';
25
+ const SUDO_ASSURANCE_COLOR = 'ansi256(248)';
26
+ const APPROVAL_SCROLL_STATUS_COLOR = 'ansi256(248)';
27
+ const APPROVAL_SCROLLBAR_THUMB = '█';
28
+ const APPROVAL_SCROLLBAR_TRACK = '░';
13
29
  const THINKING_NOTE_PREVIEW_ROWS = 3;
14
30
  const COMPOSER_INPUT_MAX_ROWS = 6;
15
31
  const AGENT_MODE_LABEL_WIDTH = 16;
16
32
  const OVERLAY_PANEL_MAX_WIDTH = 86;
17
33
  const OVERLAY_PANEL_MARGIN_LINES = 2;
34
+ const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
18
35
  const OVERLAY_BORDER_COLOR = 'yellow';
36
+ const USER_INPUT_BORDER_COLOR = 'cyan';
19
37
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
20
38
  const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
21
39
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
@@ -23,75 +41,74 @@ const MODEL_PICKER_BORDER_COLOR = 'cyan';
23
41
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
24
42
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
25
43
  const MODEL_PICKER_META_INDENT = ' ';
26
- function splitComposerInput(input, cursor, width) {
27
- const safeWidth = Math.max(1, width);
28
- const normalizedCursor = Math.min(Math.max(cursor, 0), input.length);
29
- const rows = [''];
30
- let cursorRow = 0;
31
- let cursorCol = 0;
32
- let capturedCursor = false;
33
- const captureCursor = () => {
34
- if (capturedCursor)
35
- return;
36
- const current = rows[rows.length - 1] ?? '';
37
- if (current.length >= safeWidth) {
38
- rows.push('');
39
- cursorRow = rows.length - 1;
40
- cursorCol = 0;
41
- }
42
- else {
43
- cursorRow = rows.length - 1;
44
- cursorCol = current.length;
44
+ const COMPOSER_MARKER_PATTERN = /\[Image #\d+\]|\[Pasted Text: [^\]\n]*\]/g;
45
+ function composerMarkerMask(text) {
46
+ const mask = new Array(text.length).fill(false);
47
+ for (const match of text.matchAll(COMPOSER_MARKER_PATTERN)) {
48
+ const start = match.index ?? 0;
49
+ for (let offset = start; offset < start + match[0].length; offset += 1) {
50
+ mask[offset] = true;
45
51
  }
46
- capturedCursor = true;
52
+ }
53
+ return mask;
54
+ }
55
+ function composerRowSpans(text, mask, cursorCol) {
56
+ const spans = [];
57
+ const push = (value, marker, cursor) => {
58
+ if (!value)
59
+ return;
60
+ spans.push(span(value, {
61
+ ...(marker ? { color: 'cyan' } : {}),
62
+ ...(cursor ? { inverse: true } : {}),
63
+ }));
47
64
  };
48
- for (let index = 0; index < input.length; index += 1) {
49
- const char = input[index] ?? '';
50
- if (char !== '\n' &&
51
- char !== '\r' &&
52
- (rows[rows.length - 1]?.length ?? 0) >= safeWidth) {
53
- rows.push('');
65
+ let cut = 0;
66
+ const flushTo = (end) => {
67
+ while (cut < end) {
68
+ const marker = mask[cut] ?? false;
69
+ let runEnd = cut + 1;
70
+ while (runEnd < end && (mask[runEnd] ?? false) === marker)
71
+ runEnd += 1;
72
+ push(text.slice(cut, runEnd), marker, false);
73
+ cut = runEnd;
54
74
  }
55
- if (index === normalizedCursor) {
56
- captureCursor();
57
- }
58
- if (char === '\r')
59
- continue;
60
- if (char === '\n') {
61
- rows.push('');
62
- continue;
63
- }
64
- rows[rows.length - 1] = `${rows[rows.length - 1] ?? ''}${char}`;
65
- }
66
- if (!capturedCursor) {
67
- captureCursor();
75
+ };
76
+ if (cursorCol === null) {
77
+ flushTo(text.length);
78
+ return spans;
68
79
  }
69
- return { cursorCol, cursorRow, rows };
80
+ flushTo(Math.min(cursorCol, text.length));
81
+ push(text[cursorCol] ?? ' ', mask[cursorCol] ?? false, true);
82
+ cut = Math.min(cursorCol + 1, text.length);
83
+ flushTo(text.length);
84
+ return spans;
70
85
  }
71
86
  function buildComposerInputLines(input, cursor, promptLabel, placeholder, width) {
72
87
  if (!input) {
73
88
  return [
74
- line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: 'gray' })),
89
+ line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: mutedColor() })),
75
90
  ];
76
91
  }
77
92
  const labelWidth = promptLabel.length;
78
93
  const inputWidth = Math.max(1, width - labelWidth);
79
94
  const { cursorCol, cursorRow, rows } = splitComposerInput(input, cursor, inputWidth);
80
95
  const firstRow = Math.min(Math.max(cursorRow - COMPOSER_INPUT_MAX_ROWS + 1, 0), Math.max(rows.length - COMPOSER_INPUT_MAX_ROWS, 0));
96
+ const rowOffsets = [];
97
+ let consumed = 0;
98
+ for (const row of rows) {
99
+ rowOffsets.push(consumed);
100
+ consumed += row.length;
101
+ }
102
+ const mask = composerMarkerMask(rows.join(''));
81
103
  return rows
82
104
  .slice(firstRow, firstRow + COMPOSER_INPUT_MAX_ROWS)
83
105
  .map((text, index) => {
84
106
  const absoluteRow = firstRow + index;
107
+ const rowStart = rowOffsets[absoluteRow] ?? 0;
85
108
  const label = index === 0
86
109
  ? span(promptLabel, { color: 'cyan' })
87
110
  : span(' '.repeat(labelWidth));
88
- if (absoluteRow !== cursorRow) {
89
- return line(label, span(text));
90
- }
91
- const before = text.slice(0, cursorCol);
92
- const cursorChar = text[cursorCol] ?? ' ';
93
- const after = text.slice(cursorCol + 1);
94
- return line(label, span(before), span(cursorChar, { inverse: true }), span(after));
111
+ return line(label, ...composerRowSpans(text, mask.slice(rowStart, rowStart + text.length), absoluteRow === cursorRow ? cursorCol : null));
95
112
  });
96
113
  }
97
114
  function getEntryColor(kind) {
@@ -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 privately on the TheGitAI website' },
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 }));
315
+ }
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));
245
331
  }
246
- return line(span(' ○ ', { color: 'gray' }), span(text));
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) {
@@ -349,10 +454,11 @@ function backgroundJobIndicatorSpans(state) {
349
454
  if (runningCount === 0)
350
455
  return [];
351
456
  const noun = runningCount === 1 ? 'shell' : 'shells';
457
+ const hint = state.busy ? ' · ctrl+b' : ' · /jobs';
352
458
  return [
353
459
  span(' ', { color: 'gray', dim: true }),
354
460
  span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
355
- span(' · /jobs', { color: 'gray', dim: true }),
461
+ span(hint, { color: 'gray', dim: true }),
356
462
  ];
357
463
  }
358
464
  function todoIndicatorSpans(state) {
@@ -405,20 +511,26 @@ function composerFooterLines(state) {
405
511
  }));
406
512
  return lines;
407
513
  }
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';
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';
417
528
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
418
529
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
530
+ const footerMuted = mutedStyle();
419
531
  const footerSpans = [
420
- span(helperText, { color: 'gray', dim: true }),
421
- span(' '.repeat(Math.max(1, 8)), { color: 'gray', dim: true }),
532
+ span(helperText, footerMuted),
533
+ span(' '.repeat(Math.max(1, 8)), footerMuted),
422
534
  span(agentLabel, {
423
535
  color: state.agentMode === 'plan' ? 'yellow' : 'cyan',
424
536
  }),
@@ -468,15 +580,29 @@ function jobStatusDescriptor(job, nowMs) {
468
580
  ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
469
581
  : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
470
582
  }
471
- function buildJobsPickerLines(state, width, nowMs) {
583
+ const JOBS_PICKER_CHROME_ROWS = 2;
584
+ const JOBS_PICKER_MIN_ROWS = 8;
585
+ function buildJobsPickerLines(state, width, height, nowMs) {
472
586
  const jobs = state.backgroundJobs ?? [];
587
+ const bodyBudget = Math.max(1, height - JOBS_PICKER_CHROME_ROWS);
473
588
  const lines = [
474
589
  plainLine('Background jobs', { color: 'cyan', bold: true }),
475
590
  ];
476
591
  if (jobs.length === 0) {
477
592
  lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
478
593
  }
479
- for (const [index, job] of jobs.entries()) {
594
+ let start = 0;
595
+ let visible = jobs;
596
+ if (jobs.length > bodyBudget) {
597
+ start = Math.min(Math.max(0, state.jobsPickerIndex - Math.floor(bodyBudget / 2)), jobs.length - bodyBudget);
598
+ visible = jobs.slice(start, start + bodyBudget);
599
+ if (start > 0) {
600
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
601
+ }
602
+ }
603
+ const detailBudget = Math.max(0, bodyBudget - visible.length - (start > 0 ? 1 : 0));
604
+ for (const [offset, job] of visible.entries()) {
605
+ const index = start + offset;
480
606
  const selected = index === state.jobsPickerIndex;
481
607
  const expanded = state.jobsPickerExpandedId === job.id;
482
608
  const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
@@ -485,10 +611,11 @@ function buildJobsPickerLines(state, width, nowMs) {
485
611
  bold: selected,
486
612
  }), span(` ${text}`, { color: 'gray' })));
487
613
  if (expanded) {
488
- const detailLines = (job.detailLines ?? []).length
614
+ const allDetail = (job.detailLines ?? []).length
489
615
  ? job.detailLines ?? []
490
616
  : backgroundJobPreviewLines(job, 3);
491
- if (detailLines.length === 0) {
617
+ const detailLines = allDetail.slice(-detailBudget);
618
+ if (detailBudget > 0 && detailLines.length === 0) {
492
619
  lines.push(plainLine(' (no output captured)', { color: 'gray' }));
493
620
  }
494
621
  else {
@@ -501,22 +628,57 @@ function buildJobsPickerLines(state, width, nowMs) {
501
628
  }
502
629
  }
503
630
  }
631
+ const remaining = jobs.length - (start + visible.length);
632
+ if (remaining > 0) {
633
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
634
+ }
504
635
  lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
505
636
  color: 'gray',
506
637
  }));
507
638
  return lines;
508
639
  }
640
+ export const THINKING_FALLBACK_PHRASES = [
641
+ 'Working out where to start...',
642
+ 'Deciding what comes first...',
643
+ 'Lining up the pieces...',
644
+ 'Untangling the details...',
645
+ 'Deciding what not to touch...',
646
+ 'Checking what this would break...',
647
+ 'Choosing the smaller change...',
648
+ 'Picking the least clever option...',
649
+ 'Resisting the obvious answer...',
650
+ 'Trying the boring explanation first...',
651
+ 'Checking whether the assumption holds...',
652
+ 'Asking what would have to be true...',
653
+ 'Reading it the way the machine would...',
654
+ 'Testing the story against the code...',
655
+ 'Working out what actually changed...',
656
+ 'Finding the smallest thing that explains it...',
657
+ 'Looking for the part that is not settled yet...',
658
+ 'Making sure this is the simple version...',
659
+ ];
660
+ export function pickThinkingFallbackPhrase() {
661
+ const index = Math.floor(Math.random() * THINKING_FALLBACK_PHRASES.length);
662
+ return THINKING_FALLBACK_PHRASES[index];
663
+ }
664
+ function withProgressEllipsis(title) {
665
+ const text = title.trim();
666
+ if (!text)
667
+ return text;
668
+ return /(?:\.\.\.|…)$/.test(text) ? text : `${text}...`;
669
+ }
509
670
  function thinkingHeaderLine(spinnerFrame, title, width) {
510
671
  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))
672
+ const decorated = withProgressEllipsis(title);
673
+ const fittedTitle = decorated
674
+ ? fitLine(decorated, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
513
675
  : '';
514
676
  return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
515
677
  }
516
678
  function thinkingNoteLine(note, width) {
517
679
  return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
518
680
  }
519
- function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
681
+ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
520
682
  if (!state.busy)
521
683
  return [];
522
684
  const lines = [plainLine('')];
@@ -546,11 +708,19 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
546
708
  }
547
709
  lines.push(plainLine(''));
548
710
  }
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' }));
711
+ lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
712
+ const turnMessages = state.turnMessages ?? [];
713
+ if (turnMessages.length > 0) {
714
+ lines.push(plainLine(''));
715
+ lines.push(...buildTurnMessageLines(turnMessages, width, todoProgressTick(nowMs)));
716
+ }
550
717
  const todos = state.todos ?? [];
551
718
  if (todos.length > 0) {
552
719
  lines.push(plainLine(''));
553
- lines.push(...renderTodoListLines(todos, width, { header: true }));
720
+ lines.push(...renderTodoListLines(todos, width, {
721
+ header: true,
722
+ progressTick: todoProgressTick(nowMs),
723
+ }));
554
724
  }
555
725
  const visibleTitle = state.thinkingTitle.trim();
556
726
  const visibleNotes = state.thinkingNotes.filter(Boolean);
@@ -560,11 +730,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
560
730
  const minimalText = visibleTitle && visibleTitle !== 'Thinking'
561
731
  ? visibleTitle
562
732
  : (visibleNotes[visibleNotes.length - 1] ?? '');
563
- lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
733
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText || THINKING_FALLBACK_PHRASES[0], width));
564
734
  }
565
735
  else {
566
- lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
567
- for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
736
+ const headerTitle = visibleTitle && visibleTitle !== 'Thinking' ? visibleTitle : '';
737
+ lines.push(thinkingHeaderLine(spinnerFrame, headerTitle || THINKING_FALLBACK_PHRASES[0], width));
738
+ for (const note of visibleNotes.slice(0, THINKING_NOTE_PREVIEW_ROWS)) {
568
739
  lines.push(thinkingNoteLine(note, width));
569
740
  }
570
741
  }
@@ -573,16 +744,30 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
573
744
  return lines;
574
745
  }
575
746
  function lineCharCount(row) {
576
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
747
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
577
748
  }
578
749
  function overlayPanelLine(row, width, color) {
579
750
  const padding = Math.max(0, width - lineCharCount(row));
580
751
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
581
752
  }
582
- function buildOverlayPanel(rows, width, color) {
753
+ function overlayPanelMarginLineCount(height) {
754
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
755
+ ? 0
756
+ : OVERLAY_PANEL_MARGIN_LINES;
757
+ }
758
+ function overlayPanelContentBudget(height) {
759
+ if (!Number.isFinite(height)) {
760
+ return Number.POSITIVE_INFINITY;
761
+ }
762
+ const margins = overlayPanelMarginLineCount(height) * 2;
763
+ return Math.max(1, Math.floor(height) - margins - 2);
764
+ }
765
+ function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY) {
583
766
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
584
767
  const innerWidth = Math.max(1, panelWidth - 4);
585
- const margin = Array.from({ length: OVERLAY_PANEL_MARGIN_LINES }, () => plainLine(''));
768
+ const margin = Array.from({
769
+ length: overlayPanelMarginLineCount(height),
770
+ }, () => plainLine(''));
586
771
  return [
587
772
  ...margin,
588
773
  plainLine(`╭${'─'.repeat(panelWidth - 2)}╮`, { color }),
@@ -603,7 +788,6 @@ function modelPickerPanelSideLine(content, innerWidth) {
603
788
  }
604
789
  const MODEL_PICKER_COST_WIDTH = 6;
605
790
  const MODEL_PICKER_MODEL_WIDTH = 42;
606
- const MODEL_PICKER_NUMBER_WIDTH = 4;
607
791
  const MODEL_PICKER_SEPARATOR = ' │ ';
608
792
  const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
609
793
  function modelPickerTopBorder(panelWidth) {
@@ -630,7 +814,6 @@ function modelPickerCostText(rating) {
630
814
  }
631
815
  function modelPickerNotesWidth(innerWidth) {
632
816
  return Math.max(18, innerWidth -
633
- MODEL_PICKER_NUMBER_WIDTH -
634
817
  MODEL_PICKER_MODEL_WIDTH -
635
818
  MODEL_PICKER_COST_WIDTH -
636
819
  MODEL_PICKER_SEPARATOR.length * 2);
@@ -653,12 +836,10 @@ function modelPickerItemLines(option, selected, innerWidth) {
653
836
  : selected
654
837
  ? { color: 'cyan', bold: true }
655
838
  : {};
656
- const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
657
839
  const cost = modelPickerCostText(option.costRating);
658
840
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
659
- const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
841
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_COST_WIDTH - 2);
660
842
  const row = [
661
- numberCell,
662
843
  ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
663
844
  span(' ', selectedStyle),
664
845
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -668,7 +849,6 @@ function modelPickerItemLines(option, selected, innerWidth) {
668
849
  ];
669
850
  }
670
851
  const row = [
671
- numberCell,
672
852
  ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
673
853
  span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
674
854
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -685,9 +865,9 @@ function modelPickerItemLines(option, selected, innerWidth) {
685
865
  function modelPickerHeaderLine(innerWidth) {
686
866
  const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
687
867
  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));
868
+ return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
689
869
  }
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));
870
+ 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
871
  }
692
872
  function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
693
873
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
@@ -782,10 +962,105 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
782
962
  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 }));
783
963
  return [...margin, ...body, ...margin];
784
964
  }
965
+ export function formatElapsedClock(elapsedSeconds) {
966
+ if (elapsedSeconds < 60)
967
+ return `${elapsedSeconds}s`;
968
+ return `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`;
969
+ }
970
+ export function buildWorkingClockLine(state, elapsedSeconds) {
971
+ const elapsed = formatElapsedClock(elapsedSeconds);
972
+ if (state.busyPausedAt != null) {
973
+ return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
974
+ }
975
+ const label = state.generatingImage
976
+ ? 'Generating image'
977
+ : state.analyzingImages > 0
978
+ ? state.analyzingImages > 1
979
+ ? 'Analyzing images'
980
+ : 'Analyzing image'
981
+ : 'Working';
982
+ return `${WORKING_CLOCK_ICON} ${label} · ${elapsed}`;
983
+ }
984
+ export function approvalPanelInnerWidth(width) {
985
+ return Math.max(1, Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH)) - 4);
986
+ }
987
+ export function approvalPaddingEnabled(height) {
988
+ return height >= APPROVAL_PADDING_MIN_ROWS;
989
+ }
990
+ export function approvalPreviewBudget(height) {
991
+ const chrome = APPROVAL_OVERLAY_CHROME_ROWS +
992
+ (approvalPaddingEnabled(height) ? APPROVAL_PADDING_CHROME_ROWS : 0);
993
+ return Math.max(APPROVAL_PREVIEW_MIN_ROWS, Math.min(APPROVAL_PREVIEW_MAX_ROWS, Math.floor(height / 2) - chrome));
994
+ }
995
+ export function approvalPreviewRows(prompt, innerWidth) {
996
+ const previewWidth = Math.max(8, innerWidth - APPROVAL_SCROLLBAR_COLUMNS);
997
+ if (prompt.diffPreview) {
998
+ return renderDiffPreviewLines(prompt.diffPreview, previewWidth, prompt.diffPreview.lines.length);
999
+ }
1000
+ return wrapText(prompt.body, previewWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR }));
1001
+ }
1002
+ export function approvalScrollLimit(prompt, width, height) {
1003
+ if (!prompt)
1004
+ return 0;
1005
+ return Math.max(0, approvalPreviewRows(prompt, approvalPanelInnerWidth(width)).length -
1006
+ approvalPreviewBudget(height));
1007
+ }
1008
+ function approvalScrollbarGlyphs(totalRows, visibleRows, offset) {
1009
+ const thumbRows = Math.max(1, Math.min(visibleRows, Math.round((visibleRows * visibleRows) / totalRows)));
1010
+ const maxOffset = totalRows - visibleRows;
1011
+ const thumbTop = maxOffset <= 0
1012
+ ? 0
1013
+ : Math.round((offset / maxOffset) * (visibleRows - thumbRows));
1014
+ return Array.from({ length: visibleRows }, (_, index) => index >= thumbTop && index < thumbTop + thumbRows
1015
+ ? APPROVAL_SCROLLBAR_THUMB
1016
+ : APPROVAL_SCROLLBAR_TRACK);
1017
+ }
1018
+ function withScrollbarGlyph(target, glyph, column) {
1019
+ const padding = Math.max(1, column - lineCharCount(target));
1020
+ return {
1021
+ spans: [
1022
+ ...target.spans,
1023
+ span(' '.repeat(padding)),
1024
+ span(glyph, {
1025
+ color: 'gray',
1026
+ dim: glyph === APPROVAL_SCROLLBAR_TRACK,
1027
+ }),
1028
+ ],
1029
+ };
1030
+ }
1031
+ export function buildApprovalPreviewWindow(prompt, innerWidth, height, requestedOffset) {
1032
+ const rows = approvalPreviewRows(prompt, innerWidth);
1033
+ const budget = approvalPreviewBudget(height);
1034
+ if (rows.length <= budget) {
1035
+ return {
1036
+ firstVisibleRow: rows.length === 0 ? 0 : 1,
1037
+ lastVisibleRow: rows.length,
1038
+ lines: rows,
1039
+ offset: 0,
1040
+ totalRows: rows.length,
1041
+ };
1042
+ }
1043
+ const offset = Math.max(0, Math.min(requestedOffset, rows.length - budget));
1044
+ const visible = rows.slice(offset, offset + budget);
1045
+ const glyphs = approvalScrollbarGlyphs(rows.length, budget, offset);
1046
+ return {
1047
+ firstVisibleRow: offset + 1,
1048
+ lastVisibleRow: offset + visible.length,
1049
+ lines: visible.map((target, index) => withScrollbarGlyph(target, glyphs[index], innerWidth - 1)),
1050
+ offset,
1051
+ totalRows: rows.length,
1052
+ };
1053
+ }
1054
+ export function approvalScrollStatusLine(window) {
1055
+ return `lines ${window.firstVisibleRow}–${window.lastVisibleRow} of ${window.totalRows} · PgUp/PgDn scrolls`;
1056
+ }
1057
+ export function rightAlignedLine(text, width, style = {}) {
1058
+ return plainLine(`${' '.repeat(Math.max(0, width - displayWidth(text)))}${text}`, style);
1059
+ }
785
1060
  function buildOverlayLines(state, width, height, nowMs) {
786
1061
  const lines = [];
787
1062
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
788
- const innerWidth = Math.max(1, panelWidth - 4);
1063
+ const innerWidth = approvalPanelInnerWidth(width);
789
1064
  if (state.sudoPrompt) {
790
1065
  const prompt = state.sudoPrompt;
791
1066
  const passwordWidth = Math.max(0, innerWidth - 11);
@@ -805,75 +1080,117 @@ function buildOverlayLines(state, width, height, nowMs) {
805
1080
  : line(span(' '), span(text, { color: OVERLAY_BORDER_COLOR })));
806
1081
  });
807
1082
  lines.push(plainLine(''));
808
- if (prompt.prompt.trim()) {
809
- lines.push(plainLine(prompt.prompt.trim(), { color: 'gray' }));
810
- }
1083
+ lines.push(...wrapText(SUDO_PASSWORD_ASSURANCE, innerWidth).map((text) => plainLine(text, { color: SUDO_ASSURANCE_COLOR })));
1084
+ lines.push(plainLine(''));
811
1085
  lines.push(line(span('Password: ', { color: 'cyan', bold: true }), span('•'.repeat(Math.min(prompt.passwordLength, passwordWidth)), {
812
1086
  color: 'cyan',
813
1087
  }), span(' ', { inverse: true })));
814
1088
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
815
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1089
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1090
+ }
1091
+ if (state.userInputPrompt) {
1092
+ if (height < 3) {
1093
+ return [];
1094
+ }
1095
+ return buildOverlayPanel(buildUserInputOverlayLines(state.userInputPrompt, innerWidth, overlayPanelContentBudget(height)), width, USER_INPUT_BORDER_COLOR, height);
816
1096
  }
817
1097
  if (state.approvalPrompt) {
818
1098
  const prompt = state.approvalPrompt;
1099
+ const padded = approvalPaddingEnabled(height);
819
1100
  lines.push(plainLine(prompt.title, {
820
- color: OVERLAY_BORDER_COLOR,
1101
+ color: APPROVAL_ACCENT_COLOR,
821
1102
  bold: true,
822
1103
  }));
1104
+ if (padded)
1105
+ lines.push(plainLine(''));
823
1106
  if (prompt.diffPreview && prompt.filePath) {
824
1107
  lines.push(line(span('● ', { color: 'green' }), span('Update(', { bold: true }), span(prompt.filePath, { color: 'cyan', bold: true }), span(')', { bold: true })));
825
- lines.push(...renderTranscriptEntryLines({
826
- body: '',
827
- diffPreview: prompt.diffPreview,
828
- kind: 'diff',
829
- title: '',
830
- }, innerWidth));
831
1108
  }
832
- else {
833
- lines.push(...wrapText(prompt.body, innerWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR })));
1109
+ const previewWindow = buildApprovalPreviewWindow(prompt, innerWidth, height, state.approvalScrollOffset ?? 0);
1110
+ lines.push(...previewWindow.lines);
1111
+ if (padded)
1112
+ lines.push(plainLine(''));
1113
+ if (previewWindow.totalRows > previewWindow.lines.length) {
1114
+ lines.push(rightAlignedLine(approvalScrollStatusLine(previewWindow), innerWidth, {
1115
+ color: APPROVAL_SCROLL_STATUS_COLOR,
1116
+ }));
1117
+ if (padded)
1118
+ lines.push(plainLine(''));
834
1119
  }
835
- const options = [
836
- { value: 'y', label: 'Approve once', color: 'green' },
837
- { value: 'a', label: 'Approve all remaining actions', color: 'cyan' },
838
- { value: 'n', label: 'Deny', color: 'red' },
839
- ];
840
- options.forEach((option, index) => {
1120
+ const options = (prompt.options ?? []).map((option) => option.label);
1121
+ options.forEach((label, index) => {
841
1122
  const selected = index === state.approvalCursor;
842
1123
  lines.push(line(span(selected ? '› ' : ' ', {
843
- color: selected ? OVERLAY_BORDER_COLOR : 'gray',
844
- }), span(option.value, {
845
- color: selected ? OVERLAY_BORDER_COLOR : option.color,
846
- bold: true,
847
- }), span(` ${option.label}`, {
848
- color: selected ? OVERLAY_BORDER_COLOR : undefined,
1124
+ color: selected ? APPROVAL_ACCENT_COLOR : 'gray',
1125
+ }), span(label, {
1126
+ color: selected ? APPROVAL_ACCENT_COLOR : undefined,
1127
+ bold: selected,
849
1128
  })));
850
1129
  });
851
- lines.push(plainLine('Press y, a, or n • ↑/↓ moves • Enter confirms', {
1130
+ if (padded)
1131
+ lines.push(plainLine(''));
1132
+ lines.push(plainLine('↑/↓ moves • Enter confirms • Esc denies', {
852
1133
  color: 'gray',
853
1134
  }));
854
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1135
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
855
1136
  }
856
1137
  if (state.modelPickerOpen) {
857
1138
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
858
1139
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
859
1140
  }
860
1141
  if (state.jobsPickerOpen) {
861
- lines.push(...buildJobsPickerLines(state, width, nowMs));
1142
+ lines.push(...buildJobsPickerLines(state, width, height, nowMs));
862
1143
  }
863
1144
  if (state.resumePickerOpen) {
864
1145
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
1146
+ const pickerWidth = Math.max(20, width - 2);
1147
+ const divider = () => plainLine('─'.repeat(pickerWidth), { color: 'gray', dim: true });
865
1148
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
866
1149
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
867
- for (const [index, session] of filtered.entries()) {
1150
+ lines.push(divider());
1151
+ const maxCards = Math.max(2, Math.min(filtered.length, Math.floor((height - 10) / 3)));
1152
+ let start = 0;
1153
+ if (filtered.length > maxCards) {
1154
+ start = Math.min(Math.max(0, state.resumePickerIndex - Math.floor(maxCards / 2)), filtered.length - maxCards);
1155
+ }
1156
+ const visible = filtered.slice(start, start + maxCards);
1157
+ if (start > 0) {
1158
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
1159
+ }
1160
+ for (const [offset, session] of visible.entries()) {
1161
+ const index = start + offset;
868
1162
  const selected = index === state.resumePickerIndex;
869
- const color = selected ? 'cyan' : undefined;
870
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
871
- 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 }));
1163
+ const prompt = singleLinePreview(session.lastUserMessage, pickerWidth) ||
1164
+ session.name ||
1165
+ `Session ${session.id}`;
1166
+ lines.push(line(span(selected ? '› ' : ' ', { color: 'cyan' }), span(fitLine(prompt, Math.max(10, pickerWidth - 2)), selected
1167
+ ? { color: 'cyan', bold: true }
1168
+ : session.lastUserMessage
1169
+ ? {}
1170
+ : { color: 'gray', dim: true })));
1171
+ const meta = [
1172
+ formatRelativeTime(session.updatedAt),
1173
+ pickerModelLabel(session.modelId, state.serverModels),
1174
+ session.branch ?? null,
1175
+ ]
1176
+ .filter(Boolean)
1177
+ .join(' · ');
1178
+ lines.push(line(span(' '), span(fitLine(meta, Math.max(10, pickerWidth - 4)), {
1179
+ color: selected ? 'cyan' : 'gray',
1180
+ dim: !selected,
1181
+ })));
1182
+ if (offset < visible.length - 1)
1183
+ lines.push(plainLine(''));
1184
+ }
1185
+ const remaining = filtered.length - (start + visible.length);
1186
+ if (remaining > 0) {
1187
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
872
1188
  }
873
1189
  if (filtered.length === 0) {
874
1190
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
875
1191
  }
876
- lines.push(plainLine('↑/↓ move enter resume esc start new ctrl+c quit', {
1192
+ lines.push(divider());
1193
+ lines.push(plainLine('↑/↓ move · enter resume · esc start new · ctrl+c quit', {
877
1194
  color: 'gray',
878
1195
  }));
879
1196
  }
@@ -892,6 +1209,15 @@ function buildOverlayLines(state, width, height, nowMs) {
892
1209
  function countSectionLines(sections) {
893
1210
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
894
1211
  }
1212
+ export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1213
+ const contentWidth = frameContentWidth(cols);
1214
+ const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1215
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1216
+ return {
1217
+ width: approvalPanelInnerWidth(contentWidth),
1218
+ maxRows: overlayPanelContentBudget(overlayHeight),
1219
+ };
1220
+ }
895
1221
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
896
1222
  if (maxLines <= 0 || lines.length <= maxLines) {
897
1223
  return lines;
@@ -902,33 +1228,39 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
902
1228
  return lines.slice(start, start + maxLines);
903
1229
  }
904
1230
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
905
- const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1231
+ const contentWidth = frameContentWidth(cols);
906
1232
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
907
- const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
908
- const transcriptLines = [];
909
- transcriptBlocks.forEach((block, index) => {
910
- if (index > 0) {
911
- transcriptLines.push(plainLine(''));
912
- }
913
- transcriptLines.push(...block);
914
- });
1233
+ const buildTranscriptLines = (wrapWidth) => {
1234
+ const blocks = state.transcript.map((entry) => cachedTranscriptEntryLines(entry, wrapWidth));
1235
+ const lines = [];
1236
+ blocks.forEach((block, index) => {
1237
+ if (index > 0) {
1238
+ lines.push(plainLine(''));
1239
+ }
1240
+ lines.push(...block);
1241
+ });
1242
+ return lines;
1243
+ };
1244
+ let transcriptLines = buildTranscriptLines(contentWidth);
915
1245
  const sections = [];
916
- const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds);
1246
+ const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
917
1247
  if (liveLines.length > 0) {
918
1248
  sections.push({ kind: 'live', lines: liveLines });
919
1249
  }
920
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
1250
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
921
1251
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
922
1252
  const composerLines = [];
923
- if (state.queuedMessage) {
924
- const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
1253
+ if (state.busy && state.status === 'Starting a new conversation...') {
1254
+ composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
1255
+ }
1256
+ else if (state.queuedMessage) {
925
1257
  const imageCount = state.queuedMessage.imageAttachments.length;
926
- composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
927
- ? [span(` +${imageCount} img`, { color: 'gray', dim: true })]
928
- : []), span(' ↑ edit · esc cancel', { color: 'gray', dim: true })));
1258
+ composerLines.push(line(span('Queued', { color: 'cyan', bold: true }), span(' Enter', { color: 'cyan', bold: true }), span(imageCount > 0
1259
+ ? ' sends it with the next prompt'
1260
+ : ' 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' })));
929
1261
  }
930
1262
  else {
931
- const promptLabel = state.busy ? 'queue> ' : '❯ ';
1263
+ const promptLabel = composerPromptLabel(state);
932
1264
  const placeholder = state.busy
933
1265
  ? 'Type to queue the next message'
934
1266
  : 'Type a request or /help';
@@ -939,14 +1271,34 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
939
1271
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
940
1272
  });
941
1273
  }
942
- const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
1274
+ if (state.userInputPrompt) {
1275
+ sections.push({
1276
+ kind: 'busyFooter',
1277
+ lines: composerFooterLines(state),
1278
+ });
1279
+ }
1280
+ const overlayHeight = state.userInputPrompt
1281
+ ? Math.max(0, rows - countSectionLines(sections))
1282
+ : state.jobsPickerOpen
1283
+ ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1284
+ : rows;
1285
+ const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
943
1286
  if (overlayLines.length > 0) {
944
1287
  sections.push({ kind: 'overlay', lines: overlayLines });
945
1288
  }
946
1289
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
947
- const composerReserve = state.resumePickerOpen ? 0 : 4;
1290
+ const composerReserve = state.resumePickerOpen ||
1291
+ state.approvalPrompt ||
1292
+ state.sudoPrompt ||
1293
+ state.userInputPrompt
1294
+ ? 0
1295
+ : 4;
948
1296
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
949
- const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1297
+ let transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1298
+ if (transcriptScrollLimit > 0 && contentWidth > 2) {
1299
+ transcriptLines = buildTranscriptLines(contentWidth - 2);
1300
+ transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1301
+ }
950
1302
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
951
1303
  sections.unshift({
952
1304
  kind: 'transcript',