@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.6

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
@@ -34,6 +34,10 @@ always be viewed or resumed from that computer. Continuing it through the
34
34
  service requires the TheGitAI account used for that session. Local sessions can
35
35
  also be listed while signed out or offline.
36
36
 
37
+ CLI login tokens use a rolling 24-hour inactivity timeout. If one expires during
38
+ any server request, the CLI saves the local session, removes the expired
39
+ credential, and asks you to run `ai login` before resuming.
40
+
37
41
  ## Visible to-do list
38
42
 
39
43
  For larger multi-step tasks, the agent keeps a compact to-do list on screen so
package/dist/bin/ai.js CHANGED
@@ -4,7 +4,7 @@ import { stdin as input, stdout as output } from 'node:process';
4
4
  import readline from 'node:readline/promises';
5
5
  import { ServerApi } from '../src/api/index.js';
6
6
  import { loginViaBrowser } from '../src/api/browser-login.js';
7
- import { isTransientNetworkError } from '../src/api/http.js';
7
+ import { authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
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';
@@ -268,6 +268,12 @@ export async function main() {
268
268
  printSessionExit(session);
269
269
  }
270
270
  main().catch((error) => {
271
- console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
271
+ if (isAuthenticationError(error)) {
272
+ auth.clearCliAuthConfig();
273
+ console.error(chalk.red(`\n✖ Error: ${authenticationErrorMessage(error)}\n`));
274
+ }
275
+ else {
276
+ console.error(chalk.red(`\n✖ Error: ${error.message}\n`));
277
+ }
272
278
  process.exit(1);
273
279
  });
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { ServerApiError, authorizedJson, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
4
+ import { ServerApiError, authorizedJson, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  export function getAuthConfigPath(env = process.env) {
6
6
  const configured = String(env.THEGITAI_AUTH_CONFIG ?? '').trim();
7
7
  if (configured) {
@@ -78,6 +78,6 @@ export async function logoutFromServer({ config, fetchImpl = globalThis.fetch, }
78
78
  });
79
79
  if (!response.ok && response.status !== 401) {
80
80
  const data = (await readJsonResponse(response));
81
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
81
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
82
82
  }
83
83
  }
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import http from 'node:http';
3
3
  import os from 'node:os';
4
4
  import { openUrl } from '../core/open-url.js';
5
- import { ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
5
+ import { ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, } from './http.js';
6
6
  const DEFAULT_WEBSITE_URL = 'https://thegit.ai';
7
7
  const DEFAULT_SERVER_URL = 'https://thegit.ai';
8
8
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
@@ -56,7 +56,7 @@ async function exchangeCodeForToken({ serverUrl, code, codeVerifier, fetchImpl,
56
56
  });
57
57
  const data = (await readJsonResponse(response));
58
58
  if (!response.ok) {
59
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
59
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
60
60
  }
61
61
  const token = String(data?.token ?? '').trim();
62
62
  const customer = data?.customer;
@@ -52,6 +52,7 @@ function parseSseBlock(block) {
52
52
  return { event, data: text };
53
53
  }
54
54
  }
55
+ let toolStateSeqCounter = 0;
55
56
  function toolStateFromSession(session) {
56
57
  return {
57
58
  autoYes: session.autoYes,
@@ -185,6 +186,7 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
185
186
  toolCallId: event.call.id,
186
187
  result,
187
188
  toolState: toolStateFromSession(session),
189
+ toolStateSeq: ++toolStateSeqCounter,
188
190
  };
189
191
  const trace = createTraceContext(traceId);
190
192
  const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/chat/turn/${encodeURIComponent(turnId)}/tool-result`, {
@@ -203,6 +205,29 @@ async function postToolResult({ config, turnId, event, result, session, fetchImp
203
205
  throw await readErrorResponse(response, trace.traceId);
204
206
  }
205
207
  }
208
+ const turnIdOverrides = new WeakMap();
209
+ function enterServerTurnId(session, serverSessionTurnId) {
210
+ const active = turnIdOverrides.get(session);
211
+ if (active) {
212
+ active.depth += 1;
213
+ return;
214
+ }
215
+ turnIdOverrides.set(session, {
216
+ previousTurnId: session.turnState.id,
217
+ depth: 1,
218
+ });
219
+ session.turnState.id = serverSessionTurnId;
220
+ }
221
+ function exitServerTurnId(session) {
222
+ const active = turnIdOverrides.get(session);
223
+ if (!active)
224
+ return;
225
+ active.depth -= 1;
226
+ if (active.depth === 0) {
227
+ session.turnState.id = active.previousTurnId;
228
+ turnIdOverrides.delete(session);
229
+ }
230
+ }
206
231
  async function executeAndPostToolResult({ config, projectIndex, session, event, input, fetchImpl, signal, traceId, }) {
207
232
  const turnId = String(event?.turnId ?? '').trim();
208
233
  if (!turnId || !event?.call?.id || !event.call.name) {
@@ -211,10 +236,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
211
236
  if (signal?.aborted) {
212
237
  throw new TurnCancelledError();
213
238
  }
214
- const previousTurnId = session.turnState.id;
215
239
  const serverSessionTurnId = String(event.sessionTurnId ?? '').trim();
216
240
  if (serverSessionTurnId) {
217
- session.turnState.id = serverSessionTurnId;
241
+ enterServerTurnId(session, serverSessionTurnId);
218
242
  if (!session.clientState.safety.checkpoints.some((checkpoint) => checkpoint.turnId === serverSessionTurnId)) {
219
243
  createPromptCheckpoint(session.clientState.safety, 'prompt boundary', serverSessionTurnId);
220
244
  }
@@ -237,7 +261,9 @@ async function executeAndPostToolResult({ config, projectIndex, session, event,
237
261
  });
238
262
  }
239
263
  finally {
240
- session.turnState.id = previousTurnId;
264
+ if (serverSessionTurnId) {
265
+ exitServerTurnId(session);
266
+ }
241
267
  }
242
268
  }
243
269
  async function consumeTurnStream({ response, config, projectIndex, session, input, fetchImpl, signal, traceId, }) {
@@ -250,6 +276,31 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
250
276
  const finalResult = {
251
277
  current: null,
252
278
  };
279
+ const pendingParallelTools = [];
280
+ let firstParallelFailure = null;
281
+ let rejectOnParallelFailure = null;
282
+ const parallelToolFailure = new Promise((_, reject) => {
283
+ rejectOnParallelFailure = reject;
284
+ });
285
+ parallelToolFailure.catch(() => { });
286
+ function recordParallelFailure(error) {
287
+ const failure = error ?? new Error('Local tool execution failed.');
288
+ if (firstParallelFailure == null) {
289
+ firstParallelFailure = failure;
290
+ rejectOnParallelFailure?.(failure);
291
+ }
292
+ return failure;
293
+ }
294
+ async function drainParallelTools() {
295
+ if (!pendingParallelTools.length)
296
+ return;
297
+ const pending = pendingParallelTools.splice(0);
298
+ const outcomes = await Promise.all(pending);
299
+ for (const outcome of outcomes) {
300
+ if (outcome != null)
301
+ throw outcome;
302
+ }
303
+ }
253
304
  async function handleEvent(event) {
254
305
  if (event.event === 'status') {
255
306
  const message = publicStatusMessage(event.data);
@@ -261,11 +312,26 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
261
312
  return;
262
313
  }
263
314
  if (event.event === 'tool-call') {
315
+ const data = event.data;
316
+ if (data?.parallelSafe === true) {
317
+ pendingParallelTools.push(executeAndPostToolResult({
318
+ config,
319
+ projectIndex,
320
+ session,
321
+ event: data,
322
+ input,
323
+ fetchImpl,
324
+ signal,
325
+ traceId,
326
+ }).then(() => null, (error) => recordParallelFailure(error)));
327
+ return;
328
+ }
329
+ await drainParallelTools();
264
330
  await executeAndPostToolResult({
265
331
  config,
266
332
  projectIndex,
267
333
  session,
268
- event: event.data,
334
+ event: data,
269
335
  input,
270
336
  fetchImpl,
271
337
  signal,
@@ -281,10 +347,12 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
281
347
  return;
282
348
  }
283
349
  if (event.event === 'result') {
350
+ await drainParallelTools();
284
351
  finalResult.current = event.data;
285
352
  return;
286
353
  }
287
354
  if (event.event === 'cancelled' || event.event === 'error') {
355
+ await drainParallelTools().catch(() => { });
288
356
  const message = String(event.data?.message ?? 'Server chat failed.');
289
357
  if (event.event === 'cancelled') {
290
358
  throw new TurnCancelledError(message);
@@ -292,24 +360,34 @@ async function consumeTurnStream({ response, config, projectIndex, session, inpu
292
360
  throw new ChatTurnFailedError(message, typeof event.data?.category === 'string' ? event.data.category : 'unknown_error', Boolean(event.data?.retryable), typeof event.data?.traceId === 'string' ? event.data.traceId : traceId);
293
361
  }
294
362
  }
295
- while (true) {
296
- if (signal?.aborted) {
297
- await reader.cancel().catch(() => { });
298
- throw new TurnCancelledError();
299
- }
300
- const read = await reader.read();
301
- if (read.done)
302
- break;
303
- buffer += decoder.decode(read.value, { stream: true });
304
- let separatorIndex = buffer.indexOf('\n\n');
305
- while (separatorIndex !== -1) {
306
- const block = buffer.slice(0, separatorIndex);
307
- buffer = buffer.slice(separatorIndex + 2);
308
- const event = parseSseBlock(block);
309
- if (event)
310
- await handleEvent(event);
311
- separatorIndex = buffer.indexOf('\n\n');
363
+ try {
364
+ while (true) {
365
+ if (signal?.aborted) {
366
+ await reader.cancel().catch(() => { });
367
+ throw new TurnCancelledError();
368
+ }
369
+ const read = await Promise.race([reader.read(), parallelToolFailure]);
370
+ if (read.done)
371
+ break;
372
+ buffer += decoder.decode(read.value, { stream: true });
373
+ let separatorIndex = buffer.indexOf('\n\n');
374
+ while (separatorIndex !== -1) {
375
+ const block = buffer.slice(0, separatorIndex);
376
+ buffer = buffer.slice(separatorIndex + 2);
377
+ const event = parseSseBlock(block);
378
+ if (event)
379
+ await handleEvent(event);
380
+ separatorIndex = buffer.indexOf('\n\n');
381
+ }
312
382
  }
383
+ await drainParallelTools();
384
+ }
385
+ catch (error) {
386
+ await reader.cancel().catch(() => { });
387
+ throw error;
388
+ }
389
+ finally {
390
+ await drainParallelTools().catch(() => { });
313
391
  }
314
392
  buffer += decoder.decode();
315
393
  const tail = buffer.trim();
@@ -6,11 +6,13 @@ export const CLIENT_PLATFORM_HEADER = 'x-thegitai-client-platform';
6
6
  export class ServerApiError extends Error {
7
7
  status;
8
8
  traceId;
9
- constructor(message, status, traceId) {
9
+ code;
10
+ constructor(message, status, traceId, code = '') {
10
11
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
11
12
  this.name = 'ServerApiError';
12
13
  this.status = status;
13
14
  this.traceId = traceId;
15
+ this.code = code;
14
16
  }
15
17
  }
16
18
  export function createTraceId() {
@@ -96,9 +98,20 @@ export async function readJsonResponse(response) {
96
98
  export function failureMessage(data, status) {
97
99
  return String(data?.error?.message ?? data?.message ?? `Request failed with ${status}`);
98
100
  }
101
+ export function failureCode(data) {
102
+ return typeof data?.error?.code === 'string' ? data.error.code : '';
103
+ }
104
+ export function isAuthenticationError(error) {
105
+ return error instanceof ServerApiError && error.status === 401;
106
+ }
107
+ export function authenticationErrorMessage(error) {
108
+ return error.code === 'AUTH_TOKEN_EXPIRED'
109
+ ? 'Your login expired after 24 hours of inactivity. Run `ai login` and resume this saved session.'
110
+ : 'Your login is no longer valid. Run `ai login` and resume this saved session.';
111
+ }
99
112
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
100
113
  const data = await readJsonResponse(response);
101
- return new ServerApiError(failureMessage(data, response.status), response.status, traceId);
114
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
102
115
  }
103
116
  export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
104
117
  const trace = createTraceContext();
@@ -115,7 +128,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
115
128
  });
116
129
  const data = await readJsonResponse(response);
117
130
  if (!response.ok) {
118
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
131
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
119
132
  }
120
133
  return data;
121
134
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getClientStateDir } from '../client-state.js';
4
- import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
4
+ import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
5
5
  function sanitizeModelInfo(raw) {
6
6
  if (!raw || typeof raw !== 'object') {
7
7
  return null;
@@ -75,7 +75,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
75
75
  });
76
76
  const data = (await readJsonResponse(response));
77
77
  if (!response.ok) {
78
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
78
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
79
79
  }
80
80
  const models = Array.isArray(data?.models)
81
81
  ? data.models.map(sanitizeModelInfo).filter(Boolean)
@@ -39,10 +39,21 @@ function removeFile(index, relPath) {
39
39
  index.chunksByFile.delete(relPath);
40
40
  index.fileSignatures.delete(relPath);
41
41
  }
42
+ function countIndexedChunks(index) {
43
+ return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
44
+ }
42
45
  async function initializeIndex(index) {
43
46
  if (index.initialized) {
44
- return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
47
+ return countIndexedChunks(index);
48
+ }
49
+ if (!index._initializing) {
50
+ index._initializing = scanProjectIntoIndex(index).finally(() => {
51
+ index._initializing = null;
52
+ });
45
53
  }
54
+ return index._initializing;
55
+ }
56
+ async function scanProjectIntoIndex(index) {
46
57
  const files = listProjectFiles(index.rootDir);
47
58
  const chunks = await scanFiles(index.rootDir, files);
48
59
  index.fileSignatures.clear();
@@ -106,6 +117,7 @@ export function createIndex({ rootDir, onStatus = null, onContextLog = null, })
106
117
  return {
107
118
  rootDir: path.resolve(rootDir),
108
119
  initialized: false,
120
+ _initializing: null,
109
121
  fileSignatures: new Map(),
110
122
  chunksByFile: new Map(),
111
123
  onStatus,
@@ -1,6 +1,7 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
2
  import { buildTuiFrame, formatJobElapsed, formatTodoProgress, renderTranscriptEntryLines, } from './tui/build-frame.js';
3
3
  import { createTerminalTitleController } from './tui/terminal-title.js';
4
+ import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
4
5
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
5
6
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
6
7
  import { chat, models } from '../api/index.js';
@@ -10,6 +11,8 @@ import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
10
11
  import { setScratchSession } from '../scratch-dir.js';
11
12
  import { cancelActiveCommand } from '../executor.js';
12
13
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
14
+ import { clearCliAuthConfig } from '../api/auth.js';
15
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
13
16
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
14
17
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
15
18
  import { clearConversation, } from '../session.js';
@@ -1289,6 +1292,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1289
1292
  if (process.stdout.isTTY !== true) {
1290
1293
  throw new Error('stdout is not a terminal');
1291
1294
  }
1295
+ let fatalError = null;
1292
1296
  await withTuiMode(async () => {
1293
1297
  setBackgroundJobSession(session.sessionId);
1294
1298
  setScratchSession(session.sessionId);
@@ -1303,6 +1307,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1303
1307
  let cleanupSudoPasswordPrompt = null;
1304
1308
  let sudoPasswordBuffer = '';
1305
1309
  const bridge = createRatatuiBridge();
1310
+ captureTerminalWrites();
1306
1311
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1307
1312
  let terminalCols = 80;
1308
1313
  let terminalRows = 24;
@@ -1347,7 +1352,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1347
1352
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1348
1353
  }, 2500);
1349
1354
  };
1350
- const terminalTitle = createTerminalTitleController();
1355
+ const terminalTitle = createTerminalTitleController({
1356
+ write: (title) => bridge.setTitle(title),
1357
+ });
1351
1358
  const syncTerminalTitle = () => {
1352
1359
  const state = store.getState();
1353
1360
  terminalTitle.sync({
@@ -1558,14 +1565,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1558
1565
  scheduleLiveFrameRemount();
1559
1566
  void remountTui();
1560
1567
  };
1561
- const saveActiveSession = async () => {
1562
- try {
1563
- await saveSessionBoth({ serverSessionClient, session });
1564
- }
1565
- catch (error) {
1566
- appendError(`Session save failed: ${error.message}`);
1567
- }
1568
- };
1569
1568
  const syncShellStateFromSession = () => {
1570
1569
  store.update((current) => ({
1571
1570
  ...current,
@@ -1653,9 +1652,31 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1653
1652
  status: 'Exiting...',
1654
1653
  }));
1655
1654
  void bridge.close().then(() => {
1655
+ releaseTerminalWrites();
1656
1656
  resolveDone?.();
1657
1657
  });
1658
1658
  };
1659
+ const exitForAuthenticationError = (error) => {
1660
+ if (!isAuthenticationError(error))
1661
+ return false;
1662
+ clearCliAuthConfig(session.env);
1663
+ saveSessionState(session);
1664
+ fatalError = new Error(authenticationErrorMessage(error));
1665
+ requestExit();
1666
+ return true;
1667
+ };
1668
+ const saveActiveSession = async () => {
1669
+ try {
1670
+ await saveSessionBoth({ serverSessionClient, session });
1671
+ return true;
1672
+ }
1673
+ catch (error) {
1674
+ if (exitForAuthenticationError(error))
1675
+ return false;
1676
+ appendError(`Session save failed: ${error.message}`);
1677
+ return true;
1678
+ }
1679
+ };
1659
1680
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1660
1681
  if (exiting || signal?.aborted) {
1661
1682
  resolve(null);
@@ -1792,7 +1813,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1792
1813
  selectedModelId: selected,
1793
1814
  serverModels: fresh,
1794
1815
  });
1795
- await saveActiveSession();
1816
+ if (!(await saveActiveSession()))
1817
+ return;
1796
1818
  syncShellStateFromSession();
1797
1819
  appendStaticEntry({
1798
1820
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1911,6 +1933,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1911
1933
  await switchToModel(selected.id);
1912
1934
  }
1913
1935
  catch (error) {
1936
+ if (exitForAuthenticationError(error))
1937
+ return;
1914
1938
  appendError(error.message);
1915
1939
  }
1916
1940
  };
@@ -1994,6 +2018,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1994
2018
  await remountTui();
1995
2019
  }
1996
2020
  catch (error) {
2021
+ if (exitForAuthenticationError(error))
2022
+ return;
1997
2023
  store.update((next) => ({
1998
2024
  ...next,
1999
2025
  busy: false,
@@ -2146,14 +2172,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2146
2172
  });
2147
2173
  }
2148
2174
  catch (error) {
2175
+ if (exitForAuthenticationError(error))
2176
+ return;
2149
2177
  appendError(error.message);
2150
2178
  }
2151
2179
  finally {
2152
- store.update((current) => ({
2153
- ...current,
2154
- busy: false,
2155
- status: 'Ready',
2156
- }));
2180
+ if (!exiting) {
2181
+ store.update((current) => ({
2182
+ ...current,
2183
+ busy: false,
2184
+ status: 'Ready',
2185
+ }));
2186
+ }
2157
2187
  }
2158
2188
  return;
2159
2189
  }
@@ -2167,14 +2197,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2167
2197
  await openResumePicker();
2168
2198
  }
2169
2199
  catch (error) {
2200
+ if (exitForAuthenticationError(error))
2201
+ return;
2170
2202
  appendError(error.message);
2171
2203
  }
2172
2204
  finally {
2173
- store.update((current) => ({
2174
- ...current,
2175
- busy: false,
2176
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2177
- }));
2205
+ if (!exiting) {
2206
+ store.update((current) => ({
2207
+ ...current,
2208
+ busy: false,
2209
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2210
+ }));
2211
+ }
2178
2212
  }
2179
2213
  return;
2180
2214
  }
@@ -2194,14 +2228,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2194
2228
  }
2195
2229
  }
2196
2230
  catch (error) {
2231
+ if (exitForAuthenticationError(error))
2232
+ return;
2197
2233
  appendError(error.message);
2198
2234
  }
2199
2235
  finally {
2200
- store.update((current) => ({
2201
- ...current,
2202
- busy: false,
2203
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2204
- }));
2236
+ if (!exiting) {
2237
+ store.update((current) => ({
2238
+ ...current,
2239
+ busy: false,
2240
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2241
+ }));
2242
+ }
2205
2243
  }
2206
2244
  return;
2207
2245
  }
@@ -2210,7 +2248,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2210
2248
  clearTodos();
2211
2249
  syncTodosState();
2212
2250
  latestUsageSummary = null;
2213
- await saveActiveSession();
2251
+ if (!(await saveActiveSession()))
2252
+ return;
2214
2253
  store.replaceTranscript([
2215
2254
  {
2216
2255
  body: 'Conversation cleared.',
@@ -2231,6 +2270,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2231
2270
  activeTurnAbort = turnAbort;
2232
2271
  lastTurnStartedAt = turnStartedAt;
2233
2272
  todosTouchedThisTurn = false;
2273
+ clearTodos();
2274
+ syncTodosState();
2234
2275
  const userEntry = {
2235
2276
  body: input,
2236
2277
  kind: 'user',
@@ -2267,7 +2308,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2267
2308
  return;
2268
2309
  latestUsageSummary = result.usageSummary ?? null;
2269
2310
  const turnEntries = takePendingTurnEntries();
2270
- await saveActiveSession();
2311
+ if (!(await saveActiveSession()))
2312
+ return;
2271
2313
  syncShellStateFromSession();
2272
2314
  store.update((current) => ({
2273
2315
  ...current,
@@ -2318,6 +2360,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2318
2360
  return;
2319
2361
  }
2320
2362
  const cancelled = isTurnCancelledError(error);
2363
+ if (exitForAuthenticationError(error))
2364
+ return;
2321
2365
  store.update((current) => ({
2322
2366
  ...current,
2323
2367
  activeTurnInput: '',
@@ -2342,7 +2386,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2342
2386
  title: 'System',
2343
2387
  },
2344
2388
  ]);
2345
- await saveActiveSession();
2389
+ if (!(await saveActiveSession()))
2390
+ return;
2346
2391
  }
2347
2392
  else {
2348
2393
  appendStaticEntries(turnEntries);
@@ -2570,7 +2615,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2570
2615
  setTodoSession(null);
2571
2616
  setBackgroundJobUpdateHook(null);
2572
2617
  await bridge.close();
2618
+ releaseTerminalWrites();
2573
2619
  setCommandOutputHook(null);
2574
2620
  }
2575
2621
  });
2622
+ if (fatalError)
2623
+ throw fatalError;
2576
2624
  }
@@ -155,6 +155,9 @@ export function createRatatuiBridge() {
155
155
  clear() {
156
156
  writeParent({ op: 'clear' });
157
157
  },
158
+ setTitle(title) {
159
+ writeParent({ op: 'title', text: title });
160
+ },
158
161
  async close() {
159
162
  if (closed)
160
163
  return;
@@ -2,7 +2,7 @@ import { agentModeLabel } from '../../agent-mode.js';
2
2
  import { 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;
@@ -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();
@@ -573,7 +573,7 @@ function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
573
573
  return lines;
574
574
  }
575
575
  function lineCharCount(row) {
576
- return row.spans.reduce((total, item) => total + [...item.text].length, 0);
576
+ return row.spans.reduce((total, item) => total + displayWidth(item.text), 0);
577
577
  }
578
578
  function overlayPanelLine(row, width, color) {
579
579
  const padding = Math.max(0, width - lineCharCount(row));
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { isTuiMode } from '../../runtime-mode.js';
1
2
  const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴'];
2
3
  const TITLE_BRAND = 'TheGitAI';
3
4
  const TITLE_MARK_PREFIX = '❯_';
@@ -19,6 +20,8 @@ export function formatTerminalTitle(state, spinnerFrame = 0) {
19
20
  return `${TITLE_MARK_PREFIX}● ${TITLE_BRAND}`;
20
21
  }
21
22
  export function writeTerminalTitle(title, stream = process.stdout) {
23
+ if (isTuiMode())
24
+ return;
22
25
  if (!('isTTY' in stream) || !stream.isTTY)
23
26
  return;
24
27
  stream.write(`\x1b]0;${title}\x07`);
@@ -0,0 +1,48 @@
1
+ const MAX_CAPTURED_CHARS = 1_000_000;
2
+ let restore = null;
3
+ let captured = [];
4
+ let capturedChars = 0;
5
+ function chunkToString(chunk, encoding) {
6
+ if (typeof chunk === 'string')
7
+ return chunk;
8
+ if (chunk instanceof Uint8Array) {
9
+ return Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding : 'utf8');
10
+ }
11
+ return String(chunk ?? '');
12
+ }
13
+ export function captureTerminalWrites() {
14
+ if (restore)
15
+ return;
16
+ const streams = [process.stdout, process.stderr];
17
+ const originals = streams.map((stream) => stream.write.bind(stream));
18
+ for (const stream of streams) {
19
+ stream.write = (chunk, encoding, callback) => {
20
+ if (capturedChars < MAX_CAPTURED_CHARS) {
21
+ const text = chunkToString(chunk, encoding);
22
+ captured.push(text);
23
+ capturedChars += text.length;
24
+ }
25
+ const done = typeof encoding === 'function' ? encoding : callback;
26
+ if (typeof done === 'function')
27
+ done();
28
+ return true;
29
+ };
30
+ }
31
+ restore = () => {
32
+ streams.forEach((stream, index) => {
33
+ stream.write = originals[index];
34
+ });
35
+ };
36
+ }
37
+ export function releaseTerminalWrites() {
38
+ if (!restore)
39
+ return;
40
+ restore();
41
+ restore = null;
42
+ if (captured.length > 0) {
43
+ const text = captured.join('');
44
+ captured = [];
45
+ capturedChars = 0;
46
+ process.stderr.write(text);
47
+ }
48
+ }
@@ -1,5 +1,14 @@
1
+ const CSI_PATTERN = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g;
2
+ const OSC_PATTERN = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?/g;
3
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
4
+ export function stripControlCharacters(text) {
5
+ return String(text ?? '')
6
+ .replace(OSC_PATTERN, '')
7
+ .replace(CSI_PATTERN, '')
8
+ .replace(CONTROL_CHAR_PATTERN, '');
9
+ }
1
10
  export function span(text, style = {}) {
2
- return { text, ...style };
11
+ return { text: stripControlCharacters(text), ...style };
3
12
  }
4
13
  export function line(...spans) {
5
14
  return { spans };
@@ -14,10 +23,11 @@ export function wrapText(text, width) {
14
23
  const lines = [];
15
24
  for (const rawLine of text.split('\n')) {
16
25
  let remaining = rawLine;
17
- while (remaining.length > safeWidth) {
18
- let breakAt = remaining.lastIndexOf(' ', safeWidth);
26
+ while (displayWidth(remaining) > safeWidth) {
27
+ const head = sliceToWidth(remaining, safeWidth);
28
+ let breakAt = head.lastIndexOf(' ');
19
29
  if (breakAt <= 0)
20
- breakAt = safeWidth;
30
+ breakAt = head.length;
21
31
  lines.push(remaining.slice(0, breakAt).trimEnd());
22
32
  remaining = remaining.slice(breakAt).trimStart();
23
33
  }
@@ -28,3 +38,147 @@ export function wrapText(text, width) {
28
38
  export function joinLines(blocks) {
29
39
  return blocks.flat();
30
40
  }
41
+ function isZeroWidthCodePoint(codePoint) {
42
+ return (codePoint === 0x200d ||
43
+ (codePoint >= 0x0300 && codePoint <= 0x036f) ||
44
+ (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
45
+ (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
46
+ (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
47
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
48
+ (codePoint >= 0xfe20 && codePoint <= 0xfe2f));
49
+ }
50
+ function isWideCodePoint(codePoint) {
51
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) ||
52
+ codePoint === 0x231a ||
53
+ codePoint === 0x231b ||
54
+ (codePoint >= 0x23e9 && codePoint <= 0x23ec) ||
55
+ codePoint === 0x23f0 ||
56
+ codePoint === 0x23f3 ||
57
+ (codePoint >= 0x25fd && codePoint <= 0x25fe) ||
58
+ (codePoint >= 0x2614 && codePoint <= 0x2615) ||
59
+ (codePoint >= 0x2648 && codePoint <= 0x2653) ||
60
+ codePoint === 0x267f ||
61
+ codePoint === 0x2693 ||
62
+ codePoint === 0x26a1 ||
63
+ (codePoint >= 0x26aa && codePoint <= 0x26ab) ||
64
+ (codePoint >= 0x26bd && codePoint <= 0x26be) ||
65
+ (codePoint >= 0x26c4 && codePoint <= 0x26c5) ||
66
+ codePoint === 0x26ce ||
67
+ codePoint === 0x26d4 ||
68
+ codePoint === 0x26ea ||
69
+ (codePoint >= 0x26f2 && codePoint <= 0x26f3) ||
70
+ codePoint === 0x26f5 ||
71
+ codePoint === 0x26fa ||
72
+ codePoint === 0x26fd ||
73
+ codePoint === 0x2705 ||
74
+ (codePoint >= 0x270a && codePoint <= 0x270b) ||
75
+ codePoint === 0x2728 ||
76
+ codePoint === 0x274c ||
77
+ codePoint === 0x274e ||
78
+ (codePoint >= 0x2753 && codePoint <= 0x2755) ||
79
+ codePoint === 0x2757 ||
80
+ (codePoint >= 0x2795 && codePoint <= 0x2797) ||
81
+ codePoint === 0x27b0 ||
82
+ codePoint === 0x27bf ||
83
+ (codePoint >= 0x2b1b && codePoint <= 0x2b1c) ||
84
+ codePoint === 0x2b50 ||
85
+ codePoint === 0x2b55 ||
86
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) ||
87
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) ||
88
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
89
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
90
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) ||
91
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
92
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
93
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
94
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
95
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
96
+ (codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
97
+ (codePoint >= 0x1f680 && codePoint <= 0x1f6ff) ||
98
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
99
+ (codePoint >= 0x1fa70 && codePoint <= 0x1faff) ||
100
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd));
101
+ }
102
+ export function displayWidth(text) {
103
+ const chars = [...String(text ?? '')];
104
+ let width = 0;
105
+ for (let index = 0; index < chars.length; index++) {
106
+ const codePoint = chars[index].codePointAt(0);
107
+ if (isZeroWidthCodePoint(codePoint))
108
+ continue;
109
+ const next = chars[index + 1]?.codePointAt(0);
110
+ if (next === 0xfe0f) {
111
+ width += 2;
112
+ index++;
113
+ continue;
114
+ }
115
+ if (next === 0xfe0e) {
116
+ width += 1;
117
+ index++;
118
+ continue;
119
+ }
120
+ width += isWideCodePoint(codePoint) ? 2 : 1;
121
+ }
122
+ return width;
123
+ }
124
+ export function sliceToWidth(text, width) {
125
+ const limit = Math.max(1, Math.floor(width));
126
+ const chars = [...String(text ?? '')];
127
+ let out = '';
128
+ let used = 0;
129
+ for (let index = 0; index < chars.length; index++) {
130
+ const char = chars[index];
131
+ const next = chars[index + 1];
132
+ const selector = next === '️' || next === '︎';
133
+ const cluster = selector ? `${char}${next}` : char;
134
+ const clusterWidth = displayWidth(cluster);
135
+ if (used + clusterWidth > limit)
136
+ break;
137
+ out += cluster;
138
+ used += clusterWidth;
139
+ if (selector)
140
+ index++;
141
+ }
142
+ if (!out)
143
+ return chars[0] ?? '';
144
+ return out;
145
+ }
146
+ export function padToWidth(text, width) {
147
+ const current = displayWidth(text);
148
+ if (current >= width)
149
+ return text;
150
+ return text + ' '.repeat(width - current);
151
+ }
152
+ export function wrapToWidth(text, width) {
153
+ const limit = Math.max(1, Math.floor(width));
154
+ const out = [];
155
+ for (const rawLine of String(text ?? '').split('\n')) {
156
+ let current = '';
157
+ const flush = () => {
158
+ out.push(current);
159
+ current = '';
160
+ };
161
+ for (const token of rawLine.split(/\s+/).filter(Boolean)) {
162
+ let rest = token;
163
+ while (displayWidth(rest) > limit) {
164
+ if (current)
165
+ flush();
166
+ const head = sliceToWidth(rest, limit);
167
+ out.push(head);
168
+ rest = rest.slice(head.length);
169
+ }
170
+ if (!rest)
171
+ continue;
172
+ const candidate = current ? `${current} ${rest}` : rest;
173
+ if (displayWidth(candidate) > limit) {
174
+ flush();
175
+ current = rest;
176
+ }
177
+ else {
178
+ current = candidate;
179
+ }
180
+ }
181
+ flush();
182
+ }
183
+ return out.length > 0 ? out : [''];
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.4",
3
+ "version": "1.0.0-preview.6",
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.4",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.4",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.4",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.4",
40
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.6",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.6",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.6",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.6",
44
44
  "@vscode/ripgrep": "1.18.0"
45
45
  },
46
46
  "publishConfig": {
@@ -1,112 +0,0 @@
1
- import chalk from './colors.js';
2
- function renderInline(text) {
3
- const parts = String(text ?? '').split(/(`[^`]+`)/g);
4
- return parts
5
- .map((part) => part.startsWith('`') && part.endsWith('`') && part.length > 1
6
- ? chalk.cyan(part.slice(1, -1))
7
- : part)
8
- .join('');
9
- }
10
- function isTableSeparator(line) {
11
- const cells = splitTableRow(line);
12
- return (cells.length > 1 &&
13
- cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())));
14
- }
15
- function splitTableRow(line) {
16
- const trimmed = String(line ?? '').trim();
17
- if (!trimmed.includes('|'))
18
- return [];
19
- return trimmed
20
- .replace(/^\|/, '')
21
- .replace(/\|$/, '')
22
- .split('|')
23
- .map((cell) => cell.trim());
24
- }
25
- function tableWidth(text) {
26
- return text.replace(/\x1b\[[0-9;]*m/g, '').length;
27
- }
28
- function padCell(text, width) {
29
- return `${text}${' '.repeat(Math.max(0, width - tableWidth(text)))}`;
30
- }
31
- function renderTable(rows) {
32
- if (rows.length < 2 || !isTableSeparator(rows[1].join('|')))
33
- return [];
34
- const headers = rows[0];
35
- const body = rows.slice(2).filter((row) => row.length > 0);
36
- const columnCount = Math.max(headers.length, ...body.map((row) => row.length));
37
- const normalizedRows = [headers, ...body].map((row) => Array.from({ length: columnCount }, (_, index) => renderInline(row[index] ?? '')));
38
- const widths = Array.from({ length: columnCount }, (_, index) => Math.max(...normalizedRows.map((row) => tableWidth(row[index] ?? '')), 3));
39
- const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
40
- const renderRow = (row) => `| ${row.map((cell, index) => padCell(cell, widths[index])).join(' | ')} |`;
41
- return [
42
- border,
43
- renderRow(normalizedRows[0].map((cell) => chalk.bold(cell))),
44
- border,
45
- ...normalizedRows.slice(1).map(renderRow),
46
- border,
47
- ];
48
- }
49
- function readTableBlock(lines, startIndex) {
50
- if (startIndex + 1 >= lines.length)
51
- return null;
52
- const header = splitTableRow(lines[startIndex]);
53
- const separator = splitTableRow(lines[startIndex + 1]);
54
- if (header.length < 2 || separator.length < 2 || !isTableSeparator(lines[startIndex + 1])) {
55
- return null;
56
- }
57
- const rows = [header, separator];
58
- let index = startIndex + 2;
59
- while (index < lines.length) {
60
- const row = splitTableRow(lines[index]);
61
- if (row.length < 2)
62
- break;
63
- rows.push(row);
64
- index += 1;
65
- }
66
- const rendered = renderTable(rows);
67
- return rendered.length ? { rendered, nextIndex: index } : null;
68
- }
69
- export function renderMarkdownForTerminal(markdown) {
70
- const lines = String(markdown ?? '').replace(/\r\n?/g, '\n').split('\n');
71
- const output = [];
72
- let inCodeBlock = false;
73
- for (let index = 0; index < lines.length; index++) {
74
- const line = lines[index];
75
- if (/^\s*```/.test(line)) {
76
- inCodeBlock = !inCodeBlock;
77
- continue;
78
- }
79
- if (inCodeBlock) {
80
- output.push(chalk.dim(` ${line}`));
81
- continue;
82
- }
83
- const table = readTableBlock(lines, index);
84
- if (table) {
85
- output.push(...table.rendered);
86
- index = table.nextIndex - 1;
87
- continue;
88
- }
89
- const heading = line.match(/^\s{0,3}#{1,6}\s+(.+)$/);
90
- if (heading) {
91
- output.push(chalk.bold(renderInline(heading[1].trim())));
92
- continue;
93
- }
94
- const bullet = line.match(/^(\s*)[-*]\s+(.+)$/);
95
- if (bullet) {
96
- output.push(`${bullet[1]}- ${renderInline(bullet[2].trim())}`);
97
- continue;
98
- }
99
- const numbered = line.match(/^(\s*)\d+[.)]\s+(.+)$/);
100
- if (numbered) {
101
- output.push(`${numbered[1]}- ${renderInline(numbered[2].trim())}`);
102
- continue;
103
- }
104
- const quote = line.match(/^\s*>\s?(.+)$/);
105
- if (quote) {
106
- output.push(chalk.dim(`> ${renderInline(quote[1].trim())}`));
107
- continue;
108
- }
109
- output.push(renderInline(line));
110
- }
111
- return output.join('\n').trimEnd();
112
- }