@thegitai/cli 1.0.0-preview.7 → 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);
@@ -79,7 +79,7 @@ const HELP_MARKDOWN = [
79
79
  ' browse them, press Enter to expand one and read its output, k to stop it',
80
80
  '- `/jobs output <id>` — print one job\'s full captured output',
81
81
  '- `/jobs kill <id>` — stop one background job',
82
- '- `/clear` — clear the current conversation history',
82
+ '- `/new` — start a new conversation; this session remains saved',
83
83
  '- `/exit` — quit the session',
84
84
  '',
85
85
  '## Safety & approvals',
@@ -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) {
@@ -69,6 +69,14 @@ export function createSession({ rootDir, autoYes = false, agentMode, modelId, ma
69
69
  serverState: cloneOpaqueState(serverState),
70
70
  };
71
71
  }
72
+ export function startNewConversation(session) {
73
+ clearConversation(session);
74
+ const createdAt = new Date().toISOString();
75
+ session.sessionId = createSessionId();
76
+ session.sessionName = null;
77
+ session.sessionCreatedAt = createdAt;
78
+ session.sessionUpdatedAt = createdAt;
79
+ }
72
80
  export function clearConversation(session) {
73
81
  session.history = [];
74
82
  session.serverState = preserveProviderSelection(session.serverState);
@@ -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);
@@ -15,8 +15,8 @@ import { clearCliAuthConfig } from '../api/auth.js';
15
15
  import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
16
16
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
17
17
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
18
- import { clearConversation, } from '../session.js';
19
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
18
+ import { startNewConversation, } from '../session.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';
@@ -116,8 +116,8 @@ export const SLASH_COMMANDS = [
116
116
  description: 'Background jobs: pick to view output or kill',
117
117
  },
118
118
  {
119
- command: '/clear',
120
- description: 'Clear conversation history',
119
+ command: '/new',
120
+ description: 'start a new conversation; this session remains saved',
121
121
  },
122
122
  {
123
123
  command: '/exit',
@@ -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
  }
@@ -1320,6 +1323,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1320
1323
  let latestUsageSummary = null;
1321
1324
  let pendingTurnEntries = [];
1322
1325
  let activeTurnAbort = null;
1326
+ let newConversationInFlight = false;
1323
1327
  let todosTouchedThisTurn = false;
1324
1328
  const syncTodosState = () => {
1325
1329
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1521,7 +1525,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1521
1525
  }));
1522
1526
  };
1523
1527
  const cancelActiveTurn = () => {
1524
- if (!store.getState().busy)
1528
+ if (!store.getState().busy || newConversationInFlight)
1525
1529
  return;
1526
1530
  disarmExitConfirm();
1527
1531
  dismissPendingApproval();
@@ -1660,7 +1664,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1660
1664
  if (!isAuthenticationError(error))
1661
1665
  return false;
1662
1666
  clearCliAuthConfig(session.env);
1663
- saveSessionState(session);
1667
+ if (sessionHasUserMessage(session)) {
1668
+ saveSessionState(session);
1669
+ }
1664
1670
  fatalError = new Error(authenticationErrorMessage(error));
1665
1671
  requestExit();
1666
1672
  return true;
@@ -1677,6 +1683,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1677
1683
  return true;
1678
1684
  }
1679
1685
  };
1686
+ const saveActiveSessionOrAbort = async () => {
1687
+ try {
1688
+ await saveSessionBoth({ serverSessionClient, session });
1689
+ return true;
1690
+ }
1691
+ catch (error) {
1692
+ if (exitForAuthenticationError(error))
1693
+ return false;
1694
+ appendError(`Session save failed: ${error.message}`);
1695
+ return false;
1696
+ }
1697
+ };
1680
1698
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1681
1699
  if (exiting || signal?.aborted) {
1682
1700
  resolve(null);
@@ -2243,23 +2261,53 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2243
2261
  }
2244
2262
  return;
2245
2263
  }
2246
- if (input === '/clear') {
2247
- clearConversation(session);
2264
+ if (input === '/new') {
2265
+ newConversationInFlight = true;
2266
+ store.update((current) => ({
2267
+ ...current,
2268
+ busy: true,
2269
+ busySince: Date.now(),
2270
+ imageAttachments: [],
2271
+ queuedMessage: null,
2272
+ status: 'Starting a new conversation...',
2273
+ }));
2274
+ if (!(await saveActiveSessionOrAbort())) {
2275
+ newConversationInFlight = false;
2276
+ store.update((current) => ({
2277
+ ...current,
2278
+ busy: false,
2279
+ busySince: null,
2280
+ status: 'Ready',
2281
+ }));
2282
+ await flushQueuedMessage();
2283
+ return;
2284
+ }
2285
+ startNewConversation(session);
2286
+ setBackgroundJobSession(session.sessionId);
2287
+ setScratchSession(session.sessionId);
2288
+ setTodoSession(session.sessionId);
2248
2289
  clearTodos();
2290
+ syncBackgroundJobsState();
2249
2291
  syncTodosState();
2250
2292
  latestUsageSummary = null;
2251
- if (!(await saveActiveSession()))
2252
- return;
2253
2293
  store.replaceTranscript([
2254
2294
  {
2255
- body: 'Conversation cleared.',
2295
+ body: 'Started a new conversation. The previous session remains saved.',
2256
2296
  kind: 'system',
2257
- title: 'System',
2297
+ title: 'Session',
2258
2298
  },
2259
2299
  ]);
2300
+ await saveActiveSession();
2260
2301
  syncShellStateFromSession();
2261
- store.update((current) => ({ ...current, queuedMessage: null }));
2302
+ newConversationInFlight = false;
2303
+ store.update((current) => ({
2304
+ ...current,
2305
+ busy: false,
2306
+ busySince: null,
2307
+ status: 'Ready',
2308
+ }));
2262
2309
  await remountTui();
2310
+ await flushQueuedMessage();
2263
2311
  return;
2264
2312
  }
2265
2313
  latestUsageSummary = null;
@@ -2284,6 +2332,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2284
2332
  ...current,
2285
2333
  activeTurnInput: input,
2286
2334
  activeTurnInputPreformatted: preformatted,
2335
+ analyzingImages: 0,
2287
2336
  busy: true,
2288
2337
  busySince: turnStartedAt,
2289
2338
  clockNow: turnStartedAt,
@@ -2407,6 +2456,12 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2407
2456
  }
2408
2457
  }
2409
2458
  };
2459
+ session.onImageAnalysis = (activeImageCount) => {
2460
+ store.update((current) => ({
2461
+ ...current,
2462
+ analyzingImages: Math.max(0, activeImageCount),
2463
+ }));
2464
+ };
2410
2465
  session.onStatus = (message) => {
2411
2466
  const panel = thinkingPanelFromStatus(message);
2412
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';
@@ -163,7 +163,7 @@ 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) {
@@ -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
  }
@@ -923,7 +967,10 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
923
967
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
924
968
  if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
925
969
  const composerLines = [];
926
- 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) {
927
974
  const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
928
975
  const imageCount = state.queuedMessage.imageAttachments.length;
929
976
  composerLines.push(line(span(`↳ Queued · "${preview}"`, { color: 'gray', dim: true }), ...(imageCount > 0
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.7",
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.7",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.7",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.7",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.7",
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": {