@thegitai/cli 1.0.0-beta.1

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.
Files changed (101) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +30 -0
  3. package/dist/bin/ai.js +438 -0
  4. package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
  5. package/dist/parsers/tree-sitter-c.wasm +0 -0
  6. package/dist/parsers/tree-sitter-cpp.wasm +0 -0
  7. package/dist/parsers/tree-sitter-css.wasm +0 -0
  8. package/dist/parsers/tree-sitter-go.wasm +0 -0
  9. package/dist/parsers/tree-sitter-html.wasm +0 -0
  10. package/dist/parsers/tree-sitter-java.wasm +0 -0
  11. package/dist/parsers/tree-sitter-javascript.wasm +0 -0
  12. package/dist/parsers/tree-sitter-objc.wasm +0 -0
  13. package/dist/parsers/tree-sitter-php.wasm +0 -0
  14. package/dist/parsers/tree-sitter-python.wasm +0 -0
  15. package/dist/parsers/tree-sitter-ruby.wasm +0 -0
  16. package/dist/parsers/tree-sitter-rust.wasm +0 -0
  17. package/dist/parsers/tree-sitter-tsx.wasm +0 -0
  18. package/dist/parsers/tree-sitter-typescript.wasm +0 -0
  19. package/dist/src/agent-mode.js +142 -0
  20. package/dist/src/api/auth.js +81 -0
  21. package/dist/src/api/browser-login.js +184 -0
  22. package/dist/src/api/chat.js +346 -0
  23. package/dist/src/api/contracts.js +1 -0
  24. package/dist/src/api/http.js +44 -0
  25. package/dist/src/api/index.js +11 -0
  26. package/dist/src/api/models.js +110 -0
  27. package/dist/src/api/sessions.js +72 -0
  28. package/dist/src/artifact-policy.js +207 -0
  29. package/dist/src/client-state.js +14 -0
  30. package/dist/src/core/clipboard.js +208 -0
  31. package/dist/src/core/open-url.js +32 -0
  32. package/dist/src/edit-journal.js +133 -0
  33. package/dist/src/executor.js +924 -0
  34. package/dist/src/extractors/cpp.js +18 -0
  35. package/dist/src/extractors/csharp.js +16 -0
  36. package/dist/src/extractors/css.js +12 -0
  37. package/dist/src/extractors/go.js +27 -0
  38. package/dist/src/extractors/index.js +52 -0
  39. package/dist/src/extractors/java.js +14 -0
  40. package/dist/src/extractors/javascript.js +33 -0
  41. package/dist/src/extractors/objc.js +14 -0
  42. package/dist/src/extractors/php.js +20 -0
  43. package/dist/src/extractors/python.js +11 -0
  44. package/dist/src/extractors/ruby.js +13 -0
  45. package/dist/src/extractors/rust.js +17 -0
  46. package/dist/src/extractors/utils.js +58 -0
  47. package/dist/src/help-text.js +125 -0
  48. package/dist/src/markdown-renderer.js +112 -0
  49. package/dist/src/patcher.js +279 -0
  50. package/dist/src/project-index.js +221 -0
  51. package/dist/src/repo-map-languages.js +100 -0
  52. package/dist/src/runtime-mode.js +35 -0
  53. package/dist/src/scanner.js +362 -0
  54. package/dist/src/secret-preview.js +137 -0
  55. package/dist/src/session-exit.js +17 -0
  56. package/dist/src/session-safety.js +1012 -0
  57. package/dist/src/session-store.js +266 -0
  58. package/dist/src/session.js +93 -0
  59. package/dist/src/tool-executor.js +188 -0
  60. package/dist/src/tools/code-intel.js +472 -0
  61. package/dist/src/tools/delete-file.js +27 -0
  62. package/dist/src/tools/exec-utils.js +17 -0
  63. package/dist/src/tools/find-symbol.js +70 -0
  64. package/dist/src/tools/get-diagnostics.js +22 -0
  65. package/dist/src/tools/grep-code.js +331 -0
  66. package/dist/src/tools/hover-symbol.js +95 -0
  67. package/dist/src/tools/index.js +73 -0
  68. package/dist/src/tools/list-checkpoints.js +11 -0
  69. package/dist/src/tools/list-directories.js +16 -0
  70. package/dist/src/tools/list-files.js +13 -0
  71. package/dist/src/tools/list-session-edits.js +9 -0
  72. package/dist/src/tools/list-symbols.js +55 -0
  73. package/dist/src/tools/patch-file.js +88 -0
  74. package/dist/src/tools/path-listing.js +83 -0
  75. package/dist/src/tools/read-document.js +111 -0
  76. package/dist/src/tools/read-file.js +109 -0
  77. package/dist/src/tools/restore-checkpoint.js +100 -0
  78. package/dist/src/tools/ripgrep.js +29 -0
  79. package/dist/src/tools/run-command.js +94 -0
  80. package/dist/src/tools/run-node-script.js +210 -0
  81. package/dist/src/tools/search-code.js +37 -0
  82. package/dist/src/tools/shell-diagnostics.js +707 -0
  83. package/dist/src/tools/signature-help.js +118 -0
  84. package/dist/src/tools/str-replace.js +193 -0
  85. package/dist/src/tools/types.js +1 -0
  86. package/dist/src/tools/undo-edit.js +202 -0
  87. package/dist/src/tools/write-file.js +59 -0
  88. package/dist/src/tree-sitter-runtime.js +135 -0
  89. package/dist/src/types.js +1 -0
  90. package/dist/src/ui/paste-collapse.js +22 -0
  91. package/dist/src/ui/prompt-history-store.js +96 -0
  92. package/dist/src/ui/repl.js +2238 -0
  93. package/dist/src/ui/tui/bridge.js +175 -0
  94. package/dist/src/ui/tui/build-frame.js +718 -0
  95. package/dist/src/ui/tui/markdown-render.js +455 -0
  96. package/dist/src/ui/tui/shell-input.js +488 -0
  97. package/dist/src/ui/tui/text.js +30 -0
  98. package/dist/src/ui/tui/types.js +1 -0
  99. package/dist/src/usage.js +47 -0
  100. package/dist/src/utils.js +38 -0
  101. package/package.json +38 -0
@@ -0,0 +1,2238 @@
1
+ import { createRatatuiBridge } from './tui/bridge.js';
2
+ import { buildTuiFrame, renderTranscriptEntryLines } from './tui/build-frame.js';
3
+ export { getSlashCommandSuggestions } from './tui/build-frame.js';
4
+ import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
5
+ import { chat, models } from '../api/index.js';
6
+ import { isTurnCancelledError } from '../api/chat.js';
7
+ import { cancelActiveCommand } from '../executor.js';
8
+ import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
9
+ import { clearConversation, } from '../session.js';
10
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
11
+ import { truncate } from '../utils.js';
12
+ import { writeClipboardText } from '../core/clipboard.js';
13
+ import { openUrl } from '../core/open-url.js';
14
+ import { formatInteractiveHelpText } from '../help-text.js';
15
+ import { expandPastedChunks, } from './paste-collapse.js';
16
+ import { loadPromptHistory, MAX_PROMPT_HISTORY_ENTRIES, } from './prompt-history-store.js';
17
+ const RESPONSE_STREAM_CHUNK_SIZE = 4;
18
+ const RESPONSE_STREAM_DELAY_MS = 8;
19
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
+ export function* buildResponseStreamBodies(responseText) {
21
+ let body = '';
22
+ let emitted = '';
23
+ let count = 0;
24
+ for (const char of responseText) {
25
+ body += char;
26
+ count += 1;
27
+ if (count % RESPONSE_STREAM_CHUNK_SIZE === 0) {
28
+ emitted = body;
29
+ yield body;
30
+ }
31
+ }
32
+ if (body && emitted !== body) {
33
+ yield responseText;
34
+ }
35
+ }
36
+ function sameTranscriptDraft(a, b) {
37
+ return Boolean(a &&
38
+ a.kind === b.kind &&
39
+ a.title === b.title &&
40
+ a.body === b.body &&
41
+ a.filePath === b.filePath &&
42
+ a.preformatted === b.preformatted);
43
+ }
44
+ export function buildSettledTurnTranscriptEntries(turnEntries, responseText) {
45
+ const responseBody = String(responseText ?? '').trim();
46
+ const responseEntry = responseBody
47
+ ? {
48
+ body: responseBody,
49
+ kind: 'assistant',
50
+ title: 'Response',
51
+ }
52
+ : null;
53
+ return [
54
+ ...turnEntries,
55
+ ...(responseEntry !== null && !sameTranscriptDraft(turnEntries.at(-1), responseEntry)
56
+ ? [responseEntry]
57
+ : []),
58
+ ];
59
+ }
60
+ const TUI_WIDTH_RATIO = 0.95;
61
+ const COMMAND_PREVIEW_LINES = 10;
62
+ const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
63
+ const WORKING_TOOL_PREVIEW_ITEMS = 4;
64
+ const THINKING_NOTE_PREVIEW_ROWS = 3;
65
+ const WORKING_TOOL_PREVIEW_ROWS = 3;
66
+ const AGENT_MODE_LABEL_WIDTH = 16;
67
+ const APPROVAL_OPTIONS = [
68
+ {
69
+ value: 'y',
70
+ label: 'Approve once',
71
+ description: 'Run this action only this time',
72
+ },
73
+ {
74
+ value: 'a',
75
+ label: 'Approve all remaining actions',
76
+ description: 'Turn on auto-approve for the rest of the session',
77
+ },
78
+ {
79
+ value: 'n',
80
+ label: 'Deny',
81
+ description: 'Reject this action',
82
+ },
83
+ ];
84
+ export const SLASH_COMMANDS = [
85
+ {
86
+ command: '/help',
87
+ description: 'Show available chat commands',
88
+ },
89
+ {
90
+ command: '/usage',
91
+ description: 'Show account usage percentage and reset times',
92
+ },
93
+ {
94
+ command: '/model',
95
+ description: 'Switch the active model',
96
+ },
97
+ {
98
+ command: '/resume',
99
+ description: 'Open the session picker',
100
+ },
101
+ {
102
+ command: '/clear',
103
+ description: 'Clear conversation history',
104
+ },
105
+ {
106
+ command: '/exit',
107
+ description: 'Quit the current session',
108
+ },
109
+ ];
110
+ function getShellWidth(columns) {
111
+ const safeColumns = Math.max(columns, 20);
112
+ const targetWidth = Math.floor(safeColumns * TUI_WIDTH_RATIO);
113
+ return Math.max(20, Math.min(safeColumns - 2, targetWidth));
114
+ }
115
+ function formatModelLabel(modelId, serverModels) {
116
+ return serverModels.find((model) => model.id === modelId)?.label ?? 'Unknown model';
117
+ }
118
+ function formatRelativeTime(isoDate) {
119
+ const diff = Date.now() - new Date(isoDate).getTime();
120
+ const minutes = Math.floor(diff / 60000);
121
+ if (minutes < 1)
122
+ return 'just now';
123
+ if (minutes < 60)
124
+ return `${minutes}m ago`;
125
+ const hours = Math.floor(minutes / 60);
126
+ if (hours < 24)
127
+ return `${hours}h ago`;
128
+ const days = Math.floor(hours / 24);
129
+ return `${days}d ago`;
130
+ }
131
+ function filterResumeSessions(sessions, filter, serverModels) {
132
+ const q = filter.trim().toLowerCase();
133
+ if (!q)
134
+ return sessions;
135
+ return sessions.filter((session) => {
136
+ const branch = (session.branch ?? '').toLowerCase();
137
+ const model = formatModelLabel(session.modelId, serverModels).toLowerCase();
138
+ const conversation = session.lastUserMessage.toLowerCase();
139
+ return branch.includes(q) || model.includes(q) || conversation.includes(q);
140
+ });
141
+ }
142
+ function getEntryColor(kind) {
143
+ switch (kind) {
144
+ case 'assistant':
145
+ case 'diff':
146
+ return 'green';
147
+ case 'error':
148
+ return 'red';
149
+ case 'system':
150
+ return 'yellow';
151
+ case 'tool':
152
+ return 'blue';
153
+ case 'user':
154
+ return 'cyan';
155
+ }
156
+ }
157
+ function parseInlineSegments(text) {
158
+ const source = String(text ?? '');
159
+ const segments = [];
160
+ let cursor = 0;
161
+ while (cursor < source.length) {
162
+ if (source.startsWith('**', cursor)) {
163
+ const end = source.indexOf('**', cursor + 2);
164
+ if (end > cursor + 2) {
165
+ segments.push({ kind: 'bold', text: source.slice(cursor + 2, end) });
166
+ cursor = end + 2;
167
+ continue;
168
+ }
169
+ }
170
+ if (source[cursor] === '`') {
171
+ const end = source.indexOf('`', cursor + 1);
172
+ if (end > cursor + 1) {
173
+ segments.push({ kind: 'code', text: source.slice(cursor + 1, end) });
174
+ cursor = end + 1;
175
+ continue;
176
+ }
177
+ }
178
+ let nextCursor = source.length;
179
+ const nextBold = source.indexOf('**', cursor);
180
+ const nextCode = source.indexOf('`', cursor);
181
+ if (nextBold !== -1)
182
+ nextCursor = Math.min(nextCursor, nextBold);
183
+ if (nextCode !== -1)
184
+ nextCursor = Math.min(nextCursor, nextCode);
185
+ if (nextCursor === cursor)
186
+ nextCursor += 1;
187
+ segments.push({ kind: 'text', text: source.slice(cursor, nextCursor) });
188
+ cursor = nextCursor;
189
+ }
190
+ return segments.filter((segment) => segment.text.length > 0);
191
+ }
192
+ function isEscapedMarkdownPipe(source, pipeIndex) {
193
+ let slashCount = 0;
194
+ for (let index = pipeIndex - 1; index >= 0 && source[index] === '\\'; index--) {
195
+ slashCount++;
196
+ }
197
+ return slashCount % 2 === 1;
198
+ }
199
+ function splitMarkdownTableRow(line) {
200
+ const trimmed = String(line ?? '').trim();
201
+ const withoutLeadingPipe = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed;
202
+ const trailingPipeIndex = withoutLeadingPipe.length - 1;
203
+ const hasTrailingBoundaryPipe = trailingPipeIndex >= 0 &&
204
+ withoutLeadingPipe[trailingPipeIndex] === '|' &&
205
+ !isEscapedMarkdownPipe(withoutLeadingPipe, trailingPipeIndex);
206
+ const withoutBoundaryPipes = hasTrailingBoundaryPipe
207
+ ? withoutLeadingPipe.slice(0, -1)
208
+ : withoutLeadingPipe;
209
+ const cells = [];
210
+ let cell = '';
211
+ for (let index = 0; index < withoutBoundaryPipes.length; index++) {
212
+ const char = withoutBoundaryPipes[index] ?? '';
213
+ if (char === '|' && !isEscapedMarkdownPipe(withoutBoundaryPipes, index)) {
214
+ cells.push(cell.trim().replace(/\\\|/g, '|'));
215
+ cell = '';
216
+ continue;
217
+ }
218
+ cell += char;
219
+ }
220
+ cells.push(cell.trim().replace(/\\\|/g, '|'));
221
+ return cells;
222
+ }
223
+ function isMarkdownTableRow(line) {
224
+ const cells = splitMarkdownTableRow(line);
225
+ return cells.length >= 2 && String(line ?? '').includes('|');
226
+ }
227
+ function isMarkdownTableSeparator(line) {
228
+ const cells = splitMarkdownTableRow(line);
229
+ return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell));
230
+ }
231
+ function stripInlineFormattingForWidth(text) {
232
+ return String(text ?? '')
233
+ .replace(/`([^`]*)`/g, '$1')
234
+ .replace(/\*\*([^*]+)\*\*/g, '$1');
235
+ }
236
+ const TRANSCRIPT_BAR_WIDTH = 3;
237
+ const TABLE_BORDER_WIDTH = 2;
238
+ const TABLE_CELL_OVERHEAD_WIDTH = 3;
239
+ function fitTableColumnWidths(columnWidths, maxWidth) {
240
+ const widths = columnWidths.map((width) => Math.max(3, Math.floor(width)));
241
+ if (widths.length === 0)
242
+ return widths;
243
+ const overhead = TABLE_BORDER_WIDTH + widths.length * TABLE_CELL_OVERHEAD_WIDTH;
244
+ const budget = Math.max(widths.length * 3, maxWidth - overhead);
245
+ const total = widths.reduce((sum, width) => sum + width, 0);
246
+ if (total <= budget)
247
+ return widths;
248
+ let remaining = budget;
249
+ const scaled = widths.map((width) => {
250
+ const next = Math.max(3, Math.floor((width / total) * budget));
251
+ remaining -= next;
252
+ return next;
253
+ });
254
+ while (remaining > 0) {
255
+ let targetIndex = 0;
256
+ for (let index = 1; index < widths.length; index++) {
257
+ if (widths[index] - scaled[index] > widths[targetIndex] - scaled[targetIndex]) {
258
+ targetIndex = index;
259
+ }
260
+ }
261
+ scaled[targetIndex]++;
262
+ remaining--;
263
+ }
264
+ while (remaining < 0) {
265
+ let targetIndex = -1;
266
+ for (let index = 0; index < scaled.length; index++) {
267
+ if (scaled[index] <= 3)
268
+ continue;
269
+ if (targetIndex === -1 || scaled[index] > scaled[targetIndex]) {
270
+ targetIndex = index;
271
+ }
272
+ }
273
+ if (targetIndex === -1)
274
+ break;
275
+ scaled[targetIndex]--;
276
+ remaining++;
277
+ }
278
+ return scaled;
279
+ }
280
+ function normalizeMarkdownTableCells(cells, columnCount) {
281
+ return Array.from({ length: columnCount }, (_, index) => cells[index] ?? '');
282
+ }
283
+ function parseMarkdownTableBlock(lines, startIndex) {
284
+ const headerLine = lines[startIndex] ?? '';
285
+ const separatorLine = lines[startIndex + 1] ?? '';
286
+ if (!isMarkdownTableRow(headerLine) || !isMarkdownTableSeparator(separatorLine)) {
287
+ return null;
288
+ }
289
+ const headers = splitMarkdownTableRow(headerLine);
290
+ const separators = splitMarkdownTableRow(separatorLine);
291
+ if (headers.length !== separators.length) {
292
+ return null;
293
+ }
294
+ const columnCount = Math.max(headers.length, separators.length);
295
+ const rows = [];
296
+ let nextIndex = startIndex + 2;
297
+ while (nextIndex < lines.length && isMarkdownTableRow(lines[nextIndex] ?? '')) {
298
+ const cells = splitMarkdownTableRow(lines[nextIndex] ?? '');
299
+ if (cells.length !== columnCount)
300
+ break;
301
+ rows.push(normalizeMarkdownTableCells(cells, columnCount));
302
+ nextIndex++;
303
+ }
304
+ const normalizedHeaders = normalizeMarkdownTableCells(headers, columnCount);
305
+ const columnWidths = normalizedHeaders.map((header, columnIndex) => {
306
+ const values = [header, ...rows.map((row) => row[columnIndex] ?? '')];
307
+ return Math.max(3, ...values.map((value) => stripInlineFormattingForWidth(value).length));
308
+ });
309
+ return {
310
+ nextIndex,
311
+ table: {
312
+ columnWidths,
313
+ headers: normalizedHeaders,
314
+ rows,
315
+ },
316
+ };
317
+ }
318
+ function splitFormattedLines(body) {
319
+ const lines = String(body ?? '').split('\n');
320
+ const formatted = [];
321
+ let inCodeBlock = false;
322
+ for (let index = 0; index < lines.length; index++) {
323
+ const line = lines[index] ?? '';
324
+ const trimmed = line.trim();
325
+ if (trimmed.startsWith('```')) {
326
+ inCodeBlock = !inCodeBlock;
327
+ continue;
328
+ }
329
+ if (inCodeBlock) {
330
+ formatted.push({ kind: 'code', text: line });
331
+ continue;
332
+ }
333
+ if (!trimmed) {
334
+ formatted.push({ kind: 'blank', text: '' });
335
+ continue;
336
+ }
337
+ if (/^<{3,}/.test(trimmed)) {
338
+ formatted.push({ kind: 'conflict-start', text: trimmed });
339
+ continue;
340
+ }
341
+ if (/^={3,}$/.test(trimmed)) {
342
+ formatted.push({ kind: 'conflict-sep', text: trimmed });
343
+ continue;
344
+ }
345
+ if (/^>{3,}/.test(trimmed)) {
346
+ formatted.push({ kind: 'conflict-end', text: trimmed });
347
+ continue;
348
+ }
349
+ const tableBlock = parseMarkdownTableBlock(lines, index);
350
+ if (tableBlock) {
351
+ formatted.push({ kind: 'table', table: tableBlock.table, text: '' });
352
+ index = tableBlock.nextIndex - 1;
353
+ continue;
354
+ }
355
+ const headingMatch = trimmed.match(/^(#{1,4})\s+(.*)$/);
356
+ if (headingMatch) {
357
+ formatted.push({ kind: 'heading', text: headingMatch[2] ?? '' });
358
+ continue;
359
+ }
360
+ const numberedMatch = trimmed.match(/^(\d+)\.\s+(.*)$/);
361
+ if (numberedMatch) {
362
+ formatted.push({
363
+ kind: 'numbered',
364
+ marker: numberedMatch[1],
365
+ text: numberedMatch[2] ?? '',
366
+ });
367
+ continue;
368
+ }
369
+ const bulletMatch = trimmed.match(/^[-*]\s+(.*)$/);
370
+ if (bulletMatch) {
371
+ formatted.push({ kind: 'bullet', text: bulletMatch[1] ?? '' });
372
+ continue;
373
+ }
374
+ const quoteMatch = trimmed.match(/^>\s+(.*)$/);
375
+ if (quoteMatch) {
376
+ formatted.push({ kind: 'quote', text: quoteMatch[1] ?? '' });
377
+ continue;
378
+ }
379
+ formatted.push({ kind: 'paragraph', text: line });
380
+ }
381
+ return formatted;
382
+ }
383
+ function parseDiffPreview(patchText) {
384
+ const lines = String(patchText ?? '').split('\n');
385
+ const previewLines = [];
386
+ let added = 0;
387
+ let removed = 0;
388
+ let oldLine = null;
389
+ let newLine = null;
390
+ for (const rawLine of lines) {
391
+ const hunk = rawLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
392
+ if (hunk) {
393
+ oldLine = Number(hunk[1]);
394
+ newLine = Number(hunk[2]);
395
+ previewLines.push({ kind: 'hunk', lineNumber: null, content: rawLine });
396
+ continue;
397
+ }
398
+ if (rawLine.startsWith('@@')) {
399
+ previewLines.push({ kind: 'hunk', lineNumber: null, content: rawLine });
400
+ continue;
401
+ }
402
+ if (rawLine.startsWith('--- ') || rawLine.startsWith('+++ ')) {
403
+ continue;
404
+ }
405
+ if (rawLine === '\') {
406
+ continue;
407
+ }
408
+ if (rawLine.startsWith('+')) {
409
+ added++;
410
+ previewLines.push({ kind: 'add', lineNumber: newLine, content: rawLine.slice(1) });
411
+ if (newLine !== null)
412
+ newLine++;
413
+ continue;
414
+ }
415
+ if (rawLine.startsWith('-')) {
416
+ removed++;
417
+ previewLines.push({ kind: 'remove', lineNumber: oldLine, content: rawLine.slice(1) });
418
+ if (oldLine !== null)
419
+ oldLine++;
420
+ continue;
421
+ }
422
+ const content = rawLine.startsWith(' ') ? rawLine.slice(1) : rawLine;
423
+ previewLines.push({ kind: 'context', lineNumber: newLine ?? oldLine, content });
424
+ if (oldLine !== null)
425
+ oldLine++;
426
+ if (newLine !== null)
427
+ newLine++;
428
+ }
429
+ return { added, lines: previewLines, removed };
430
+ }
431
+ function diffLineColor(kind) {
432
+ switch (kind) {
433
+ case 'add':
434
+ return 'green';
435
+ case 'remove':
436
+ return 'red';
437
+ case 'hunk':
438
+ return 'cyan';
439
+ case 'context':
440
+ return 'gray';
441
+ }
442
+ }
443
+ function diffLinePrefix(kind) {
444
+ switch (kind) {
445
+ case 'add':
446
+ return '+';
447
+ case 'remove':
448
+ return '-';
449
+ case 'hunk':
450
+ case 'context':
451
+ return ' ';
452
+ }
453
+ }
454
+ function splitDiffLines(text) {
455
+ if (text === '')
456
+ return [];
457
+ const lines = text.split('\n');
458
+ return text.endsWith('\n') && lines[lines.length - 1] === ''
459
+ ? lines.slice(0, -1)
460
+ : lines;
461
+ }
462
+ function buildStrReplaceDiff(oldString, newString) {
463
+ const oldLines = splitDiffLines(String(oldString ?? ''));
464
+ const newLines = splitDiffLines(String(newString ?? ''));
465
+ const lines = [`@@ -1,${oldLines.length} +1,${newLines.length} @@`];
466
+ if (oldLines.length > 0)
467
+ lines.push(oldLines.map((line) => `-${line}`).join('\n'));
468
+ if (newLines.length > 0)
469
+ lines.push(newLines.map((line) => `+${line}`).join('\n'));
470
+ return lines.join('\n');
471
+ }
472
+ function buildWriteFileDiff(content) {
473
+ const lines = splitDiffLines(String(content ?? ''));
474
+ if (lines.length === 0)
475
+ return '';
476
+ return `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}`;
477
+ }
478
+ function buildDeleteFileDiff(content) {
479
+ const lines = splitDiffLines(String(content ?? ''));
480
+ if (lines.length === 0)
481
+ return '';
482
+ return `@@ -1,${lines.length} +0,0 @@\n${lines.map((line) => `-${line}`).join('\n')}`;
483
+ }
484
+ function isFileChangeTool(name) {
485
+ return (name === 'patch_file' ||
486
+ name === 'str_replace' ||
487
+ name === 'write_file' ||
488
+ name === 'delete_file' ||
489
+ name === 'undo_edit');
490
+ }
491
+ function getToolFilePath(call, result) {
492
+ return String(call.args?.filePath ??
493
+ call.args?.file_path ??
494
+ result?.filePath ??
495
+ '').trim();
496
+ }
497
+ function formatToolResultState(result) {
498
+ if (result?.skipped)
499
+ return 'Skipped';
500
+ if (result?.blocked)
501
+ return 'Blocked';
502
+ if (result?.ok === true)
503
+ return 'OK';
504
+ return 'Failed';
505
+ }
506
+ function formatRunCommandResultState(result) {
507
+ if (result?.timedOut)
508
+ return 'Timed out';
509
+ if (result?.exitCode != null)
510
+ return `Exit code ${result.exitCode}`;
511
+ return formatToolResultState(result);
512
+ }
513
+ function formatToolArgs(call, result) {
514
+ const filePath = getToolFilePath(call, result);
515
+ if (filePath)
516
+ return filePath;
517
+ if (call.name === 'search_code' || call.name === 'grep_code') {
518
+ return truncate(String(call.args?.query ?? call.args?.pattern ?? ''), 120);
519
+ }
520
+ if (call.name === 'thegitai_web_fetch') {
521
+ return truncate(String(call.args?.url ?? ''), 120);
522
+ }
523
+ if (call.name === 'thegitai_web_search') {
524
+ return truncate(String(call.args?.query ?? ''), 120);
525
+ }
526
+ return '';
527
+ }
528
+ function buildFileChangeEntry(event) {
529
+ const { call, result } = event;
530
+ const filePath = getToolFilePath(call, result) || '(unknown file)';
531
+ const ok = result?.ok === true;
532
+ const skipped = result?.skipped === true;
533
+ const error = typeof result?.error === 'string' && result.error.trim()
534
+ ? truncate(result.error.trim(), 180)
535
+ : '';
536
+ if (!ok) {
537
+ const verb = call.name === 'delete_file'
538
+ ? 'Delete'
539
+ : call.name === 'undo_edit'
540
+ ? 'Undo'
541
+ : 'Edit';
542
+ return {
543
+ body: error || `${call.name} ${filePath}`,
544
+ filePath,
545
+ kind: skipped ? 'system' : 'error',
546
+ title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
547
+ };
548
+ }
549
+ if (call.name === 'undo_edit') {
550
+ const dryRun = result?.dryRun === true ||
551
+ result?.dry_run === true ||
552
+ call.args?.dryRun === true ||
553
+ call.args?.dry_run === true;
554
+ const records = dryRun && Array.isArray(result?.previewed)
555
+ ? result.previewed
556
+ : Array.isArray(result?.reverted)
557
+ ? result.reverted
558
+ : [];
559
+ const files = records
560
+ .map((record) => String(record?.filePath ?? '').trim())
561
+ .filter((file) => Boolean(file));
562
+ const uniqueFiles = Array.from(new Set(files));
563
+ const titleFile = uniqueFiles.length === 1 ? uniqueFiles[0] : `${uniqueFiles.length} files`;
564
+ return {
565
+ body: '',
566
+ filePath: uniqueFiles[0] ?? filePath,
567
+ kind: 'tool',
568
+ title: dryRun
569
+ ? `Undo preview: ${titleFile}`
570
+ : `Undid assistant edit: ${titleFile}`,
571
+ };
572
+ }
573
+ if (call.name === 'delete_file') {
574
+ if (result?.deleted !== true) {
575
+ return {
576
+ body: '',
577
+ filePath,
578
+ kind: skipped ? 'system' : 'tool',
579
+ title: `Delete skipped: ${filePath}`,
580
+ };
581
+ }
582
+ const content = typeof result?.content === 'string' ? result.content : '';
583
+ const diffText = buildDeleteFileDiff(content);
584
+ const preview = diffText ? parseDiffPreview(diffText) : undefined;
585
+ const summary = preview ? ` (+0 -${preview.removed})` : '';
586
+ return {
587
+ body: '',
588
+ diffPreview: preview,
589
+ filePath,
590
+ kind: 'diff',
591
+ title: `Deleted ${filePath}${summary}`,
592
+ };
593
+ }
594
+ if (call.name === 'write_file') {
595
+ const content = typeof call.args?.content === 'string' ? call.args.content : '';
596
+ const diffText = buildWriteFileDiff(content);
597
+ const preview = diffText ? parseDiffPreview(diffText) : undefined;
598
+ const verb = result?.created === true ? 'Created' : 'Wrote';
599
+ const summary = preview ? ` (+${preview.added} -0)` : '';
600
+ return {
601
+ body: '',
602
+ diffPreview: preview,
603
+ filePath,
604
+ kind: 'diff',
605
+ title: `${verb} ${filePath}${summary}`,
606
+ };
607
+ }
608
+ let diffText = '';
609
+ if (call.name === 'patch_file') {
610
+ diffText = typeof call.args?.patch === 'string' ? call.args.patch : '';
611
+ }
612
+ else if (call.name === 'str_replace') {
613
+ const oldString = typeof call.args?.old_string === 'string'
614
+ ? call.args.old_string
615
+ : typeof call.args?.oldString === 'string'
616
+ ? call.args.oldString
617
+ : '';
618
+ const newString = typeof call.args?.new_string === 'string'
619
+ ? call.args.new_string
620
+ : typeof call.args?.newString === 'string'
621
+ ? call.args.newString
622
+ : '';
623
+ diffText = buildStrReplaceDiff(oldString, newString);
624
+ }
625
+ const preview = diffText ? parseDiffPreview(diffText) : undefined;
626
+ const summary = preview ? ` (+${preview.added} -${preview.removed})` : '';
627
+ return {
628
+ body: '',
629
+ diffPreview: preview,
630
+ filePath,
631
+ kind: 'diff',
632
+ title: `Edited ${filePath}${summary}`,
633
+ };
634
+ }
635
+ function buildWorkingToolEntry(event) {
636
+ const { call, result } = event;
637
+ const error = typeof result?.error === 'string' && result.error.trim()
638
+ ? `\n${truncate(result.error.trim(), 180)}`
639
+ : '';
640
+ if (call.name === 'run_command') {
641
+ const command = String(call.args?.command ?? result?.command ?? '').trim();
642
+ return {
643
+ body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
644
+ kind: result?.ok === true ? 'tool' : 'error',
645
+ title: 'Shell',
646
+ };
647
+ }
648
+ if (call.name === 'run_node_script') {
649
+ return {
650
+ body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
651
+ kind: result?.ok === true ? 'tool' : 'error',
652
+ title: 'Node',
653
+ };
654
+ }
655
+ if (isFileChangeTool(call.name)) {
656
+ return buildFileChangeEntry(event);
657
+ }
658
+ const args = formatToolArgs(call, result);
659
+ return {
660
+ body: `${call.name}${args ? ` ${args}` : ''} -> ${formatToolResultState(result)}${error}`,
661
+ kind: result?.ok === true ? 'tool' : 'error',
662
+ title: 'Tool',
663
+ };
664
+ }
665
+ function findPendingToolCallByName(pendingCalls, name) {
666
+ for (const [id, call] of pendingCalls) {
667
+ if (call.name !== name)
668
+ continue;
669
+ pendingCalls.delete(id);
670
+ return call;
671
+ }
672
+ return null;
673
+ }
674
+ function textFromHistoryEntry(entry) {
675
+ return (entry.parts ?? [])
676
+ .filter((part) => typeof part?.text === 'string' && part.text.trim())
677
+ .map((part) => part.text.trim())
678
+ .join('\n\n');
679
+ }
680
+ function displayUserTextFromHistoryEntry(entry) {
681
+ const text = textFromHistoryEntry(entry);
682
+ const markers = [
683
+ 'Current user request:\n',
684
+ 'Current user message:\n',
685
+ 'User request:\n',
686
+ 'User message:\n',
687
+ ];
688
+ const marker = markers.find((candidate) => text.includes(candidate)) ?? '';
689
+ if (!marker)
690
+ return text;
691
+ const start = text.indexOf(marker);
692
+ const contentStart = start + marker.length;
693
+ const contentEnd = text.indexOf('\n\n', contentStart);
694
+ return text
695
+ .slice(contentStart, contentEnd === -1 ? text.length : contentEnd)
696
+ .trim();
697
+ }
698
+ function buildTranscriptFromSessionHistory(history) {
699
+ const entries = [];
700
+ const pendingCalls = new Map();
701
+ for (const entry of history) {
702
+ if (!entry?.parts?.length)
703
+ continue;
704
+ for (const part of entry.parts) {
705
+ const call = part?.functionCall;
706
+ if (!call || typeof call !== 'object')
707
+ continue;
708
+ const callId = String(call.id ?? '').trim();
709
+ if (!callId || !call.name)
710
+ continue;
711
+ pendingCalls.set(callId, call);
712
+ }
713
+ for (const part of entry.parts) {
714
+ const functionResponse = part?.functionResponse;
715
+ if (!functionResponse || typeof functionResponse !== 'object')
716
+ continue;
717
+ const responseName = String(functionResponse.name ?? '').trim();
718
+ const responseId = String(functionResponse.id ?? functionResponse.toolCallId ?? '').trim();
719
+ let call = null;
720
+ if (responseId) {
721
+ call = pendingCalls.get(responseId) ?? null;
722
+ if (call)
723
+ pendingCalls.delete(responseId);
724
+ }
725
+ if (!call && responseName) {
726
+ call = findPendingToolCallByName(pendingCalls, responseName);
727
+ }
728
+ if (!call || !isFileChangeTool(call.name))
729
+ continue;
730
+ entries.push(buildFileChangeEntry({
731
+ call,
732
+ result: functionResponse.response ?? {},
733
+ }));
734
+ }
735
+ if (entry.role === 'user' && entry.kind === 'turnStart') {
736
+ const text = displayUserTextFromHistoryEntry(entry);
737
+ if (text) {
738
+ entries.push({ body: text, kind: 'user', title: 'You' });
739
+ }
740
+ continue;
741
+ }
742
+ const text = textFromHistoryEntry(entry);
743
+ if ((entry.role === 'model' || entry.role === 'assistant') && text) {
744
+ entries.push({ body: text, kind: 'assistant', title: 'Response' });
745
+ }
746
+ }
747
+ return entries;
748
+ }
749
+ function createInitialTranscript() {
750
+ return [
751
+ {
752
+ body: 'Interactive mode ready. Type /help for commands.',
753
+ kind: 'system',
754
+ title: 'System',
755
+ },
756
+ ];
757
+ }
758
+ function createSessionTranscript(session) {
759
+ const transcript = buildTranscriptFromSessionHistory(session.history);
760
+ return transcript.length ? transcript : createInitialTranscript();
761
+ }
762
+ function createShellStore(initialState) {
763
+ let state = initialState;
764
+ let nextEntryId = 1;
765
+ const listeners = new Set();
766
+ const notify = () => {
767
+ for (const listener of listeners)
768
+ listener();
769
+ };
770
+ return {
771
+ getState: () => state,
772
+ subscribe: (listener) => {
773
+ listeners.add(listener);
774
+ return () => {
775
+ listeners.delete(listener);
776
+ };
777
+ },
778
+ setState: (nextState) => {
779
+ state = nextState;
780
+ notify();
781
+ },
782
+ update: (updater) => {
783
+ state = updater(state);
784
+ notify();
785
+ },
786
+ appendEntry: (entry) => {
787
+ const id = nextEntryId++;
788
+ state = {
789
+ ...state,
790
+ transcript: [...state.transcript, { ...entry, id }],
791
+ transcriptScrollOffset: 0,
792
+ };
793
+ notify();
794
+ return id;
795
+ },
796
+ appendEntries: (entries) => {
797
+ if (entries.length === 0)
798
+ return [];
799
+ const appended = entries.map((entry) => ({
800
+ ...entry,
801
+ id: nextEntryId++,
802
+ }));
803
+ state = {
804
+ ...state,
805
+ transcript: [...state.transcript, ...appended],
806
+ transcriptScrollOffset: 0,
807
+ };
808
+ notify();
809
+ return appended.map((entry) => entry.id);
810
+ },
811
+ appendWorkingTool: (entry) => {
812
+ const lastEntry = state.workingTools.at(-1);
813
+ if (sameTranscriptDraft(lastEntry, entry))
814
+ return;
815
+ state = {
816
+ ...state,
817
+ workingTools: [...state.workingTools, entry].slice(-WORKING_TOOL_PREVIEW_ITEMS),
818
+ };
819
+ notify();
820
+ },
821
+ replaceTranscript: (entries) => {
822
+ state = {
823
+ ...state,
824
+ transcript: entries.map((entry) => ({ ...entry, id: nextEntryId++ })),
825
+ transcriptScrollOffset: 0,
826
+ workingTools: [],
827
+ };
828
+ notify();
829
+ },
830
+ updateEntry: (id, changes) => {
831
+ const index = state.transcript.findIndex((entry) => entry.id === id);
832
+ if (index === -1)
833
+ return;
834
+ const current = state.transcript[index];
835
+ const next = { ...current, ...changes };
836
+ if (current.body === next.body &&
837
+ current.kind === next.kind &&
838
+ current.title === next.title) {
839
+ return;
840
+ }
841
+ const nextTranscript = [...state.transcript];
842
+ nextTranscript[index] = next;
843
+ state = { ...state, transcript: nextTranscript };
844
+ notify();
845
+ },
846
+ };
847
+ }
848
+ function shallowArrayEqual(a, b) {
849
+ if (a === b)
850
+ return true;
851
+ if (a.length !== b.length)
852
+ return false;
853
+ for (let index = 0; index < a.length; index++) {
854
+ if (!Object.is(a[index], b[index]))
855
+ return false;
856
+ }
857
+ return true;
858
+ }
859
+ function createInitialShellState(session, serverModels, debugUi) {
860
+ return {
861
+ activeTurnInput: '',
862
+ activeTurnInputPreformatted: false,
863
+ agentMode: session.agentMode,
864
+ approvalCursor: getDefaultApprovalCursor(),
865
+ approvalPrompt: null,
866
+ autoYes: session.autoYes,
867
+ busy: false,
868
+ busySince: null,
869
+ clockNow: Date.now(),
870
+ commandCursor: 0,
871
+ commandLog: [],
872
+ contextStatus: 'Idle',
873
+ currentModelId: session.modelId,
874
+ cursor: 0,
875
+ exiting: false,
876
+ input: '',
877
+ maxToolSteps: session.maxToolSteps,
878
+ modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
879
+ modelPickerOpen: false,
880
+ projectRoot: session.rootDir,
881
+ sessionId: session.sessionId,
882
+ showSessionId: debugUi.showSessionId,
883
+ promptHistory: loadPromptHistory(session.env),
884
+ promptHistoryCursor: null,
885
+ promptHistoryDraft: '',
886
+ resumePickerFilter: '',
887
+ resumePickerIndex: 0,
888
+ resumePickerOpen: false,
889
+ resumePickerSessions: [],
890
+ transcriptScrollOffset: 0,
891
+ queuedMessage: null,
892
+ serverModels: serverModels.models,
893
+ sudoPrompt: null,
894
+ status: 'Ready',
895
+ exitConfirmUntil: null,
896
+ thinkingTitle: '',
897
+ thinkingNotes: [],
898
+ tokenUsage: formatClientTokenUsage(null),
899
+ transcript: [],
900
+ turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
901
+ pastedChunks: [],
902
+ imageAttachments: [],
903
+ workingTools: [],
904
+ };
905
+ }
906
+ function formatTokenCount(value) {
907
+ return Math.max(0, Math.round(value)).toLocaleString();
908
+ }
909
+ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
910
+ if (!usageSummary)
911
+ return '';
912
+ const inputTokens = usageSummary?.inputTokens ?? 0;
913
+ const outputTokens = usageSummary?.outputTokens ?? 0;
914
+ const reasoningTokens = usageSummary?.reasoningTokens ?? 0;
915
+ const cacheTokens = usageSummary?.cacheTokens ?? 0;
916
+ const cacheWriteTokens = usageSummary?.cacheWriteTokens ?? 0;
917
+ const indexTokens = usageSummary?.indexTokens ?? 0;
918
+ return [
919
+ 'Session tokens',
920
+ `in ${formatTokenCount(inputTokens)}`,
921
+ `out ${formatTokenCount(outputTokens)}`,
922
+ ...(reasoningTokens > 0 ? [`think ${formatTokenCount(reasoningTokens)}`] : []),
923
+ `cache ${formatTokenCount(cacheTokens)}`,
924
+ `write ${formatTokenCount(cacheWriteTokens)}`,
925
+ `index ${formatTokenCount(indexTokens)}`,
926
+ ].join(' • ');
927
+ }
928
+ export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
929
+ const trimmed = String(projectRoot ?? '').trim();
930
+ if (!trimmed)
931
+ return '.';
932
+ const normalizedHome = String(homeDir ?? '').trim().replace(/\/+$/, '');
933
+ if (normalizedHome && trimmed === normalizedHome)
934
+ return '~';
935
+ if (normalizedHome && trimmed.startsWith(`${normalizedHome}/`)) {
936
+ return `~/${trimmed.slice(normalizedHome.length + 1)}`;
937
+ }
938
+ return trimmed;
939
+ }
940
+ export function formatPromptSessionIdLabel(showSessionId, sessionId) {
941
+ return showSessionId ? String(sessionId ?? '').trim() : '';
942
+ }
943
+ function thinkingNoteFromStatus(status) {
944
+ const text = String(status ?? '').trim();
945
+ if (!text.startsWith('Thinking:'))
946
+ return null;
947
+ const note = text.slice('Thinking:'.length).trim();
948
+ return note ? note : null;
949
+ }
950
+ function splitThinkingLines(text) {
951
+ return text
952
+ .split('\n')
953
+ .map((line) => line.trim())
954
+ .filter((line) => line.length > 0 && line !== 'Thinking')
955
+ .flatMap((line) => line
956
+ .split(/(?<=[.!?])\s+(?=[A-Z0-9"'`])/)
957
+ .map((part) => part.trim())
958
+ .filter(Boolean))
959
+ .map((line) => truncate(line, 120));
960
+ }
961
+ function thinkingPanelFromStatus(status) {
962
+ const text = thinkingNoteFromStatus(status);
963
+ if (!text)
964
+ return null;
965
+ const rawLines = text
966
+ .split('\n')
967
+ .map((line) => line.trim())
968
+ .filter(Boolean);
969
+ if (rawLines.length === 0)
970
+ return null;
971
+ if (rawLines.length === 1 && rawLines[0].length <= 72) {
972
+ return {
973
+ title: truncate(rawLines[0], 72),
974
+ notes: [],
975
+ };
976
+ }
977
+ const title = rawLines.length > 1 && rawLines[0].length <= 72
978
+ ? truncate(rawLines[0], 72)
979
+ : 'Thinking';
980
+ const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
981
+ return {
982
+ title,
983
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
984
+ };
985
+ }
986
+ export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
987
+ export const EXIT_CTRL_C_CONFIRM_MS = 3000;
988
+ export function isExitConfirmActive(exitConfirmUntil, now = Date.now()) {
989
+ return exitConfirmUntil != null && now < exitConfirmUntil;
990
+ }
991
+ export function getInputCommandToken(input) {
992
+ const trimmed = String(input ?? '').trim();
993
+ if (!trimmed.startsWith('/'))
994
+ return '';
995
+ const firstSpaceIndex = trimmed.indexOf(' ');
996
+ return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
997
+ }
998
+ function shouldShowCommandPalette(state) {
999
+ const trimmed = String(state.input ?? '').trim();
1000
+ return (!state.busy &&
1001
+ !state.exiting &&
1002
+ !state.modelPickerOpen &&
1003
+ !state.resumePickerOpen &&
1004
+ trimmed.startsWith('/') &&
1005
+ !trimmed.includes(' '));
1006
+ }
1007
+ export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1008
+ const currentShowsCommands = shouldShowCommandPalette(current);
1009
+ const nextShowsCommands = shouldShowCommandPalette({
1010
+ ...current,
1011
+ input: nextInput,
1012
+ });
1013
+ return currentShowsCommands !== nextShowsCommands;
1014
+ }
1015
+ export function applySlashCommandSuggestion(currentInput, suggestion) {
1016
+ const token = getInputCommandToken(currentInput);
1017
+ if (!token) {
1018
+ return { cursor: suggestion.command.length, input: suggestion.command };
1019
+ }
1020
+ const tokenStart = currentInput.indexOf(token);
1021
+ const before = currentInput.slice(0, tokenStart);
1022
+ const after = currentInput.slice(tokenStart + token.length);
1023
+ return {
1024
+ cursor: before.length + suggestion.command.length,
1025
+ input: `${before}${suggestion.command}${after}`,
1026
+ };
1027
+ }
1028
+ export function isExactSlashCommandToken(token) {
1029
+ return SLASH_COMMANDS.some((command) => command.command === token);
1030
+ }
1031
+ export function insertAtCursor(state, text) {
1032
+ const before = state.input.slice(0, state.cursor);
1033
+ const after = state.input.slice(state.cursor);
1034
+ return {
1035
+ cursor: state.cursor + text.length,
1036
+ input: `${before}${text}${after}`,
1037
+ };
1038
+ }
1039
+ export function deleteBeforeCursor(state) {
1040
+ if (state.cursor <= 0)
1041
+ return null;
1042
+ return {
1043
+ cursor: state.cursor - 1,
1044
+ input: `${state.input.slice(0, state.cursor - 1)}${state.input.slice(state.cursor)}`,
1045
+ };
1046
+ }
1047
+ export function deleteAtCursor(state) {
1048
+ if (state.cursor >= state.input.length)
1049
+ return null;
1050
+ return {
1051
+ cursor: state.cursor,
1052
+ input: `${state.input.slice(0, state.cursor)}${state.input.slice(state.cursor + 1)}`,
1053
+ };
1054
+ }
1055
+ function appendPromptToHistory(history, prompt) {
1056
+ const trimmed = prompt.trim();
1057
+ if (!trimmed || trimmed.startsWith('/'))
1058
+ return history;
1059
+ const last = history[history.length - 1];
1060
+ if (last === trimmed)
1061
+ return history;
1062
+ const next = [...history.filter((entry) => entry !== trimmed), trimmed];
1063
+ if (next.length > MAX_PROMPT_HISTORY_ENTRIES) {
1064
+ next.splice(0, next.length - MAX_PROMPT_HISTORY_ENTRIES);
1065
+ }
1066
+ return next;
1067
+ }
1068
+ export function navigatePromptHistory(state, direction) {
1069
+ if (state.promptHistory.length === 0)
1070
+ return state;
1071
+ if (direction === 'previous') {
1072
+ const nextCursor = state.promptHistoryCursor === null
1073
+ ? state.promptHistory.length - 1
1074
+ : Math.max(state.promptHistoryCursor - 1, 0);
1075
+ const draft = state.promptHistoryCursor === null ? state.input : state.promptHistoryDraft;
1076
+ const nextInput = state.promptHistory[nextCursor] ?? '';
1077
+ return {
1078
+ ...state,
1079
+ commandCursor: 0,
1080
+ cursor: nextInput.length,
1081
+ input: nextInput,
1082
+ promptHistoryCursor: nextCursor,
1083
+ promptHistoryDraft: draft,
1084
+ };
1085
+ }
1086
+ if (state.promptHistoryCursor === null)
1087
+ return state;
1088
+ const nextCursor = state.promptHistoryCursor + 1;
1089
+ if (nextCursor >= state.promptHistory.length) {
1090
+ const restored = state.promptHistoryDraft;
1091
+ return {
1092
+ ...state,
1093
+ commandCursor: 0,
1094
+ cursor: restored.length,
1095
+ input: restored,
1096
+ promptHistoryCursor: null,
1097
+ promptHistoryDraft: '',
1098
+ };
1099
+ }
1100
+ const nextInput = state.promptHistory[nextCursor] ?? '';
1101
+ return {
1102
+ ...state,
1103
+ commandCursor: 0,
1104
+ cursor: nextInput.length,
1105
+ input: nextInput,
1106
+ promptHistoryCursor: nextCursor,
1107
+ };
1108
+ }
1109
+ function getDefaultApprovalCursor() {
1110
+ const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1111
+ return denyIndex === -1 ? 0 : denyIndex;
1112
+ }
1113
+ export function getNextApprovalCursor(currentIndex, direction) {
1114
+ return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1115
+ }
1116
+ export function getApprovalChoiceForCursor(cursor) {
1117
+ return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1118
+ ?.value ?? 'n');
1119
+ }
1120
+ export function resolveApprovalChoiceFromInput(input) {
1121
+ const normalizedInput = String(input ?? '').trim().toLowerCase();
1122
+ if (normalizedInput === 'y')
1123
+ return 'y';
1124
+ if (normalizedInput === 'a')
1125
+ return 'a';
1126
+ if (normalizedInput === 'n')
1127
+ return 'n';
1128
+ return null;
1129
+ }
1130
+ export function buildModelPickerOptions(currentModelId, serverModels) {
1131
+ return serverModels.map((model) => ({
1132
+ id: model.id,
1133
+ label: model.label,
1134
+ meta: model.id === currentModelId ? 'current' : '',
1135
+ }));
1136
+ }
1137
+ function getDefaultModelPickerIndex(currentModelId, serverModels) {
1138
+ const options = buildModelPickerOptions(currentModelId, serverModels);
1139
+ const currentIndex = options.findIndex((option) => option.id === currentModelId);
1140
+ return currentIndex === -1 ? 0 : currentIndex;
1141
+ }
1142
+ export function getNextModelPickerIndex(options, currentIndex, direction) {
1143
+ if (!options.length)
1144
+ return 0;
1145
+ return (currentIndex + direction + options.length) % options.length;
1146
+ }
1147
+ export const TranscriptEntryCard = {
1148
+ render: renderTranscriptEntryLines,
1149
+ memoized: true,
1150
+ };
1151
+ function appendCommandLog(state, text) {
1152
+ const parts = text.replace(/\r\n?/g, '\n').split('\n');
1153
+ const complete = parts.filter((line) => line.trim().length > 0);
1154
+ if (complete.length === 0)
1155
+ return state;
1156
+ return {
1157
+ ...state,
1158
+ commandLog: [...state.commandLog, ...complete].slice(-COMMAND_PREVIEW_LINES),
1159
+ };
1160
+ }
1161
+ async function saveSessionBoth({ serverSessionClient, session, }) {
1162
+ saveSessionState(session);
1163
+ await serverSessionClient.save(session);
1164
+ }
1165
+ function shouldUseRatatuiShell() {
1166
+ if (process.env.THEGITAI_PLAIN === '1')
1167
+ return false;
1168
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1169
+ }
1170
+ export function shouldUseClientRatatuiShell() {
1171
+ return shouldUseRatatuiShell();
1172
+ }
1173
+ export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1174
+ if (!shouldUseRatatuiShell()) {
1175
+ throw new Error('Client TUI requires an interactive terminal.');
1176
+ }
1177
+ await withTuiMode(async () => {
1178
+ const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1179
+ store.replaceTranscript(createSessionTranscript(session));
1180
+ let currentServerModels = serverModels;
1181
+ let sessionAutoYes = session.autoYes;
1182
+ let resolveDone = null;
1183
+ let resolveApprovalChoice = null;
1184
+ let resolveSudoPassword = null;
1185
+ let cleanupSudoPasswordPrompt = null;
1186
+ let sudoPasswordBuffer = '';
1187
+ const bridge = createRatatuiBridge();
1188
+ const { handleShellKeyEvent } = await import('./tui/shell-input.js');
1189
+ let terminalCols = 80;
1190
+ let terminalRows = 24;
1191
+ let exiting = false;
1192
+ let remountPromise = null;
1193
+ let liveFrameRemountTimer = null;
1194
+ let spinnerFrame = 0;
1195
+ let tuiReady = false;
1196
+ let lastTurnStartedAt = null;
1197
+ let latestUsageSummary = null;
1198
+ let pendingTurnEntries = [];
1199
+ let activeTurnAbort = null;
1200
+ let activeTurnGeneration = 0;
1201
+ let exitCtrlCArmed = false;
1202
+ let exitCtrlCTimer = null;
1203
+ let transientStatusTimer = null;
1204
+ const done = new Promise((resolve) => {
1205
+ resolveDone = resolve;
1206
+ });
1207
+ const scheduleLiveFrameRemount = () => {
1208
+ if (exiting || liveFrameRemountTimer)
1209
+ return;
1210
+ liveFrameRemountTimer = setTimeout(() => {
1211
+ liveFrameRemountTimer = null;
1212
+ if (exiting)
1213
+ return;
1214
+ void remountTui();
1215
+ }, 0);
1216
+ };
1217
+ const showTransientStatus = (status) => {
1218
+ if (transientStatusTimer) {
1219
+ clearTimeout(transientStatusTimer);
1220
+ transientStatusTimer = null;
1221
+ }
1222
+ store.update((current) => ({ ...current, status }));
1223
+ transientStatusTimer = setTimeout(() => {
1224
+ transientStatusTimer = null;
1225
+ store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1226
+ }, 2500);
1227
+ };
1228
+ const renderCurrentFrame = () => {
1229
+ if (!tuiReady)
1230
+ return;
1231
+ const state = store.getState();
1232
+ const elapsedSeconds = state.busySince
1233
+ ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1234
+ : 0;
1235
+ bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
1236
+ };
1237
+ const remountTui = async () => {
1238
+ if (remountPromise) {
1239
+ await remountPromise;
1240
+ return;
1241
+ }
1242
+ remountPromise = (async () => {
1243
+ if (exiting)
1244
+ return;
1245
+ bridge.clear();
1246
+ renderCurrentFrame();
1247
+ })();
1248
+ try {
1249
+ await remountPromise;
1250
+ }
1251
+ finally {
1252
+ remountPromise = null;
1253
+ }
1254
+ };
1255
+ const appendStaticEntries = (entries) => store.appendEntries(entries);
1256
+ const appendStaticEntry = (entry) => {
1257
+ const ids = appendStaticEntries([entry]);
1258
+ return ids[0] ?? -1;
1259
+ };
1260
+ const queueTurnEntry = (entry) => {
1261
+ if (sameTranscriptDraft(pendingTurnEntries.at(-1), entry))
1262
+ return;
1263
+ pendingTurnEntries = [...pendingTurnEntries, entry];
1264
+ };
1265
+ const appendTurnAwareEntry = (entry) => {
1266
+ if (store.getState().busy) {
1267
+ queueTurnEntry(entry);
1268
+ return;
1269
+ }
1270
+ appendStaticEntry(entry);
1271
+ };
1272
+ const appendSettledTurnEntries = async (turnEntries, responseText, shouldContinue = () => true) => {
1273
+ const entries = buildSettledTurnTranscriptEntries(turnEntries, responseText);
1274
+ const responseEntry = entries.at(-1);
1275
+ if (!responseEntry ||
1276
+ responseEntry.kind !== 'assistant' ||
1277
+ responseEntry.title !== 'Response' ||
1278
+ !responseEntry.body) {
1279
+ appendStaticEntries(entries);
1280
+ await remountTui();
1281
+ return;
1282
+ }
1283
+ appendStaticEntries(entries.slice(0, -1));
1284
+ const responseId = appendStaticEntry({
1285
+ ...responseEntry,
1286
+ body: '',
1287
+ });
1288
+ await remountTui();
1289
+ let first = true;
1290
+ for (const body of buildResponseStreamBodies(responseEntry.body)) {
1291
+ if (!first) {
1292
+ await wait(RESPONSE_STREAM_DELAY_MS);
1293
+ }
1294
+ if (exiting)
1295
+ return;
1296
+ if (!shouldContinue()) {
1297
+ store.updateEntry(responseId, { body: responseEntry.body });
1298
+ return;
1299
+ }
1300
+ store.updateEntry(responseId, { body });
1301
+ first = false;
1302
+ }
1303
+ };
1304
+ const takePendingTurnEntries = () => {
1305
+ const entries = pendingTurnEntries;
1306
+ pendingTurnEntries = [];
1307
+ return entries;
1308
+ };
1309
+ const appendError = (message) => {
1310
+ appendStaticEntry({
1311
+ body: message,
1312
+ kind: 'error',
1313
+ title: 'Error',
1314
+ });
1315
+ };
1316
+ const handleAppSelectionCopy = (text) => {
1317
+ try {
1318
+ writeClipboardText(text);
1319
+ showTransientStatus('Copied selection');
1320
+ }
1321
+ catch (error) {
1322
+ appendError(`Selection copy failed: ${error.message}`);
1323
+ }
1324
+ };
1325
+ const handleLinkCopy = (url) => {
1326
+ try {
1327
+ writeClipboardText(url);
1328
+ showTransientStatus('Copied link');
1329
+ }
1330
+ catch (error) {
1331
+ appendError(`Link copy failed: ${error.message}`);
1332
+ }
1333
+ };
1334
+ const handleLinkOpen = async (url) => {
1335
+ try {
1336
+ const opened = await openUrl(url);
1337
+ showTransientStatus(opened ? 'Opened link' : 'Could not open link');
1338
+ }
1339
+ catch (error) {
1340
+ appendError(`Link open failed: ${error.message}`);
1341
+ }
1342
+ };
1343
+ const disarmExitConfirm = () => {
1344
+ exitCtrlCArmed = false;
1345
+ if (exitCtrlCTimer) {
1346
+ clearTimeout(exitCtrlCTimer);
1347
+ exitCtrlCTimer = null;
1348
+ }
1349
+ if (store.getState().exitConfirmUntil != null) {
1350
+ store.update((current) => ({
1351
+ ...current,
1352
+ exitConfirmUntil: null,
1353
+ status: current.status === EXIT_CTRL_C_CONFIRM_MESSAGE
1354
+ ? 'Ready'
1355
+ : current.status,
1356
+ }));
1357
+ }
1358
+ };
1359
+ const dismissPendingApproval = () => {
1360
+ if (!resolveApprovalChoice)
1361
+ return;
1362
+ const pendingResolve = resolveApprovalChoice;
1363
+ resolveApprovalChoice = null;
1364
+ pendingResolve('n');
1365
+ store.update((current) => ({
1366
+ ...current,
1367
+ approvalCursor: getDefaultApprovalCursor(),
1368
+ approvalPrompt: null,
1369
+ }));
1370
+ };
1371
+ const dismissPendingSudoPassword = () => {
1372
+ if (!resolveSudoPassword)
1373
+ return;
1374
+ const pendingResolve = resolveSudoPassword;
1375
+ resolveSudoPassword = null;
1376
+ cleanupSudoPasswordPrompt?.();
1377
+ cleanupSudoPasswordPrompt = null;
1378
+ sudoPasswordBuffer = '';
1379
+ pendingResolve(null);
1380
+ store.update((current) => ({
1381
+ ...current,
1382
+ sudoPrompt: null,
1383
+ }));
1384
+ };
1385
+ const cancelActiveTurn = () => {
1386
+ if (!store.getState().busy)
1387
+ return;
1388
+ disarmExitConfirm();
1389
+ dismissPendingApproval();
1390
+ dismissPendingSudoPassword();
1391
+ activeTurnGeneration += 1;
1392
+ activeTurnAbort?.abort();
1393
+ activeTurnAbort = null;
1394
+ cancelActiveCommand();
1395
+ // Ctrl+C with a queued message: recall it into the composer for editing
1396
+ // rather than auto-submitting it against the cancelled turn. (Esc with a
1397
+ // queued message clears the slot in shell-input without cancelling.)
1398
+ const queued = store.getState().queuedMessage;
1399
+ const cancelledEntries = [
1400
+ ...takePendingTurnEntries(),
1401
+ {
1402
+ body: 'Turn cancelled.',
1403
+ kind: 'error',
1404
+ title: 'Cancelled',
1405
+ },
1406
+ ];
1407
+ store.update((current) => ({
1408
+ ...current,
1409
+ activeTurnInput: '',
1410
+ activeTurnInputPreformatted: false,
1411
+ busy: false,
1412
+ busySince: null,
1413
+ commandLog: [],
1414
+ cursor: queued ? queued.body.length : current.cursor,
1415
+ imageAttachments: queued ? queued.imageAttachments : [],
1416
+ input: queued ? queued.body : current.input,
1417
+ pastedChunks: queued ? queued.pastedChunks : current.pastedChunks,
1418
+ promptHistoryCursor: queued ? null : current.promptHistoryCursor,
1419
+ promptHistoryDraft: queued ? '' : current.promptHistoryDraft,
1420
+ queuedMessage: null,
1421
+ status: 'Ready',
1422
+ thinkingTitle: '',
1423
+ thinkingNotes: [],
1424
+ workingTools: [],
1425
+ tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1426
+ }));
1427
+ appendStaticEntries(cancelledEntries);
1428
+ lastTurnStartedAt = null;
1429
+ if (queued)
1430
+ scheduleLiveFrameRemount();
1431
+ void remountTui();
1432
+ };
1433
+ const saveActiveSession = async () => {
1434
+ try {
1435
+ await saveSessionBoth({ serverSessionClient, session });
1436
+ }
1437
+ catch (error) {
1438
+ appendError(`Session save failed: ${error.message}`);
1439
+ }
1440
+ };
1441
+ const syncShellStateFromSession = () => {
1442
+ store.update((current) => ({
1443
+ ...current,
1444
+ agentMode: session.agentMode,
1445
+ autoYes: sessionAutoYes,
1446
+ commandCursor: 0,
1447
+ currentModelId: session.modelId,
1448
+ sessionId: session.sessionId,
1449
+ modelPickerIndex: current.modelPickerOpen
1450
+ ? getDefaultModelPickerIndex(session.modelId, current.serverModels)
1451
+ : current.modelPickerIndex,
1452
+ tokenUsage: formatClientTokenUsage(lastTurnStartedAt == null ? null : Date.now() - lastTurnStartedAt, latestUsageSummary),
1453
+ turnCounter: Math.max(current.turnCounter, session.history.filter((entry) => entry.role === 'user').length),
1454
+ }));
1455
+ };
1456
+ const setAgentMode = (mode, status = `Mode: ${agentModeLabel(mode)}`) => {
1457
+ session.agentMode = mode;
1458
+ session.autoYes = mode === 'auto-accept';
1459
+ sessionAutoYes = session.autoYes;
1460
+ store.update((current) => ({
1461
+ ...current,
1462
+ agentMode: mode,
1463
+ autoYes: sessionAutoYes,
1464
+ status,
1465
+ }));
1466
+ };
1467
+ const cycleAgentMode = () => {
1468
+ setAgentMode(nextAgentMode(session.agentMode));
1469
+ scheduleLiveFrameRemount();
1470
+ };
1471
+ const clearLiveFrameRemountTimer = () => {
1472
+ if (!liveFrameRemountTimer)
1473
+ return;
1474
+ clearTimeout(liveFrameRemountTimer);
1475
+ liveFrameRemountTimer = null;
1476
+ };
1477
+ const handleCtrlC = () => {
1478
+ if (exiting)
1479
+ return;
1480
+ if (store.getState().busy) {
1481
+ cancelActiveTurn();
1482
+ return;
1483
+ }
1484
+ if (exitCtrlCArmed) {
1485
+ disarmExitConfirm();
1486
+ requestExit();
1487
+ return;
1488
+ }
1489
+ exitCtrlCArmed = true;
1490
+ const exitConfirmUntil = Date.now() + EXIT_CTRL_C_CONFIRM_MS;
1491
+ store.update((current) => ({
1492
+ ...current,
1493
+ exitConfirmUntil,
1494
+ status: EXIT_CTRL_C_CONFIRM_MESSAGE,
1495
+ }));
1496
+ exitCtrlCTimer = setTimeout(() => {
1497
+ exitCtrlCTimer = null;
1498
+ exitCtrlCArmed = false;
1499
+ store.update((current) => ({
1500
+ ...current,
1501
+ exitConfirmUntil: null,
1502
+ status: current.status === EXIT_CTRL_C_CONFIRM_MESSAGE
1503
+ ? 'Ready'
1504
+ : current.status,
1505
+ }));
1506
+ }, EXIT_CTRL_C_CONFIRM_MS);
1507
+ exitCtrlCTimer.unref?.();
1508
+ };
1509
+ const requestExit = () => {
1510
+ if (exiting)
1511
+ return;
1512
+ disarmExitConfirm();
1513
+ if (transientStatusTimer) {
1514
+ clearTimeout(transientStatusTimer);
1515
+ transientStatusTimer = null;
1516
+ }
1517
+ dismissPendingApproval();
1518
+ dismissPendingSudoPassword();
1519
+ exiting = true;
1520
+ store.update((current) => ({
1521
+ ...current,
1522
+ exiting: true,
1523
+ queuedMessage: null,
1524
+ status: 'Exiting...',
1525
+ }));
1526
+ void bridge.close().then(() => {
1527
+ resolveDone?.();
1528
+ });
1529
+ };
1530
+ const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1531
+ if (exiting || signal?.aborted) {
1532
+ resolve(null);
1533
+ return;
1534
+ }
1535
+ dismissPendingSudoPassword();
1536
+ const abortPrompt = () => dismissPendingSudoPassword();
1537
+ sudoPasswordBuffer = '';
1538
+ resolveSudoPassword = resolve;
1539
+ if (signal) {
1540
+ signal.addEventListener('abort', abortPrompt, { once: true });
1541
+ cleanupSudoPasswordPrompt = () => signal.removeEventListener('abort', abortPrompt);
1542
+ }
1543
+ else {
1544
+ cleanupSudoPasswordPrompt = null;
1545
+ }
1546
+ store.update((current) => ({
1547
+ ...current,
1548
+ sudoPrompt: {
1549
+ command,
1550
+ passwordLength: 0,
1551
+ prompt,
1552
+ returnStatus: current.status,
1553
+ },
1554
+ status: 'Sudo authentication required',
1555
+ }));
1556
+ });
1557
+ const handleSudoPasswordInput = (event) => {
1558
+ const pendingResolve = resolveSudoPassword;
1559
+ if (!pendingResolve)
1560
+ return;
1561
+ if (event.kind === 'cancel') {
1562
+ resolveSudoPassword = null;
1563
+ cleanupSudoPasswordPrompt?.();
1564
+ cleanupSudoPasswordPrompt = null;
1565
+ sudoPasswordBuffer = '';
1566
+ store.update((current) => ({
1567
+ ...current,
1568
+ sudoPrompt: null,
1569
+ status: current.sudoPrompt?.returnStatus ?? current.status,
1570
+ }));
1571
+ pendingResolve(null);
1572
+ return;
1573
+ }
1574
+ if (event.kind === 'submit') {
1575
+ const password = sudoPasswordBuffer;
1576
+ resolveSudoPassword = null;
1577
+ cleanupSudoPasswordPrompt?.();
1578
+ cleanupSudoPasswordPrompt = null;
1579
+ sudoPasswordBuffer = '';
1580
+ store.update((current) => ({
1581
+ ...current,
1582
+ sudoPrompt: null,
1583
+ status: current.sudoPrompt?.returnStatus ?? current.status,
1584
+ }));
1585
+ pendingResolve(password);
1586
+ return;
1587
+ }
1588
+ if (event.kind === 'backspace') {
1589
+ sudoPasswordBuffer = sudoPasswordBuffer.slice(0, -1);
1590
+ }
1591
+ else {
1592
+ sudoPasswordBuffer += event.char;
1593
+ }
1594
+ store.update((current) => ({
1595
+ ...current,
1596
+ sudoPrompt: current.sudoPrompt
1597
+ ? { ...current.sudoPrompt, passwordLength: sudoPasswordBuffer.length }
1598
+ : null,
1599
+ }));
1600
+ };
1601
+ const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1602
+ if (exiting) {
1603
+ resolve('n');
1604
+ return;
1605
+ }
1606
+ resolveApprovalChoice = resolve;
1607
+ store.update((current) => ({
1608
+ ...current,
1609
+ approvalCursor: getDefaultApprovalCursor(),
1610
+ approvalPrompt: {
1611
+ title,
1612
+ body,
1613
+ diffPreview: options.diff && options.filePath
1614
+ ? parseDiffPreview(options.diff)
1615
+ : undefined,
1616
+ filePath: options.filePath,
1617
+ returnStatus: current.status,
1618
+ },
1619
+ status: title,
1620
+ }));
1621
+ });
1622
+ const handleInlineApprovalChoice = async (choice) => {
1623
+ const current = store.getState();
1624
+ const pendingResolve = resolveApprovalChoice;
1625
+ resolveApprovalChoice = null;
1626
+ store.update((next) => ({
1627
+ ...next,
1628
+ approvalCursor: getDefaultApprovalCursor(),
1629
+ approvalPrompt: null,
1630
+ status: current.approvalPrompt?.returnStatus ?? next.status,
1631
+ }));
1632
+ pendingResolve?.(choice);
1633
+ };
1634
+ const refreshServerModels = async () => {
1635
+ currentServerModels = await models.fetchServerModels({ config: authConfig });
1636
+ store.update((current) => ({
1637
+ ...current,
1638
+ serverModels: currentServerModels.models,
1639
+ }));
1640
+ return currentServerModels;
1641
+ };
1642
+ const openModelPicker = async () => {
1643
+ const fresh = await refreshServerModels();
1644
+ store.update((current) => ({
1645
+ ...current,
1646
+ commandCursor: 0,
1647
+ cursor: 0,
1648
+ input: '',
1649
+ modelPickerIndex: getDefaultModelPickerIndex(current.currentModelId, fresh.models),
1650
+ modelPickerOpen: true,
1651
+ status: 'Select a model',
1652
+ }));
1653
+ };
1654
+ const switchToModel = async (modelId) => {
1655
+ const requested = Number(modelId);
1656
+ const fresh = currentServerModels.models.some((model) => model.id === requested)
1657
+ ? currentServerModels
1658
+ : await refreshServerModels();
1659
+ const selected = models.validateServerModel(modelId, fresh);
1660
+ session.modelId = selected;
1661
+ models.updateSelectedModelCache({
1662
+ config: authConfig,
1663
+ selectedModelId: selected,
1664
+ serverModels: fresh,
1665
+ });
1666
+ await saveActiveSession();
1667
+ syncShellStateFromSession();
1668
+ appendStaticEntry({
1669
+ body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
1670
+ kind: 'system',
1671
+ title: 'Model',
1672
+ });
1673
+ };
1674
+ const handleInlineModelSelection = async () => {
1675
+ const current = store.getState();
1676
+ const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
1677
+ const selectedIndex = Math.min(current.modelPickerIndex, Math.max(options.length - 1, 0));
1678
+ const selected = options[selectedIndex];
1679
+ store.update((next) => ({
1680
+ ...next,
1681
+ modelPickerOpen: false,
1682
+ status: 'Ready',
1683
+ }));
1684
+ if (!selected) {
1685
+ appendStaticEntry({
1686
+ body: 'Model selection cancelled.',
1687
+ kind: 'system',
1688
+ title: 'Model',
1689
+ });
1690
+ return;
1691
+ }
1692
+ if (selected.id === current.currentModelId) {
1693
+ appendStaticEntry({
1694
+ body: `Already using ${formatModelLabel(selected.id, current.serverModels)}.`,
1695
+ kind: 'system',
1696
+ title: 'Model',
1697
+ });
1698
+ return;
1699
+ }
1700
+ try {
1701
+ await switchToModel(selected.id);
1702
+ }
1703
+ catch (error) {
1704
+ appendError(error.message);
1705
+ }
1706
+ };
1707
+ const loadInteractiveSessionList = async () => {
1708
+ const local = listSessionMetadata(session.rootDir, session.env);
1709
+ if (local.length > 0)
1710
+ return local;
1711
+ return serverSessionClient.list(session.rootDir);
1712
+ };
1713
+ const loadInteractiveSession = async (identifier) => {
1714
+ const local = loadSessionSnapshot(session.rootDir, identifier, session.env);
1715
+ if (local)
1716
+ return local;
1717
+ return serverSessionClient.load(session.rootDir, identifier);
1718
+ };
1719
+ const openResumePicker = async () => {
1720
+ const sessionList = await loadInteractiveSessionList();
1721
+ store.update((current) => ({
1722
+ ...current,
1723
+ commandCursor: 0,
1724
+ cursor: 0,
1725
+ input: '',
1726
+ resumePickerFilter: '',
1727
+ resumePickerIndex: 0,
1728
+ resumePickerOpen: true,
1729
+ resumePickerSessions: sessionList,
1730
+ status: 'Select a session to resume',
1731
+ }));
1732
+ };
1733
+ const handleInlineResumeSelection = async () => {
1734
+ const current = store.getState();
1735
+ const filtered = filterResumeSessions(current.resumePickerSessions, current.resumePickerFilter, current.serverModels);
1736
+ const selectedIndex = Math.min(current.resumePickerIndex, Math.max(filtered.length - 1, 0));
1737
+ const selected = filtered[selectedIndex];
1738
+ if (!selected) {
1739
+ store.update((next) => ({
1740
+ ...next,
1741
+ resumePickerFilter: '',
1742
+ resumePickerIndex: 0,
1743
+ resumePickerOpen: false,
1744
+ resumePickerSessions: [],
1745
+ status: 'Ready',
1746
+ }));
1747
+ return;
1748
+ }
1749
+ const resumeStartedAt = Date.now();
1750
+ store.update((next) => ({
1751
+ ...next,
1752
+ busy: true,
1753
+ busySince: resumeStartedAt,
1754
+ clockNow: resumeStartedAt,
1755
+ status: 'Loading session...',
1756
+ }));
1757
+ try {
1758
+ const snapshot = await loadInteractiveSession(selected.id);
1759
+ applySessionSnapshot(session, snapshot);
1760
+ await saveActiveSession();
1761
+ syncShellStateFromSession();
1762
+ store.update((next) => ({
1763
+ ...next,
1764
+ busy: false,
1765
+ busySince: null,
1766
+ resumePickerFilter: '',
1767
+ resumePickerIndex: 0,
1768
+ resumePickerOpen: false,
1769
+ resumePickerSessions: [],
1770
+ status: 'Ready',
1771
+ }));
1772
+ store.replaceTranscript([
1773
+ {
1774
+ body: `Resumed session${session.sessionName ? ` "${session.sessionName}"` : ''} (${session.sessionId})`,
1775
+ kind: 'system',
1776
+ title: 'Session',
1777
+ },
1778
+ ...buildTranscriptFromSessionHistory(session.history),
1779
+ ]);
1780
+ await remountTui();
1781
+ }
1782
+ catch (error) {
1783
+ store.update((next) => ({
1784
+ ...next,
1785
+ busy: false,
1786
+ busySince: null,
1787
+ resumePickerFilter: '',
1788
+ resumePickerIndex: 0,
1789
+ resumePickerOpen: false,
1790
+ resumePickerSessions: [],
1791
+ status: 'Ready',
1792
+ }));
1793
+ appendError(`Failed to resume session: ${error.message}`);
1794
+ }
1795
+ };
1796
+ const flushQueuedMessage = async () => {
1797
+ const queued = store.getState().queuedMessage;
1798
+ if (!queued || store.getState().busy || exiting) {
1799
+ return;
1800
+ }
1801
+ // Rehydrate attachments/chunks before submit: handleSubmit expands
1802
+ // pastedChunks from the store and the turn picks up imageAttachments.
1803
+ store.update((current) => ({
1804
+ ...current,
1805
+ imageAttachments: queued.imageAttachments,
1806
+ pastedChunks: queued.pastedChunks,
1807
+ queuedMessage: null,
1808
+ }));
1809
+ await handleSubmit(queued.body);
1810
+ };
1811
+ const handleSubmit = async (rawInput) => {
1812
+ const chunks = store.getState().pastedChunks;
1813
+ const expanded = chunks.length
1814
+ ? expandPastedChunks(String(rawInput ?? ''), chunks)
1815
+ : String(rawInput ?? '');
1816
+ const input = expanded.trim();
1817
+ const preformatted = chunks.length > 0;
1818
+ if (store.getState().busy) {
1819
+ if (!input || exiting)
1820
+ return;
1821
+ if (input.startsWith('/')) {
1822
+ appendTurnAwareEntry({
1823
+ body: "Slash commands can't be queued while a turn is running.",
1824
+ kind: 'system',
1825
+ title: 'Queued',
1826
+ });
1827
+ return;
1828
+ }
1829
+ // Snapshot the raw (placeholder) body plus chunks/images so recall and
1830
+ // flush round-trip the collapsed paste + attachments. Hold at most one;
1831
+ // a second enqueue replaces the slot.
1832
+ const pending = store.getState();
1833
+ const snapshot = {
1834
+ body: String(rawInput ?? ''),
1835
+ imageAttachments: pending.imageAttachments,
1836
+ pastedChunks: pending.pastedChunks,
1837
+ };
1838
+ scheduleLiveFrameRemount();
1839
+ store.update((current) => ({
1840
+ ...current,
1841
+ cursor: 0,
1842
+ imageAttachments: [],
1843
+ input: '',
1844
+ pastedChunks: [],
1845
+ queuedMessage: snapshot,
1846
+ transcriptScrollOffset: 0,
1847
+ }));
1848
+ return;
1849
+ }
1850
+ const imageAttachments = store.getState().imageAttachments;
1851
+ scheduleLiveFrameRemount();
1852
+ store.update((current) => ({
1853
+ ...current,
1854
+ cursor: 0,
1855
+ input: '',
1856
+ pastedChunks: [],
1857
+ promptHistory: appendPromptToHistory(current.promptHistory, input),
1858
+ promptHistoryCursor: null,
1859
+ promptHistoryDraft: '',
1860
+ }));
1861
+ appendPromptHistory(input);
1862
+ if (!input || exiting) {
1863
+ store.update((current) => ({ ...current, imageAttachments: [] }));
1864
+ return;
1865
+ }
1866
+ if (input === '/exit' || input === '/quit') {
1867
+ requestExit();
1868
+ return;
1869
+ }
1870
+ if (input === '/help') {
1871
+ appendStaticEntry({
1872
+ body: formatInteractiveHelpText(),
1873
+ kind: 'system',
1874
+ title: 'Help',
1875
+ });
1876
+ return;
1877
+ }
1878
+ if (input === '/usage') {
1879
+ store.update((current) => ({
1880
+ ...current,
1881
+ busy: true,
1882
+ status: 'Loading usage...',
1883
+ }));
1884
+ try {
1885
+ appendStaticEntry({
1886
+ body: await usageText(),
1887
+ kind: 'system',
1888
+ title: 'Usage',
1889
+ });
1890
+ }
1891
+ catch (error) {
1892
+ appendError(error.message);
1893
+ }
1894
+ finally {
1895
+ store.update((current) => ({
1896
+ ...current,
1897
+ busy: false,
1898
+ status: 'Ready',
1899
+ }));
1900
+ }
1901
+ return;
1902
+ }
1903
+ if (input === '/resume') {
1904
+ store.update((current) => ({
1905
+ ...current,
1906
+ busy: true,
1907
+ status: 'Loading sessions...',
1908
+ }));
1909
+ try {
1910
+ await openResumePicker();
1911
+ }
1912
+ catch (error) {
1913
+ appendError(error.message);
1914
+ }
1915
+ finally {
1916
+ store.update((current) => ({
1917
+ ...current,
1918
+ busy: false,
1919
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
1920
+ }));
1921
+ }
1922
+ return;
1923
+ }
1924
+ if (input === '/model' || input.startsWith('/model ')) {
1925
+ const inlineSelection = input.slice('/model'.length).trim();
1926
+ store.update((current) => ({
1927
+ ...current,
1928
+ busy: true,
1929
+ status: inlineSelection ? 'Switching model...' : 'Loading models...',
1930
+ }));
1931
+ try {
1932
+ if (!inlineSelection || inlineSelection === 'list') {
1933
+ await openModelPicker();
1934
+ }
1935
+ else {
1936
+ await switchToModel(inlineSelection);
1937
+ }
1938
+ }
1939
+ catch (error) {
1940
+ appendError(error.message);
1941
+ }
1942
+ finally {
1943
+ store.update((current) => ({
1944
+ ...current,
1945
+ busy: false,
1946
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
1947
+ }));
1948
+ }
1949
+ return;
1950
+ }
1951
+ if (input === '/clear') {
1952
+ clearConversation(session);
1953
+ latestUsageSummary = null;
1954
+ await saveActiveSession();
1955
+ store.replaceTranscript([
1956
+ {
1957
+ body: 'Conversation cleared.',
1958
+ kind: 'system',
1959
+ title: 'System',
1960
+ },
1961
+ ]);
1962
+ syncShellStateFromSession();
1963
+ store.update((current) => ({ ...current, queuedMessage: null }));
1964
+ await remountTui();
1965
+ return;
1966
+ }
1967
+ latestUsageSummary = null;
1968
+ disarmExitConfirm();
1969
+ const turnStartedAt = Date.now();
1970
+ const turnGeneration = ++activeTurnGeneration;
1971
+ const turnAbort = new AbortController();
1972
+ activeTurnAbort = turnAbort;
1973
+ lastTurnStartedAt = turnStartedAt;
1974
+ const userEntry = {
1975
+ body: input,
1976
+ kind: 'user',
1977
+ preformatted,
1978
+ title: 'You',
1979
+ };
1980
+ pendingTurnEntries = [];
1981
+ queueTurnEntry(userEntry);
1982
+ store.update((current) => ({
1983
+ ...current,
1984
+ activeTurnInput: input,
1985
+ activeTurnInputPreformatted: preformatted,
1986
+ busy: true,
1987
+ busySince: turnStartedAt,
1988
+ clockNow: turnStartedAt,
1989
+ status: 'Running turn...',
1990
+ thinkingTitle: '',
1991
+ thinkingNotes: [],
1992
+ transcriptScrollOffset: 0,
1993
+ tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
1994
+ turnCounter: current.turnCounter + 1,
1995
+ workingTools: [],
1996
+ }));
1997
+ try {
1998
+ const result = await chat.sendServerUserMessage({
1999
+ config: authConfig,
2000
+ projectIndex,
2001
+ session,
2002
+ input,
2003
+ imageAttachments,
2004
+ signal: turnAbort.signal,
2005
+ });
2006
+ if (turnGeneration !== activeTurnGeneration)
2007
+ return;
2008
+ latestUsageSummary = result.usageSummary ?? null;
2009
+ const turnEntries = takePendingTurnEntries();
2010
+ await saveActiveSession();
2011
+ syncShellStateFromSession();
2012
+ store.update((current) => ({
2013
+ ...current,
2014
+ activeTurnInput: '',
2015
+ activeTurnInputPreformatted: false,
2016
+ busy: true,
2017
+ commandLog: [],
2018
+ imageAttachments: [],
2019
+ status: result.waitingForApproval ? 'Awaiting approval' : 'Finishing response...',
2020
+ thinkingTitle: '',
2021
+ thinkingNotes: [],
2022
+ tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2023
+ workingTools: [],
2024
+ }));
2025
+ await appendSettledTurnEntries(turnEntries, result.text ?? '', () => turnGeneration === activeTurnGeneration && !turnAbort.signal.aborted);
2026
+ if (turnGeneration !== activeTurnGeneration)
2027
+ return;
2028
+ store.update((current) => ({
2029
+ ...current,
2030
+ busy: false,
2031
+ busySince: null,
2032
+ status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2033
+ tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2034
+ }));
2035
+ await flushQueuedMessage();
2036
+ }
2037
+ catch (error) {
2038
+ if (turnGeneration !== activeTurnGeneration) {
2039
+ if (isTurnCancelledError(error)) {
2040
+ await saveActiveSession();
2041
+ }
2042
+ return;
2043
+ }
2044
+ const cancelled = isTurnCancelledError(error);
2045
+ store.update((current) => ({
2046
+ ...current,
2047
+ activeTurnInput: '',
2048
+ activeTurnInputPreformatted: false,
2049
+ busy: false,
2050
+ busySince: null,
2051
+ commandLog: [],
2052
+ imageAttachments: [],
2053
+ status: cancelled ? 'Ready' : 'Turn failed',
2054
+ thinkingTitle: '',
2055
+ thinkingNotes: [],
2056
+ tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2057
+ workingTools: [],
2058
+ }));
2059
+ const turnEntries = takePendingTurnEntries();
2060
+ if (cancelled) {
2061
+ appendStaticEntries([
2062
+ ...turnEntries,
2063
+ {
2064
+ body: 'Turn cancelled.',
2065
+ kind: 'system',
2066
+ title: 'System',
2067
+ },
2068
+ ]);
2069
+ await saveActiveSession();
2070
+ }
2071
+ else {
2072
+ appendStaticEntries(turnEntries);
2073
+ appendError(error.message);
2074
+ }
2075
+ await remountTui();
2076
+ // A cancelled turn never auto-submits the queue (Ctrl+C recalls it via
2077
+ // cancelActiveTurn); only a genuine error flushes a pending message.
2078
+ if (!cancelled && turnGeneration === activeTurnGeneration) {
2079
+ await flushQueuedMessage();
2080
+ }
2081
+ }
2082
+ finally {
2083
+ if (activeTurnAbort === turnAbort) {
2084
+ activeTurnAbort = null;
2085
+ }
2086
+ if (turnGeneration === activeTurnGeneration) {
2087
+ lastTurnStartedAt = null;
2088
+ }
2089
+ }
2090
+ };
2091
+ session.onStatus = (message) => {
2092
+ const panel = thinkingPanelFromStatus(message);
2093
+ store.update((current) => ({
2094
+ ...current,
2095
+ status: message,
2096
+ thinkingTitle: panel?.title ?? current.thinkingTitle,
2097
+ thinkingNotes: panel?.notes ?? current.thinkingNotes,
2098
+ tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2099
+ }));
2100
+ };
2101
+ session.onContextLog = (message) => {
2102
+ store.update((current) => ({
2103
+ ...current,
2104
+ contextStatus: message,
2105
+ tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
2106
+ }));
2107
+ };
2108
+ session.onToolEvent = (event) => {
2109
+ syncShellStateFromSession();
2110
+ if (isFileChangeTool(event.call.name)) {
2111
+ const entry = buildFileChangeEntry(event);
2112
+ store.appendWorkingTool(entry);
2113
+ appendTurnAwareEntry(entry);
2114
+ return;
2115
+ }
2116
+ store.appendWorkingTool(buildWorkingToolEntry(event));
2117
+ };
2118
+ projectIndex.onStatus = session.onStatus;
2119
+ projectIndex.onContextLog = session.onContextLog;
2120
+ session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2121
+ session.confirmCommand = async (command) => {
2122
+ if (exiting)
2123
+ return false;
2124
+ if (sessionAutoYes)
2125
+ return true;
2126
+ const choice = await openApprovalPrompt('Approve command?', command);
2127
+ if (choice === 'a') {
2128
+ setAgentMode('auto-accept');
2129
+ syncShellStateFromSession();
2130
+ appendTurnAwareEntry({
2131
+ body: 'Auto-approve enabled for the rest of this session.',
2132
+ kind: 'system',
2133
+ title: 'Approvals',
2134
+ });
2135
+ return true;
2136
+ }
2137
+ return choice === 'y';
2138
+ };
2139
+ session.confirmPatch = async (filePath, patch) => {
2140
+ if (exiting)
2141
+ return false;
2142
+ if (sessionAutoYes)
2143
+ return true;
2144
+ const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2145
+ diff: patch,
2146
+ filePath,
2147
+ });
2148
+ if (choice === 'a') {
2149
+ setAgentMode('auto-accept');
2150
+ syncShellStateFromSession();
2151
+ appendTurnAwareEntry({
2152
+ body: 'Auto-approve enabled for the rest of this session.',
2153
+ kind: 'system',
2154
+ title: 'Approvals',
2155
+ });
2156
+ return true;
2157
+ }
2158
+ return choice === 'y';
2159
+ };
2160
+ const shellInputHandlers = {
2161
+ getTranscriptScrollLimit: () => {
2162
+ const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
2163
+ const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2164
+ return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2165
+ },
2166
+ onCancelTurn: cancelActiveTurn,
2167
+ onCycleAgentMode: cycleAgentMode,
2168
+ onCtrlC: handleCtrlC,
2169
+ onLinkCopy: handleLinkCopy,
2170
+ onLinkOpen: handleLinkOpen,
2171
+ onLiveFrameShapeChange: scheduleLiveFrameRemount,
2172
+ onRequestExit: requestExit,
2173
+ onResolveApproval: handleInlineApprovalChoice,
2174
+ onResumeSession: handleInlineResumeSelection,
2175
+ onSelectionCopy: handleAppSelectionCopy,
2176
+ onSelectModel: handleInlineModelSelection,
2177
+ onSudoPasswordInput: handleSudoPasswordInput,
2178
+ onSubmit: handleSubmit,
2179
+ };
2180
+ const unsubscribe = store.subscribe(() => {
2181
+ renderCurrentFrame();
2182
+ });
2183
+ const spinnerTimer = setInterval(() => {
2184
+ spinnerFrame = (spinnerFrame + 1) % 6;
2185
+ if (store.getState().busy) {
2186
+ renderCurrentFrame();
2187
+ }
2188
+ }, 120);
2189
+ const clockTimer = setInterval(() => {
2190
+ if (store.getState().busy) {
2191
+ renderCurrentFrame();
2192
+ }
2193
+ }, 1000);
2194
+ let initialPromptDelivered = false;
2195
+ bridge.onEvent((message) => {
2196
+ if (message.op === 'ready') {
2197
+ tuiReady = true;
2198
+ terminalCols = message.cols;
2199
+ terminalRows = message.rows;
2200
+ bridge.clear();
2201
+ renderCurrentFrame();
2202
+ if (!initialPromptDelivered && initialPrompt) {
2203
+ initialPromptDelivered = true;
2204
+ void handleSubmit(initialPrompt);
2205
+ }
2206
+ return;
2207
+ }
2208
+ if (message.op === 'closed') {
2209
+ resolveDone?.();
2210
+ return;
2211
+ }
2212
+ if (message.op === 'event') {
2213
+ if (message.kind === 'resize') {
2214
+ terminalCols = message.cols;
2215
+ terminalRows = message.rows;
2216
+ void remountTui();
2217
+ return;
2218
+ }
2219
+ handleShellKeyEvent(store, shellInputHandlers, message);
2220
+ renderCurrentFrame();
2221
+ }
2222
+ });
2223
+ setCommandOutputHook((text) => {
2224
+ store.update((current) => appendCommandLog(current, text));
2225
+ });
2226
+ try {
2227
+ await done;
2228
+ }
2229
+ finally {
2230
+ clearInterval(spinnerTimer);
2231
+ clearInterval(clockTimer);
2232
+ unsubscribe();
2233
+ clearLiveFrameRemountTimer();
2234
+ await bridge.close();
2235
+ setCommandOutputHook(null);
2236
+ }
2237
+ });
2238
+ }