@thegitai/cli 1.0.0-preview.45 → 1.0.0-preview.46

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.
package/README.md CHANGED
@@ -17,10 +17,10 @@ Supports Node.js 22 and newer. Node.js 24 is recommended.
17
17
  ## Updates
18
18
 
19
19
  `ai` checks for a newer release each time it starts, and offers to install it
20
- for you. TheGitAI serves the current release only, so an older CLI can fail
21
- partway through a session. Press Enter to update — the CLI installs it and asks
22
- you to start `ai` again, so the whole session runs one version — or choose
23
- **Skip** to go on with the version you have. Skipping lasts for that session
20
+ for you. TheGitAI works best on the latest version, and an older CLI may not
21
+ work optimally. **Update now** is already selected, so Enter takes it — the CLI
22
+ installs the update and asks you to start `ai` again, so the whole session runs
23
+ one version — while ↑/↓ moves to **Skip** to go on with the version you have. Skipping lasts for that session
24
24
  only; the next start asks again. Ctrl+C quits without installing anything.
25
25
 
26
26
  ## Usage
@@ -148,12 +148,9 @@ export function agentModeLabel(mode) {
148
148
  return 'Bypass all permissions';
149
149
  if (mode === 'plan')
150
150
  return 'Plan · read-only';
151
- return 'Default · asks to edit & run unless permitted';
151
+ return 'Default · Ask for Approval';
152
152
  }
153
153
  export function agentModeLabelTiers(mode) {
154
- if (mode === 'default') {
155
- return [agentModeLabel(mode), 'Default · asks to edit & run'];
156
- }
157
154
  return [agentModeLabel(mode)];
158
155
  }
159
156
  export function agentModeShortLabel(mode) {
@@ -46,10 +46,12 @@ const HELP_MARKDOWN = [
46
46
  '## Updates',
47
47
  '',
48
48
  '- On start, `ai` says when a newer release is published and offers to install',
49
- ' it. TheGitAI serves the current release only, so an older CLI can fail',
50
- ' partway through a session, in ways the transcript cannot explain.',
51
- '- Enter takes the update; the CLI installs it and asks you to start `ai`',
52
- ' again, so the whole session runs one version.',
49
+ ' it. TheGitAI works best on the latest version, and an older CLI may not',
50
+ ' work optimally.',
51
+ '- **Update now** is selected when the offer appears, so **Enter** takes the',
52
+ ' update; **↑/↓** moves to **Skip**, and the number keys still jump straight',
53
+ ' to a row. The CLI installs the update and asks you to start `ai` again, so',
54
+ ' the whole session runs one version.',
53
55
  '- Skipping starts the session on the version you have and asks again next',
54
56
  ' time. A release cannot be silenced for good — that is what keeps a broken',
55
57
  ' combination from lasting.',
@@ -64,10 +66,10 @@ const HELP_MARKDOWN = [
64
66
  '',
65
67
  '## Modes',
66
68
  '',
67
- '- Default · asks to edit & run unless permitted — asks before creating,',
68
- ' editing or deleting files, before running commands, and before touching a',
69
- ' website. Each answer can be remembered for the rest of the session, and',
70
- ' once it is, Default stops asking for that action; see Safety & approvals.',
69
+ '- Default · Ask for Approval — asks before creating, editing or deleting',
70
+ ' files, before running commands, and before touching a website. Each answer',
71
+ ' can be remembered for the rest of the session, and once it is, Default',
72
+ ' stops asking for that action; see Safety & approvals.',
71
73
  '- Bypass all permissions — approves every command, file edit, deletion and',
72
74
  ' browser action for the rest of the session, without asking. Shown in red',
73
75
  ' in the footer. Blocked commands stay blocked.',
@@ -117,6 +119,11 @@ const HELP_MARKDOWN = [
117
119
  '',
118
120
  '## Chat commands',
119
121
  '',
122
+ '- Type `/` to open the command picker. It ranks what you type, so a typo or',
123
+ ' a stray plural (`/moddel`, `/models`) still finds the command, and a word',
124
+ ' from the description does too (`/bug` finds `/report`). It shows the five',
125
+ ' best matches and counts the rest; **↑/↓** chooses, **Tab** or **Enter**',
126
+ ' accepts, **Esc** closes it.',
120
127
  '- `/help` — show this help',
121
128
  '- `/about` — show version and platform info',
122
129
  '- `/report` — file a bug privately through the TheGitAI website in your',
@@ -7,7 +7,8 @@ import { backendFor, createNotificationQueue, } from './tui/desktop-notification
7
7
  import { readPreferences, writePreferences } from '../preferences.js';
8
8
  import { createTerminalTitleController } from './tui/terminal-title.js';
9
9
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
10
- export { getSlashCommandSuggestions } from './tui/build-frame.js';
10
+ import { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slashCommandMatchesToken, } from './slash-commands.js';
11
+ export { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slashCommandMatchesToken, };
11
12
  import { composerInputWidth, frameContentWidth } from './tui/composer-layout.js';
12
13
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
13
14
  import { chat, models } from '../api/index.js';
@@ -110,52 +111,6 @@ export function buildTurnFailureHintEntries(cancelled) {
110
111
  },
111
112
  ];
112
113
  }
113
- export const SLASH_COMMANDS = [
114
- {
115
- command: '/help',
116
- description: 'Show available chat commands',
117
- },
118
- {
119
- command: '/about',
120
- description: 'Show version and platform info',
121
- },
122
- {
123
- command: '/report',
124
- description: 'Report a bug privately on the TheGitAI website',
125
- },
126
- {
127
- command: '/usage',
128
- description: 'Show account usage percentage and reset times',
129
- },
130
- {
131
- command: '/model',
132
- description: 'Switch the active model',
133
- },
134
- {
135
- command: '/resume',
136
- description: 'Open the session picker',
137
- },
138
- {
139
- command: '/jobs',
140
- description: 'Background jobs: pick to view output or kill',
141
- },
142
- {
143
- command: '/notify',
144
- description: 'Toggle desktop notifications when a turn ends (Ctrl+G)',
145
- },
146
- {
147
- command: '/new',
148
- description: 'start a new conversation; this session remains saved',
149
- },
150
- {
151
- command: '/logout',
152
- description: 'Sign out and quit',
153
- },
154
- {
155
- command: '/exit',
156
- description: 'Quit the current session',
157
- },
158
- ];
159
114
  export function composerAttachmentsAfterCancel(queuedAttachments, inFlightAttachments) {
160
115
  const seen = new Set();
161
116
  const kept = [];
@@ -1255,16 +1210,6 @@ export const EXIT_CTRL_C_CONFIRM_MS = 3000;
1255
1210
  export function isExitConfirmActive(exitConfirmUntil, now = Date.now()) {
1256
1211
  return exitConfirmUntil != null && now < exitConfirmUntil;
1257
1212
  }
1258
- export function getInputCommandToken(input) {
1259
- const trimmed = String(input ?? '').trim();
1260
- if (!trimmed.startsWith('/'))
1261
- return '';
1262
- const firstSpaceIndex = trimmed.indexOf(' ');
1263
- const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1264
- if (token.indexOf('/', 1) !== -1)
1265
- return '';
1266
- return token;
1267
- }
1268
1213
  function shouldShowCommandPalette(state) {
1269
1214
  const trimmed = String(state.input ?? '').trim();
1270
1215
  return (!state.busy &&
@@ -1272,8 +1217,9 @@ function shouldShowCommandPalette(state) {
1272
1217
  !state.modelPickerOpen &&
1273
1218
  !state.resumePickerOpen &&
1274
1219
  trimmed.startsWith('/') &&
1275
- !trimmed.includes(' ') &&
1276
- getInputCommandToken(trimmed) !== '');
1220
+ !String(state.input ?? '').includes(' ') &&
1221
+ getInputCommandToken(trimmed) !== '' &&
1222
+ getSlashCommandSuggestions(trimmed).length > 0);
1277
1223
  }
1278
1224
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1279
1225
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1296,9 +1242,6 @@ export function applySlashCommandSuggestion(currentInput, suggestion) {
1296
1242
  input: `${before}${suggestion.command}${after}`,
1297
1243
  };
1298
1244
  }
1299
- export function isExactSlashCommandToken(token) {
1300
- return SLASH_COMMANDS.some((command) => command.command === token);
1301
- }
1302
1245
  export function insertAtCursor(state, text) {
1303
1246
  const before = state.input.slice(0, state.cursor);
1304
1247
  const after = state.input.slice(state.cursor);
@@ -0,0 +1,121 @@
1
+ export const SLASH_COMMANDS = [
2
+ { command: '/model', description: 'Switch the active model' },
3
+ { command: '/help', description: 'Show available chat commands' },
4
+ { command: '/usage', description: 'Show account usage percentage and reset times' },
5
+ {
6
+ command: '/resume',
7
+ description: 'Open the session picker to resume a previous session',
8
+ },
9
+ { command: '/new', description: 'start a new conversation; this session remains saved' },
10
+ { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
11
+ {
12
+ command: '/notify',
13
+ description: 'Toggle desktop notifications when a turn ends (Ctrl+G)',
14
+ },
15
+ { command: '/report', description: 'Report a bug privately on the TheGitAI website' },
16
+ { command: '/about', description: 'Show version and platform info' },
17
+ { command: '/logout', description: 'Sign out and quit' },
18
+ { command: '/exit', description: 'Quit the current session', aliases: ['/quit'] },
19
+ ];
20
+ export const SLASH_COMMAND_SUGGESTION_LIMIT = 5;
21
+ export function getInputCommandToken(input) {
22
+ const trimmed = String(input ?? '').trimStart();
23
+ const match = trimmed.match(/^\/[^\s]*/);
24
+ const token = match?.[0] ?? '';
25
+ if (token.indexOf('/', 1) !== -1)
26
+ return '';
27
+ return token;
28
+ }
29
+ export function slashCommandMatchesToken(option, token) {
30
+ return option.command === token || (option.aliases?.includes(token) ?? false);
31
+ }
32
+ const MATCH_EXACT = 0;
33
+ const MATCH_PREFIX = 100;
34
+ const MATCH_OVERTYPED = 200;
35
+ const MATCH_TYPO = 300;
36
+ const MATCH_IN_NAME = 400;
37
+ const MATCH_IN_DESCRIPTION = 500;
38
+ const MATCH_NONE = 600;
39
+ function typoBudget(body) {
40
+ if (body.length <= 3)
41
+ return 0;
42
+ if (body.length <= 5)
43
+ return 1;
44
+ return 2;
45
+ }
46
+ function editDistanceWithin(a, b, max) {
47
+ if (a === b)
48
+ return 0;
49
+ if (max <= 0)
50
+ return null;
51
+ if (Math.abs(a.length - b.length) > max)
52
+ return null;
53
+ const width = b.length + 1;
54
+ let beforePrevious = [];
55
+ let previous = Array.from({ length: width }, (_, index) => index);
56
+ for (let i = 1; i <= a.length; i += 1) {
57
+ const current = new Array(width);
58
+ current[0] = i;
59
+ let best = i;
60
+ for (let j = 1; j < width; j += 1) {
61
+ const substitution = a[i - 1] === b[j - 1] ? 0 : 1;
62
+ let cost = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + substitution);
63
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
64
+ cost = Math.min(cost, beforePrevious[j - 2] + 1);
65
+ }
66
+ current[j] = cost;
67
+ if (cost < best)
68
+ best = cost;
69
+ }
70
+ if (best > max)
71
+ return null;
72
+ beforePrevious = previous;
73
+ previous = current;
74
+ }
75
+ const distance = previous[b.length];
76
+ return distance <= max ? distance : null;
77
+ }
78
+ function scoreSlashCommand(option, token) {
79
+ const command = option.command;
80
+ const body = token.slice(1);
81
+ if (!body)
82
+ return MATCH_PREFIX;
83
+ const spellings = [command, ...(option.aliases ?? [])];
84
+ if (slashCommandMatchesToken(option, token))
85
+ return MATCH_EXACT;
86
+ if (spellings.some((spelling) => spelling.startsWith(token)))
87
+ return MATCH_PREFIX;
88
+ const overtyped = spellings.find((spelling) => token.startsWith(spelling));
89
+ if (overtyped) {
90
+ return MATCH_OVERTYPED + Math.min(token.length - overtyped.length, 99);
91
+ }
92
+ const distances = spellings
93
+ .map((spelling) => editDistanceWithin(body, spelling.slice(1), typoBudget(body)))
94
+ .filter((distance) => distance !== null);
95
+ if (distances.length)
96
+ return MATCH_TYPO + Math.min(...distances);
97
+ if (command.includes(body))
98
+ return MATCH_IN_NAME;
99
+ if (option.description.toLowerCase().includes(body))
100
+ return MATCH_IN_DESCRIPTION;
101
+ return MATCH_NONE;
102
+ }
103
+ function rankSlashCommands(input) {
104
+ const token = getInputCommandToken(input).toLowerCase();
105
+ if (!token)
106
+ return [];
107
+ return SLASH_COMMANDS.map((option, order) => ({
108
+ option,
109
+ order,
110
+ score: scoreSlashCommand(option, token),
111
+ }))
112
+ .filter((entry) => entry.score < MATCH_NONE)
113
+ .sort((a, b) => a.score - b.score || a.order - b.order)
114
+ .map((entry) => entry.option);
115
+ }
116
+ export function getSlashCommandSuggestions(input) {
117
+ return rankSlashCommands(input).slice(0, SLASH_COMMAND_SUGGESTION_LIMIT);
118
+ }
119
+ export function getHiddenSlashCommandCount(input) {
120
+ return Math.max(0, rankSlashCommands(input).length - SLASH_COMMAND_SUGGESTION_LIMIT);
121
+ }
@@ -1,6 +1,7 @@
1
1
  import { agentModeColor, agentModeLabelTiers, agentModeShortLabel, } from '../../agent-mode.js';
2
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
6
  import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
7
  import { isDarkTerminalBackground, mutedColor, mutedStyle } from './terminal-theme.js';
@@ -189,19 +190,6 @@ export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HO
189
190
  export function formatPromptSessionIdLabel(showSessionId, sessionId) {
190
191
  return showSessionId ? String(sessionId ?? '').trim() : '';
191
192
  }
192
- const CLIENT_SLASH_COMMANDS = [
193
- { command: '/help', description: 'Show available chat commands' },
194
- { command: '/about', description: 'Show version and platform info' },
195
- { command: '/usage', description: 'Show account usage percentage and reset times' },
196
- { command: '/model', description: 'Switch the active model' },
197
- { command: '/resume', description: 'Open the session picker to resume a previous session' },
198
- { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
199
- { command: '/notify', description: 'Toggle desktop notifications when a turn ends (Ctrl+G)' },
200
- { command: '/report', description: 'Report a bug privately on the TheGitAI website' },
201
- { command: '/new', description: 'start a new conversation; this session remains saved' },
202
- { command: '/logout', description: 'Sign out and quit' },
203
- { command: '/exit', description: 'Quit the current session' },
204
- ];
205
193
  function buildModelPickerOptions(currentModelId, serverModels) {
206
194
  return serverModels.map((model) => ({
207
195
  id: model.id,
@@ -213,38 +201,7 @@ function buildModelPickerOptions(currentModelId, serverModels) {
213
201
  note: model.description,
214
202
  }));
215
203
  }
216
- function getInputCommandToken(input) {
217
- const trimmed = String(input ?? '').trimStart();
218
- const match = trimmed.match(/^\/[^\s]*/);
219
- const token = match?.[0] ?? '';
220
- if (token.indexOf('/', 1) !== -1)
221
- return '';
222
- return token;
223
- }
224
- function scoreSlashCommand(option, token) {
225
- if (option.command === token)
226
- return 0;
227
- if (option.command.startsWith(token))
228
- return 1;
229
- if (option.command.includes(token.slice(1)))
230
- return 2;
231
- if (option.description.toLowerCase().includes(token.slice(1)))
232
- return 3;
233
- return 4;
234
- }
235
- export function getSlashCommandSuggestions(input) {
236
- const token = getInputCommandToken(input).toLowerCase();
237
- if (!token)
238
- return [];
239
- const ranked = [...CLIENT_SLASH_COMMANDS].sort((a, b) => {
240
- const scoreDiff = scoreSlashCommand(a, token) - scoreSlashCommand(b, token);
241
- if (scoreDiff !== 0)
242
- return scoreDiff;
243
- return a.command.localeCompare(b.command);
244
- });
245
- const matches = ranked.filter((option) => scoreSlashCommand(option, token) < 4);
246
- return (matches.length ? matches : ranked).slice(0, 5);
247
- }
204
+ export { getSlashCommandSuggestions };
248
205
  function formatModelLabel(modelId, serverModels) {
249
206
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
250
207
  }
@@ -1061,7 +1018,7 @@ function commandPaletteItemLines(option, selected, innerWidth) {
1061
1018
  modelPickerPanelSideLine(line(span(`${MODEL_PICKER_META_INDENT}${option.description}`, { color: 'gray' })), innerWidth),
1062
1019
  ];
1063
1020
  }
1064
- function buildCommandPalettePanel(suggestions, selectedIndex, width) {
1021
+ function buildCommandPalettePanel(suggestions, selectedIndex, hiddenCount, width) {
1065
1022
  const panelWidth = Math.max(28, Math.min(width, MODEL_PICKER_PANEL_MAX_WIDTH));
1066
1023
  const innerWidth = Math.max(1, panelWidth - 4);
1067
1024
  const margin = Array.from({ length: MODEL_PICKER_PANEL_MARGIN_LINES }, () => plainLine(''));
@@ -1080,6 +1037,12 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
1080
1037
  body.push(commandPaletteSeparatorLine(innerWidth));
1081
1038
  }
1082
1039
  });
1040
+ if (hiddenCount > 0) {
1041
+ body.push(commandPaletteSeparatorLine(innerWidth), modelPickerPanelSideLine(plainLine(` ${hiddenCount} more • keep typing to narrow`, {
1042
+ color: 'gray',
1043
+ dim: true,
1044
+ }), innerWidth));
1045
+ }
1083
1046
  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 }));
1084
1047
  return [...margin, ...body, ...margin];
1085
1048
  }
@@ -1329,7 +1292,7 @@ function buildOverlayLines(state, width, height, nowMs) {
1329
1292
  state.input.trimStart().startsWith('/') &&
1330
1293
  !state.input.includes(' ') &&
1331
1294
  suggestions.length > 0) {
1332
- lines.push(...buildCommandPalettePanel(suggestions, state.commandCursor, width));
1295
+ lines.push(...buildCommandPalettePanel(suggestions, state.commandCursor, getHiddenSlashCommandCount(state.input), width));
1333
1296
  }
1334
1297
  return lines;
1335
1298
  }
@@ -3,7 +3,7 @@ import { moveComposerCursorVertically } from './composer-layout.js';
3
3
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
4
4
  import { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
5
5
  import { tryCacheAttachmentBytes } from '../../core/session-image-store.js';
6
- import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, isExactSlashCommandToken, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, } from '../repl.js';
6
+ import { applySlashCommandSuggestion, buildModelPickerOptions, deleteAtCursor, deleteBeforeCursor, getInputCommandToken, getNextApprovalCursor, getNextModelPickerIndex, getSlashCommandSuggestions, insertAtCursor, navigatePromptHistory, shouldRemountLiveFrameForComposerInputChange, slashCommandMatchesToken, } from '../repl.js';
7
7
  import { buildPastePlaceholder, shouldCollapsePaste, } from '../paste-collapse.js';
8
8
  import { handleUserInputPromptEvent, } from './user-input.js';
9
9
  const APPROVAL_PREVIEW_PAGE_ROWS = 3;
@@ -56,7 +56,9 @@ function shouldShowCommandPalette(state) {
56
56
  return false;
57
57
  }
58
58
  const token = getInputCommandToken(state.input);
59
- return token.startsWith('/') && !token.includes(' ');
59
+ if (!token.startsWith('/') || state.input.includes(' '))
60
+ return false;
61
+ return getSlashCommandSuggestions(state.input).length > 0;
60
62
  }
61
63
  function insertPastedText(store, handlers, text) {
62
64
  const current = store.getState();
@@ -549,10 +551,10 @@ export function handleShellKeyEvent(store, handlers, event) {
549
551
  }
550
552
  if (key.returnKey) {
551
553
  const token = getInputCommandToken(state.input);
552
- if (commandPaletteActive && !isExactSlashCommandToken(token)) {
553
- const suggestion = commandSuggestions[Math.min(state.commandCursor, commandSuggestions.length - 1)];
554
- if (!suggestion)
555
- return;
554
+ const suggestion = commandSuggestions[Math.min(state.commandCursor, commandSuggestions.length - 1)];
555
+ if (commandPaletteActive &&
556
+ suggestion &&
557
+ !slashCommandMatchesToken(suggestion, token)) {
556
558
  store.update((current) => {
557
559
  const nextInput = applySlashCommandSuggestion(current.input, suggestion);
558
560
  prepareForComposerInputChange(current, nextInput.input);
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
+ import { emitKeypressEvents } from 'node:readline';
4
5
  import readline from 'node:readline/promises';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import chalk from './colors.js';
@@ -35,21 +36,55 @@ export function buildUpdateOffer({ currentVersion, latestVersion, moduleDir, })
35
36
  display: [program, ...args].join(' '),
36
37
  };
37
38
  }
38
- export function formatUpdateScreen(offer) {
39
+ export const UPDATE_OPTIONS = ['update', 'skip'];
40
+ const UPDATE_KEY_HINT = '↑/↓ choose • Enter accepts • Ctrl+C quits';
41
+ function formatUpdateHeader(offer) {
39
42
  return [
40
43
  '',
41
44
  ` ${chalk.yellow('✨ Update available')} — ${chalk.dim(offer.currentVersion)} → ${chalk.bold(offer.latestVersion)}`,
42
45
  '',
43
- ' TheGitAI supports the current release only. On an older CLI a session',
44
- ' can fail partway through, in ways this terminal cannot explain.',
46
+ ' TheGitAI works best with the latest version. TheGitAI may not work',
47
+ ' optimally if not updated.',
45
48
  '',
46
- ` ${chalk.cyan('1.')} Update now ${chalk.dim(`(runs \`${offer.display}\`)`)}`,
47
- ` ${chalk.cyan('2.')} Skip ${chalk.dim('(Agent may not work optimally)')}`,
49
+ ].join('\n');
50
+ }
51
+ export function formatUpdateOptions(offer, selectedIndex = 0) {
52
+ const rows = [
53
+ { note: `(runs \`${offer.display}\`)`, text: 'Update now' },
54
+ { note: '(Agent may not work optimally)', text: 'Skip' },
55
+ ];
56
+ return [
57
+ ...rows.map(({ note, text }, index) => index === selectedIndex
58
+ ? ` ${chalk.cyan(`› ${index + 1}. ${text}`)} ${chalk.dim(note)}`
59
+ : ` ${chalk.dim(`${index + 1}.`)} ${text} ${chalk.dim(note)}`),
48
60
  '',
49
- ` ${chalk.dim('Press Enter to update, or 2 to skip.')}`,
61
+ ` ${chalk.dim(UPDATE_KEY_HINT)}`,
50
62
  '',
51
63
  ].join('\n');
52
64
  }
65
+ export function formatUpdateScreen(offer, selectedIndex = 0) {
66
+ return `${formatUpdateHeader(offer)}\n${formatUpdateOptions(offer, selectedIndex)}`;
67
+ }
68
+ export function readUpdateKey(key, selectedIndex) {
69
+ const name = key.name ?? '';
70
+ if (key.ctrl && (name === 'c' || name === 'd')) {
71
+ return { kind: 'answer', answer: 'cancel' };
72
+ }
73
+ if (name === 'return' || name === 'enter') {
74
+ return { kind: 'answer', answer: UPDATE_OPTIONS[selectedIndex] ?? 'update' };
75
+ }
76
+ if (name === 'up' || (name === 'tab' && key.sequence === '\u001b[Z')) {
77
+ return { kind: 'select', index: Math.max(selectedIndex - 1, 0) };
78
+ }
79
+ if (name === 'down' || name === 'tab') {
80
+ return { kind: 'select', index: Math.min(selectedIndex + 1, UPDATE_OPTIONS.length - 1) };
81
+ }
82
+ const digit = Number.parseInt(key.sequence ?? '', 10);
83
+ if (Number.isInteger(digit) && digit >= 1 && digit <= UPDATE_OPTIONS.length) {
84
+ return { kind: 'answer', answer: UPDATE_OPTIONS[digit - 1] };
85
+ }
86
+ return { kind: 'ignore' };
87
+ }
53
88
  export function readUpdateChoice(answer) {
54
89
  const value = answer.trim().toLowerCase();
55
90
  if (value === '' || value === '1' || value === 'y' || value === 'yes') {
@@ -67,7 +102,56 @@ export async function resolveUpdateAnswer(question) {
67
102
  throw error;
68
103
  }
69
104
  }
70
- async function askUpdateChoice() {
105
+ export function renderedRowCount(text, columns) {
106
+ const width = Math.max(1, columns);
107
+ const lines = text.split('\n');
108
+ if (lines[lines.length - 1] === '')
109
+ lines.pop();
110
+ return lines.reduce((rows, line) => {
111
+ const visible = [...line.replace(/\x1b\[[0-9;]*m/g, '')].length;
112
+ return rows + Math.max(1, Math.ceil(visible / width));
113
+ }, 0);
114
+ }
115
+ async function askUpdateChoiceWithKeys(offer) {
116
+ let selected = 0;
117
+ const show = (index) => {
118
+ const rows = renderedRowCount(`${formatUpdateOptions(offer, selected)}\n`, output.columns ?? 80);
119
+ selected = index;
120
+ output.write(`\u001b[${rows}A\u001b[0J${formatUpdateOptions(offer, selected)}\n`);
121
+ };
122
+ emitKeypressEvents(input);
123
+ const wasRaw = input.isRaw === true;
124
+ input.setRawMode(true);
125
+ input.resume();
126
+ try {
127
+ return await new Promise((resolve) => {
128
+ const onKeypress = (_text, key) => {
129
+ const action = readUpdateKey(key ?? {}, selected);
130
+ if (action.kind === 'ignore')
131
+ return;
132
+ if (action.kind === 'select') {
133
+ if (action.index !== selected)
134
+ show(action.index);
135
+ return;
136
+ }
137
+ const chosen = UPDATE_OPTIONS.indexOf(action.answer);
138
+ if (chosen >= 0 && chosen !== selected)
139
+ show(chosen);
140
+ input.off('keypress', onKeypress);
141
+ resolve(action.answer);
142
+ };
143
+ input.on('keypress', onKeypress);
144
+ });
145
+ }
146
+ finally {
147
+ if (!wasRaw)
148
+ input.setRawMode(false);
149
+ input.pause();
150
+ }
151
+ }
152
+ async function askUpdateChoice(offer) {
153
+ if (input.isTTY && output.isTTY)
154
+ return await askUpdateChoiceWithKeys(offer);
71
155
  const rl = readline.createInterface({ input, output });
72
156
  try {
73
157
  return await resolveUpdateAnswer(() => rl.question(' ▸ '));
@@ -93,7 +177,7 @@ export async function offerUpdateAtStartup({ currentVersion, latestVersion, ask
93
177
  if (!offer)
94
178
  return 'none';
95
179
  write(`${formatUpdateScreen(offer)}\n`);
96
- const answer = await ask();
180
+ const answer = await ask(offer);
97
181
  if (answer === 'cancel') {
98
182
  write(`\n ${chalk.dim('Cancelled. Nothing was installed.')}\n\n`);
99
183
  return 'cancelled';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.45",
3
+ "version": "1.0.0-preview.46",
4
4
  "description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
5
5
  "keywords": [
6
6
  "agentic-ai",
@@ -44,11 +44,11 @@
44
44
  "@lydell/node-pty-linux-x64": "1.1.0",
45
45
  "@lydell/node-pty-win32-arm64": "1.1.0",
46
46
  "@lydell/node-pty-win32-x64": "1.1.0",
47
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.45",
48
- "@thegitai/tui-darwin-x64": "1.0.0-preview.45",
49
- "@thegitai/tui-linux-arm64": "1.0.0-preview.45",
50
- "@thegitai/tui-linux-x64": "1.0.0-preview.45",
51
- "@thegitai/tui-win32-x64": "1.0.0-preview.45",
47
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.46",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.46",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.46",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.46",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.46",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {