@thegitai/cli 1.0.0-preview.1 → 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 (36) hide show
  1. package/README.md +16 -4
  2. package/dist/bin/ai.js +57 -287
  3. package/dist/src/api/auth.js +2 -2
  4. package/dist/src/api/browser-login.js +72 -3
  5. package/dist/src/api/chat.js +125 -24
  6. package/dist/src/api/http.js +16 -3
  7. package/dist/src/api/models.js +9 -4
  8. package/dist/src/help-text.js +19 -7
  9. package/dist/src/patcher.js +96 -9
  10. package/dist/src/project-index.js +13 -1
  11. package/dist/src/project-orientation.js +99 -0
  12. package/dist/src/scratch-dir.js +51 -33
  13. package/dist/src/session-store.js +52 -20
  14. package/dist/src/session.js +8 -0
  15. package/dist/src/tool-executor.js +38 -6
  16. package/dist/src/tools/delete-file.js +22 -4
  17. package/dist/src/tools/patch-file.js +30 -5
  18. package/dist/src/tools/read-file.js +3 -1
  19. package/dist/src/tools/replace-document-text.js +7 -1
  20. package/dist/src/tools/run-command.js +37 -19
  21. package/dist/src/tools/run-node-script.js +24 -4
  22. package/dist/src/tools/str-replace.js +30 -5
  23. package/dist/src/tools/write-file.js +25 -5
  24. package/dist/src/turn-failure-marker.js +11 -0
  25. package/dist/src/ui/prompt-history-store.js +1 -1
  26. package/dist/src/ui/repl.js +188 -49
  27. package/dist/src/ui/tui/bridge.js +3 -0
  28. package/dist/src/ui/tui/build-frame.js +179 -82
  29. package/dist/src/ui/tui/markdown-render.js +72 -73
  30. package/dist/src/ui/tui/shell-input.js +42 -13
  31. package/dist/src/ui/tui/terminal-title.js +3 -0
  32. package/dist/src/ui/tui/terminal-writes.js +48 -0
  33. package/dist/src/ui/tui/text.js +158 -4
  34. package/dist/src/utils.js +9 -0
  35. package/package.json +18 -6
  36. package/dist/src/markdown-renderer.js +0 -112
@@ -1,8 +1,8 @@
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
8
  const TODO_PANEL_MAX_ROWS = 12;
@@ -17,9 +17,9 @@ const OVERLAY_PANEL_MAX_WIDTH = 86;
17
17
  const OVERLAY_PANEL_MARGIN_LINES = 2;
18
18
  const OVERLAY_BORDER_COLOR = 'yellow';
19
19
  const OVERLAY_WARNING_COLOR = 'ansi256(208)';
20
- const MODEL_PICKER_PANEL_MAX_WIDTH = 86;
20
+ const MODEL_PICKER_PANEL_MAX_WIDTH = 144;
21
21
  const MODEL_PICKER_PANEL_MARGIN_LINES = 2;
22
- const MODEL_PICKER_BORDER_COLOR = 'gray';
22
+ const MODEL_PICKER_BORDER_COLOR = 'cyan';
23
23
  const MODEL_PICKER_ACCENT_COLOR = 'cyan';
24
24
  const MODEL_PICKER_HIGHLIGHT_BG = 'ansi256(87)';
25
25
  const MODEL_PICKER_META_INDENT = ' ';
@@ -133,14 +133,14 @@ function diffLinePrefix(kind) {
133
133
  return ' ';
134
134
  }
135
135
  }
136
- function fitLine(content, maxWidth) {
136
+ export function fitLine(content, maxWidth) {
137
137
  if (maxWidth <= 0)
138
138
  return '';
139
- if (content.length <= maxWidth)
139
+ if (displayWidth(content) <= maxWidth)
140
140
  return content;
141
141
  if (maxWidth === 1)
142
142
  return '…';
143
- return `${content.slice(0, maxWidth - 1)}…`;
143
+ return `${sliceToWidth(content, maxWidth - 1)}…`;
144
144
  }
145
145
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
146
146
  const trimmed = String(projectRoot ?? '').trim();
@@ -163,15 +163,18 @@ const CLIENT_SLASH_COMMANDS = [
163
163
  { command: '/model', description: 'Switch the active model' },
164
164
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
165
165
  { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
166
- { command: '/clear', description: 'Clear conversation history' },
166
+ { command: '/new', description: 'start a new conversation; this session remains saved' },
167
167
  { command: '/exit', description: 'Quit the current session' },
168
168
  ];
169
169
  function buildModelPickerOptions(currentModelId, serverModels) {
170
170
  return serverModels.map((model) => ({
171
171
  id: model.id,
172
172
  label: model.label,
173
- meta: model.id === currentModelId ? 'current' : '',
173
+ publicId: model.id,
174
+ costRating: model.costRating,
175
+ current: model.id === currentModelId,
174
176
  disabled: false,
177
+ note: model.description,
175
178
  }));
176
179
  }
177
180
  function getInputCommandToken(input) {
@@ -209,6 +212,9 @@ export function getSlashCommandSuggestions(input) {
209
212
  function formatModelLabel(modelId, serverModels) {
210
213
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
211
214
  }
215
+ function pickerModelLabel(modelId, serverModels) {
216
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
217
+ }
212
218
  function filterResumeSessions(sessions, filter, serverModels) {
213
219
  const q = filter.trim().toLowerCase();
214
220
  if (!q)
@@ -402,15 +408,18 @@ function composerFooterLines(state) {
402
408
  }));
403
409
  return lines;
404
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';
405
416
  const helperText = state.busy
406
- ? state.queuedMessage
407
- ? 'Enter re-queues • ↑ edit queued • Esc cancels queued'
408
- : 'Enter queues • Esc / Ctrl+C cancel turn'
417
+ ? busyHelperText
409
418
  : process.platform === 'win32'
410
- ? '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'
411
420
  : process.platform === 'darwin'
412
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits'
413
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C quits';
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';
414
423
  const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
415
424
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
416
425
  const footerSpans = [
@@ -543,7 +552,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
543
552
  }
544
553
  lines.push(plainLine(''));
545
554
  }
546
- lines.push(plainLine(`${WORKING_CLOCK_ICON} Working · ${elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`}`, { color: 'yellow' }));
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' }));
547
561
  const todos = state.todos ?? [];
548
562
  if (todos.length > 0) {
549
563
  lines.push(plainLine(''));
@@ -570,7 +584,7 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
570
584
  return lines;
571
585
  }
572
586
  function lineCharCount(row) {
573
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
587
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
574
588
  }
575
589
  function overlayPanelLine(row, width, color) {
576
590
  const padding = Math.max(0, width - lineCharCount(row));
@@ -598,77 +612,118 @@ function padSpansToInnerWidth(spans, innerWidth, fill) {
598
612
  function modelPickerPanelSideLine(content, innerWidth) {
599
613
  return overlayPanelLine(content, innerWidth, MODEL_PICKER_BORDER_COLOR);
600
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;
601
620
  function modelPickerTopBorder(panelWidth) {
602
- const prefix = '╭─ Models ';
603
- const suffix = '';
604
- const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
605
- return line(span('╭─ ', { color: MODEL_PICKER_BORDER_COLOR }), span('Models', { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(` ${'─'.repeat(dashCount)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
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
+ ];
606
659
  }
607
660
  function modelPickerItemLines(option, selected, innerWidth) {
608
- const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
609
- if (selected) {
610
- const titleSpans = padSpansToInnerWidth([
611
- span('▌', {
612
- color: MODEL_PICKER_ACCENT_COLOR,
613
- bold: true,
614
- ...highlight,
615
- }),
616
- span('▶ ', {
617
- color: MODEL_PICKER_ACCENT_COLOR,
618
- bold: true,
619
- ...highlight,
620
- }),
621
- span('o ', { color: MODEL_PICKER_ACCENT_COLOR, ...highlight }),
622
- span(option.label, {
623
- color: MODEL_PICKER_ACCENT_COLOR,
624
- bold: true,
625
- ...highlight,
626
- }),
627
- ], innerWidth, highlight);
628
- const lines = [modelPickerPanelSideLine(line(...titleSpans), innerWidth)];
629
- if (option.meta) {
630
- const metaSpans = padSpansToInnerWidth([
631
- span(`${MODEL_PICKER_META_INDENT}${option.meta}`, {
632
- color: 'gray',
633
- ...highlight,
634
- }),
635
- ], innerWidth, highlight);
636
- lines.push(modelPickerPanelSideLine(line(...metaSpans), innerWidth));
637
- }
638
- return lines;
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
+ ];
639
680
  }
640
- const labelColor = option.disabled ? 'gray' : MODEL_PICKER_ACCENT_COLOR;
641
- const lines = [
642
- modelPickerPanelSideLine(line(span(' o ', { color: labelColor }), span(option.label, { color: labelColor, bold: !option.disabled })), innerWidth),
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),
643
694
  ];
644
- if (option.meta) {
645
- lines.push(modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.meta}`, { color: 'gray' })), innerWidth));
646
- }
647
- return lines;
648
695
  }
649
- function modelPickerSeparatorLine(innerWidth) {
650
- 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));
651
702
  }
652
- function buildModelPickerPanel(options, selectedIndex, width) {
703
+ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
653
704
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
654
705
  const innerWidth = Math.max(1, panelWidth - 4);
655
- 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(''));
656
712
  const body = [
657
- plainLine('TheGitAI - Model Selection', {
658
- color: MODEL_PICKER_ACCENT_COLOR,
659
- bold: true,
660
- }),
661
- plainLine(''),
662
713
  modelPickerTopBorder(panelWidth),
663
- modelPickerPanelSideLine(plainLine(''), innerWidth),
714
+ modelPickerPanelSideLine(modelPickerHeaderLine(innerWidth), innerWidth),
715
+ modelPickerDivider(panelWidth),
664
716
  ];
665
717
  options.forEach((option, index) => {
666
718
  body.push(...modelPickerItemLines(option, index === selectedIndex, innerWidth));
667
- if (index < options.length - 1) {
668
- body.push(modelPickerSeparatorLine(innerWidth));
719
+ if (useRowSpacing && index < options.length - 1) {
720
+ body.push(modelPickerPanelSideLine(plainLine(''), innerWidth));
669
721
  }
670
722
  });
671
- body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Enter select • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
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 }));
672
727
  return [...margin, ...body, ...margin];
673
728
  }
674
729
  function commandPaletteTopBorder(panelWidth) {
@@ -677,6 +732,9 @@ function commandPaletteTopBorder(panelWidth) {
677
732
  const dashCount = Math.max(0, panelWidth - prefix.length - suffix.length);
678
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 }));
679
734
  }
735
+ function commandPaletteSeparatorLine(innerWidth) {
736
+ return modelPickerPanelSideLine(line(span('┈'.repeat(Math.max(1, innerWidth)), { color: 'gray', dim: true })), innerWidth);
737
+ }
680
738
  function commandPaletteItemLines(option, selected, innerWidth) {
681
739
  const highlight = { bgColor: MODEL_PICKER_HIGHLIGHT_BG };
682
740
  if (selected) {
@@ -729,13 +787,13 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
729
787
  suggestions.forEach((suggestion, index) => {
730
788
  body.push(...commandPaletteItemLines(suggestion, index === selectedIndex, innerWidth));
731
789
  if (index < suggestions.length - 1) {
732
- body.push(modelPickerSeparatorLine(innerWidth));
790
+ body.push(commandPaletteSeparatorLine(innerWidth));
733
791
  }
734
792
  });
735
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 }));
736
794
  return [...margin, ...body, ...margin];
737
795
  }
738
- function buildOverlayLines(state, width, nowMs) {
796
+ function buildOverlayLines(state, width, height, nowMs) {
739
797
  const lines = [];
740
798
  const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
741
799
  const innerWidth = Math.max(1, panelWidth - 4);
@@ -808,25 +866,61 @@ function buildOverlayLines(state, width, nowMs) {
808
866
  }
809
867
  if (state.modelPickerOpen) {
810
868
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
811
- lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
869
+ lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
812
870
  }
813
871
  if (state.jobsPickerOpen) {
814
872
  lines.push(...buildJobsPickerLines(state, width, nowMs));
815
873
  }
816
874
  if (state.resumePickerOpen) {
817
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 });
818
878
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
819
879
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
820
- 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;
821
892
  const selected = index === state.resumePickerIndex;
822
- const color = selected ? 'cyan' : undefined;
823
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
824
- lines.push(plainLine(`${selected ? '› ' : ' '}${formatRelativeTime(session.updatedAt).padEnd(11)} ${(session.branch ?? '(no branch)').slice(0, 13).padEnd(14)} ${model.padEnd(26)} ${(session.lastUserMessage.slice(0, 40) || '(empty)')}`, { color, bold: selected }));
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 }));
825
918
  }
826
919
  if (filtered.length === 0) {
827
920
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
828
921
  }
829
- 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', {
830
924
  color: 'gray',
831
925
  }));
832
926
  }
@@ -873,7 +967,10 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
873
967
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
874
968
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
875
969
  const composerLines = [];
876
- 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) {
877
974
  const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
878
975
  const imageCount = state.queuedMessage.imageAttachments.length;
879
976
  composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
@@ -892,7 +989,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
892
989
  lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
893
990
  });
894
991
  }
895
- const overlayLines = buildOverlayLines(state, contentWidth, nowMs);
992
+ const overlayLines = buildOverlayLines(state, contentWidth, rows, nowMs);
896
993
  if (overlayLines.length > 0) {
897
994
  sections.push({ kind: 'overlay', lines: overlayLines });
898
995
  }
@@ -1,6 +1,8 @@
1
- import { line, plainLine, span, wrapText } from './text.js';
2
- const TABLE_BORDER_WIDTH = 2;
3
- const TABLE_CELL_OVERHEAD_WIDTH = 3;
1
+ import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, wrapToWidth, } from './text.js';
2
+ const MIN_TABLE_COLUMN_WIDTH = 3;
3
+ function tableRowOverhead(columnCount) {
4
+ return 3 * columnCount + 1;
5
+ }
4
6
  function parseInlineSegments(text) {
5
7
  const source = String(text ?? '');
6
8
  const segments = [];
@@ -110,45 +112,37 @@ function stripInlineFormattingForWidth(text) {
110
112
  .replace(/\*\*([^*]+)\*\*/g, '$1');
111
113
  }
112
114
  function fitTableColumnWidths(columnWidths, maxWidth) {
113
- const widths = columnWidths.map((width) => Math.max(3, Math.floor(width)));
114
- if (widths.length === 0)
115
- return widths;
116
- const overhead = TABLE_BORDER_WIDTH + widths.length * TABLE_CELL_OVERHEAD_WIDTH;
117
- const budget = Math.max(widths.length * 3, maxWidth - overhead);
118
- const total = widths.reduce((sum, width) => sum + width, 0);
119
- if (total <= budget)
120
- return widths;
121
- let remaining = budget;
122
- const scaled = widths.map((width) => {
123
- const next = Math.max(3, Math.floor((width / total) * budget));
124
- remaining -= next;
125
- return next;
126
- });
127
- while (remaining > 0) {
128
- let targetIndex = 0;
129
- for (let index = 1; index < widths.length; index++) {
130
- if (widths[index] - scaled[index] > widths[targetIndex] - scaled[targetIndex]) {
131
- targetIndex = index;
132
- }
133
- }
134
- scaled[targetIndex]++;
135
- remaining--;
115
+ const columnCount = columnWidths.length;
116
+ if (columnCount === 0)
117
+ return [];
118
+ const natural = columnWidths.map((width) => Math.max(1, Math.floor(width)));
119
+ const budget = Math.floor(maxWidth) - tableRowOverhead(columnCount);
120
+ const total = natural.reduce((sum, width) => sum + width, 0);
121
+ if (budget >= total)
122
+ return natural;
123
+ const floorWidth = Math.max(1, Math.min(MIN_TABLE_COLUMN_WIDTH, Math.floor(budget / columnCount)));
124
+ const widths = natural.map((width) => Math.min(width, floorWidth));
125
+ let used = widths.reduce((sum, width) => sum + width, 0);
126
+ if (used > budget) {
127
+ const share = Math.max(1, Math.floor(budget / columnCount));
128
+ for (let index = 0; index < columnCount; index++)
129
+ widths[index] = share;
130
+ used = share * columnCount;
136
131
  }
137
- while (remaining < 0) {
138
- let targetIndex = -1;
139
- for (let index = 0; index < scaled.length; index++) {
140
- if (scaled[index] <= 3)
132
+ let remaining = budget - used;
133
+ while (remaining > 0) {
134
+ let grew = false;
135
+ for (let index = 0; index < columnCount && remaining > 0; index++) {
136
+ if (widths[index] >= natural[index])
141
137
  continue;
142
- if (targetIndex === -1 || scaled[index] > scaled[targetIndex]) {
143
- targetIndex = index;
144
- }
138
+ widths[index]++;
139
+ remaining--;
140
+ grew = true;
145
141
  }
146
- if (targetIndex === -1)
142
+ if (!grew)
147
143
  break;
148
- scaled[targetIndex]--;
149
- remaining++;
150
144
  }
151
- return scaled;
145
+ return widths;
152
146
  }
153
147
  function normalizeMarkdownTableCells(cells, columnCount) {
154
148
  return Array.from({ length: columnCount }, (_, index) => cells[index] ?? '');
@@ -176,7 +170,7 @@ function parseMarkdownTableBlock(lines, startIndex) {
176
170
  const normalizedHeaders = normalizeMarkdownTableCells(headers, columnCount);
177
171
  const columnWidths = normalizedHeaders.map((header, columnIndex) => {
178
172
  const values = [header, ...rows.map((row) => row[columnIndex] ?? '')];
179
- return Math.max(3, ...values.map((value) => stripInlineFormattingForWidth(value).length));
173
+ return Math.max(MIN_TABLE_COLUMN_WIDTH, ...values.map((value) => displayWidth(stripInlineFormattingForWidth(value))));
180
174
  });
181
175
  return {
182
176
  nextIndex,
@@ -280,24 +274,33 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
280
274
  rowWidth = 0;
281
275
  };
282
276
  const appendSpan = (part) => {
283
- const current = rows[rows.length - 1];
284
- const limit = safeWidth - (indent ? prefix.length : 0);
277
+ let current = rows[rows.length - 1];
278
+ const limit = safeWidth - (rows.length === 1 && indent ? displayWidth(prefix) : 0);
285
279
  let remaining = part.text;
286
280
  while (remaining.length > 0) {
287
281
  const room = limit - rowWidth;
288
282
  if (room <= 0) {
289
283
  startRow();
284
+ current = rows[rows.length - 1];
290
285
  continue;
291
286
  }
292
- if (remaining.length <= room) {
287
+ const width = displayWidth(remaining);
288
+ if (width <= room) {
293
289
  current.push(span(remaining, styleFromSpan(part)));
294
- rowWidth += remaining.length;
290
+ rowWidth += width;
295
291
  remaining = '';
296
292
  break;
297
293
  }
298
- current.push(span(remaining.slice(0, room), styleFromSpan(part)));
299
- remaining = remaining.slice(room);
294
+ const head = sliceToWidth(remaining, room);
295
+ if (displayWidth(head) > room && current.length > 0) {
296
+ startRow();
297
+ current = rows[rows.length - 1];
298
+ continue;
299
+ }
300
+ current.push(span(head, styleFromSpan(part)));
301
+ remaining = remaining.slice(head.length);
300
302
  startRow();
303
+ current = rows[rows.length - 1];
301
304
  }
302
305
  };
303
306
  for (const segment of segments) {
@@ -316,36 +319,35 @@ function wrapInlineToLines(text, width, bodyColor, prefix = '') {
316
319
  return line(...bodySpans);
317
320
  });
318
321
  }
319
- function padCell(text, width) {
320
- const plain = stripInlineFormattingForWidth(text);
321
- if (plain.length >= width)
322
- return plain.slice(0, width);
323
- return plain + ' '.repeat(width - plain.length);
324
- }
325
322
  function renderTableLines(table, width) {
326
323
  const columnWidths = fitTableColumnWidths(table.columnWidths, width);
324
+ const border = (left, joint, right) => line(span(left +
325
+ columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join(joint) +
326
+ right, { color: 'cyan', dim: true }));
327
327
  const renderRow = (cells, bold) => {
328
- const parts = columnWidths.map((colWidth, index) => span(` ${padCell(cells[index] ?? '', colWidth)} `, {
329
- color: 'cyan',
330
- bold,
331
- }));
332
- return line(span('│', { color: 'cyan' }), ...parts, span('│', { color: 'cyan' }));
328
+ const wrapped = columnWidths.map((colWidth, index) => wrapToWidth(stripInlineFormattingForWidth(cells[index] ?? ''), colWidth));
329
+ const height = Math.max(1, ...wrapped.map((cellLines) => cellLines.length));
330
+ const rows = [];
331
+ for (let row = 0; row < height; row++) {
332
+ const spans = [span('│', { color: 'cyan' })];
333
+ for (let column = 0; column < columnWidths.length; column++) {
334
+ const text = wrapped[column]?.[row] ?? '';
335
+ spans.push(span(` ${padToWidth(text, columnWidths[column])} `, {
336
+ color: 'cyan',
337
+ bold,
338
+ }));
339
+ spans.push(span('│', { color: 'cyan' }));
340
+ }
341
+ rows.push(line(...spans));
342
+ }
343
+ return rows;
333
344
  };
334
- const separator = line(span('├' +
335
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┼') +
336
- '┤', { color: 'cyan', dim: true }));
337
- const top = line(span('┌' +
338
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┬') +
339
- '┐', { color: 'cyan', dim: true }));
340
- const bottom = line(span('└' +
341
- columnWidths.map((colWidth) => '─'.repeat(colWidth + 2)).join('┴') +
342
- '┘', { color: 'cyan', dim: true }));
343
345
  return [
344
- top,
345
- renderRow(table.headers, true),
346
- separator,
347
- ...table.rows.map((row) => renderRow(row, false)),
348
- bottom,
346
+ border('┌', '┬', '┐'),
347
+ ...renderRow(table.headers, true),
348
+ border('├', '┼', '┤'),
349
+ ...table.rows.flatMap((row) => renderRow(row, false)),
350
+ border('└', '┴', '┘'),
349
351
  ];
350
352
  }
351
353
  function getEntryColor(kind) {
@@ -410,10 +412,7 @@ export function renderFormattedBodyLines(body, width, kind) {
410
412
  }
411
413
  if (formattedLine.kind === 'table' && formattedLine.table) {
412
414
  output.push(...renderTableLines(formattedLine.table, bodyWidth).map((tableLine) => {
413
- const spans = tableLine.spans.map((part) => ({
414
- ...part,
415
- text: ` ${part.text}`,
416
- }));
415
+ const spans = tableLine.spans.map((part, index) => index === 0 ? { ...part, text: ` ${part.text}` } : part);
417
416
  return { spans };
418
417
  }));
419
418
  continue;