@thegitai/cli 1.0.0-preview.8 → 1.0.0-preview.9

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/dist/bin/ai.js CHANGED
@@ -8,7 +8,7 @@ import { authenticationErrorMessage, isAuthenticationError, isTransientNetworkEr
8
8
  import { formatCliHelpText } from '../src/help-text.js';
9
9
  import { createIndex } from '../src/project-index.js';
10
10
  import { createSession } from '../src/session.js';
11
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../src/session-store.js';
11
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../src/session-store.js';
12
12
  import { runClientInteractive } from '../src/ui/repl.js';
13
13
  import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
14
14
  import { formatSessionExitNotice } from '../src/session-exit.js';
@@ -116,6 +116,8 @@ function modelLabel(serverModels, modelId) {
116
116
  `Model ${modelId}`);
117
117
  }
118
118
  async function saveSessionBoth({ session, serverSessionClient, }) {
119
+ if (!sessionHasUserMessage(session))
120
+ return;
119
121
  saveSessionState(session);
120
122
  try {
121
123
  await serverSessionClient.save(session);
@@ -1,4 +1,6 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import crypto from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
2
4
  import http from 'node:http';
3
5
  import os from 'node:os';
4
6
  import { openUrl } from '../core/open-url.js';
@@ -21,6 +23,73 @@ function defaultDeviceName() {
21
23
  return os.hostname();
22
24
  }
23
25
  }
26
+ function readLinuxOsPrettyName() {
27
+ try {
28
+ const content = readFileSync('/etc/os-release', 'utf8');
29
+ return content.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)?.[1]?.trim() ?? '';
30
+ }
31
+ catch {
32
+ return '';
33
+ }
34
+ }
35
+ function macArchLabel() {
36
+ try {
37
+ if (process.arch === 'arm64')
38
+ return 'Apple Silicon';
39
+ if (os.cpus().some((cpu) => cpu.model.includes('Apple'))) {
40
+ return 'Apple Silicon';
41
+ }
42
+ }
43
+ catch {
44
+ }
45
+ return 'Intel';
46
+ }
47
+ export function describeOperatingSystem() {
48
+ try {
49
+ if (process.platform === 'linux') {
50
+ const base = readLinuxOsPrettyName() || `Linux ${os.release()}`;
51
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
52
+ }
53
+ if (process.platform === 'darwin') {
54
+ let version = '';
55
+ try {
56
+ version = execSync('sw_vers -productVersion', {
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ })
59
+ .toString()
60
+ .trim();
61
+ }
62
+ catch {
63
+ }
64
+ return `${version ? `macOS ${version}` : 'macOS'}, ${macArchLabel()}`;
65
+ }
66
+ if (process.platform === 'win32') {
67
+ let label = '';
68
+ try {
69
+ label = os.version();
70
+ }
71
+ catch {
72
+ }
73
+ const build = Number(os.release().split('.')[2] ?? '0');
74
+ if (build >= 22000)
75
+ label = label.replace(/Windows 10/i, 'Windows 11');
76
+ const base = label || `Windows ${os.release()}`;
77
+ return process.arch === 'arm64' ? `${base}, ARM64` : base;
78
+ }
79
+ return `${process.platform} ${os.release()}`;
80
+ }
81
+ catch {
82
+ return process.platform;
83
+ }
84
+ }
85
+ export function withOperatingSystemInfo(name) {
86
+ const trimmed = name.trim();
87
+ const osLabel = describeOperatingSystem();
88
+ const combined = osLabel && !trimmed.includes(osLabel)
89
+ ? `${trimmed} (${osLabel})`
90
+ : trimmed;
91
+ return combined.slice(0, 180);
92
+ }
24
93
  export function generatePkce() {
25
94
  const verifier = crypto.randomBytes(32).toString('base64url');
26
95
  const challenge = crypto
@@ -77,7 +146,7 @@ export async function loginViaBrowser(options) {
77
146
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
78
147
  const openBrowser = options.openBrowser ?? openUrl;
79
148
  const onUrl = options.onUrl ?? (() => { });
80
- const deviceName = options.deviceName ?? defaultDeviceName();
149
+ const deviceName = withOperatingSystemInfo(options.deviceName ?? defaultDeviceName());
81
150
  const { verifier, challenge } = generatePkce();
82
151
  if (options.noBrowser) {
83
152
  const authUrl = buildAuthUrl(websiteUrl, {
@@ -146,6 +146,9 @@ function publicStatusMessage(data) {
146
146
  : 'tool';
147
147
  if (event.phase === 'thinking')
148
148
  return 'Thinking...';
149
+ if (event.phase === 'analyzing_image') {
150
+ return (event.imageCount ?? 1) > 1 ? 'Analyzing images...' : 'Analyzing image...';
151
+ }
149
152
  if (event.phase === 'running_tool')
150
153
  return `Running ${toolName}...`;
151
154
  if (event.phase === 'waiting_for_tool')
@@ -303,6 +306,13 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
303
306
  }
304
307
  async function handleEvent(event) {
305
308
  if (event.event === 'status') {
309
+ const data = event.data;
310
+ if (data?.phase === 'analyzing_image') {
311
+ session.onImageAnalysis?.(Math.max(1, Number(data.imageCount ?? 1) || 1));
312
+ }
313
+ else if (data?.phase) {
314
+ session.onImageAnalysis?.(0);
315
+ }
306
316
  const message = publicStatusMessage(event.data);
307
317
  if (message)
308
318
  session.onStatus(message);
@@ -1,12 +1,13 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { getClientStateDir } from './client-state.js';
5
6
  import { normalizeAssistantEditJournal } from './edit-journal.js';
6
7
  import { cloneSessionSafetyState, createSessionSafetyState, mergeLocalSessionSafetyState, normalizeSessionSafetyState, } from './session-safety.js';
7
- import { truncate } from './utils.js';
8
+ import { singleLinePreview } from './utils.js';
8
9
  const SESSION_STORE_VERSION = 1;
9
- const MAX_RECENT_SESSIONS = 5;
10
+ export const MAX_RECENT_SESSIONS = 10;
10
11
  function cloneJson(value) {
11
12
  return JSON.parse(JSON.stringify(value ?? null));
12
13
  }
@@ -77,6 +78,12 @@ function listSessionFiles(rootDir, env = process.env) {
77
78
  .filter((name) => name.endsWith('.json'))
78
79
  .map((name) => path.join(dir, name));
79
80
  }
81
+ function normalizeBranch(value) {
82
+ const text = String(value ?? '')
83
+ .replace(/[\r\n\t]/g, ' ')
84
+ .trim();
85
+ return text ? text.slice(0, 120) : null;
86
+ }
80
87
  function normalizeHistory(value) {
81
88
  if (!Array.isArray(value))
82
89
  return [];
@@ -107,6 +114,7 @@ function normalizeSnapshot(raw, rootDir) {
107
114
  createdAt: normalizeIsoDate(raw.createdAt),
108
115
  updatedAt: normalizeIsoDate(raw.updatedAt),
109
116
  modelId,
117
+ branch: normalizeBranch(raw.branch),
110
118
  history: cloneJson(normalizeHistory(raw.history)),
111
119
  clientState: sanitizeClientState(raw.clientState),
112
120
  serverState: sanitizeOpaqueState(raw.serverState),
@@ -154,6 +162,21 @@ export function pruneSavedSessions(rootDir, env = process.env) {
154
162
  }
155
163
  }
156
164
  }
165
+ export function readGitBranch(rootDir) {
166
+ try {
167
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
168
+ cwd: rootDir,
169
+ encoding: 'utf8',
170
+ timeout: 1500,
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ });
173
+ const branch = result.status === 0 ? String(result.stdout ?? '').trim() : '';
174
+ return branch && branch !== 'HEAD' ? branch : null;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
157
180
  export function snapshotFromSession(session) {
158
181
  const now = new Date().toISOString();
159
182
  const createdAt = normalizeIsoDate(session.sessionCreatedAt ?? now);
@@ -168,6 +191,7 @@ export function snapshotFromSession(session) {
168
191
  createdAt,
169
192
  updatedAt: now,
170
193
  modelId: session.modelId,
194
+ branch: readGitBranch(session.rootDir),
171
195
  history: cloneJson(session.history),
172
196
  clientState: {
173
197
  editCounter: session.clientState.editCounter,
@@ -210,6 +234,9 @@ export function applySessionSnapshot(session, snapshot, options = {}) {
210
234
  safety: mergeLocalSessionSafetyState(session.clientState.safety, snapshot.clientState.safety),
211
235
  };
212
236
  }
237
+ export function sessionHasUserMessage(session) {
238
+ return session.history.some((entry) => Boolean(userPromptText(entry)));
239
+ }
213
240
  function extractMarkedSection(text, marker) {
214
241
  const index = text.indexOf(marker);
215
242
  if (index === -1)
@@ -218,23 +245,27 @@ function extractMarkedSection(text, marker) {
218
245
  const end = after.indexOf('\n\n');
219
246
  return (end === -1 ? after : after.slice(0, end)).trim();
220
247
  }
221
- function extractLastUserMessage(history) {
222
- for (let i = history.length - 1; i >= 0; i--) {
223
- const entry = history[i];
224
- if (!entry || entry.role !== 'user')
225
- continue;
226
- const text = (entry.parts ?? [])
227
- .map((part) => (typeof part?.text === 'string' ? part.text : ''))
228
- .filter(Boolean)
229
- .join('\n')
230
- .trim();
231
- if (!text)
232
- continue;
233
- const request = extractMarkedSection(text, 'Current user request:') ||
234
- extractMarkedSection(text, 'User request:');
235
- const message = extractMarkedSection(text, 'Current user message:') ||
236
- extractMarkedSection(text, 'User message:');
237
- return truncate(request || message || text, 120);
248
+ export function userPromptText(entry) {
249
+ if (!entry || entry.role !== 'user' || entry.kind !== 'turnStart')
250
+ return '';
251
+ const text = (entry.parts ?? [])
252
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
253
+ .filter(Boolean)
254
+ .join('\n')
255
+ .trim();
256
+ if (!text)
257
+ return '';
258
+ const request = extractMarkedSection(text, 'Current user request:') ||
259
+ extractMarkedSection(text, 'User request:');
260
+ const message = extractMarkedSection(text, 'Current user message:') ||
261
+ extractMarkedSection(text, 'User message:');
262
+ return (request || message || text).trim();
263
+ }
264
+ function extractFirstUserPrompt(history) {
265
+ for (const entry of history) {
266
+ const text = userPromptText(entry);
267
+ if (text)
268
+ return singleLinePreview(text, 120);
238
269
  }
239
270
  return '';
240
271
  }
@@ -247,8 +278,9 @@ function metadataFromSnapshot(snapshot) {
247
278
  updatedAt: snapshot.updatedAt,
248
279
  modelId: snapshot.modelId,
249
280
  messageCount: snapshot.history.length,
250
- lastUserMessage: extractLastUserMessage(snapshot.history),
281
+ lastUserMessage: extractFirstUserPrompt(snapshot.history),
251
282
  summaryPreview: '',
283
+ branch: snapshot.branch ?? null,
252
284
  };
253
285
  }
254
286
  export function listSessionMetadata(rootDir, env = process.env) {
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
4
  const HISTORY_FILE = 'prompt-history.json';
5
5
  const LEGACY_HISTORY_FILE = 'prompt-history.txt';
6
- export const MAX_PROMPT_HISTORY_ENTRIES = 15;
6
+ export const MAX_PROMPT_HISTORY_ENTRIES = 20;
7
7
  const ENTRY_SEPARATOR = '\x1e';
8
8
  function getHistoryFilePath(env = process.env) {
9
9
  return path.join(getClientStateDir(env), HISTORY_FILE);
@@ -16,7 +16,7 @@ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.
16
16
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
17
17
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
18
18
  import { startNewConversation, } from '../session.js';
19
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
19
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../session-store.js';
20
20
  import { truncate } from '../utils.js';
21
21
  import { writeClipboardText } from '../core/clipboard.js';
22
22
  import { openUrl } from '../core/open-url.js';
@@ -963,6 +963,7 @@ function createInitialShellState(session, serverModels, debugUi) {
963
963
  activeTurnInput: '',
964
964
  activeTurnInputPreformatted: false,
965
965
  agentMode: session.agentMode,
966
+ analyzingImages: 0,
966
967
  approvalCursor: getDefaultApprovalCursor(),
967
968
  approvalPrompt: null,
968
969
  autoYes: session.autoYes,
@@ -1282,6 +1283,8 @@ function appendCommandLog(state, text) {
1282
1283
  };
1283
1284
  }
1284
1285
  async function saveSessionBoth({ serverSessionClient, session, }) {
1286
+ if (!sessionHasUserMessage(session))
1287
+ return;
1285
1288
  saveSessionState(session);
1286
1289
  await serverSessionClient.save(session);
1287
1290
  }
@@ -1661,7 +1664,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1661
1664
  if (!isAuthenticationError(error))
1662
1665
  return false;
1663
1666
  clearCliAuthConfig(session.env);
1664
- saveSessionState(session);
1667
+ if (sessionHasUserMessage(session)) {
1668
+ saveSessionState(session);
1669
+ }
1665
1670
  fatalError = new Error(authenticationErrorMessage(error));
1666
1671
  requestExit();
1667
1672
  return true;
@@ -2327,6 +2332,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2327
2332
  ...current,
2328
2333
  activeTurnInput: input,
2329
2334
  activeTurnInputPreformatted: preformatted,
2335
+ analyzingImages: 0,
2330
2336
  busy: true,
2331
2337
  busySince: turnStartedAt,
2332
2338
  clockNow: turnStartedAt,
@@ -2450,6 +2456,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2450
2456
  }
2451
2457
  }
2452
2458
  };
2459
+ session.onImageAnalysis = (activeImageCount) => {
2460
+ store.update((current) => ({
2461
+ ...current,
2462
+ analyzingImages: Math.max(0, activeImageCount),
2463
+ }));
2464
+ };
2453
2465
  session.onStatus = (message) => {
2454
2466
  const panel = thinkingPanelFromStatus(message);
2455
2467
  store.update((current) => ({
@@ -1,5 +1,5 @@
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
5
  import { displayWidth, line, plainLine, sliceToWidth, span, wrapText, } from './text.js';
@@ -212,6 +212,9 @@ export function getSlashCommandSuggestions(input) {
212
212
  function formatModelLabel(modelId, serverModels) {
213
213
  return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
214
214
  }
215
+ function pickerModelLabel(modelId, serverModels) {
216
+ return formatModelLabel(modelId, serverModels).replace(/\s*\([^)]*\)\s*$/, '');
217
+ }
215
218
  function filterResumeSessions(sessions, filter, serverModels) {
216
219
  const q = filter.trim().toLowerCase();
217
220
  if (!q)
@@ -549,7 +552,12 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
549
552
  }
550
553
  lines.push(plainLine(''));
551
554
  }
552
- 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' }));
553
561
  const todos = state.todos ?? [];
554
562
  if (todos.length > 0) {
555
563
  lines.push(plainLine(''));
@@ -865,18 +873,54 @@ function buildOverlayLines(state, width, height, nowMs) {
865
873
  }
866
874
  if (state.resumePickerOpen) {
867
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 });
868
878
  lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
869
879
  lines.push(line(span('Search: ', { color: 'gray' }), span(state.resumePickerFilter, {}), span('█', { color: 'gray' })));
870
- 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;
871
892
  const selected = index === state.resumePickerIndex;
872
- const color = selected ? 'cyan' : undefined;
873
- const model = truncate(formatModelLabel(session.modelId, state.serverModels), 26);
874
- 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 }));
875
918
  }
876
919
  if (filtered.length === 0) {
877
920
  lines.push(plainLine('No sessions match.', { color: 'gray' }));
878
921
  }
879
- 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', {
880
924
  color: 'gray',
881
925
  }));
882
926
  }
package/dist/src/utils.js CHANGED
@@ -17,6 +17,15 @@ export function truncate(text, maxChars = 4000) {
17
17
  return text;
18
18
  return `${text.slice(0, maxChars)}\n... (truncated)`;
19
19
  }
20
+ export function singleLinePreview(text, maxChars) {
21
+ const collapsed = String(text ?? '')
22
+ .replace(/\n\.\.\. \(truncated\)\s*$/, '…')
23
+ .replace(/\s+/g, ' ')
24
+ .trim();
25
+ if (collapsed.length <= maxChars)
26
+ return collapsed;
27
+ return `${collapsed.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
28
+ }
20
29
  export function clampInteger(value, fallback, max) {
21
30
  const numeric = Number(value);
22
31
  if (!Number.isFinite(numeric) || numeric <= 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.8",
3
+ "version": "1.0.0-preview.9",
4
4
  "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,10 +37,10 @@
37
37
  "@lydell/node-pty-linux-x64": "1.1.0",
38
38
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
39
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.8",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.8",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.8",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.8",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.9",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.9",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.9",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.9",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {