@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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 (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  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 +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -1,8 +1,13 @@
1
- import { agentModeLabel } from '../../agent-mode.js';
2
- import { truncate } from '../../utils.js';
1
+ import { agentModeColor, agentModeLabelTiers, agentModeShortLabel, } from '../../agent-mode.js';
2
+ import { singleLinePreview, truncate } from '../../utils.js';
3
3
  import { formatClientTokenUsage } from '../repl.js';
4
+ import { getHiddenSlashCommandCount, getSlashCommandSuggestions, } from '../slash-commands.js';
4
5
  import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdown-render.js';
5
- import { displayWidth, line, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, } from './text.js';
7
+ import { isDarkTerminalBackground, mutedColor, mutedStyle } from './terminal-theme.js';
8
+ import { buildShortcutGroups, shortcutsPanelAvailable, } from './shortcuts.js';
9
+ import { buildUserInputOverlayLines, } from './user-input.js';
10
+ import { composerPromptLabel, frameContentWidth, splitComposerInput, } from './composer-layout.js';
6
11
  const WORKING_CLOCK_ICON = '◷';
7
12
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
13
  const TODO_PANEL_MAX_ROWS = 12;
@@ -10,12 +15,41 @@ const TODO_IN_PROGRESS_COLOR = 'ansi256(214)';
10
15
  const COMMAND_PREVIEW_LINES = 10;
11
16
  const WORKING_TOOL_PREVIEW_ROWS = 3;
12
17
  const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
18
+ const APPROVAL_PREVIEW_MAX_ROWS = 20;
19
+ const APPROVAL_PREVIEW_MIN_ROWS = 4;
20
+ const APPROVAL_OVERLAY_CHROME_ROWS = 11;
21
+ const APPROVAL_PADDING_CHROME_ROWS = 4;
22
+ const APPROVAL_PADDING_MIN_ROWS = 30;
23
+ const APPROVAL_SCROLLBAR_COLUMNS = 2;
24
+ const APPROVAL_ACCENT_COLOR = 'cyan';
25
+ const SUDO_PASSWORD_ASSURANCE = 'The model never sees this. Your password goes straight to sudo on this ' +
26
+ 'computer, then is discarded — never sent to our servers, saved, or logged.';
27
+ const SUDO_ASSURANCE_COLOR = 'ansi256(248)';
28
+ const APPROVAL_SCROLL_STATUS_COLOR = 'ansi256(248)';
29
+ const APPROVAL_SCROLLBAR_THUMB = '█';
30
+ const APPROVAL_SCROLLBAR_TRACK = '░';
13
31
  const THINKING_NOTE_PREVIEW_ROWS = 3;
14
32
  const COMPOSER_INPUT_MAX_ROWS = 6;
15
- const AGENT_MODE_LABEL_WIDTH = 16;
33
+ const FOOTER_HINT_MIN_GAP = 2;
34
+ const FOOTER_HELP_HINT = '? for shortcuts';
35
+ const FOOTER_CANCEL_HINT = 'esc cancels turn';
36
+ const FOOTER_NOTIFICATIONS_ON = 'Notifications ON';
37
+ const FOOTER_NOTIFICATIONS_OFF = 'Notifications OFF';
38
+ export function footerNotificationHint(status) {
39
+ const text = String(status ?? '').trim();
40
+ if (text === 'Notifications on')
41
+ return FOOTER_NOTIFICATIONS_ON;
42
+ if (text === 'Notifications off')
43
+ return FOOTER_NOTIFICATIONS_OFF;
44
+ return '';
45
+ }
46
+ const FOOTER_CYCLE_HINT = ' (shift+tab to cycle)';
16
47
  const OVERLAY_PANEL_MAX_WIDTH = 86;
48
+ const USER_INPUT_PANEL_MAX_WIDTH = 120;
17
49
  const OVERLAY_PANEL_MARGIN_LINES = 2;
50
+ const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
18
51
  const OVERLAY_BORDER_COLOR = 'yellow';
52
+ const USER_INPUT_BORDER_COLOR = 'cyan';
19
53
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
20
54
  const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
21
55
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
@@ -23,75 +57,74 @@ const MODEL_PICKER_BORDER_COLOR = 'cyan';
23
57
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
24
58
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
25
59
  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;
60
+ const COMPOSER_MARKER_PATTERN = /\[Image #\d+\]|\[Pasted Text: [^\]\n]*\]/g;
61
+ function composerMarkerMask(text) {
62
+ const mask = new Array(text.length).fill(false);
63
+ for (const match of text.matchAll(COMPOSER_MARKER_PATTERN)) {
64
+ const start = match.index ?? 0;
65
+ for (let offset = start; offset < start + match[0].length; offset += 1) {
66
+ mask[offset] = true;
41
67
  }
42
- else {
43
- cursorRow = rows.length - 1;
44
- cursorCol = current.length;
45
- }
46
- capturedCursor = true;
68
+ }
69
+ return mask;
70
+ }
71
+ function composerRowSpans(text, mask, cursorCol) {
72
+ const spans = [];
73
+ const push = (value, marker, cursor) => {
74
+ if (!value)
75
+ return;
76
+ spans.push(span(value, {
77
+ ...(marker ? { color: 'cyan' } : {}),
78
+ ...(cursor ? { inverse: true } : {}),
79
+ }));
47
80
  };
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('');
81
+ let cut = 0;
82
+ const flushTo = (end) => {
83
+ while (cut < end) {
84
+ const marker = mask[cut] ?? false;
85
+ let runEnd = cut + 1;
86
+ while (runEnd < end && (mask[runEnd] ?? false) === marker)
87
+ runEnd += 1;
88
+ push(text.slice(cut, runEnd), marker, false);
89
+ cut = runEnd;
54
90
  }
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();
91
+ };
92
+ if (cursorCol === null) {
93
+ flushTo(text.length);
94
+ return spans;
68
95
  }
69
- return { cursorCol, cursorRow, rows };
96
+ flushTo(Math.min(cursorCol, text.length));
97
+ push(text[cursorCol] ?? ' ', mask[cursorCol] ?? false, true);
98
+ cut = Math.min(cursorCol + 1, text.length);
99
+ flushTo(text.length);
100
+ return spans;
70
101
  }
71
102
  function buildComposerInputLines(input, cursor, promptLabel, placeholder, width) {
72
103
  if (!input) {
73
104
  return [
74
- line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: 'gray' })),
105
+ line(span(promptLabel, { color: 'cyan' }), span(' ', { inverse: true }), span(placeholder, { color: mutedColor() })),
75
106
  ];
76
107
  }
77
108
  const labelWidth = promptLabel.length;
78
109
  const inputWidth = Math.max(1, width - labelWidth);
79
110
  const { cursorCol, cursorRow, rows } = splitComposerInput(input, cursor, inputWidth);
80
111
  const firstRow = Math.min(Math.max(cursorRow - COMPOSER_INPUT_MAX_ROWS + 1, 0), Math.max(rows.length - COMPOSER_INPUT_MAX_ROWS, 0));
112
+ const rowOffsets = [];
113
+ let consumed = 0;
114
+ for (const row of rows) {
115
+ rowOffsets.push(consumed);
116
+ consumed += row.length;
117
+ }
118
+ const mask = composerMarkerMask(rows.join(''));
81
119
  return rows
82
120
  .slice(firstRow, firstRow + COMPOSER_INPUT_MAX_ROWS)
83
121
  .map((text, index) => {
84
122
  const absoluteRow = firstRow + index;
123
+ const rowStart = rowOffsets[absoluteRow] ?? 0;
85
124
  const label = index === 0
86
125
  ? span(promptLabel, { color: 'cyan' })
87
126
  : 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));
127
+ return line(label, ...composerRowSpans(text, mask.slice(rowStart, rowStart + text.length), absoluteRow === cursorRow ? cursorCol : null));
95
128
  });
96
129
  }
97
130
  function getEntryColor(kind) {
@@ -157,15 +190,6 @@ export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HO
157
190
  export function formatPromptSessionIdLabel(showSessionId, sessionId) {
158
191
  return showSessionId ? String(sessionId ?? '').trim() : '';
159
192
  }
160
- const CLIENT_SLASH_COMMANDS = [
161
- { command: '/help', description: 'Show available chat commands' },
162
- { command: '/usage', description: 'Show account usage percentage and reset times' },
163
- { command: '/model', description: 'Switch the active model' },
164
- { command: '/resume', description: 'Open the session picker to resume a previous session' },
165
- { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
166
- { command: '/clear', description: 'Clear conversation history' },
167
- { command: '/exit', description: 'Quit the current session' },
168
- ];
169
193
  function buildModelPickerOptions(currentModelId, serverModels) {
170
194
  return serverModels.map((model) => ({
171
195
  id: model.id,
@@ -177,41 +201,13 @@ function buildModelPickerOptions(currentModelId, serverModels) {
177
201
  note: model.description,
178
202
  }));
179
203
  }
180
- function getInputCommandToken(input) {
181
- const trimmed = String(input ?? '').trimStart();
182
- const match = trimmed.match(/^\/[^\s]*/);
183
- const token = match?.[0] ?? '';
184
- if (token.indexOf('/', 1) !== -1)
185
- return '';
186
- return token;
187
- }
188
- function scoreSlashCommand(option, token) {
189
- if (option.command === token)
190
- return 0;
191
- if (option.command.startsWith(token))
192
- return 1;
193
- if (option.command.includes(token.slice(1)))
194
- return 2;
195
- if (option.description.toLowerCase().includes(token.slice(1)))
196
- return 3;
197
- return 4;
198
- }
199
- export function getSlashCommandSuggestions(input) {
200
- const token = getInputCommandToken(input).toLowerCase();
201
- if (!token)
202
- return [];
203
- const ranked = [...CLIENT_SLASH_COMMANDS].sort((a, b) => {
204
- const scoreDiff = scoreSlashCommand(a, token) - scoreSlashCommand(b, token);
205
- if (scoreDiff !== 0)
206
- return scoreDiff;
207
- return a.command.localeCompare(b.command);
208
- });
209
- const matches = ranked.filter((option) => scoreSlashCommand(option, token) < 4);
210
- return (matches.length ? matches : ranked).slice(0, 5);
211
- }
204
+ export { getSlashCommandSuggestions };
212
205
  function formatModelLabel(modelId, serverModels) {
213
206
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
214
207
  }
208
+ function pickerModelLabel(modelId, serverModels) {
209
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
210
+ }
215
211
  function filterResumeSessions(sessions, filter, serverModels) {
216
212
  const q = filter.trim().toLowerCase();
217
213
  if (!q)
@@ -235,26 +231,90 @@ function formatRelativeTime(isoDate) {
235
231
  return `${hours}h ago`;
236
232
  return `${Math.floor(hours / 24)}d ago`;
237
233
  }
238
- function todoItemLine(item, width) {
234
+ const TODO_IN_PROGRESS_FRAMES = [
235
+ '\u25CB',
236
+ '\u25D4',
237
+ '\u25D1',
238
+ '\u25D5',
239
+ '\u25CF',
240
+ ];
241
+ const TODO_IN_PROGRESS_STATIC_GLYPH = '\u25D1';
242
+ export const TODO_IN_PROGRESS_STEP_MS = 1_500;
243
+ export function todoProgressTick(nowMs) {
244
+ return Math.floor(nowMs / TODO_IN_PROGRESS_STEP_MS);
245
+ }
246
+ function todoInProgressGlyph(progressTick) {
247
+ if (progressTick === null)
248
+ return TODO_IN_PROGRESS_STATIC_GLYPH;
249
+ const index = ((progressTick % TODO_IN_PROGRESS_FRAMES.length) +
250
+ TODO_IN_PROGRESS_FRAMES.length) %
251
+ TODO_IN_PROGRESS_FRAMES.length;
252
+ return TODO_IN_PROGRESS_FRAMES[index];
253
+ }
254
+ function todoItemLine(item, width, progressTick = null) {
239
255
  const text = fitLine(item.text, Math.max(8, width - 5));
240
256
  if (item.status === 'completed') {
241
- return line(span(' ✔ ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
257
+ return line(span(' ✔ ', { color: 'green' }), span(text, mutedStyle()));
242
258
  }
243
259
  if (item.status === 'in_progress') {
244
- return line(span(' ◐ ', { color: TODO_IN_PROGRESS_COLOR }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
260
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
261
+ color: TODO_IN_PROGRESS_COLOR,
262
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
263
+ }
264
+ return line(span(' ○ ', { color: mutedColor() }), isDarkTerminalBackground()
265
+ ? span(text, { color: mutedColor() })
266
+ : span(text));
267
+ }
268
+ const TURN_MESSAGE_PANEL_MAX_ROWS = 6;
269
+ const TURN_MESSAGE_LABEL = {
270
+ queued: 'queued',
271
+ sending: 'Processing . . .',
272
+ delivered: 'delivered',
273
+ };
274
+ function turnMessageLine(message, width, progressTick) {
275
+ const DECORATION = 6;
276
+ const budget = Math.max(1, width - DECORATION);
277
+ const preferredLabel = Math.max(...Object.values(TURN_MESSAGE_LABEL).map((value) => displayWidth(value)));
278
+ const labelWidth = Math.min(Math.max(1, Math.floor(budget / 2)), Math.max(preferredLabel, displayWidth(message.note ?? '')));
279
+ const label = fitLine(message.note ?? TURN_MESSAGE_LABEL[message.state], labelWidth);
280
+ const textWidth = Math.max(1, budget - labelWidth);
281
+ const text = padToWidth(fitLine(`"${message.text}"`, textWidth), textWidth);
282
+ if (message.state === 'delivered') {
283
+ return line(span(' ● ', { color: 'green' }), span(text, { color: 'gray', dim: true }), span(` ${label}`, { color: 'green' }));
284
+ }
285
+ if (message.state === 'sending') {
286
+ return line(span(` ${todoInProgressGlyph(progressTick)} `, {
287
+ color: TODO_IN_PROGRESS_COLOR,
288
+ }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }), span(` ${label}`, { color: TODO_IN_PROGRESS_COLOR }));
289
+ }
290
+ return line(span(' ○ ', { color: 'cyan' }), span(text, { color: 'cyan' }), span(` ${label}`, { color: 'gray' }));
291
+ }
292
+ export function buildTurnMessageLines(messages, width, progressTick = null) {
293
+ if (messages.length === 0)
294
+ return [];
295
+ const lines = [
296
+ line(span('Your messages', { color: 'cyan', bold: true })),
297
+ ];
298
+ const visible = messages.slice(-(TURN_MESSAGE_PANEL_MAX_ROWS - 1));
299
+ const hidden = messages.length - visible.length;
300
+ if (hidden > 0) {
301
+ lines.push(line(span(` ● ${hidden} earlier`, { color: 'gray', dim: true })));
245
302
  }
246
- return line(span(' ○ ', { color: 'gray' }), span(text));
303
+ for (const message of visible) {
304
+ lines.push(turnMessageLine(message, width, progressTick));
305
+ }
306
+ return lines;
247
307
  }
248
308
  export function formatTodoProgress(items) {
249
309
  const done = items.filter((item) => item.status === 'completed').length;
250
310
  return `${done}/${items.length} done`;
251
311
  }
252
- export function renderTodoListLines(items, width, { header = false } = {}) {
312
+ export function renderTodoListLines(items, width, { header = false, progressTick = null } = {}) {
253
313
  if (items.length === 0)
254
314
  return [];
255
315
  const lines = [];
256
316
  if (header) {
257
- lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: 'gray' })));
317
+ lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: mutedColor() })));
258
318
  }
259
319
  const itemRowBudget = TODO_PANEL_MAX_ROWS - (header ? 1 : 0);
260
320
  let collapsedDone = 0;
@@ -268,19 +328,16 @@ export function renderTodoListLines(items, width, { header = false } = {}) {
268
328
  collapsedDone = Math.min(over, leadingDone);
269
329
  }
270
330
  if (collapsedDone > 0) {
271
- lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, { color: 'gray', dim: true })));
331
+ lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, mutedStyle())));
272
332
  }
273
333
  const remaining = items.slice(collapsedDone);
274
334
  const budget = itemRowBudget - (collapsedDone > 0 ? 1 : 0);
275
335
  const visible = remaining.length > budget ? remaining.slice(0, budget - 1) : remaining;
276
336
  for (const item of visible) {
277
- lines.push(todoItemLine(item, width));
337
+ lines.push(todoItemLine(item, width, progressTick));
278
338
  }
279
339
  if (remaining.length > visible.length) {
280
- lines.push(plainLine(` … +${remaining.length - visible.length} more`, {
281
- color: 'gray',
282
- dim: true,
283
- }));
340
+ lines.push(plainLine(` … +${remaining.length - visible.length} more`, mutedStyle()));
284
341
  }
285
342
  return lines;
286
343
  }
@@ -293,20 +350,45 @@ export function renderTranscriptEntryLines(entry, width) {
293
350
  ? renderPreformattedBodyLines(entry.body, width, entry.kind)
294
351
  : renderFormattedBodyLines(entry.body, width, entry.kind)));
295
352
  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
- }
353
+ lines.push(...renderDiffPreviewLines(entry.diffPreview, width));
302
354
  }
303
355
  if (entry.todoList && entry.todoList.length > 0) {
304
356
  lines.push(...renderTodoListLines(entry.todoList, width));
305
357
  }
306
358
  return lines;
307
359
  }
360
+ const transcriptEntryLineCache = new WeakMap();
361
+ const MAX_CACHED_TRANSCRIPT_WIDTHS = 2;
362
+ function cachedTranscriptEntryLines(entry, width) {
363
+ let byWidth = transcriptEntryLineCache.get(entry);
364
+ if (!byWidth) {
365
+ byWidth = new Map();
366
+ transcriptEntryLineCache.set(entry, byWidth);
367
+ }
368
+ const cached = byWidth.get(width);
369
+ if (cached)
370
+ return cached;
371
+ const lines = renderTranscriptEntryLines(entry, width);
372
+ if (byWidth.size >= MAX_CACHED_TRANSCRIPT_WIDTHS) {
373
+ const oldest = byWidth.keys().next().value;
374
+ if (oldest !== undefined)
375
+ byWidth.delete(oldest);
376
+ }
377
+ byWidth.set(width, lines);
378
+ return lines;
379
+ }
380
+ function renderDiffPreviewLines(preview, width, maxDiffLines = TRANSCRIPT_DIFF_PREVIEW_LINES) {
381
+ return [
382
+ plainLine(` Added ${preview.added} line${preview.added === 1 ? '' : 's'}, removed ${preview.removed} line${preview.removed === 1 ? '' : 's'}`, { color: 'gray' }),
383
+ ...preview.lines.slice(0, maxDiffLines).map((diffLine) => line(span(`${diffLinePrefix(diffLine.kind)} `, {
384
+ color: diffLineColor(diffLine.kind),
385
+ }), span(fitLine(diffLine.content || ' ', width - 4), {
386
+ color: diffLineColor(diffLine.kind),
387
+ }))),
388
+ ];
389
+ }
308
390
  function tokenUsageLines(usage) {
309
- const match = usage.match(/^Session tokens • in ([^•]+) • out ([^•]+)(?: • think ([^•]+))? • cache ([^•]+)(?: • write ([^•]+))?(?: • index ([^•]+))?$/);
391
+ const match = usage.match(/^Session tokens • in ([^•]+) • out ([^•]+)(?: • think ([^•]+))? • cache ([^•]+)(?: • write ([^•]+))?$/);
310
392
  if (!match) {
311
393
  return [plainLine(usage, { color: 'cyan' })];
312
394
  }
@@ -324,15 +406,16 @@ function tokenUsageLines(usage) {
324
406
  if (match[5]) {
325
407
  spans.push(span(' Write ', { color: 'gray' }), span(match[5].trim(), { color: 'yellow' }));
326
408
  }
327
- if (match[6]) {
328
- spans.push(span(' Index ', { color: 'gray' }), span(match[6].trim(), { color: 'blue' }));
329
- }
330
409
  return [line(...spans)];
331
410
  }
411
+ export const AGENT_MODE_LOCKED_STATUS = 'Mode locked while the agent is working';
332
412
  function footerTransientStatus(status) {
333
413
  const text = String(status ?? '').trim();
334
414
  if (!text)
335
415
  return null;
416
+ if (text === AGENT_MODE_LOCKED_STATUS) {
417
+ return { color: 'yellow', text, icon: '!' };
418
+ }
336
419
  if (text === 'Copied selection' || text === 'Copied link') {
337
420
  return { color: 'green', text, icon: '✓' };
338
421
  }
@@ -349,10 +432,11 @@ function backgroundJobIndicatorSpans(state) {
349
432
  if (runningCount === 0)
350
433
  return [];
351
434
  const noun = runningCount === 1 ? 'shell' : 'shells';
435
+ const hint = state.busy ? ' · ctrl+b' : ' · /jobs';
352
436
  return [
353
437
  span(' ', { color: 'gray', dim: true }),
354
438
  span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
355
- span(' · /jobs', { color: 'gray', dim: true }),
439
+ span(hint, { color: 'gray', dim: true }),
356
440
  ];
357
441
  }
358
442
  function todoIndicatorSpans(state) {
@@ -372,9 +456,47 @@ function todoIndicatorSpans(state) {
372
456
  }),
373
457
  ];
374
458
  }
375
- function composerFooterLines(state) {
459
+ function composerFooterRightHints(state) {
460
+ const cancelHint = state.busy && !state.queuedMessage && !state.userInputPrompt
461
+ ? FOOTER_CANCEL_HINT
462
+ : '';
463
+ const helpHint = shortcutsPanelAvailable(state) ? FOOTER_HELP_HINT : '';
464
+ const notifyHint = footerNotificationHint(state.transientStatus);
465
+ const join = (...parts) => parts.filter(Boolean).join(' · ');
466
+ const tiers = [];
467
+ if (notifyHint) {
468
+ tiers.push(join(cancelHint, notifyHint, helpHint));
469
+ tiers.push(join(notifyHint, helpHint));
470
+ tiers.push(join(cancelHint, notifyHint));
471
+ tiers.push(notifyHint);
472
+ }
473
+ if (cancelHint && helpHint)
474
+ tiers.push(`${cancelHint} · ${helpHint}`);
475
+ if (cancelHint)
476
+ tiers.push(cancelHint);
477
+ if (helpHint)
478
+ tiers.push(helpHint);
479
+ tiers.push('');
480
+ return tiers.filter((tier, index) => tiers.indexOf(tier) === index);
481
+ }
482
+ export function fitComposerFooterRow({ modeLabels, modeLabelFallback, cycleHint, rightHints, leadWidth, tailWidth, contentWidth, }) {
483
+ const candidates = rightHints.flatMap((rightHint) => (cycleHint ? [cycleHint, ''] : ['']).flatMap((cycle) => modeLabels.map((modeLabel) => ({ modeLabel, cycleHint: cycle, rightHint }))));
484
+ const hasTail = (rightHint) => Boolean(rightHint) || tailWidth > 0;
485
+ const rowWidth = (choice, gap) => leadWidth +
486
+ displayWidth(choice.modeLabel) +
487
+ displayWidth(choice.cycleHint) +
488
+ (hasTail(choice.rightHint) ? gap : 0) +
489
+ displayWidth(choice.rightHint) +
490
+ tailWidth;
491
+ const chosen = candidates.find((choice) => rowWidth(choice, FOOTER_HINT_MIN_GAP) <= contentWidth) ?? { modeLabel: modeLabelFallback, cycleHint: '', rightHint: '' };
492
+ const gap = hasTail(chosen.rightHint)
493
+ ? Math.max(FOOTER_HINT_MIN_GAP, contentWidth - rowWidth(chosen, 0))
494
+ : 0;
495
+ return { ...chosen, gap };
496
+ }
497
+ function composerFooterLines(state, contentWidth) {
376
498
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
377
- const transientStatus = footerTransientStatus(state.status);
499
+ const transientStatus = footerTransientStatus(state.transientStatus ?? null);
378
500
  const lines = [
379
501
  line(span('TheGitAI', { color: 'cyan', bold: true }), span(' • ', { color: 'gray', dim: true }), span(formatPromptDirectoryLabel(state.projectRoot), { color: 'cyan' }), ...(transientStatus
380
502
  ? [
@@ -392,11 +514,6 @@ function composerFooterLines(state) {
392
514
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
393
515
  : []), ...backgroundJobIndicatorSpans(state), ...todoIndicatorSpans(state)),
394
516
  plainLine(''),
395
- plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
396
- color: 'cyan',
397
- dim: true,
398
- }),
399
- plainLine(''),
400
517
  ];
401
518
  if (state.exitConfirmUntil != null) {
402
519
  lines.push(plainLine(state.status || 'Press Ctrl+C again to quit.', {
@@ -405,28 +522,36 @@ function composerFooterLines(state) {
405
522
  }));
406
523
  return lines;
407
524
  }
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';
417
- const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
418
525
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
419
- const footerSpans = [
420
- span(helperText, { color: 'gray', dim: true }),
421
- span(' '.repeat(Math.max(1, 8)), { color: 'gray', dim: true }),
422
- span(agentLabel, {
423
- color: state.agentMode === 'plan' ? 'yellow' : 'cyan',
424
- }),
425
- span(tokenUsageText, { color: 'cyan' }),
426
- ];
427
- lines.push(line(...footerSpans));
526
+ lines.push(composerFooterModeLine(state, contentWidth, formatModelLabel(state.currentModelId, state.serverModels), tokenUsageText
527
+ ? displayWidth(tokenUsageText) + FOOTER_USAGE_SEPARATOR.length
528
+ : 0, tokenUsageText
529
+ ? [
530
+ span(FOOTER_USAGE_SEPARATOR, mutedStyle()),
531
+ span(tokenUsageText, { color: 'cyan' }),
532
+ ]
533
+ : []));
428
534
  return lines;
429
535
  }
536
+ function composerFooterModeLine(state, contentWidth, modelLabel, usageWidth, usageSpans) {
537
+ const lead = modelLabel ? `${modelLabel}${FOOTER_MODEL_SEPARATOR}` : '';
538
+ const row = fitComposerFooterRow({
539
+ modeLabels: agentModeLabelTiers(state.agentMode),
540
+ modeLabelFallback: agentModeShortLabel(state.agentMode),
541
+ cycleHint: state.busy ? '' : FOOTER_CYCLE_HINT,
542
+ rightHints: composerFooterRightHints(state),
543
+ leadWidth: displayWidth(lead),
544
+ tailWidth: usageWidth,
545
+ contentWidth,
546
+ });
547
+ const footerMuted = mutedStyle();
548
+ return line(...(lead ? [span(lead, { color: 'cyan', dim: true })] : []), span(row.modeLabel, {
549
+ color: agentModeColor(state.agentMode),
550
+ bold: state.agentMode === 'auto-accept',
551
+ }), ...(row.cycleHint ? [span(row.cycleHint, footerMuted)] : []), ...(row.gap ? [span(' '.repeat(row.gap), footerMuted)] : []), ...(row.rightHint ? [span(row.rightHint, footerMuted)] : []), ...usageSpans);
552
+ }
553
+ const FOOTER_USAGE_SEPARATOR = ' ';
554
+ const FOOTER_MODEL_SEPARATOR = ' ';
430
555
  export function formatJobElapsed(elapsedMs) {
431
556
  const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
432
557
  if (totalSeconds < 60)
@@ -468,15 +593,29 @@ function jobStatusDescriptor(job, nowMs) {
468
593
  ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
469
594
  : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
470
595
  }
471
- function buildJobsPickerLines(state, width, nowMs) {
596
+ const JOBS_PICKER_CHROME_ROWS = 2;
597
+ const JOBS_PICKER_MIN_ROWS = 8;
598
+ function buildJobsPickerLines(state, width, height, nowMs) {
472
599
  const jobs = state.backgroundJobs ?? [];
600
+ const bodyBudget = Math.max(1, height - JOBS_PICKER_CHROME_ROWS);
473
601
  const lines = [
474
602
  plainLine('Background jobs', { color: 'cyan', bold: true }),
475
603
  ];
476
604
  if (jobs.length === 0) {
477
605
  lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
478
606
  }
479
- for (const [index, job] of jobs.entries()) {
607
+ let start = 0;
608
+ let visible = jobs;
609
+ if (jobs.length > bodyBudget) {
610
+ start = Math.min(Math.max(0, state.jobsPickerIndex - Math.floor(bodyBudget / 2)), jobs.length - bodyBudget);
611
+ visible = jobs.slice(start, start + bodyBudget);
612
+ if (start > 0) {
613
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
614
+ }
615
+ }
616
+ const detailBudget = Math.max(0, bodyBudget - visible.length - (start > 0 ? 1 : 0));
617
+ for (const [offset, job] of visible.entries()) {
618
+ const index = start + offset;
480
619
  const selected = index === state.jobsPickerIndex;
481
620
  const expanded = state.jobsPickerExpandedId === job.id;
482
621
  const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
@@ -485,10 +624,11 @@ function buildJobsPickerLines(state, width, nowMs) {
485
624
  bold: selected,
486
625
  }), span(` ${text}`, { color: 'gray' })));
487
626
  if (expanded) {
488
- const detailLines = (job.detailLines ?? []).length
627
+ const allDetail = (job.detailLines ?? []).length
489
628
  ? job.detailLines ?? []
490
629
  : backgroundJobPreviewLines(job, 3);
491
- if (detailLines.length === 0) {
630
+ const detailLines = allDetail.slice(-detailBudget);
631
+ if (detailBudget > 0 && detailLines.length === 0) {
492
632
  lines.push(plainLine(' (no output captured)', { color: 'gray' }));
493
633
  }
494
634
  else {
@@ -501,22 +641,71 @@ function buildJobsPickerLines(state, width, nowMs) {
501
641
  }
502
642
  }
503
643
  }
644
+ const remaining = jobs.length - (start + visible.length);
645
+ if (remaining > 0) {
646
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
647
+ }
504
648
  lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
505
649
  color: 'gray',
506
650
  }));
507
651
  return lines;
508
652
  }
653
+ export const THINKING_FALLBACK_PHRASES = [
654
+ 'Working out where to start...',
655
+ 'Deciding what comes first...',
656
+ 'Lining up the pieces...',
657
+ 'Untangling the details...',
658
+ 'Deciding what not to touch...',
659
+ 'Checking what this would break...',
660
+ 'Choosing the smaller change...',
661
+ 'Picking the least clever option...',
662
+ 'Resisting the obvious answer...',
663
+ 'Trying the boring explanation first...',
664
+ 'Checking whether the assumption holds...',
665
+ 'Asking what would have to be true...',
666
+ 'Reading it the way the machine would...',
667
+ 'Testing the story against the code...',
668
+ 'Working out what actually changed...',
669
+ 'Finding the smallest thing that explains it...',
670
+ 'Looking for the part that is not settled yet...',
671
+ 'Making sure this is the simple version...',
672
+ ];
673
+ export function pickThinkingFallbackPhrase() {
674
+ const index = Math.floor(Math.random() * THINKING_FALLBACK_PHRASES.length);
675
+ return THINKING_FALLBACK_PHRASES[index];
676
+ }
677
+ function withProgressEllipsis(title) {
678
+ const text = title.trim();
679
+ if (!text)
680
+ return text;
681
+ return /(?:\.\.\.|…)$/.test(text) ? text : `${text}...`;
682
+ }
509
683
  function thinkingHeaderLine(spinnerFrame, title, width) {
510
684
  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))
685
+ const decorated = withProgressEllipsis(title);
686
+ const fittedTitle = decorated
687
+ ? fitLine(decorated, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
513
688
  : '';
514
689
  return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
515
690
  }
516
691
  function thinkingNoteLine(note, width) {
517
692
  return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
518
693
  }
519
- function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
694
+ function buildRecoveryStatusLines(status, width) {
695
+ const [label = '', ...rest] = status.split(' · ');
696
+ const detail = rest.join(' · ');
697
+ const labelSpan = span(fitLine(label, Math.max(1, width - 2)), { color: 'yellow', bold: true });
698
+ if (!detail)
699
+ return [line(span('● ', { color: 'yellow' }), labelSpan)];
700
+ if (displayWidth(`● ${label} · ${detail}`) <= width) {
701
+ return [line(span('● ', { color: 'yellow' }), labelSpan, span(` · ${detail}`))];
702
+ }
703
+ return [
704
+ line(span('● ', { color: 'yellow' }), labelSpan),
705
+ ...wrapText(detail, Math.max(1, width - 2)).map((row) => plainLine(` ${row}`)),
706
+ ];
707
+ }
708
+ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds, nowMs) {
520
709
  if (!state.busy)
521
710
  return [];
522
711
  const lines = [plainLine('')];
@@ -546,11 +735,22 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
546
735
  }
547
736
  lines.push(plainLine(''));
548
737
  }
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' }));
738
+ lines.push(plainLine(buildWorkingClockLine(state, elapsedSeconds), { color: 'yellow' }));
739
+ if (state.recoveryStatus) {
740
+ lines.push(...buildRecoveryStatusLines(state.recoveryStatus, width));
741
+ }
742
+ const turnMessages = state.turnMessages ?? [];
743
+ if (turnMessages.length > 0) {
744
+ lines.push(plainLine(''));
745
+ lines.push(...buildTurnMessageLines(turnMessages, width, todoProgressTick(nowMs)));
746
+ }
550
747
  const todos = state.todos ?? [];
551
748
  if (todos.length > 0) {
552
749
  lines.push(plainLine(''));
553
- lines.push(...renderTodoListLines(todos, width, { header: true }));
750
+ lines.push(...renderTodoListLines(todos, width, {
751
+ header: true,
752
+ progressTick: todoProgressTick(nowMs),
753
+ }));
554
754
  }
555
755
  const visibleTitle = state.thinkingTitle.trim();
556
756
  const visibleNotes = state.thinkingNotes.filter(Boolean);
@@ -560,11 +760,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
560
760
  const minimalText = visibleTitle && visibleTitle !== 'Thinking'
561
761
  ? visibleTitle
562
762
  : (visibleNotes[visibleNotes.length - 1] ?? '');
563
- lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
763
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText || THINKING_FALLBACK_PHRASES[0], width));
564
764
  }
565
765
  else {
566
- lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
567
- for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
766
+ const headerTitle = visibleTitle && visibleTitle !== 'Thinking' ? visibleTitle : '';
767
+ lines.push(thinkingHeaderLine(spinnerFrame, headerTitle || THINKING_FALLBACK_PHRASES[0], width));
768
+ for (const note of visibleNotes.slice(0, THINKING_NOTE_PREVIEW_ROWS)) {
568
769
  lines.push(thinkingNoteLine(note, width));
569
770
  }
570
771
  }
@@ -579,10 +780,24 @@ function overlayPanelLine(row, width, color) {
579
780
  const padding = Math.max(0, width - lineCharCount(row));
580
781
  return line(span('│ ', { color }), ...row.spans, span(' '.repeat(padding)), span(' │', { color }));
581
782
  }
582
- function buildOverlayPanel(rows, width, color) {
583
- const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
783
+ function overlayPanelMarginLineCount(height) {
784
+ return height < OVERLAY_PANEL_MARGIN_MIN_ROWS
785
+ ? 0
786
+ : OVERLAY_PANEL_MARGIN_LINES;
787
+ }
788
+ function overlayPanelContentBudget(height) {
789
+ if (!Number.isFinite(height)) {
790
+ return Number.POSITIVE_INFINITY;
791
+ }
792
+ const margins = overlayPanelMarginLineCount(height) * 2;
793
+ return Math.max(1, Math.floor(height) - margins - 2);
794
+ }
795
+ function buildOverlayPanel(rows, width, color, height = Number.POSITIVE_INFINITY, maxWidth = OVERLAY_PANEL_MAX_WIDTH) {
796
+ const panelWidth = Math.max(24, Math.min(width, maxWidth));
584
797
  const innerWidth = Math.max(1, panelWidth - 4);
585
- const margin = Array.from({ length: OVERLAY_PANEL_MARGIN_LINES }, () => plainLine(''));
798
+ const margin = Array.from({
799
+ length: overlayPanelMarginLineCount(height),
800
+ }, () => plainLine(''));
586
801
  return [
587
802
  ...margin,
588
803
  plainLine(`╭${'─'.repeat(panelWidth - 2)}╮`, { color }),
@@ -603,7 +818,6 @@ function modelPickerPanelSideLine(content, innerWidth) {
603
818
  }
604
819
  const MODEL_PICKER_COST_WIDTH = 6;
605
820
  const MODEL_PICKER_MODEL_WIDTH = 42;
606
- const MODEL_PICKER_NUMBER_WIDTH = 4;
607
821
  const MODEL_PICKER_SEPARATOR = ' │ ';
608
822
  const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
609
823
  function modelPickerTopBorder(panelWidth) {
@@ -630,7 +844,6 @@ function modelPickerCostText(rating) {
630
844
  }
631
845
  function modelPickerNotesWidth(innerWidth) {
632
846
  return Math.max(18, innerWidth -
633
- MODEL_PICKER_NUMBER_WIDTH -
634
847
  MODEL_PICKER_MODEL_WIDTH -
635
848
  MODEL_PICKER_COST_WIDTH -
636
849
  MODEL_PICKER_SEPARATOR.length * 2);
@@ -653,12 +866,10 @@ function modelPickerItemLines(option, selected, innerWidth) {
653
866
  : selected
654
867
  ? { color: 'cyan', bold: true }
655
868
  : {};
656
- const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
657
869
  const cost = modelPickerCostText(option.costRating);
658
870
  if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
659
- const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
871
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_COST_WIDTH - 2);
660
872
  const row = [
661
- numberCell,
662
873
  ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
663
874
  span(' ', selectedStyle),
664
875
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -668,7 +879,6 @@ function modelPickerItemLines(option, selected, innerWidth) {
668
879
  ];
669
880
  }
670
881
  const row = [
671
- numberCell,
672
882
  ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
673
883
  span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
674
884
  modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
@@ -685,9 +895,9 @@ function modelPickerItemLines(option, selected, innerWidth) {
685
895
  function modelPickerHeaderLine(innerWidth) {
686
896
  const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
687
897
  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));
898
+ return line(modelPickerCell('Model', Math.max(1, innerWidth), heading));
689
899
  }
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));
900
+ 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
901
  }
692
902
  function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
693
903
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
@@ -715,6 +925,71 @@ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
715
925
  body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
716
926
  return [...margin, ...body, ...margin];
717
927
  }
928
+ const SHORTCUTS_PANEL_MAX_WIDTH = 96;
929
+ const SHORTCUTS_KEY_COLUMN_MIN = 10;
930
+ function shortcutsTopBorder(panelWidth) {
931
+ const fullTitle = ' TheGitAI - Keyboard & mouse ';
932
+ const compactTitle = ' Shortcuts ';
933
+ const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
934
+ const available = Math.max(0, panelWidth - 2 - [...title].length);
935
+ const left = Math.floor(available / 2);
936
+ const right = available - left;
937
+ 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 }));
938
+ }
939
+ export function buildShortcutsPanel(groups, width, availableHeight) {
940
+ const panelWidth = Math.max(28, Math.min(width, SHORTCUTS_PANEL_MAX_WIDTH));
941
+ const innerWidth = Math.max(1, panelWidth - 4);
942
+ const keyWidth = Math.max(SHORTCUTS_KEY_COLUMN_MIN, ...groups.flatMap((group) => group.rows.map((row) => displayWidth(row.keys))));
943
+ const side = (row) => overlayPanelLine(row, innerWidth, MODEL_PICKER_BORDER_COLOR);
944
+ const groupLines = (group, spaced) => [
945
+ ...(spaced ? [side(plainLine(''))] : []),
946
+ side(plainLine(fitLine(group.title, innerWidth), {
947
+ color: MODEL_PICKER_ACCENT_COLOR,
948
+ bold: true,
949
+ })),
950
+ ...group.rows.map((row) => side(line(span(` ${row.keys}${' '.repeat(Math.max(0, keyWidth - displayWidth(row.keys)))} `, { bold: true }), span(fitLine(row.description, Math.max(1, innerWidth - keyWidth - 4)), { color: 'gray' })))),
951
+ ];
952
+ const chromeRows = 4;
953
+ const budget = Math.max(1, availableHeight - chromeRows);
954
+ const spacedBody = groups.flatMap((group, index) => groupLines(group, index > 0));
955
+ const plainBody = groups.flatMap((group) => groupLines(group, false));
956
+ let body = spacedBody;
957
+ let hidden = 0;
958
+ if (spacedBody.length > budget) {
959
+ body = plainBody;
960
+ if (plainBody.length > budget) {
961
+ body = [];
962
+ hidden = groups.reduce((total, group) => total + group.rows.length, 0);
963
+ for (const group of groups) {
964
+ const whole = groupLines(group, false);
965
+ if (body.length + whole.length <= budget) {
966
+ body = [...body, ...whole];
967
+ hidden -= group.rows.length;
968
+ continue;
969
+ }
970
+ const room = budget - body.length - 1;
971
+ if (room >= 1) {
972
+ const kept = group.rows.slice(0, room);
973
+ body = [...body, ...groupLines({ ...group, rows: kept }, false)];
974
+ hidden -= kept.length;
975
+ }
976
+ break;
977
+ }
978
+ }
979
+ }
980
+ const hint = hidden > 0 ? `esc close • ${hidden} more in /help` : 'esc close';
981
+ return [
982
+ shortcutsTopBorder(panelWidth),
983
+ ...body,
984
+ plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
985
+ color: MODEL_PICKER_BORDER_COLOR,
986
+ }),
987
+ side(plainLine(fitLine(hint, innerWidth), { color: 'gray' })),
988
+ plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, {
989
+ color: MODEL_PICKER_BORDER_COLOR,
990
+ }),
991
+ ];
992
+ }
718
993
  function commandPaletteTopBorder(panelWidth) {
719
994
  const prefix = '╭─ Commands ';
720
995
  const suffix = '╮';
@@ -760,7 +1035,7 @@ function commandPaletteItemLines(option, selected, innerWidth) {
760
1035
  modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.description}`, { color: 'gray' })), innerWidth),
761
1036
  ];
762
1037
  }
763
- function buildCommandPalettePanel(suggestions, selectedIndex, width) {
1038
+ function buildCommandPalettePanel(suggestions, selectedIndex, hiddenCount, width) {
764
1039
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
765
1040
  const innerWidth = Math.max(1, panelWidth - 4);
766
1041
  const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
@@ -779,13 +1054,117 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
779
1054
  body.push(commandPaletteSeparatorLine(innerWidth));
780
1055
  }
781
1056
  });
1057
+ if (hiddenCount > 0) {
1058
+ body.push(commandPaletteSeparatorLine(innerWidth), modelPickerPanelSideLine(plainLine(` ${hiddenCount} more • keep typing to narrow`, {
1059
+ color: 'gray',
1060
+ dim: true,
1061
+ }), innerWidth));
1062
+ }
782
1063
  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
1064
  return [...margin, ...body, ...margin];
784
1065
  }
1066
+ export function formatElapsedClock(elapsedSeconds) {
1067
+ if (elapsedSeconds < 60)
1068
+ return `${elapsedSeconds}s`;
1069
+ return `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`;
1070
+ }
1071
+ export function buildWorkingClockLine(state, elapsedSeconds) {
1072
+ const elapsed = formatElapsedClock(elapsedSeconds);
1073
+ if (state.busyPausedAt != null) {
1074
+ return `${WORKING_CLOCK_ICON} Paused · ${elapsed} · waiting for your response`;
1075
+ }
1076
+ const label = state.generatingImage
1077
+ ? 'Generating image'
1078
+ : state.analyzingImages > 0
1079
+ ? state.analyzingImages > 1
1080
+ ? 'Analyzing images'
1081
+ : 'Analyzing image'
1082
+ : 'Working';
1083
+ return `${WORKING_CLOCK_ICON} ${label} · ${elapsed}`;
1084
+ }
1085
+ export function approvalPanelInnerWidth(width) {
1086
+ return Math.max(1, Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH)) - 4);
1087
+ }
1088
+ export function userInputPanelInnerWidth(width) {
1089
+ return Math.max(1, Math.max(24, Math.min(width, USER_INPUT_PANEL_MAX_WIDTH)) - 4);
1090
+ }
1091
+ export function approvalPaddingEnabled(height) {
1092
+ return height >= APPROVAL_PADDING_MIN_ROWS;
1093
+ }
1094
+ export function approvalPreviewBudget(height) {
1095
+ const chrome = APPROVAL_OVERLAY_CHROME_ROWS +
1096
+ (approvalPaddingEnabled(height) ? APPROVAL_PADDING_CHROME_ROWS : 0);
1097
+ return Math.max(APPROVAL_PREVIEW_MIN_ROWS, Math.min(APPROVAL_PREVIEW_MAX_ROWS, Math.floor(height / 2) - chrome));
1098
+ }
1099
+ export function approvalPreviewRows(prompt, innerWidth) {
1100
+ const previewWidth = Math.max(8, innerWidth - APPROVAL_SCROLLBAR_COLUMNS);
1101
+ if (prompt.diffPreview) {
1102
+ return renderDiffPreviewLines(prompt.diffPreview, previewWidth, prompt.diffPreview.lines.length);
1103
+ }
1104
+ return wrapText(prompt.body, previewWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR }));
1105
+ }
1106
+ export function approvalScrollLimit(prompt, width, height) {
1107
+ if (!prompt)
1108
+ return 0;
1109
+ return Math.max(0, approvalPreviewRows(prompt, approvalPanelInnerWidth(width)).length -
1110
+ approvalPreviewBudget(height));
1111
+ }
1112
+ function approvalScrollbarGlyphs(totalRows, visibleRows, offset) {
1113
+ const thumbRows = Math.max(1, Math.min(visibleRows, Math.round((visibleRows * visibleRows) / totalRows)));
1114
+ const maxOffset = totalRows - visibleRows;
1115
+ const thumbTop = maxOffset <= 0
1116
+ ? 0
1117
+ : Math.round((offset / maxOffset) * (visibleRows - thumbRows));
1118
+ return Array.from({ length: visibleRows }, (_, index) => index >= thumbTop && index < thumbTop + thumbRows
1119
+ ? APPROVAL_SCROLLBAR_THUMB
1120
+ : APPROVAL_SCROLLBAR_TRACK);
1121
+ }
1122
+ function withScrollbarGlyph(target, glyph, column) {
1123
+ const padding = Math.max(1, column - lineCharCount(target));
1124
+ return {
1125
+ spans: [
1126
+ ...target.spans,
1127
+ span(' '.repeat(padding)),
1128
+ span(glyph, {
1129
+ color: 'gray',
1130
+ dim: glyph === APPROVAL_SCROLLBAR_TRACK,
1131
+ }),
1132
+ ],
1133
+ };
1134
+ }
1135
+ export function buildApprovalPreviewWindow(prompt, innerWidth, height, requestedOffset) {
1136
+ const rows = approvalPreviewRows(prompt, innerWidth);
1137
+ const budget = approvalPreviewBudget(height);
1138
+ if (rows.length <= budget) {
1139
+ return {
1140
+ firstVisibleRow: rows.length === 0 ? 0 : 1,
1141
+ lastVisibleRow: rows.length,
1142
+ lines: rows,
1143
+ offset: 0,
1144
+ totalRows: rows.length,
1145
+ };
1146
+ }
1147
+ const offset = Math.max(0, Math.min(requestedOffset, rows.length - budget));
1148
+ const visible = rows.slice(offset, offset + budget);
1149
+ const glyphs = approvalScrollbarGlyphs(rows.length, budget, offset);
1150
+ return {
1151
+ firstVisibleRow: offset + 1,
1152
+ lastVisibleRow: offset + visible.length,
1153
+ lines: visible.map((target, index) => withScrollbarGlyph(target, glyphs[index], innerWidth - 1)),
1154
+ offset,
1155
+ totalRows: rows.length,
1156
+ };
1157
+ }
1158
+ export function approvalScrollStatusLine(window) {
1159
+ return `lines ${window.firstVisibleRow}–${window.lastVisibleRow} of ${window.totalRows} · PgUp/PgDn scrolls`;
1160
+ }
1161
+ export function rightAlignedLine(text, width, style = {}) {
1162
+ return plainLine(`${' '.repeat(Math.max(0, width - displayWidth(text)))}${text}`, style);
1163
+ }
785
1164
  function buildOverlayLines(state, width, height, nowMs) {
786
1165
  const lines = [];
787
1166
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
788
- const innerWidth = Math.max(1, panelWidth - 4);
1167
+ const innerWidth = approvalPanelInnerWidth(width);
789
1168
  if (state.sudoPrompt) {
790
1169
  const prompt = state.sudoPrompt;
791
1170
  const passwordWidth = Math.max(0, innerWidth - 11);
@@ -805,75 +1184,120 @@ function buildOverlayLines(state, width, height, nowMs) {
805
1184
  : line(span(' '), span(text, { color: OVERLAY_BORDER_COLOR })));
806
1185
  });
807
1186
  lines.push(plainLine(''));
808
- if (prompt.prompt.trim()) {
809
- lines.push(plainLine(prompt.prompt.trim(), { color: 'gray' }));
810
- }
1187
+ lines.push(...wrapText(SUDO_PASSWORD_ASSURANCE, innerWidth).map((text) => plainLine(text, { color: SUDO_ASSURANCE_COLOR })));
1188
+ lines.push(plainLine(''));
811
1189
  lines.push(line(span('Password: ', { color: 'cyan', bold: true }), span('•'.repeat(Math.min(prompt.passwordLength, passwordWidth)), {
812
1190
  color: 'cyan',
813
1191
  }), span(' ', { inverse: true })));
814
1192
  lines.push(plainLine('Press Enter to submit, Escape to cancel', { color: 'gray' }));
815
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1193
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1194
+ }
1195
+ if (state.userInputPrompt) {
1196
+ if (height < 3) {
1197
+ return [];
1198
+ }
1199
+ return buildOverlayPanel(buildUserInputOverlayLines(state.userInputPrompt, userInputPanelInnerWidth(width), overlayPanelContentBudget(height)), width, USER_INPUT_BORDER_COLOR, height, USER_INPUT_PANEL_MAX_WIDTH);
816
1200
  }
817
1201
  if (state.approvalPrompt) {
818
1202
  const prompt = state.approvalPrompt;
1203
+ const padded = approvalPaddingEnabled(height);
819
1204
  lines.push(plainLine(prompt.title, {
820
- color: OVERLAY_BORDER_COLOR,
1205
+ color: APPROVAL_ACCENT_COLOR,
821
1206
  bold: true,
822
1207
  }));
1208
+ if (padded)
1209
+ lines.push(plainLine(''));
823
1210
  if (prompt.diffPreview && prompt.filePath) {
824
1211
  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
1212
  }
832
- else {
833
- lines.push(...wrapText(prompt.body, innerWidth).map((text) => plainLine(text, { color: OVERLAY_BORDER_COLOR })));
1213
+ const previewWindow = buildApprovalPreviewWindow(prompt, innerWidth, height, state.approvalScrollOffset ?? 0);
1214
+ lines.push(...previewWindow.lines);
1215
+ if (padded)
1216
+ lines.push(plainLine(''));
1217
+ if (previewWindow.totalRows > previewWindow.lines.length) {
1218
+ lines.push(rightAlignedLine(approvalScrollStatusLine(previewWindow), innerWidth, {
1219
+ color: APPROVAL_SCROLL_STATUS_COLOR,
1220
+ }));
1221
+ if (padded)
1222
+ lines.push(plainLine(''));
834
1223
  }
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) => {
1224
+ const options = (prompt.options ?? []).map((option) => option.label);
1225
+ options.forEach((label, index) => {
841
1226
  const selected = index === state.approvalCursor;
842
1227
  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,
1228
+ color: selected ? APPROVAL_ACCENT_COLOR : 'gray',
1229
+ }), span(label, {
1230
+ color: selected ? APPROVAL_ACCENT_COLOR : undefined,
1231
+ bold: selected,
849
1232
  })));
850
1233
  });
851
- lines.push(plainLine('Press y, a, or n • ↑/↓ moves • Enter confirms', {
1234
+ if (padded)
1235
+ lines.push(plainLine(''));
1236
+ lines.push(plainLine('↑/↓ moves • Enter confirms • Esc denies', {
852
1237
  color: 'gray',
853
1238
  }));
854
- return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR);
1239
+ return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1240
+ }
1241
+ if (state.shortcutsOpen) {
1242
+ lines.push(...buildShortcutsPanel(buildShortcutGroups(), width, height));
855
1243
  }
856
1244
  if (state.modelPickerOpen) {
857
1245
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
858
1246
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
859
1247
  }
860
1248
  if (state.jobsPickerOpen) {
861
- lines.push(...buildJobsPickerLines(state, width, nowMs));
1249
+ lines.push(...buildJobsPickerLines(state, width, height, nowMs));
862
1250
  }
863
1251
  if (state.resumePickerOpen) {
864
1252
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
1253
+ const pickerWidth = Math.max(20, width - 2);
1254
+ const divider = () => plainLine('─'.repeat(pickerWidth), { color: 'gray', dim: true });
865
1255
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
866
1256
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
867
- for (const [index, session] of filtered.entries()) {
1257
+ lines.push(divider());
1258
+ const maxCards = Math.max(2, Math.min(filtered.length, Math.floor((height - 10) / 3)));
1259
+ let start = 0;
1260
+ if (filtered.length > maxCards) {
1261
+ start = Math.min(Math.max(0, state.resumePickerIndex - Math.floor(maxCards / 2)), filtered.length - maxCards);
1262
+ }
1263
+ const visible = filtered.slice(start, start + maxCards);
1264
+ if (start > 0) {
1265
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
1266
+ }
1267
+ for (const [offset, session] of visible.entries()) {
1268
+ const index = start + offset;
868
1269
  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 }));
1270
+ const prompt = singleLinePreview(session.lastUserMessage, pickerWidth) ||
1271
+ session.name ||
1272
+ `Session ${session.id}`;
1273
+ lines.push(line(span(selected ? '› ' : ' ', { color: 'cyan' }), span(fitLine(prompt, Math.max(10, pickerWidth - 2)), selected
1274
+ ? { color: 'cyan', bold: true }
1275
+ : session.lastUserMessage
1276
+ ? {}
1277
+ : { color: 'gray', dim: true })));
1278
+ const meta = [
1279
+ formatRelativeTime(session.updatedAt),
1280
+ pickerModelLabel(session.modelId, state.serverModels),
1281
+ session.branch ?? null,
1282
+ ]
1283
+ .filter(Boolean)
1284
+ .join(' · ');
1285
+ lines.push(line(span(' '), span(fitLine(meta, Math.max(10, pickerWidth - 4)), {
1286
+ color: selected ? 'cyan' : 'gray',
1287
+ dim: !selected,
1288
+ })));
1289
+ if (offset < visible.length - 1)
1290
+ lines.push(plainLine(''));
1291
+ }
1292
+ const remaining = filtered.length - (start + visible.length);
1293
+ if (remaining > 0) {
1294
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
872
1295
  }
873
1296
  if (filtered.length === 0) {
874
1297
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
875
1298
  }
876
- lines.push(plainLine('↑/↓ move enter resume esc start new ctrl+c quit', {
1299
+ lines.push(divider());
1300
+ lines.push(plainLine('↑/↓ move · enter resume · esc start new · ctrl+c quit', {
877
1301
  color: 'gray',
878
1302
  }));
879
1303
  }
@@ -885,13 +1309,22 @@ function buildOverlayLines(state, width, height, nowMs) {
885
1309
  state.input.trimStart().startsWith('/') &&
886
1310
  !state.input.includes(' ') &&
887
1311
  suggestions.length > 0) {
888
- lines.push(...buildCommandPalettePanel(suggestions, state.commandCursor, width));
1312
+ lines.push(...buildCommandPalettePanel(suggestions, state.commandCursor, getHiddenSlashCommandCount(state.input), width));
889
1313
  }
890
1314
  return lines;
891
1315
  }
892
1316
  function countSectionLines(sections) {
893
1317
  return sections.reduce((sum, section) => sum + section.lines.length, 0);
894
1318
  }
1319
+ export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1320
+ const contentWidth = frameContentWidth(cols);
1321
+ const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1322
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state, contentWidth).length);
1323
+ return {
1324
+ width: userInputPanelInnerWidth(contentWidth),
1325
+ maxRows: overlayPanelContentBudget(overlayHeight),
1326
+ };
1327
+ }
895
1328
  function sliceTranscriptLines(lines, maxLines, scrollOffset) {
896
1329
  if (maxLines <= 0 || lines.length <= maxLines) {
897
1330
  return lines;
@@ -902,33 +1335,43 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
902
1335
  return lines.slice(start, start + maxLines);
903
1336
  }
904
1337
  export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
905
- const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
1338
+ const contentWidth = frameContentWidth(cols);
906
1339
  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
- });
1340
+ const buildTranscriptLines = (wrapWidth) => {
1341
+ const blocks = state.transcript.map((entry) => cachedTranscriptEntryLines(entry, wrapWidth));
1342
+ const lines = [];
1343
+ blocks.forEach((block, index) => {
1344
+ if (index > 0) {
1345
+ lines.push(plainLine(''));
1346
+ }
1347
+ lines.push(...block);
1348
+ });
1349
+ return lines;
1350
+ };
1351
+ let transcriptLines = buildTranscriptLines(contentWidth);
915
1352
  const sections = [];
916
- const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds);
1353
+ const liveLines = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs);
917
1354
  if (liveLines.length > 0) {
918
1355
  sections.push({ kind: 'live', lines: liveLines });
919
1356
  }
920
- const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
921
- if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
1357
+ const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
1358
+ if (!state.resumePickerOpen &&
1359
+ !state.modelPickerOpen &&
1360
+ !state.jobsPickerOpen &&
1361
+ !state.shortcutsOpen &&
1362
+ !overlayActive) {
922
1363
  const composerLines = [];
923
- if (state.queuedMessage) {
924
- const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
1364
+ if (state.busy && state.status === 'Starting a new conversation...') {
1365
+ composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
1366
+ }
1367
+ else if (state.queuedMessage) {
925
1368
  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 })));
1369
+ composerLines.push(line(span('Queued', { color: 'cyan', bold: true }), span(' Enter', { color: 'cyan', bold: true }), span(imageCount > 0
1370
+ ? ' sends it with the next prompt'
1371
+ : ' 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
1372
  }
930
1373
  else {
931
- const promptLabel = state.busy ? 'queue> ' : '❯ ';
1374
+ const promptLabel = composerPromptLabel(state);
932
1375
  const placeholder = state.busy
933
1376
  ? 'Type to queue the next message'
934
1377
  : 'Type a request or /help';
@@ -936,17 +1379,37 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
936
1379
  }
937
1380
  sections.push({
938
1381
  kind: 'composer',
939
- lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
1382
+ lines: [...composerLines, plainLine(''), ...composerFooterLines(state, contentWidth)],
940
1383
  });
941
1384
  }
942
- const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
1385
+ if (state.userInputPrompt) {
1386
+ sections.push({
1387
+ kind: 'busyFooter',
1388
+ lines: composerFooterLines(state, contentWidth),
1389
+ });
1390
+ }
1391
+ const overlayHeight = state.userInputPrompt
1392
+ ? Math.max(0, rows - countSectionLines(sections))
1393
+ : state.jobsPickerOpen || state.shortcutsOpen
1394
+ ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1395
+ : rows;
1396
+ const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
943
1397
  if (overlayLines.length > 0) {
944
1398
  sections.push({ kind: 'overlay', lines: overlayLines });
945
1399
  }
946
1400
  const reservedLines = countSectionLines(sections.filter((section) => section.kind !== 'transcript'));
947
- const composerReserve = state.resumePickerOpen ? 0 : 4;
1401
+ const composerReserve = state.resumePickerOpen ||
1402
+ state.approvalPrompt ||
1403
+ state.sudoPrompt ||
1404
+ state.userInputPrompt
1405
+ ? 0
1406
+ : 4;
948
1407
  const transcriptBudget = Math.max(1, rows - reservedLines - composerReserve - 1);
949
- const transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1408
+ let transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1409
+ if (transcriptScrollLimit > 0 && contentWidth > 2) {
1410
+ transcriptLines = buildTranscriptLines(contentWidth - 2);
1411
+ transcriptScrollLimit = Math.max(0, transcriptLines.length - transcriptBudget);
1412
+ }
950
1413
  const transcriptScrollOffset = Math.min(Math.max(state.transcriptScrollOffset, 0), transcriptScrollLimit);
951
1414
  sections.unshift({
952
1415
  kind: 'transcript',