@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.10

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 (64) hide show
  1. package/README.md +49 -3
  2. package/dist/bin/ai.js +83 -197
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/agent-mode.js +5 -0
  5. package/dist/src/api/auth.js +4 -4
  6. package/dist/src/api/browser-login.js +72 -19
  7. package/dist/src/api/chat.js +182 -35
  8. package/dist/src/api/http.js +65 -4
  9. package/dist/src/api/models.js +33 -22
  10. package/dist/src/artifact-policy.js +3 -0
  11. package/dist/src/background-jobs.js +410 -0
  12. package/dist/src/cli-args.js +0 -5
  13. package/dist/src/client-environment.js +2 -0
  14. package/dist/src/colors.js +50 -0
  15. package/dist/src/core/clipboard.js +19 -0
  16. package/dist/src/core/image-path-extractor.js +144 -0
  17. package/dist/src/executor.js +48 -12
  18. package/dist/src/help-text.js +30 -13
  19. package/dist/src/patcher.js +97 -12
  20. package/dist/src/project-index.js +13 -1
  21. package/dist/src/project-orientation.js +99 -0
  22. package/dist/src/scanner.js +50 -12
  23. package/dist/src/scratch-dir.js +75 -0
  24. package/dist/src/secret-preview.js +0 -10
  25. package/dist/src/session-safety.js +0 -19
  26. package/dist/src/session-store.js +52 -21
  27. package/dist/src/session.js +8 -0
  28. package/dist/src/todo-list.js +106 -0
  29. package/dist/src/tool-executor.js +194 -21
  30. package/dist/src/tools/delete-file.js +23 -5
  31. package/dist/src/tools/index.js +6 -0
  32. package/dist/src/tools/patch-file.js +33 -7
  33. package/dist/src/tools/path-suggest.js +81 -8
  34. package/dist/src/tools/read-document.js +2 -2
  35. package/dist/src/tools/read-file.js +17 -8
  36. package/dist/src/tools/replace-document-text.js +10 -12
  37. package/dist/src/tools/restore-checkpoint.js +1 -1
  38. package/dist/src/tools/run-command.js +109 -24
  39. package/dist/src/tools/run-node-script.js +27 -5
  40. package/dist/src/tools/shell-job-kill.js +48 -0
  41. package/dist/src/tools/shell-job-output.js +51 -0
  42. package/dist/src/tools/str-replace.js +33 -7
  43. package/dist/src/tools/undo-edit.js +1 -1
  44. package/dist/src/tools/update-todos.js +27 -0
  45. package/dist/src/tools/write-file.js +26 -6
  46. package/dist/src/tree-sitter-runtime.js +8 -1
  47. package/dist/src/turn-failure-marker.js +11 -0
  48. package/dist/src/ui/prompt-history-store.js +1 -1
  49. package/dist/src/ui/repl.js +500 -71
  50. package/dist/src/ui/tui/bridge.js +3 -4
  51. package/dist/src/ui/tui/build-frame.js +393 -100
  52. package/dist/src/ui/tui/markdown-render.js +72 -73
  53. package/dist/src/ui/tui/shell-input.js +75 -17
  54. package/dist/src/ui/tui/terminal-title.js +84 -0
  55. package/dist/src/ui/tui/terminal-writes.js +48 -0
  56. package/dist/src/ui/tui/text.js +158 -4
  57. package/dist/src/utils.js +9 -0
  58. package/dist/src/version.js +0 -6
  59. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  60. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  61. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  62. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  63. package/package.json +27 -16
  64. package/dist/src/markdown-renderer.js +0 -112
@@ -1,10 +1,12 @@
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, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
6
  const WORKING_CLOCK_ICON = '◷';
7
7
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
8
+ const TODO_PANEL_MAX_ROWS = 12;
9
+ const TODO_IN_PROGRESS_COLOR = 'ansi256(214)';
8
10
  const COMMAND_PREVIEW_LINES = 10;
9
11
  const WORKING_TOOL_PREVIEW_ROWS = 3;
10
12
  const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
@@ -15,9 +17,9 @@ const OVERLAY_PANEL_MAX_WIDTH = 86;
15
17
  const OVERLAY_PANEL_MARGIN_LINES = 2;
16
18
  const OVERLAY_BORDER_COLOR = 'yellow';
17
19
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
18
- const MODEL_PICKER_PANEL_MAX_WIDTH = 86;
20
+ const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
19
21
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
20
- const MODEL_PICKER_BORDER_COLOR = 'gray';
22
+ const MODEL_PICKER_BORDER_COLOR = 'cyan';
21
23
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
22
24
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
23
25
  const MODEL_PICKER_META_INDENT = ' ';
@@ -131,6 +133,15 @@ function diffLinePrefix(kind) {
131
133
  return ' ';
132
134
  }
133
135
  }
136
+ export function fitLine(content, maxWidth) {
137
+ if (maxWidth <= 0)
138
+ return '';
139
+ if (displayWidth(content) <= maxWidth)
140
+ return content;
141
+ if (maxWidth === 1)
142
+ return '…';
143
+ return `${sliceToWidth(content, maxWidth - 1)}…`;
144
+ }
134
145
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
135
146
  const trimmed = String(projectRoot ?? '').trim();
136
147
  if (!trimmed)
@@ -151,21 +162,28 @@ const CLIENT_SLASH_COMMANDS = [
151
162
  { command: '/usage', description: 'Show account usage percentage and reset times' },
152
163
  { command: '/model', description: 'Switch the active model' },
153
164
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
154
- { command: '/clear', description: 'Clear conversation history' },
165
+ { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
166
+ { command: '/new', description: 'start a new conversation; this session remains saved' },
155
167
  { command: '/exit', description: 'Quit the current session' },
156
168
  ];
157
169
  function buildModelPickerOptions(currentModelId, serverModels) {
158
170
  return serverModels.map((model) => ({
159
171
  id: model.id,
160
172
  label: model.label,
161
- meta: model.id === currentModelId ? 'current' : '',
173
+ publicId: model.id,
174
+ costRating: model.costRating,
175
+ current: model.id === currentModelId,
162
176
  disabled: false,
177
+ note: model.description,
163
178
  }));
164
179
  }
165
180
  function getInputCommandToken(input) {
166
181
  const trimmed = String(input ?? '').trimStart();
167
182
  const match = trimmed.match(/^\/[^\s]*/);
168
- return match?.[0] ?? '';
183
+ const token = match?.[0] ?? '';
184
+ if (token.indexOf('/', 1) !== -1)
185
+ return '';
186
+ return token;
169
187
  }
170
188
  function scoreSlashCommand(option, token) {
171
189
  if (option.command === token)
@@ -194,6 +212,9 @@ export function getSlashCommandSuggestions(input) {
194
212
  function formatModelLabel(modelId, serverModels) {
195
213
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
196
214
  }
215
+ function pickerModelLabel(modelId, serverModels) {
216
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
217
+ }
197
218
  function filterResumeSessions(sessions, filter, serverModels) {
198
219
  const q = filter.trim().toLowerCase();
199
220
  if (!q)
@@ -217,6 +238,55 @@ function formatRelativeTime(isoDate) {
217
238
  return `${hours}h ago`;
218
239
  return `${Math.floor(hours / 24)}d ago`;
219
240
  }
241
+ function todoItemLine(item, width) {
242
+ const text = fitLine(item.text, Math.max(8, width - 5));
243
+ if (item.status === 'completed') {
244
+ return line(span(' ✔ ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
245
+ }
246
+ if (item.status === 'in_progress') {
247
+ return line(span(' ◐ ', { color: TODO_IN_PROGRESS_COLOR }), span(text, { color: TODO_IN_PROGRESS_COLOR, bold: true }));
248
+ }
249
+ return line(span(' ○ ', { color: 'gray' }), span(text));
250
+ }
251
+ export function formatTodoProgress(items) {
252
+ const done = items.filter((item) => item.status === 'completed').length;
253
+ return `${done}/${items.length} done`;
254
+ }
255
+ export function renderTodoListLines(items, width, { header = false } = {}) {
256
+ if (items.length === 0)
257
+ return [];
258
+ const lines = [];
259
+ if (header) {
260
+ lines.push(line(span('To-dos', { color: 'cyan', bold: true }), span(` · ${formatTodoProgress(items)}`, { color: 'gray' })));
261
+ }
262
+ const itemRowBudget = TODO_PANEL_MAX_ROWS - (header ? 1 : 0);
263
+ let collapsedDone = 0;
264
+ if (items.length > itemRowBudget) {
265
+ const over = items.length - (itemRowBudget - 1);
266
+ let leadingDone = 0;
267
+ while (leadingDone < items.length &&
268
+ items[leadingDone].status === 'completed') {
269
+ leadingDone += 1;
270
+ }
271
+ collapsedDone = Math.min(over, leadingDone);
272
+ }
273
+ if (collapsedDone > 0) {
274
+ lines.push(line(span(' ✔ ', { color: 'green' }), span(`${collapsedDone} completed`, { color: 'gray', dim: true })));
275
+ }
276
+ const remaining = items.slice(collapsedDone);
277
+ const budget = itemRowBudget - (collapsedDone > 0 ? 1 : 0);
278
+ const visible = remaining.length > budget ? remaining.slice(0, budget - 1) : remaining;
279
+ for (const item of visible) {
280
+ lines.push(todoItemLine(item, width));
281
+ }
282
+ if (remaining.length > visible.length) {
283
+ lines.push(plainLine(` … +${remaining.length - visible.length} more`, {
284
+ color: 'gray',
285
+ dim: true,
286
+ }));
287
+ }
288
+ return lines;
289
+ }
220
290
  export function renderTranscriptEntryLines(entry, width) {
221
291
  const color = getEntryColor(entry.kind);
222
292
  const lines = [
@@ -228,11 +298,14 @@ export function renderTranscriptEntryLines(entry, width) {
228
298
  if (entry.diffPreview) {
229
299
  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' }));
230
300
  for (const diffLine of entry.diffPreview.lines.slice(0, TRANSCRIPT_DIFF_PREVIEW_LINES)) {
231
- lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(truncate(diffLine.content || ' ', width - 4), {
301
+ lines.push(line(span(`${diffLinePrefix(diffLine.kind)} `, { color: diffLineColor(diffLine.kind) }), span(fitLine(diffLine.content || ' ', width - 4), {
232
302
  color: diffLineColor(diffLine.kind),
233
303
  })));
234
304
  }
235
305
  }
306
+ if (entry.todoList && entry.todoList.length > 0) {
307
+ lines.push(...renderTodoListLines(entry.todoList, width));
308
+ }
236
309
  return lines;
237
310
  }
238
311
  function tokenUsageLines(usage) {
@@ -274,6 +347,34 @@ function footerTransientStatus(status) {
274
347
  }
275
348
  return null;
276
349
  }
350
+ function backgroundJobIndicatorSpans(state) {
351
+ const runningCount = (state.backgroundJobs ?? []).filter((job) => job.status === 'running').length;
352
+ if (runningCount === 0)
353
+ return [];
354
+ const noun = runningCount === 1 ? 'shell' : 'shells';
355
+ return [
356
+ span(' ', { color: 'gray', dim: true }),
357
+ span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
358
+ span(' · /jobs', { color: 'gray', dim: true }),
359
+ ];
360
+ }
361
+ function todoIndicatorSpans(state) {
362
+ if (state.busy)
363
+ return [];
364
+ const items = state.todos ?? [];
365
+ if (items.length === 0)
366
+ return [];
367
+ const done = items.filter((item) => item.status === 'completed').length;
368
+ if (done >= items.length)
369
+ return [];
370
+ return [
371
+ span(' ', { color: 'gray', dim: true }),
372
+ span(`◐ ${done}/${items.length} to-dos`, {
373
+ color: TODO_IN_PROGRESS_COLOR,
374
+ bold: true,
375
+ }),
376
+ ];
377
+ }
277
378
  function composerFooterLines(state) {
278
379
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
279
380
  const transientStatus = footerTransientStatus(state.status);
@@ -292,7 +393,7 @@ function composerFooterLines(state) {
292
393
  ]
293
394
  : []), ...(visibleSessionId
294
395
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
295
- : [])),
396
+ : []), ...backgroundJobIndicatorSpans(state), ...todoIndicatorSpans(state)),
296
397
  plainLine(''),
297
398
  plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
298
399
  color: 'cyan',
@@ -307,15 +408,18 @@ function composerFooterLines(state) {
307
408
  }));
308
409
  return lines;
309
410
  }
411
+ const busyHelperText = state.queuedMessage
412
+ ? 'Enter re-queues • ↑ edit queued • Esc / Ctrl+C clear queued'
413
+ : state.input
414
+ ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
415
+ : 'Enter queues • Esc / Ctrl+C cancel turn';
310
416
  const helperText = state.busy
311
- ? state.queuedMessage
312
- ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
313
- : 'Enter queues • Esc / Ctrl+C cancel turn'
417
+ ? busyHelperText
314
418
  : process.platform === 'win32'
315
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C quits'
419
+ ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
316
420
  : process.platform === 'darwin'
317
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
318
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
421
+ ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
422
+ : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
319
423
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
320
424
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
321
425
  const footerSpans = [
@@ -329,6 +433,95 @@ function composerFooterLines(state) {
329
433
  lines.push(line(...footerSpans));
330
434
  return lines;
331
435
  }
436
+ export function formatJobElapsed(elapsedMs) {
437
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
438
+ if (totalSeconds < 60)
439
+ return `${totalSeconds}s`;
440
+ const minutes = Math.floor(totalSeconds / 60);
441
+ if (minutes < 60) {
442
+ return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
443
+ }
444
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
445
+ }
446
+ function backgroundJobPreviewLines(job, maxTailLines) {
447
+ const lines = [];
448
+ const first = String(job.firstOutputLine ?? '').trim();
449
+ if (first) {
450
+ lines.push(first);
451
+ }
452
+ for (const outputLine of (job.tailLines ?? []).slice(-maxTailLines)) {
453
+ const trimmed = String(outputLine ?? '').trim();
454
+ if (!trimmed)
455
+ continue;
456
+ if (lines.length === 1 && trimmed === first)
457
+ continue;
458
+ lines.push(trimmed);
459
+ }
460
+ return lines;
461
+ }
462
+ function jobStatusDescriptor(job, nowMs) {
463
+ const ran = formatJobElapsed((job.endedAt ?? (nowMs > 0 ? nowMs : Date.now())) - job.startedAt);
464
+ if (job.status === 'running') {
465
+ return { glyph: '●', color: 'green', text: `running · ${ran}` };
466
+ }
467
+ if (job.status === 'killed') {
468
+ return { glyph: '■', color: 'gray', text: `killed · ran ${ran}` };
469
+ }
470
+ if (job.status === 'error') {
471
+ return { glyph: '✖', color: 'red', text: 'failed to start' };
472
+ }
473
+ return job.exitCode === 0
474
+ ? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
475
+ : { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
476
+ }
477
+ function buildJobsPickerLines(state, width, nowMs) {
478
+ const jobs = state.backgroundJobs ?? [];
479
+ const lines = [
480
+ plainLine('Background jobs', { color: 'cyan', bold: true }),
481
+ ];
482
+ if (jobs.length === 0) {
483
+ lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
484
+ }
485
+ for (const [index, job] of jobs.entries()) {
486
+ const selected = index === state.jobsPickerIndex;
487
+ const expanded = state.jobsPickerExpandedId === job.id;
488
+ const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
489
+ lines.push(line(span(selected ? '› ' : ' ', { color: selected ? 'cyan' : 'gray' }), span(`${glyph} `, { color }), span(job.id.padEnd(6), { color: selected ? 'cyan' : undefined, bold: selected }), span(` ${truncate(job.command, Math.max(12, width - 34)).padEnd(Math.max(12, Math.min(40, width - 34)))}`, {
490
+ color: selected ? 'cyan' : undefined,
491
+ bold: selected,
492
+ }), span(` ${text}`, { color: 'gray' })));
493
+ if (expanded) {
494
+ const detailLines = (job.detailLines ?? []).length
495
+ ? job.detailLines ?? []
496
+ : backgroundJobPreviewLines(job, 3);
497
+ if (detailLines.length === 0) {
498
+ lines.push(plainLine(' (no output captured)', { color: 'gray' }));
499
+ }
500
+ else {
501
+ for (const outputLine of detailLines) {
502
+ lines.push(line(span(' │ ', { color: 'gray', dim: true }), span(truncate(outputLine, Math.max(8, width - 8)), {
503
+ color: 'gray',
504
+ dim: true,
505
+ })));
506
+ }
507
+ }
508
+ }
509
+ }
510
+ lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
511
+ color: 'gray',
512
+ }));
513
+ return lines;
514
+ }
515
+ function thinkingHeaderLine(spinnerFrame, title, width) {
516
+ const spinner = `${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `;
517
+ const fittedTitle = title
518
+ ? fitLine(title, Math.max(8, width - spinner.length - 'Thinking'.length - 3))
519
+ : '';
520
+ return line(span(spinner, { color: 'green' }), span('Thinking', { color: 'green', bold: true }), ...(fittedTitle ? [span(` · ${fittedTitle}`, { color: 'ansi256(248)' })] : []));
521
+ }
522
+ function thinkingNoteLine(note, width) {
523
+ return line(span('│ ', { color: 'green' }), span(fitLine(note, Math.max(8, width - 3)), { color: 'ansi256(248)' }));
524
+ }
332
525
  function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
333
526
  if (!state.busy)
334
527
  return [];
@@ -342,39 +535,56 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
342
535
  }, width));
343
536
  lines.push(plainLine(''));
344
537
  }
345
- 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' }));
346
- const visibleTitle = state.thinkingTitle.trim();
347
- const visibleNotes = state.thinkingNotes.filter(Boolean).slice(-THINKING_NOTE_PREVIEW_ROWS);
348
- if (visibleTitle || visibleNotes.length > 0) {
349
- lines.push(plainLine(''));
350
- lines.push(line(span(`${BRAILLE_SPINNER_FRAMES[spinnerFrame % BRAILLE_SPINNER_FRAMES.length]} `, {
351
- color: 'green',
352
- }), span('Thinking', { color: 'green', bold: true }), ...(visibleTitle ? [span(` · ${visibleTitle}`, { color: 'ansi256(248)' })] : [])));
353
- for (const note of visibleNotes) {
354
- lines.push(line(span('│ ', { color: 'green' }), span(note, { color: 'ansi256(248)' })));
355
- }
356
- }
357
538
  const toolEntries = state.workingTools.slice(-WORKING_TOOL_PREVIEW_ROWS);
358
539
  if (toolEntries.length > 0) {
359
- lines.push(plainLine(''));
360
540
  for (const entry of toolEntries) {
361
541
  const color = getEntryColor(entry.kind);
362
542
  const body = entry.body.split('\n')[0]?.trim();
363
543
  lines.push(line(span('● ', { color }), span(entry.title, { color, bold: true }), ...(body ? [span(` ${truncate(body, 140)}`, { color })] : [])));
364
544
  }
545
+ lines.push(plainLine(''));
365
546
  }
366
547
  const logLines = state.commandLog.slice(-COMMAND_PREVIEW_LINES);
367
548
  if (logLines.length > 0) {
368
- lines.push(plainLine(''));
549
+ lines.push(plainLine('⋮ output', { color: 'gray', dim: true }));
369
550
  for (const outputLine of logLines) {
370
551
  lines.push(plainLine(outputLine, { color: 'gray', dim: true }));
371
552
  }
553
+ lines.push(plainLine(''));
554
+ }
555
+ const busyLabel = state.analyzingImages > 0
556
+ ? state.analyzingImages > 1
557
+ ? 'Analyzing images'
558
+ : 'Analyzing image'
559
+ : 'Working';
560
+ lines.push(plainLine(`${WORKING_CLOCK_ICON} ${busyLabel} · ${elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`}`, { color: 'yellow' }));
561
+ const todos = state.todos ?? [];
562
+ if (todos.length > 0) {
563
+ lines.push(plainLine(''));
564
+ lines.push(...renderTodoListLines(todos, width, { header: true }));
565
+ }
566
+ const visibleTitle = state.thinkingTitle.trim();
567
+ const visibleNotes = state.thinkingNotes.filter(Boolean);
568
+ if (visibleTitle || visibleNotes.length > 0) {
569
+ lines.push(plainLine(''));
570
+ if (todos.length > 0) {
571
+ const minimalText = visibleTitle && visibleTitle !== 'Thinking'
572
+ ? visibleTitle
573
+ : (visibleNotes[visibleNotes.length - 1] ?? '');
574
+ lines.push(thinkingHeaderLine(spinnerFrame, minimalText, width));
575
+ }
576
+ else {
577
+ lines.push(thinkingHeaderLine(spinnerFrame, visibleTitle, width));
578
+ for (const note of visibleNotes.slice(-THINKING_NOTE_PREVIEW_ROWS)) {
579
+ lines.push(thinkingNoteLine(note, width));
580
+ }
581
+ }
372
582
  }
373
583
  lines.push(plainLine(''));
374
584
  return lines;
375
585
  }
376
586
  function lineCharCount(row) {
377
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
587
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
378
588
  }
379
589
  function overlayPanelLine(row, width, color) {
380
590
  const padding = Math.max(0, width - lineCharCount(row));
@@ -402,77 +612,118 @@ function padSpansToInnerWidth(spans, innerWidth, fill) {
402
612
  function modelPickerPanelSideLine(content, innerWidth) {
403
613
  return overlayPanelLine(content, innerWidth, MODEL_PICKER_BORDER_COLOR);
404
614
  }
615
+ const MODEL_PICKER_COST_WIDTH = 6;
616
+ const MODEL_PICKER_MODEL_WIDTH = 42;
617
+ const MODEL_PICKER_NUMBER_WIDTH = 4;
618
+ const MODEL_PICKER_SEPARATOR = ' │ ';
619
+ const MODEL_PICKER_WIDE_MIN_WIDTH = 78;
405
620
  function modelPickerTopBorder(panelWidth) {
406
- const prefix = '╭─ Models ';
407
- const suffix = '';
408
- const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
409
- return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Models', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
621
+ const fullTitle = ' TheGitAI - Model Selection ';
622
+ const compactTitle = ' Model Selection ';
623
+ const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
624
+ const available = Math.max(0, panelWidth - 2 - [...title].length);
625
+ const left = Math.floor(available / 2);
626
+ const right = available - left;
627
+ 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 }));
628
+ }
629
+ function modelPickerDivider(panelWidth) {
630
+ return plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
631
+ color: MODEL_PICKER_BORDER_COLOR,
632
+ });
633
+ }
634
+ function modelPickerCell(text, width, style = {}) {
635
+ const fitted = fitLine(text, width);
636
+ return span(`${fitted}${' '.repeat(Math.max(0, width - [...fitted].length))}`, style);
637
+ }
638
+ function modelPickerCostText(rating) {
639
+ const steps = Math.max(1, Math.min(3, Math.round(rating)));
640
+ return '$'.repeat(steps);
641
+ }
642
+ function modelPickerNotesWidth(innerWidth) {
643
+ return Math.max(18, innerWidth -
644
+ MODEL_PICKER_NUMBER_WIDTH -
645
+ MODEL_PICKER_MODEL_WIDTH -
646
+ MODEL_PICKER_COST_WIDTH -
647
+ MODEL_PICKER_SEPARATOR.length * 2);
648
+ }
649
+ function modelPickerModelSpans(option, selected, width, showCurrentTag, labelStyle, selectedStyle) {
650
+ const tag = option.current && showCurrentTag ? ' (current)' : '';
651
+ const labelWidth = Math.max(1, width - [...tag].length);
652
+ const label = fitLine(`${selected ? '▶ ' : ' '}${option.label}`, labelWidth);
653
+ const used = [...label].length + [...tag].length;
654
+ return [
655
+ span(label, { ...labelStyle, ...selectedStyle }),
656
+ span(tag, { color: 'gray', ...selectedStyle }),
657
+ span(' '.repeat(Math.max(0, width - used)), selectedStyle),
658
+ ];
410
659
  }
411
660
  function modelPickerItemLines(option, selected, innerWidth) {
412
- const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
413
- if (selected) {
414
- const titleSpans = padSpansToInnerWidth([
415
- span('▌', {
416
- color: MODEL_PICKER_ACCENT_COLOR,
417
- bold: true,
418
- ...highlight,
419
- }),
420
- span('▶ ', {
421
- color: MODEL_PICKER_ACCENT_COLOR,
422
- bold: true,
423
- ...highlight,
424
- }),
425
- span('o ', { color: MODEL_PICKER_ACCENT_COLOR, ...highlight }),
426
- span(option.label, {
427
- color: MODEL_PICKER_ACCENT_COLOR,
428
- bold: true,
429
- ...highlight,
430
- }),
431
- ], innerWidth, highlight);
432
- const lines = [modelPickerPanelSideLine(line(...titleSpans), innerWidth)];
433
- if (option.meta) {
434
- const metaSpans = padSpansToInnerWidth([
435
- span(`${MODEL_PICKER_META_INDENT}${option.meta}`, {
436
- color: 'gray',
437
- ...highlight,
438
- }),
439
- ], innerWidth, highlight);
440
- lines.push(modelPickerPanelSideLine(line(...metaSpans), innerWidth));
441
- }
442
- return lines;
661
+ const selectedStyle = selected ? { bgColor: MODEL_PICKER_HIGHLIGHT_BG } : {};
662
+ const labelStyle = option.disabled
663
+ ? { color: 'gray' }
664
+ : selected
665
+ ? { color: 'cyan', bold: true }
666
+ : {};
667
+ const numberCell = modelPickerCell(String(option.publicId), MODEL_PICKER_NUMBER_WIDTH, { color: 'cyan', bold: selected, ...selectedStyle });
668
+ const cost = modelPickerCostText(option.costRating);
669
+ if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
670
+ const modelWidth = Math.max(12, innerWidth - MODEL_PICKER_NUMBER_WIDTH - MODEL_PICKER_COST_WIDTH - 2);
671
+ const row = [
672
+ numberCell,
673
+ ...modelPickerModelSpans(option, selected, modelWidth, false, labelStyle, selectedStyle),
674
+ span(' ', selectedStyle),
675
+ modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
676
+ ];
677
+ return [
678
+ modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
679
+ ];
443
680
  }
444
- const labelColor = option.disabled ? 'gray' : MODEL_PICKER_ACCENT_COLOR;
445
- const lines = [
446
- modelPickerPanelSideLine(line(span(' o ', { color: labelColor }), span(option.label, { color: labelColor, bold: !option.disabled })), innerWidth),
681
+ const row = [
682
+ numberCell,
683
+ ...modelPickerModelSpans(option, selected, MODEL_PICKER_MODEL_WIDTH, true, labelStyle, selectedStyle),
684
+ span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
685
+ modelPickerCell(cost, MODEL_PICKER_COST_WIDTH, selectedStyle),
686
+ span(MODEL_PICKER_SEPARATOR, { color: 'gray', ...selectedStyle }),
687
+ modelPickerCell(option.note, modelPickerNotesWidth(innerWidth), {
688
+ color: 'gray',
689
+ ...selectedStyle,
690
+ }),
691
+ ];
692
+ return [
693
+ modelPickerPanelSideLine(line(...padSpansToInnerWidth(row, innerWidth, selectedStyle)), innerWidth),
447
694
  ];
448
- if (option.meta) {
449
- lines.push(modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.meta}`, { color: 'gray' })), innerWidth));
450
- }
451
- return lines;
452
695
  }
453
- function modelPickerSeparatorLine(innerWidth) {
454
- return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
696
+ function modelPickerHeaderLine(innerWidth) {
697
+ const heading = { color: MODEL_PICKER_ACCENT_COLOR, bold: true };
698
+ if (innerWidth < MODEL_PICKER_WIDE_MIN_WIDTH) {
699
+ return line(modelPickerCell('#', MODEL_PICKER_NUMBER_WIDTH, heading), modelPickerCell('Model', Math.max(1, innerWidth - MODEL_PICKER_NUMBER_WIDTH), heading));
700
+ }
701
+ 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));
455
702
  }
456
- function buildModelPickerPanel(options, selectedIndex, width) {
703
+ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
457
704
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
458
705
  const innerWidth = Math.max(1, panelWidth - 4);
459
- const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
706
+ const compactBodyLineCount = options.length + 6;
707
+ const spacerLineCount = Math.max(0, options.length - 1);
708
+ const useRowSpacing = compactBodyLineCount + spacerLineCount <= availableHeight;
709
+ const bodyLineCount = compactBodyLineCount + (useRowSpacing ? spacerLineCount : 0);
710
+ const marginLineCount = Math.max(0, Math.min(MODEL_PICKER_PANEL_MARGIN_LINES, Math.floor((availableHeight - bodyLineCount) / 2)));
711
+ const margin = Array.from({ length: marginLineCount }, () => plainLine(''));
460
712
  const body = [
461
- plainLine('TheGitAI - Model Selection', {
462
- color: MODEL_PICKER_ACCENT_COLOR,
463
- bold: true,
464
- }),
465
- plainLine(''),
466
713
  modelPickerTopBorder(panelWidth),
467
- modelPickerPanelSideLine(plainLine(''), innerWidth),
714
+ modelPickerPanelSideLine(modelPickerHeaderLine(innerWidth), innerWidth),
715
+ modelPickerDivider(panelWidth),
468
716
  ];
469
717
  options.forEach((option, index) => {
470
718
  body.push(...modelPickerItemLines(option, index === selectedIndex, innerWidth));
471
- if (index < options.length - 1) {
472
- body.push(modelPickerSeparatorLine(innerWidth));
719
+ if (useRowSpacing && index < options.length - 1) {
720
+ body.push(modelPickerPanelSideLine(plainLine(''), innerWidth));
473
721
  }
474
722
  });
475
- body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Enter select • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
723
+ const fullHint = '↑/↓ navigate • Enter select • Esc cancel';
724
+ const compactHint = '↑/↓ • enter • esc';
725
+ const hint = [...fullHint].length <= innerWidth ? fullHint : compactHint;
726
+ body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
476
727
  return [...margin, ...body, ...margin];
477
728
  }
478
729
  function commandPaletteTopBorder(panelWidth) {
@@ -481,6 +732,9 @@ function commandPaletteTopBorder(panelWidth) {
481
732
  const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
482
733
  return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Commands', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
483
734
  }
735
+ function commandPaletteSeparatorLine(innerWidth) {
736
+ return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
737
+ }
484
738
  function commandPaletteItemLines(option, selected, innerWidth) {
485
739
  const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
486
740
  if (selected) {
@@ -533,13 +787,13 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
533
787
  suggestions.forEach((suggestion, index) => {
534
788
  body.push(...commandPaletteItemLines(suggestion, index === selectedIndex, innerWidth));
535
789
  if (index < suggestions.length - 1) {
536
- body.push(modelPickerSeparatorLine(innerWidth));
790
+ body.push(commandPaletteSeparatorLine(innerWidth));
537
791
  }
538
792
  });
539
793
  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 }));
540
794
  return [...margin, ...body, ...margin];
541
795
  }
542
- function buildOverlayLines(state, width) {
796
+ function buildOverlayLines(state, width, height, nowMs) {
543
797
  const lines = [];
544
798
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
545
799
  const innerWidth = Math.max(1, panelWidth - 4);
@@ -612,22 +866,61 @@ function buildOverlayLines(state, width) {
612
866
  }
613
867
  if (state.modelPickerOpen) {
614
868
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
615
- lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
869
+ lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
870
+ }
871
+ if (state.jobsPickerOpen) {
872
+ lines.push(...buildJobsPickerLines(state, width, nowMs));
616
873
  }
617
874
  if (state.resumePickerOpen) {
618
875
  const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
876
+ const pickerWidth = Math.max(20, width - 2);
877
+ const divider = () => plainLine('─'.repeat(pickerWidth), { color: 'gray', dim: true });
619
878
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
620
879
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
621
- for (const [index, session] of filtered.entries()) {
880
+ lines.push(divider());
881
+ const maxCards = Math.max(2, Math.min(filtered.length, Math.floor((height - 10) / 3)));
882
+ let start = 0;
883
+ if (filtered.length > maxCards) {
884
+ start = Math.min(Math.max(0, state.resumePickerIndex - Math.floor(maxCards / 2)), filtered.length - maxCards);
885
+ }
886
+ const visible = filtered.slice(start, start + maxCards);
887
+ if (start > 0) {
888
+ lines.push(plainLine(` … ${start} newer`, { color: 'gray', dim: true }));
889
+ }
890
+ for (const [offset, session] of visible.entries()) {
891
+ const index = start + offset;
622
892
  const selected = index === state.resumePickerIndex;
623
- const color = selected ? 'cyan' : undefined;
624
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
625
- 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 }));
893
+ const prompt = singleLinePreview(session.lastUserMessage, pickerWidth) ||
894
+ session.name ||
895
+ `Session ${session.id}`;
896
+ lines.push(line(span(selected ? '› ' : ' ', { color: 'cyan' }), span(fitLine(prompt, Math.max(10, pickerWidth - 2)), selected
897
+ ? { color: 'cyan', bold: true }
898
+ : session.lastUserMessage
899
+ ? {}
900
+ : { color: 'gray', dim: true })));
901
+ const meta = [
902
+ formatRelativeTime(session.updatedAt),
903
+ pickerModelLabel(session.modelId, state.serverModels),
904
+ session.branch ?? null,
905
+ ]
906
+ .filter(Boolean)
907
+ .join(' · ');
908
+ lines.push(line(span(' '), span(fitLine(meta, Math.max(10, pickerWidth - 4)), {
909
+ color: selected ? 'cyan' : 'gray',
910
+ dim: !selected,
911
+ })));
912
+ if (offset < visible.length - 1)
913
+ lines.push(plainLine(''));
914
+ }
915
+ const remaining = filtered.length - (start + visible.length);
916
+ if (remaining > 0) {
917
+ lines.push(plainLine(` … ${remaining} older`, { color: 'gray', dim: true }));
626
918
  }
627
919
  if (filtered.length === 0) {
628
920
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
629
921
  }
630
- lines.push(plainLine('↑/↓ move enter resume esc start new ctrl+c quit', {
922
+ lines.push(divider());
923
+ lines.push(plainLine('↑/↓ move · enter resume · esc start new · ctrl+c quit', {
631
924
  color: 'gray',
632
925
  }));
633
926
  }
@@ -655,7 +948,7 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
655
948
  const start = Math.max(0, lines.length - maxLines - offset);
656
949
  return lines.slice(start, start + maxLines);
657
950
  }
658
- export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
951
+ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
659
952
  const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
660
953
  const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
661
954
  const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
@@ -672,12 +965,12 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
672
965
  sections.push({ kind: 'live', lines: liveLines });
673
966
  }
674
967
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
675
- // Composer stays visible while busy unless a blocking overlay is active. When
676
- // one message is already queued, the queued chip is shown here in place of the
677
- // input box (only one message is ever held) so there is no empty prompt.
678
- if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
968
+ if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
679
969
  const composerLines = [];
680
- if (state.queuedMessage) {
970
+ if (state.busy && state.status === 'Starting a new conversation...') {
971
+ composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
972
+ }
973
+ else if (state.queuedMessage) {
681
974
  const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
682
975
  const imageCount = state.queuedMessage.imageAttachments.length;
683
976
  composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
@@ -696,7 +989,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
696
989
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
697
990
  });
698
991
  }
699
- const overlayLines = buildOverlayLines(state, contentWidth);
992
+ const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
700
993
  if (overlayLines.length > 0) {
701
994
  sections.push({ kind: 'overlay', lines: overlayLines });
702
995
  }