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

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.
@@ -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();
@@ -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';
@@ -1303,6 +1304,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1303
1304
  let cleanupSudoPasswordPrompt = null;
1304
1305
  let sudoPasswordBuffer = '';
1305
1306
  const bridge = createRatatuiBridge();
1307
+ captureTerminalWrites();
1306
1308
  const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1307
1309
  let terminalCols = 80;
1308
1310
  let terminalRows = 24;
@@ -1347,7 +1349,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1347
1349
  store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1348
1350
  }, 2500);
1349
1351
  };
1350
- const terminalTitle = createTerminalTitleController();
1352
+ const terminalTitle = createTerminalTitleController({
1353
+ write: (title) => bridge.setTitle(title),
1354
+ });
1351
1355
  const syncTerminalTitle = () => {
1352
1356
  const state = store.getState();
1353
1357
  terminalTitle.sync({
@@ -1653,6 +1657,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1653
1657
  status: 'Exiting...',
1654
1658
  }));
1655
1659
  void bridge.close().then(() => {
1660
+ releaseTerminalWrites();
1656
1661
  resolveDone?.();
1657
1662
  });
1658
1663
  };
@@ -2231,6 +2236,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2231
2236
  activeTurnAbort = turnAbort;
2232
2237
  lastTurnStartedAt = turnStartedAt;
2233
2238
  todosTouchedThisTurn = false;
2239
+ clearTodos();
2240
+ syncTodosState();
2234
2241
  const userEntry = {
2235
2242
  body: input,
2236
2243
  kind: 'user',
@@ -2570,6 +2577,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2570
2577
  setTodoSession(null);
2571
2578
  setBackgroundJobUpdateHook(null);
2572
2579
  await bridge.close();
2580
+ releaseTerminalWrites();
2573
2581
  setCommandOutputHook(null);
2574
2582
  }
2575
2583
  });
@@ -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.5",
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.5",
41
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.5",
42
+ "@thegitai/tui-linux-x64": "1.0.0-preview.5",
43
+ "@thegitai/tui-win32-x64": "1.0.0-preview.5",
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
- }