moderado 0.4.33 → 0.4.46

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 (109) hide show
  1. package/README.md +14 -9
  2. package/dist/commands/chat.d.ts +7 -70
  3. package/dist/commands/chat.d.ts.map +1 -1
  4. package/dist/commands/chat.js +305 -456
  5. package/dist/commands/chat.js.map +1 -1
  6. package/dist/commands/chat_meta.d.ts +36 -0
  7. package/dist/commands/chat_meta.d.ts.map +1 -0
  8. package/dist/commands/chat_meta.js +102 -0
  9. package/dist/commands/chat_meta.js.map +1 -0
  10. package/dist/commands/chat_outcome.d.ts +31 -0
  11. package/dist/commands/chat_outcome.d.ts.map +1 -0
  12. package/dist/commands/chat_outcome.js +70 -0
  13. package/dist/commands/chat_outcome.js.map +1 -0
  14. package/dist/commands/chat_search.d.ts +24 -0
  15. package/dist/commands/chat_search.d.ts.map +1 -0
  16. package/dist/commands/chat_search.js +61 -0
  17. package/dist/commands/chat_search.js.map +1 -0
  18. package/dist/commands/chat_slash.d.ts +17 -0
  19. package/dist/commands/chat_slash.d.ts.map +1 -0
  20. package/dist/commands/chat_slash.js +108 -0
  21. package/dist/commands/chat_slash.js.map +1 -0
  22. package/dist/commands/run.d.ts.map +1 -1
  23. package/dist/commands/run.js +25 -1
  24. package/dist/commands/run.js.map +1 -1
  25. package/dist/config.d.ts +1 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +18 -2
  28. package/dist/config.js.map +1 -1
  29. package/dist/diagnostic_log.d.ts.map +1 -1
  30. package/dist/diagnostic_log.js +1 -0
  31. package/dist/diagnostic_log.js.map +1 -1
  32. package/dist/http_shutdown.d.ts +22 -0
  33. package/dist/http_shutdown.d.ts.map +1 -0
  34. package/dist/http_shutdown.js +36 -0
  35. package/dist/http_shutdown.js.map +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +16 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/self_update.d.ts +6 -0
  40. package/dist/self_update.d.ts.map +1 -1
  41. package/dist/self_update.js +17 -1
  42. package/dist/self_update.js.map +1 -1
  43. package/dist/sessions.d.ts.map +1 -1
  44. package/dist/sessions.js +5 -1
  45. package/dist/sessions.js.map +1 -1
  46. package/dist/transcript.d.ts +19 -0
  47. package/dist/transcript.d.ts.map +1 -0
  48. package/dist/transcript.js +76 -0
  49. package/dist/transcript.js.map +1 -0
  50. package/dist/turn_summary.d.ts +76 -0
  51. package/dist/turn_summary.d.ts.map +1 -0
  52. package/dist/turn_summary.js +305 -0
  53. package/dist/turn_summary.js.map +1 -0
  54. package/dist/ui/box.d.ts +19 -0
  55. package/dist/ui/box.d.ts.map +1 -1
  56. package/dist/ui/box.js +119 -12
  57. package/dist/ui/box.js.map +1 -1
  58. package/dist/ui/chart.d.ts +2 -0
  59. package/dist/ui/chart.d.ts.map +1 -0
  60. package/dist/ui/chart.js +97 -0
  61. package/dist/ui/chart.js.map +1 -0
  62. package/dist/ui/composer.d.ts +80 -0
  63. package/dist/ui/composer.d.ts.map +1 -0
  64. package/dist/ui/composer.js +762 -0
  65. package/dist/ui/composer.js.map +1 -0
  66. package/dist/ui/help_content.d.ts +1 -1
  67. package/dist/ui/help_content.d.ts.map +1 -1
  68. package/dist/ui/help_content.js +4 -2
  69. package/dist/ui/help_content.js.map +1 -1
  70. package/dist/ui/model_selector.d.ts +1 -0
  71. package/dist/ui/model_selector.d.ts.map +1 -1
  72. package/dist/ui/model_selector.js +61 -18
  73. package/dist/ui/model_selector.js.map +1 -1
  74. package/dist/ui/popup.d.ts.map +1 -1
  75. package/dist/ui/popup.js +13 -0
  76. package/dist/ui/popup.js.map +1 -1
  77. package/dist/ui/provider_connect.d.ts +8 -0
  78. package/dist/ui/provider_connect.d.ts.map +1 -1
  79. package/dist/ui/provider_connect.js +81 -23
  80. package/dist/ui/provider_connect.js.map +1 -1
  81. package/dist/ui/renderer.d.ts +49 -4
  82. package/dist/ui/renderer.d.ts.map +1 -1
  83. package/dist/ui/renderer.js +167 -31
  84. package/dist/ui/renderer.js.map +1 -1
  85. package/dist/ui/terminal_approval.d.ts +21 -0
  86. package/dist/ui/terminal_approval.d.ts.map +1 -1
  87. package/dist/ui/terminal_approval.js +145 -47
  88. package/dist/ui/terminal_approval.js.map +1 -1
  89. package/dist/ui/welcome.d.ts +234 -69
  90. package/dist/ui/welcome.d.ts.map +1 -1
  91. package/dist/ui/welcome.js +588 -693
  92. package/dist/ui/welcome.js.map +1 -1
  93. package/dist/vendor/providers/presets.d.ts +16 -0
  94. package/dist/vendor/providers/presets.d.ts.map +1 -1
  95. package/dist/vendor/providers/presets.js +13 -0
  96. package/dist/vendor/providers/presets.js.map +1 -1
  97. package/dist/vendor/tools/checkpoints.js +5 -5
  98. package/dist/vendor/tools/checkpoints.js.map +1 -1
  99. package/dist/vendor/tools/jail.d.ts +8 -0
  100. package/dist/vendor/tools/jail.d.ts.map +1 -1
  101. package/dist/vendor/tools/jail.js +13 -2
  102. package/dist/vendor/tools/jail.js.map +1 -1
  103. package/dist/vendor/tools/tools/apply_patch.d.ts.map +1 -1
  104. package/dist/vendor/tools/tools/apply_patch.js +6 -3
  105. package/dist/vendor/tools/tools/apply_patch.js.map +1 -1
  106. package/dist/vendor/tools/tools/git_diff.d.ts.map +1 -1
  107. package/dist/vendor/tools/tools/git_diff.js +5 -3
  108. package/dist/vendor/tools/tools/git_diff.js.map +1 -1
  109. package/package.json +24 -7
@@ -1,7 +1,9 @@
1
- import readline from 'node:readline';
2
- import { displayWidth } from './box.js';
1
+ import fs from 'node:fs';
2
+ import { displayWidth, sliceTailToWidth, sliceToWidth, truncateToWidth } from './box.js';
3
+ import { renderChartBlock } from './chart.js';
4
+ import { SUMMARY_FENCE } from '../turn_summary.js';
5
+ import { closeHttpConnections } from '../http_shutdown.js';
3
6
  import { overlayCentered, dimLines, shadowUnder } from './popup.js';
4
- import { activeMentionToken, filterMentionCandidates } from './file_mentions.js';
5
7
  import { getHelpCommandGroups } from './help_content.js';
6
8
  export const MODERADO_ASCII_LOGO = [
7
9
  '\x1b[38;5;75m __ _______ ____ __________ ___ ____ ____ \x1b[0m',
@@ -18,7 +20,18 @@ export function stripAnsi(str) {
18
20
  function visibleLen(str) {
19
21
  return displayWidth(str);
20
22
  }
21
- function composerInputWindow(input, caret, width) {
23
+ /**
24
+ * SGR mouse report, e.g. "\x1b[<0;12;5M" for a primary-button press on column 12
25
+ * of row 5. readline splits that escape into several keypress events ("0", ";",
26
+ * "5", "M"), so the raw data chunk is parsed instead of the derived keystrokes.
27
+ *
28
+ * These live here rather than in the composer because enabling and disabling
29
+ * mouse reporting is terminal state: `exitCleanly` has to turn it off too.
30
+ */
31
+ export const MOUSE_REPORT_PATTERN = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
32
+ export const MOUSE_REPORT_ON = '\x1b[?1000h\x1b[?1006h';
33
+ export const MOUSE_REPORT_OFF = '\x1b[?1000l\x1b[?1006l';
34
+ export function composerInputWindow(input, caret, width) {
22
35
  const capacity = Math.max(1, width - 3);
23
36
  const start = Math.max(0, Math.min(caret, input.length - capacity));
24
37
  return { text: input.slice(start, start + capacity), caretColumn: caret - start };
@@ -28,6 +41,11 @@ export function getWelcomeCardWidth(width) {
28
41
  const terminalWidth = width ?? (process.stdout.columns || 80);
29
42
  return Math.max(70, Math.min(terminalWidth - 2, 120));
30
43
  }
44
+ /** Chat fills the available columns; the startup Welcome card keeps its own width. */
45
+ export function getChatCardWidth(width) {
46
+ const terminalWidth = width ?? (process.stdout.columns || 80);
47
+ return Math.max(1, terminalWidth - 2);
48
+ }
31
49
  /** Left indent that centers a block of `blockWidth` visible columns on the terminal. */
32
50
  export function getWelcomeIndent(blockWidth, width) {
33
51
  const terminalWidth = width ?? (process.stdout.columns || 80);
@@ -50,11 +68,156 @@ function centerBlock(lines, width) {
50
68
  */
51
69
  export function getChatAnswerArea(width) {
52
70
  const terminalWidth = width ?? (process.stdout.columns || 80);
53
- const cardWidth = getWelcomeCardWidth(terminalWidth);
71
+ const cardWidth = getChatCardWidth(terminalWidth);
54
72
  const indent = getWelcomeIndent(cardWidth, terminalWidth);
55
- const textWidth = Math.max(20, cardWidth - 2);
73
+ const textWidth = Math.max(1, cardWidth - 2);
56
74
  return { indent, cardWidth, textWidth, startColumn: indent + 1, wrapWidth: indent + textWidth };
57
75
  }
76
+ /** The chat header is a single compact brand row. */
77
+ export const CHAT_HEADER_ROWS = 1;
78
+ /** Rows the composer card occupies: blank surface, input, blank surface, mode, workspace. */
79
+ export const CHAT_COMPOSER_ROWS = 5;
80
+ /**
81
+ * Fixed row geometry for the chat window. The answer area is bounded by the
82
+ * composer: a long answer is windowed instead of pushing the input card, the
83
+ * mode row, and the workspace row off screen (or painting over them).
84
+ */
85
+ export function getChatLayout(width, height, question = '', composerRows = CHAT_COMPOSER_ROWS) {
86
+ const terminalWidth = width ?? (process.stdout.columns || 80);
87
+ // Honor the real viewport: a hard floor here would emit a frame taller than a
88
+ // short terminal and scroll the composer away. A very short terminal simply
89
+ // shows fewer sections (the composer always wins — see renderChatScreen).
90
+ const terminalHeight = Math.max(2, height ?? (process.stdout.rows || 24));
91
+ const area = getChatAnswerArea(terminalWidth);
92
+ const wrappedQuestion = wrapText(question, Math.max(1, area.textWidth - 2)).length;
93
+ const questionTop = CHAT_HEADER_ROWS + 2;
94
+ // Rows above the answer: header, blank, question rows, status, blank.
95
+ const answerTopOffset = CHAT_HEADER_ROWS + 4;
96
+ // The frame has to end on the second-to-last row: a trailing newline written
97
+ // on the bottom row scrolls the viewport and drags the composer out of place.
98
+ // So the composer is pinned to the viewport bottom and keeps every row it
99
+ // needs — a queued-commands box makes it taller, and the answer area gives up
100
+ // those rows instead of pushing the card off screen.
101
+ const composerTop = Math.max(2, terminalHeight - Math.max(CHAT_COMPOSER_ROWS, composerRows));
102
+ // A pathological question must never starve the answer or the composer; when
103
+ // a tall composer leaves no room at all the question is dropped, not the card.
104
+ const questionRows = Math.max(0, Math.min(wrappedQuestion, composerTop - answerTopOffset - 2));
105
+ const statusRow = questionTop + questionRows;
106
+ const answerTop = statusRow + 2;
107
+ return {
108
+ area,
109
+ questionTop,
110
+ questionRows,
111
+ statusRow,
112
+ answerTop,
113
+ answerRows: Math.max(0, composerTop - 1 - answerTop),
114
+ composerTop,
115
+ terminalWidth,
116
+ };
117
+ }
118
+ /** Rows the composer card occupies for a given layout, including any queued-commands box. */
119
+ export function getChatComposerRows(options) {
120
+ return renderWelcomeCard(options).split('\n').length;
121
+ }
122
+ /** Invisible prefix that marks a rendered line as part of the turn summary. */
123
+ const SUMMARY_MARK = '\u0001';
124
+ /**
125
+ * Wrap an answer for the card, marking the turn summary as it goes.
126
+ *
127
+ * The fence has to be resolved *before* wrapping, not after: a summary line that
128
+ * wraps would lose its marker on the continuation, and a block whose opening
129
+ * marker scrolled off the top would stop being styled exactly when it is
130
+ * longest. Every wrapped line inside the fence carries `SUMMARY_MARK`, so the
131
+ * renderer can tint any row on its own.
132
+ */
133
+ export function wrapAnswerLines(text, textWidth) {
134
+ const lines = [];
135
+ let inSummary = false;
136
+ const answerLines = text.split('\n');
137
+ for (let index = 0; index < answerLines.length; index += 1) {
138
+ const rawLine = answerLines[index];
139
+ const trimmed = rawLine.trim();
140
+ if (trimmed === '```chart') {
141
+ const closing = answerLines.findIndex((line, lineIndex) => lineIndex > index && line.trim() === '```');
142
+ if (closing !== -1) {
143
+ const chartLines = renderChartBlock(answerLines.slice(index + 1, closing).join('\n'), textWidth);
144
+ if (chartLines) {
145
+ lines.push(...chartLines);
146
+ index = closing;
147
+ continue;
148
+ }
149
+ }
150
+ }
151
+ if (!inSummary && trimmed === SUMMARY_FENCE) {
152
+ inSummary = true;
153
+ continue;
154
+ }
155
+ if (inSummary && trimmed === '```') {
156
+ inSummary = false;
157
+ continue;
158
+ }
159
+ if (!inSummary) {
160
+ lines.push(...wrapText(rawLine, textWidth));
161
+ continue;
162
+ }
163
+ // The rail and its gap cost two cells, so summary prose wraps two cells early.
164
+ for (const wrapped of wrapText(rawLine, Math.max(8, textWidth - 2)))
165
+ lines.push(SUMMARY_MARK + wrapped);
166
+ }
167
+ return lines;
168
+ }
169
+ /**
170
+ * Window the answer around an offset, measured in rows scrolled up from the
171
+ * tail. `offset = 0` is the live view (newest rows). One row is reserved for the
172
+ * "earlier lines hidden" note whenever anything is above the window.
173
+ */
174
+ export function windowChatAnswerAt(text, maxRows, textWidth, offsetFromBottom = 0) {
175
+ const wrapped = wrapAnswerLines(text.trimEnd(), textWidth);
176
+ if (wrapped.length <= maxRows)
177
+ return { lines: wrapped, hiddenAbove: 0, hiddenBelow: 0 };
178
+ // The note costs a row, so the content capacity is one row smaller — and the
179
+ // maximum offset follows from that capacity, not from the raw row count.
180
+ const capacity = Math.max(1, maxRows - 1);
181
+ const maxOffset = Math.max(0, wrapped.length - capacity);
182
+ const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
183
+ const end = wrapped.length - offset;
184
+ const start = Math.max(0, end - capacity);
185
+ return { lines: wrapped.slice(start, end), hiddenAbove: start, hiddenBelow: wrapped.length - end };
186
+ }
187
+ /**
188
+ * Style one answer row.
189
+ *
190
+ * Inside the turn-summary fence every row becomes a band: a rail in the accent
191
+ * colour, a tinted background that reaches the right edge of the card, and
192
+ * headings in warm bold. This is what makes the report read as a different kind
193
+ * of thing from the prose above it, rather than three more paragraphs.
194
+ */
195
+ export function renderAnswerLine(line, textWidth) {
196
+ // Activity-log rows share the window with the answer but read as a record of
197
+ // what happened: dim, no rail, no band. One function, two kinds of row.
198
+ if (line.startsWith(LOG_MARK)) {
199
+ return `\x1b[38;5;244m${truncateToWidth(line.slice(LOG_MARK.length), textWidth)}\x1b[0m`;
200
+ }
201
+ if (!line.startsWith(SUMMARY_MARK)) {
202
+ const heading = line.match(/^\s*(?:#{1,6}\s+([^#]+?)|\*\*(.+?)\*\*)\s*$/);
203
+ return heading
204
+ ? `\x1b[1;38;5;25m${(heading[1] ?? heading[2]).trim()}\x1b[0m`
205
+ : `\x1b[38;5;253m${line}\x1b[0m`;
206
+ }
207
+ const body = line.slice(SUMMARY_MARK.length);
208
+ const heading = body.match(/^#{1,6}\s+(.+)$/);
209
+ const bullet = body.match(/^[-*]\s+(.+)$/);
210
+ const text = heading ? heading[1].trim() : bullet ? `· ${bullet[1].trim()}` : body;
211
+ const tone = heading ? '1;38;5;222' : '38;5;253';
212
+ // Pad to the card so the tint is a solid band instead of a ragged highlight.
213
+ const padded = `${text}${' '.repeat(Math.max(0, textWidth - 2 - displayWidth(text)))}`;
214
+ return `\x1b[48;5;236m\x1b[38;5;75m▍\x1b[0;48;5;236m\x1b[${tone}m ${padded}\x1b[0m`;
215
+ }
216
+ export function chatAnswerOverflowNote(hidden) {
217
+ // Short on purpose: the status row carries the scroll position, and this row
218
+ // has to fit a 70-column card without wrapping.
219
+ return `… +${hidden} earlier line${hidden === 1 ? '' : 's'} hidden · PgUp`;
220
+ }
58
221
  export function renderQueuedCommandsBox(commands, width) {
59
222
  if (!commands || commands.length === 0)
60
223
  return [];
@@ -82,7 +245,9 @@ export function renderQueuedCommandsBox(commands, width) {
82
245
  return lines;
83
246
  }
84
247
  export function renderWelcomeCard(options) {
85
- const width = getWelcomeCardWidth(options.width);
248
+ const width = options.chatQuestion === undefined
249
+ ? getWelcomeCardWidth(options.width)
250
+ : getChatCardWidth(options.width);
86
251
  const indent = ' '.repeat(getWelcomeIndent(width, options.width));
87
252
  const displayInput = options.input && options.input.length > 0
88
253
  ? composerInputWindow(options.input, options.inputCaret ?? options.input.length, width).text
@@ -108,16 +273,26 @@ export function renderWelcomeCard(options) {
108
273
  : processUsage;
109
274
  const processInfo = options.chatQuestion ? `${processPrefix}${processSuffix}` : details.slice(0, modeSpace);
110
275
  const left4 = processInfo ? `\x1b[38;5;244m${processInfo}\x1b[0m` : '';
111
- const right4 = options.mode === 'Plan'
112
- ? '\x1b[1;38;5;75m[Plan]\x1b[0m / Execute (Tab)'
113
- : 'Plan / \x1b[1;38;5;75m[Execute]\x1b[0m (Tab)';
114
- const right4Raw = options.mode === 'Plan' ? '[Plan] / Execute (Tab)' : 'Plan / [Execute] (Tab)';
115
- const spaces4Count = Math.max(1, width - visibleLen(left4) - right4Raw.length);
116
- const line4 = left4 + ' '.repeat(spaces4Count) + right4;
276
+ const compactChatFooter = options.chatQuestion !== undefined && width < 72;
277
+ const right4Raw = compactChatFooter
278
+ ? options.mode === 'Plan' ? '[Plan]' : '[Execute]'
279
+ : options.mode === 'Plan' ? '[Plan] / Execute (Tab)' : 'Plan / [Execute] (Tab)';
280
+ const compactMode = right4Raw.length > width ? (options.mode === 'Plan' ? 'P' : 'E') : right4Raw;
281
+ const right4 = compactChatFooter
282
+ ? `\x1b[1;38;5;75m${compactMode}\x1b[0m`
283
+ : options.mode === 'Plan'
284
+ ? '\x1b[1;38;5;75m[Plan]\x1b[0m / Execute (Tab)'
285
+ : 'Plan / \x1b[1;38;5;75m[Execute]\x1b[0m (Tab)';
286
+ const right4Visible = compactChatFooter ? compactMode : right4Raw;
287
+ const left4Fit = compactChatFooter
288
+ ? truncateToWidth(processInfo, Math.max(0, width - right4Visible.length - 1))
289
+ : left4;
290
+ const spaces4Count = Math.max(0, width - visibleLen(left4Fit) - right4Visible.length);
291
+ const line4 = left4Fit + ' '.repeat(spaces4Count) + right4;
117
292
  // Line 5: Working directory (left) ... provider, model, and Auto-approve (right)
118
- const approvalLabelRaw = options.autoApprove
119
- ? 'Auto-approve enabled (Shift+Tab)'
120
- : 'Auto-approve off (Shift+Tab)';
293
+ const approvalLabelRaw = compactChatFooter
294
+ ? width < 20 ? (options.autoApprove ? 'On' : 'Off') : (options.autoApprove ? 'Auto-approve on' : 'Auto-approve off')
295
+ : options.autoApprove ? 'Auto-approve enabled (Shift+Tab)' : 'Auto-approve off (Shift+Tab)';
121
296
  const modelSpace = Math.max(8, width - approvalLabelRaw.length - 10);
122
297
  const provider = options.provider?.trim() ?? '';
123
298
  const providerSpace = Math.max(1, modelSpace - 6);
@@ -138,7 +313,23 @@ export function renderWelcomeCard(options) {
138
313
  : '\x1b[38;5;242mAuto-approve off (Shift+Tab)\x1b[0m';
139
314
  const modelLabel = `\x1b[38;5;180m${modelText}\x1b[0m`;
140
315
  const spaces5Count = Math.max(1, width - shortWs.length - modelText.length - approvalLabelRaw.length - 1);
141
- const line5 = left5 + ' '.repeat(spaces5Count) + modelLabel + ' ' + approvalLabel;
316
+ const line5 = compactChatFooter
317
+ ? (() => {
318
+ const fieldBudget = Math.max(0, width - approvalLabelRaw.length - 2);
319
+ const workspaceBudget = Math.ceil(fieldBudget / 2);
320
+ const modelBudget = fieldBudget - workspaceBudget;
321
+ const workspaceText = sliceTailToWidth(options.workspace, workspaceBudget);
322
+ const modelField = sliceTailToWidth(modelText, modelBudget);
323
+ if (fieldBudget === 0)
324
+ return truncateToWidth(approvalLabel, width);
325
+ const fields = [
326
+ workspaceText ? `\x1b[38;5;245m${workspaceText}\x1b[0m` : '',
327
+ modelField ? `\x1b[38;5;180m${modelField}\x1b[0m` : '',
328
+ `\x1b[38;5;242m${approvalLabelRaw}\x1b[0m`,
329
+ ].filter(Boolean);
330
+ return fields.join(' ');
331
+ })()
332
+ : left5 + ' '.repeat(spaces5Count) + modelLabel + ' ' + approvalLabel;
142
333
  const cardLines = [];
143
334
  const queuedBox = renderQueuedCommandsBox(options.queuedCommands ?? [], width);
144
335
  if (queuedBox.length > 0) {
@@ -259,6 +450,12 @@ export function renderModeradoHeader(width) {
259
450
  ...centerBlock([COMMAND_HINT], width),
260
451
  ].join('\n');
261
452
  }
453
+ /** Compact wordmark used after the welcome screen transitions into chat. */
454
+ export function renderChatHeader(width) {
455
+ const terminalWidth = width ?? (process.stdout.columns || 80);
456
+ const label = 'MODERADO'.slice(0, Math.max(1, terminalWidth));
457
+ return ' '.repeat(Math.max(0, terminalWidth - label.length)) + `\x1b[1;38;5;75m${label}\x1b[0m`;
458
+ }
262
459
  /** Word-wrap plain text to `maxWidth` visible columns, hard-breaking long words. */
263
460
  export function wrapText(text, maxWidth) {
264
461
  const out = [];
@@ -316,104 +513,388 @@ export function wrapText(text, maxWidth) {
316
513
  export function renderChatScreen(options, height) {
317
514
  const terminalWidth = options.width ?? (process.stdout.columns || 80);
318
515
  const terminalHeight = height ?? process.stdout.rows ?? 24;
319
- const area = getChatAnswerArea(terminalWidth);
516
+ const question = options.chatQuestion ?? '';
517
+ // The composer is measured before the layout: a queued-commands box or a
518
+ // mention box makes the card taller, and the answer area must give up those
519
+ // rows instead of pushing the card past the bottom of the viewport.
520
+ const composer = renderWelcomeCard(options).split('\n');
521
+ const layout = getChatLayout(terminalWidth, terminalHeight, question, composer.length);
522
+ const area = layout.area;
320
523
  const indent = ' '.repeat(area.indent);
321
524
  const lines = [];
322
- lines.push(...renderModeradoHeader(terminalWidth).split('\n'));
525
+ lines.push(renderChatHeader(terminalWidth));
323
526
  lines.push('');
324
527
  // Row 1: the user's question, wrapped onto the same columns as the composer card.
325
528
  const questionMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
326
- for (const [index, questionLine] of wrapText(options.chatQuestion ?? '', area.textWidth - 2).entries()) {
327
- const questionText = `${index === 0 ? questionMarker : ' '} ${questionLine}`;
529
+ const questionLines = wrapText(question, Math.max(1, area.textWidth - 2));
530
+ const visibleQuestion = questionLines.slice(0, layout.questionRows);
531
+ for (const [index, questionLine] of visibleQuestion.entries()) {
532
+ const clipped = index === layout.questionRows - 1 && questionLines.length > layout.questionRows
533
+ ? `${questionLine.slice(0, Math.max(0, area.textWidth - 3))}…`
534
+ : questionLine;
535
+ const questionText = `${index === 0 ? questionMarker : ' '} ${clipped}`;
328
536
  lines.push(indent + String.fromCharCode(27) + '[48;5;236m' + questionText + ' '.repeat(Math.max(0, area.cardWidth - visibleLen(questionText))) + String.fromCharCode(27) + '[0m');
329
537
  }
330
538
  const thoughtTime = options.chatThoughtTime ?? 0;
331
539
  const thoughtTimeLabel = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
332
- const chatStatus = !options.chatAnswer?.trim()
333
- ? ''
334
- : options.chatTaskDone
335
- ? `✔ Done · Thought for ${thoughtTimeLabel}`
336
- : options.tokenUsage
540
+ const answerText = options.chatAnswer ?? '';
541
+ const answerOffset = options.chatAnswerOffset ?? 0;
542
+ // The activity log and the answer are one list, so both the scroll position
543
+ // and the window are computed over the merged rows.
544
+ const transcriptWidth = getChatTranscriptWidth(layout);
545
+ const transcript = buildChatTranscriptRows(options.chatActivity, answerText, transcriptWidth);
546
+ // While the reader is scrolled up, the status row belongs to the scroll
547
+ // position: the turn clock is not what they are looking for.
548
+ const scrollWindow = transcript.rows.length > 0 && layout.answerRows > 0 && answerOffset > 0
549
+ ? windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset)
550
+ : undefined;
551
+ const activityElapsed = options.chatActivity
552
+ ? (options.chatActivity.elapsedLabel ?? formatElapsed(thoughtTime))
553
+ : '';
554
+ // An offset that no longer hides anything (the answer shrank, or the viewport
555
+ // grew) must report the live view, not claim a scroll position that is gone.
556
+ // Sitting at the very top still counts: rows are hidden *below* the window.
557
+ const scrolled = scrollWindow !== undefined
558
+ && (scrollWindow.hiddenAbove > 0 || scrollWindow.hiddenBelow > 0);
559
+ const chatStatus = scrolled
560
+ ? `${scrollWindow.hiddenAbove === 0 ? 'Top of the turn' : `↑ ${scrollWindow.hiddenAbove} above`} · ↓ ${scrollWindow.hiddenBelow} below · PgUp/PgDn`
561
+ : options.chatActivity
562
+ ? truncateToWidth(`${options.chatActivity.glyph} ${options.chatActivity.label} · ${activityElapsed}`, transcriptWidth)
563
+ : !answerText.trim()
337
564
  ? ''
338
- : `Thought for ${thoughtTimeLabel}`;
339
- lines.push(chatStatus ? indent + `\x1b[38;5;244m${chatStatus.slice(0, Math.max(0, area.cardWidth - 1))}\x1b[0m` : '');
340
- // Answer section: multi-line model answer
341
- if (options.chatAnswer && options.chatAnswer.trim().length > 0) {
565
+ : options.chatTaskDone
566
+ ? `✔ Done · Thought for ${thoughtTimeLabel}`
567
+ : options.tokenUsage
568
+ ? ''
569
+ : `Thought for ${thoughtTimeLabel}`;
570
+ lines.push(chatStatus
571
+ ? indent + `\x1b[38;5;244m${chatStatus}\x1b[0m`
572
+ : '');
573
+ // Transcript section: the activity log followed by the answer, windowed to the
574
+ // rows the composer leaves free. The log is never dropped to make room for the
575
+ // answer — that trade is what produced the one-line view.
576
+ if (transcript.rows.length > 0 && layout.answerRows > 0) {
342
577
  lines.push('');
343
- for (const answerLine of wrapText(options.chatAnswer.trim(), area.textWidth)) {
344
- const heading = answerLine.match(/^\s*(?:#{1,6}\s+([^#]+?)|\*\*(.+?)\*\*)\s*$/);
345
- lines.push(indent + (heading
346
- ? `\x1b[1;38;5;25m${(heading[1] ?? heading[2]).trim()}\x1b[0m`
347
- : `\x1b[38;5;253m${answerLine}\x1b[0m`));
578
+ const { lines: transcriptLines, hiddenAbove } = windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset);
579
+ if (hiddenAbove > 0)
580
+ lines.push(indent + `\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), transcriptWidth)}\x1b[0m`);
581
+ for (const transcriptLine of transcriptLines) {
582
+ lines.push(indent + renderAnswerLine(transcriptLine, getChatTranscriptWidth(layout)));
348
583
  }
349
584
  }
350
- const composer = renderWelcomeCard(options).split('\n');
351
- lines.push(...Array(Math.max(1, terminalHeight - lines.length - composer.length - 1)).fill(''));
585
+ // Hard guarantee: the composer starts at `composerTop` and the frame ends one
586
+ // row above the viewport bottom, whatever the content did. This holds for any
587
+ // composer the viewport can hold (rows >= card height + 2); a terminal too
588
+ // short to show the card at all cannot be contained by layout arithmetic.
589
+ while (lines.length < layout.composerTop - 1)
590
+ lines.push('');
591
+ if (lines.length > layout.composerTop - 1)
592
+ lines.length = layout.composerTop - 1;
352
593
  lines.push(...composer);
353
594
  return lines.join('\n') + '\n';
354
595
  }
355
- /** Place the terminal cursor on the first row of the bottom composer. */
356
- export function renderChatComposerCursor(options, inputLength) {
357
- const width = getWelcomeCardWidth(options.width);
358
- const column = getWelcomeIndent(width, options.width) + 2 + Math.min(inputLength, width - 3);
359
- return `\x1b[1 q\x1b[?25h\x1b[4A\r\x1b[${column}C`;
596
+ /**
597
+ * Place the terminal cursor on the input row. Passing the layout's
598
+ * `composerTop` addresses the row absolutely, which stays correct when a
599
+ * queued-commands box grows the card; the relative form is the legacy path.
600
+ */
601
+ export function renderChatComposerCursor(options, inputLength, composerTop) {
602
+ const width = getChatCardWidth(options.width);
603
+ const indent = getWelcomeIndent(width, options.width);
604
+ const caretColumn = indent + 3 + Math.min(inputLength, Math.max(0, width - 3));
605
+ if (composerTop !== undefined)
606
+ return `\x1b[1 q\x1b[?25h\x1b[${composerTop + 1};${caretColumn}H`;
607
+ return `\x1b[1 q\x1b[?25h\x1b[4A\r\x1b[${indent + 2 + Math.min(inputLength, Math.max(0, width - 3))}C`;
360
608
  }
361
609
  export function navigateQuestionHistory(history, direction, index, draft) {
362
610
  const nextIndex = Math.max(0, Math.min(history.length, index + direction));
363
611
  return { input: nextIndex === history.length ? draft : history[nextIndex], index: nextIndex, draft };
364
612
  }
613
+ /** Silence before the row admits it is waiting on the model. */
614
+ export const WAIT_NOTICE_AFTER_MS = 10_000;
615
+ /** How often that notice is refreshed while the wait continues. */
616
+ export const WAIT_NOTICE_REPEAT_MS = 20_000;
617
+ /**
618
+ * What to say while the model has produced nothing.
619
+ *
620
+ * Silent for the first ten seconds — that is normal inference. After that the
621
+ * row says it is waiting, and re-announces every twenty seconds with the length
622
+ * of the silence, so a long wait never looks like a hung process. The bucket is
623
+ * quantised so the line changes on that cadence instead of every second.
624
+ */
625
+ export function describeWait(silentForMs) {
626
+ if (!Number.isFinite(silentForMs) || silentForMs < WAIT_NOTICE_AFTER_MS)
627
+ return undefined;
628
+ const repeats = Math.floor((silentForMs - WAIT_NOTICE_AFTER_MS) / WAIT_NOTICE_REPEAT_MS);
629
+ if (repeats === 0)
630
+ return { label: 'Waiting on the model' };
631
+ const announcedAt = (WAIT_NOTICE_AFTER_MS + repeats * WAIT_NOTICE_REPEAT_MS) / 1000;
632
+ return { label: 'Waiting on the model', elapsedLabel: `no output for ${announcedAt}s` };
633
+ }
634
+ /**
635
+ * Elapsed time as a short label: `8s`, `1m 04s`.
636
+ *
637
+ * This replaced the step counter. `Step 1/25` was true for minutes at a time
638
+ * while one inference ran, so it measured the engine's internal loop rather
639
+ * than anything the person waiting could act on.
640
+ */
641
+ export function formatElapsed(seconds) {
642
+ if (!Number.isFinite(seconds) || seconds <= 0)
643
+ return '0s';
644
+ if (seconds < 1)
645
+ return '<1s';
646
+ const total = Math.round(seconds);
647
+ if (total < 60)
648
+ return `${total}s`;
649
+ const minutes = Math.floor(total / 60);
650
+ return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
651
+ }
652
+ /**
653
+ * Shorten the middle of a label, keeping the head (the verb) and the tail (the
654
+ * filename, which is the part a person actually reads). Measured in terminal
655
+ * cells, not code units, so a CJK path cannot overflow the card.
656
+ */
657
+ export function elideMiddle(text, max) {
658
+ if (max <= 0)
659
+ return '';
660
+ if (displayWidth(text) <= max)
661
+ return text;
662
+ if (max <= 3)
663
+ return truncateToWidth(text, max);
664
+ const budget = max - 1;
665
+ const head = Math.ceil(budget / 2);
666
+ const tail = budget - head;
667
+ return `${sliceToWidth(text, head)}…${sliceTailToWidth(text, tail)}`;
668
+ }
669
+ /**
670
+ * Shorten the engine's progress sentence into a phase the user can follow.
671
+ *
672
+ * The status row is the only thing moving during a long, silent inference — the
673
+ * reasoning stream is deliberately not displayed — so it names what the agent is
674
+ * doing instead of repeating the step counter.
675
+ */
676
+ export function describeActivity(status) {
677
+ const text = status
678
+ .trim()
679
+ .replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')
680
+ .replace(/\s*\.{3}$/, '')
681
+ .trim();
682
+ if (/^inferring\b/i.test(text))
683
+ return 'Thinking';
684
+ if (/^discovering and selecting models/i.test(text))
685
+ return 'Selecting model';
686
+ if (/^retrying\b/i.test(text))
687
+ return 'Retrying request';
688
+ if (/^delegating sub-task/i.test(text))
689
+ return 'Delegating a sub-task';
690
+ return text || 'Working';
691
+ }
692
+ const TOOL_VERBS = {
693
+ read_file: 'Reading',
694
+ write_file: 'Writing',
695
+ edit_file: 'Editing',
696
+ apply_patch: 'Editing',
697
+ list_files: 'Listing',
698
+ search_files: 'Searching',
699
+ run_command: 'Running',
700
+ run_diagnostics: 'Running',
701
+ };
702
+ /**
703
+ * Name the work a tool call is about to do, from its parameters.
704
+ *
705
+ * `Running read_file` told the person nothing they could not already see; the
706
+ * path does. This is the part of the status row that keeps moving during a long
707
+ * coding turn, so it carries the real target.
708
+ */
709
+ export function describeToolActivity(toolName, parameters = {}) {
710
+ const text = (key) => {
711
+ const value = parameters[key];
712
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
713
+ };
714
+ if (toolName === 'apply_patch') {
715
+ const edits = Array.isArray(parameters.edits) ? parameters.edits : [];
716
+ if (edits.length > 1)
717
+ return `Editing ${edits.length} files`;
718
+ const first = edits[0];
719
+ const path = typeof first?.path === 'string' ? first.path : undefined;
720
+ return path ? `Editing ${path}` : 'Editing files';
721
+ }
722
+ if (toolName === 'run_command') {
723
+ const command = text('command');
724
+ if (!command)
725
+ return 'Running a command';
726
+ const args = Array.isArray(parameters.args)
727
+ ? parameters.args.filter((arg) => typeof arg === 'string')
728
+ : [];
729
+ return `Running ${[command, ...args].join(' ')}`;
730
+ }
731
+ if (toolName === 'search_files') {
732
+ const query = text('query');
733
+ return query ? `Searching "${query}"` : 'Searching the workspace';
734
+ }
735
+ if (toolName === 'list_files') {
736
+ const subpath = text('subpath');
737
+ return subpath && subpath !== '.' ? `Listing ${subpath}` : 'Listing the workspace';
738
+ }
739
+ if (toolName === 'run_diagnostics') {
740
+ const script = text('script');
741
+ return script ? `Running ${script}` : 'Running diagnostics';
742
+ }
743
+ if (toolName === 'web_search')
744
+ return 'Searching the web';
745
+ if (toolName === 'git_diff')
746
+ return 'Checking changes';
747
+ const verb = TOOL_VERBS[toolName];
748
+ const path = text('path');
749
+ if (verb && path)
750
+ return `${verb} ${path}`;
751
+ if (verb)
752
+ return verb;
753
+ return `Running ${toolName}`;
754
+ }
365
755
  /**
366
756
  * Update the fixed chat elapsed-time row without repainting the terminal.
367
757
  * With an activity, the row is a live "still running" indicator that stays
368
758
  * visible after text starts streaming and during silent tool execution.
369
759
  */
370
- export function renderChatThoughtTimeUpdate(thoughtTime, taskStep, width, activity) {
760
+ export function renderChatThoughtTimeUpdate(thoughtTime, width, activity, row = 13) {
371
761
  const area = getChatAnswerArea(width);
372
- const label = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
373
- const stepText = taskStep && taskStep.maxSteps > 0 && taskStep.step >= 0
374
- ? `Step ${taskStep.step}/${taskStep.maxSteps}`
375
- : undefined;
376
- const status = (activity
377
- ? [`${activity.glyph} ${activity.label}`, stepText, label].filter(Boolean).join(' · ')
378
- : stepText ? `Processing task… ${stepText} · ${label}` : `Thought for ${label}`).slice(0, area.cardWidth);
379
- return `\x1b7\x1b[13;${area.startColumn}H\x1b[K\x1b[38;5;244m${status}\x1b[0m\x1b8`;
762
+ // A wait phase reports silence, not the turn clock; two timers side by side
763
+ // would be ambiguous.
764
+ const elapsed = activity?.elapsedLabel ?? formatElapsed(thoughtTime);
765
+ const paint = (status) => `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${truncateToWidth(status, area.cardWidth)}\x1b[0m\x1b8`;
766
+ if (!activity)
767
+ return paint(`Thought for ${elapsed}`);
768
+ // The timer is reserved before the label gets any room, so a long path can
769
+ // never push the elapsed time off the row.
770
+ const timer = ` · ${elapsed}`;
771
+ const timerCells = displayWidth(timer);
772
+ const label = elideMiddle(
773
+ // The engine's sentence often carries its own step prefix; the row no longer
774
+ // prints a counter, so drop it rather than showing engine internals.
775
+ `${activity.glyph} ${activity.label.replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')}`, Math.max(12, area.cardWidth - timerCells - 2));
776
+ const reasoning = activity.reasoning?.replace(/\s+/g, ' ').trim() ?? '';
777
+ // Everything here is measured in cells: a CJK reasoning tail is twice as wide
778
+ // as its length suggests, and the overflow used to wrap outside the card.
779
+ const room = area.cardWidth - displayWidth(label) - timerCells - 3;
780
+ const excerpt = reasoning && room > 8
781
+ ? (displayWidth(reasoning) > room ? `…${sliceTailToWidth(reasoning, room - 1)}` : reasoning)
782
+ : '';
783
+ // Clock first, then the message: the timer belongs to the phase, so the
784
+ // trailing text can be cut without taking the time with it.
785
+ return paint(excerpt ? `${label}${timer} · ${excerpt}` : `${label}${timer}`);
380
786
  }
381
787
  /**
382
- * Write one streamed fragment into the answer area without repainting the chat
383
- * frame. Wrapped lines return to `startColumn` so the answer keeps the same
384
- * columns as the composer card instead of stretching across the terminal.
788
+ * Invisible prefix marking a rendered row as part of the live activity log.
789
+ *
790
+ * A second mark next to `SUMMARY_MARK` lets one renderer style both kinds of
791
+ * row in the same scrollable list without them knowing about each other.
385
792
  */
386
- export function renderChatAnswerDelta(delta, start, width, startColumn = 1) {
387
- let row = start.row;
388
- let column = start.column;
389
- let writeColumn = start.column;
390
- let sequence = '';
391
- let line = '';
392
- const flush = () => {
393
- if (line.length === 0)
394
- return;
395
- sequence += `\x1b[${row};${writeColumn}H\x1b[38;5;253m${line}\x1b[0m`;
396
- line = '';
397
- };
398
- for (const character of delta) {
399
- if (character === '\n') {
400
- flush();
401
- row++;
402
- column = startColumn;
403
- writeColumn = startColumn;
404
- continue;
405
- }
406
- line += character;
407
- column++;
408
- if (column > width) {
409
- flush();
410
- row++;
411
- column = startColumn;
412
- writeColumn = startColumn;
413
- }
793
+ const LOG_MARK = '\u0002';
794
+ /**
795
+ * Cells the transcript may use on this layout.
796
+ *
797
+ * The card has a 70-column floor, the terminal does not: on a narrow window the
798
+ * transcript still has to stop at the screen edge, not at the card's. Wrapping
799
+ * to the card width and painting anyway is what let a long row spill past the
800
+ * right border of the frame, so the wrap width is the narrower of the two and
801
+ * every row is clamped to it.
802
+ */
803
+ export function getChatTranscriptWidth(layout) {
804
+ const { area } = layout;
805
+ return Math.min(area.textWidth, Math.min(area.cardWidth, Math.max(8, layout.terminalWidth - area.startColumn + 1)));
806
+ }
807
+ /**
808
+ * Rows of the live turn transcript: the activity log first, the answer after.
809
+ *
810
+ * This is the Cline model. Cline keeps one scrollable list where every action
811
+ * and every message is a row, the newest pinned to the bottom and the rest
812
+ * reachable by scrolling. Merging the two here is what fixes "the output
813
+ * becomes one line again": the activity log used to be a separate block that
814
+ * handed its rows back the moment the first token streamed, so a long fix
815
+ * explanation left nothing on screen but a single clipped status row — exactly
816
+ * when there was most to read.
817
+ *
818
+ * The log and the answer now share the window, so the record of what the agent
819
+ * did and the answer it produced are read in one place and scroll together.
820
+ */
821
+ export function buildChatTranscriptRows(activity, answer, textWidth) {
822
+ const rows = [];
823
+ for (const entry of activity?.history ?? []) {
824
+ rows.push(LOG_MARK + `${entry.glyph} ${entry.label}`);
825
+ }
826
+ const reasoning = activity?.reasoning?.replace(/\s+/g, ' ').trim();
827
+ if (reasoning) {
828
+ // Two columns of indent mark the thought as subordinate to the phase, and
829
+ // wrapping (not truncating) is what turns one clipped line into prose.
830
+ for (const line of wrapText(reasoning, Math.max(8, textWidth - 2)))
831
+ rows.push(LOG_MARK + ` ${line}`);
414
832
  }
415
- flush();
416
- return { sequence, row, column };
833
+ const answerStart = rows.length;
834
+ if (answer.trim().length > 0)
835
+ rows.push(...wrapAnswerLines(answer.trimEnd(), textWidth));
836
+ return { rows, answerStart };
837
+ }
838
+ /**
839
+ * Window the transcript around an offset, measured in rows scrolled up from the
840
+ * tail. `offset = 0` is the live view (newest rows). One row is reserved for
841
+ * the "earlier lines hidden" note whenever anything is above the window.
842
+ */
843
+ export function windowChatTranscriptAt(rows, maxRows, offsetFromBottom = 0) {
844
+ if (rows.length <= maxRows)
845
+ return { lines: [...rows], hiddenAbove: 0, hiddenBelow: 0 };
846
+ // The note costs a row, so the content capacity is one row smaller — and the
847
+ // maximum offset follows from that capacity, not from the raw row count.
848
+ const capacity = Math.max(1, maxRows - 1);
849
+ const maxOffset = Math.max(0, rows.length - capacity);
850
+ const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
851
+ const end = rows.length - offset;
852
+ const start = Math.max(0, end - capacity);
853
+ return { lines: [...rows.slice(start, end)], hiddenAbove: start, hiddenBelow: rows.length - end };
854
+ }
855
+ /** Rows the transcript occupies for a layout; 0 when the viewport is tiny. */
856
+ export function getChatTranscriptRows(layout) {
857
+ return Math.max(0, layout.answerRows);
858
+ }
859
+ /**
860
+ * Paint the pinned phase header on the status row.
861
+ *
862
+ * One row, always: the phase and its clock. Everything it used to squeeze into
863
+ * that row now lives in the transcript below it.
864
+ */
865
+ export function renderChatTranscriptHeader(activity, layout, thoughtTime) {
866
+ const { area } = layout;
867
+ const cells = getChatTranscriptWidth(layout);
868
+ const elapsed = activity.elapsedLabel ?? formatElapsed(thoughtTime);
869
+ const header = `\x1b[38;5;250m${truncateToWidth(`${activity.glyph} ${activity.label} · ${elapsed}`, cells)}\x1b[0m`;
870
+ return `\x1b7\x1b[${layout.statusRow};${area.startColumn}H\x1b[2K${header}\x1b8`;
871
+ }
872
+ /**
873
+ * Repaint the transcript in place: exactly the rows the layout reserves,
874
+ * between the status row and the composer. The window scrolls (oldest rows
875
+ * leave first) instead of writing over the composer, so a long turn can never
876
+ * damage the bottom rows. The cursor is saved and restored, so the caller keeps
877
+ * its position on the input line.
878
+ *
879
+ * Unlike the answer window it replaced, this never gives its rows up: the log
880
+ * and the answer are one list, so the newest rows stay on screen and the rest
881
+ * is one PgUp away. Every row is clamped to the card in terminal cells, so a
882
+ * wide CJK glyph or a long path cannot push a row past the right border.
883
+ */
884
+ export function renderChatTranscriptWindow(activity, answer, layout, offsetFromBottom = 0) {
885
+ const { area } = layout;
886
+ const cells = getChatTranscriptWidth(layout);
887
+ const { rows } = buildChatTranscriptRows(activity, answer, cells);
888
+ const { lines, hiddenAbove } = windowChatTranscriptAt(rows, layout.answerRows, offsetFromBottom);
889
+ const visible = hiddenAbove > 0
890
+ ? [`\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), cells)}\x1b[0m`, ...lines.map((line) => renderAnswerLine(line, cells))]
891
+ : lines.map((line) => renderAnswerLine(line, cells));
892
+ let sequence = '\x1b7';
893
+ for (let index = 0; index < layout.answerRows; index++) {
894
+ const row = layout.answerTop + index;
895
+ sequence += `\x1b[${row};${area.startColumn}H\x1b[2K${visible[index] ?? ''}`;
896
+ }
897
+ return sequence + '\x1b8';
417
898
  }
418
899
  export function renderFullWelcomeScreen(options, height) {
419
900
  if (options.chatQuestion !== undefined) {
@@ -510,14 +991,27 @@ export function renderExitMessage(message) {
510
991
  * screen buffer, disable raw mode, fully clear the OS terminal, print the
511
992
  * farewell message, and terminate the process.
512
993
  */
513
- export function exitCleanly(message) {
994
+ /**
995
+ * Exit Moderado cleanly: restore cursor visibility & style, leave the alternate
996
+ * screen buffer, disable raw mode, fully clear the OS terminal, print the
997
+ * farewell message, and let the process end.
998
+ *
999
+ * A hard `process.exit()` here would race the Windows console handle and, with
1000
+ * an open undici pool, abort inside libuv (`win/async.c:94`). The farewell is
1001
+ * written synchronously, connections are closed, and the returned promise never
1002
+ * settles so callers stop while the event loop drains.
1003
+ */
1004
+ export async function exitCleanly(message) {
514
1005
  notifyCleanExit();
515
1006
  const stdout = process.stdout;
516
- if (stdout.isTTY) {
517
- stdout.write(MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message));
1007
+ const payload = stdout.isTTY
1008
+ ? MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message)
1009
+ : renderExitMessage(message);
1010
+ try {
1011
+ fs.writeSync(1, payload);
518
1012
  }
519
- else {
520
- stdout.write(renderExitMessage(message));
1013
+ catch {
1014
+ stdout.write(payload);
521
1015
  }
522
1016
  const stdin = process.stdin;
523
1017
  if (stdin.isTTY) {
@@ -526,609 +1020,10 @@ export function exitCleanly(message) {
526
1020
  }
527
1021
  catch { /* ignore */ }
528
1022
  }
529
- process.exit(0);
530
- }
531
- function isMcpCommandInput(value) {
532
- const command = value.trim();
533
- return command === '/mcp' || command.startsWith('/mcp ');
534
- }
535
- /**
536
- * SGR mouse report, e.g. "\x1b[<0;12;5M" for a primary-button press on column 12
537
- * of row 5. readline splits that escape into several keypress events ("0", ";",
538
- * "5", "M"), so the raw data chunk is parsed instead of the derived keystrokes.
539
- */
540
- const MOUSE_REPORT_PATTERN = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
541
- const MOUSE_REPORT_ON = '\x1b[?1000h\x1b[?1006h';
542
- const MOUSE_REPORT_OFF = '\x1b[?1000l\x1b[?1006l';
543
- export async function promptInteractiveTurn(options) {
544
- const stdin = process.stdin;
545
- const stdout = process.stdout;
546
- let currentModel = options.model;
547
- let currentMode = options.initialMode ?? 'Execute';
548
- let currentAutoApprove = options.initialAutoApprove ?? true;
549
- let input = '';
550
- /** Caret index inside `input` (0 .. input.length) - the composer is editable. */
551
- let caret = 0;
552
- /** True while keypress events derived from a mouse report are being dropped. */
553
- let ignoreComposerKeypress = false;
554
- /** Terminal height, with the same fallback the renderer uses. */
555
- const getRows = () => stdout.rows || 24;
556
- let commandSelection = 0;
557
- const questionHistory = options.questionHistory ?? [];
558
- let questionHistoryIndex = questionHistory.length;
559
- let questionHistoryDraft = '';
560
- let cachedFiles = [];
561
- if (options.onMentionComplete) {
562
- Promise.resolve(options.onMentionComplete('')).then((files) => {
563
- cachedFiles = Array.isArray(files) ? files : [];
564
- updateMentions();
565
- }).catch(() => { cachedFiles = []; });
566
- }
567
- let mentionFiles = [];
568
- let mentionSelection = 0;
569
- const updateMentions = () => {
570
- const mention = activeMentionToken(input, caret);
571
- if (mention && cachedFiles.length > 0) {
572
- mentionFiles = filterMentionCandidates(cachedFiles, mention.token, 5);
573
- mentionSelection = Math.min(mentionSelection, Math.max(0, mentionFiles.length - 1));
574
- }
575
- else {
576
- mentionFiles = [];
577
- mentionSelection = 0;
578
- }
579
- };
580
- /** Replace the composer text, leaving the caret at the end of the new value. */
581
- const setInput = (value) => {
582
- input = value;
583
- caret = value.length;
584
- };
585
- const getOptions = () => ({
586
- model: currentModel,
587
- provider: options.getProviderName?.(),
588
- tokens: options.tokens,
589
- cost: options.cost,
590
- usageAvailable: options.usageAvailable,
591
- usageEstimated: options.usageEstimated,
592
- workspace: options.workspace,
593
- mode: currentMode,
594
- autoApprove: currentAutoApprove,
595
- input,
596
- inputCaret: caret,
597
- commandSelection,
598
- chatQuestion: options.chatQuestion,
599
- chatAnswer: options.chatAnswer,
600
- chatThoughtTime: options.chatThoughtTime,
601
- chatTaskDone: options.chatTaskDone,
602
- outputTokenRate: options.outputTokenRate,
603
- tokenUsage: options.tokenUsage,
604
- queuedCommands: options.queuedCommands,
605
- isTurnSettled: true,
606
- });
607
- // ── Non-TTY fallback ──────────────────────────────────────────────────────
608
- if (!stdin.isTTY) {
609
- stdout.write('\x1b]0;Moderado\x07');
610
- stdout.write(renderFullWelcomeScreen(getOptions()));
611
- return new Promise((resolve) => {
612
- const rl = readline.createInterface({ input: stdin, output: stdout });
613
- rl.question('\x1b[1;38;5;75m❯\x1b[0m ', (answer) => {
614
- rl.close();
615
- resolve({ text: answer.trim(), mode: currentMode, autoApprove: currentAutoApprove });
616
- });
617
- });
618
- }
619
- // ── TTY Interactive Loop ───────────────────────────────────────────────────
620
- readline.emitKeypressEvents(stdin);
621
- // A previous turn's cleanup() pauses stdin — resume it so keystrokes keep
622
- // flowing after an answer (the main app must never appear dead post-answer).
623
- stdin.resume();
624
- stdin.setRawMode(true);
625
- const getExtraLines = () => mentionFiles.length > 0 ? mentionFiles.length + 2 : 0;
626
- /** Rows between the painted composer line and the terminal's last row. */
627
- const getComposerOffset = () => getWelcomeBottomPadding(getOptions(), getRows()) + 4 + getExtraLines();
628
- /** Screen row and 1-based column of the composer's first text cell. */
629
- const getComposerAnchor = () => ({
630
- row: getRows() - getComposerOffset(),
631
- column: getWelcomeIndent(getWelcomeCardWidth()) + 3,
632
- });
633
- /**
634
- * Move cursor to the ❯ input line (Line 2 inside the centered card).
635
- * Absolute addressing (`row;column H`) works no matter where the cursor
636
- * currently sits — relative "move up N" escapes the input line when
637
- * pressed repeatedly, e.g. browsing history with Left/Right.
638
- */
639
- const positionCursorOnInput = () => {
640
- const { row, column } = getComposerAnchor();
641
- const { caretColumn } = composerInputWindow(input, caret, getWelcomeCardWidth());
642
- stdout.write(`\x1b[1 q\x1b[?25h\x1b[${row};${column + caretColumn}H`);
643
- };
644
- /** Full-screen redraw: clear everything, repaint welcome TUI, reposition cursor. */
645
- const redrawFull = () => {
646
- stdout.write('\x1b[H\x1b[J');
647
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
648
- positionCursorOnInput();
649
- };
650
- /** Repaint the welcome view so it remains centered after every edit or resize. */
651
- const redrawCard = () => {
652
- redrawFull();
653
- };
654
- const onResize = () => redrawFull();
655
- /**
656
- * Place the caret where the user clicked on the composer line. Only a primary
657
- * press is honoured; releases, drags, wheel and other buttons are ignored.
658
- */
659
- const onMouseData = (chunk) => {
660
- const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
661
- let reported = false;
662
- for (const report of text.matchAll(MOUSE_REPORT_PATTERN)) {
663
- reported = true;
664
- if (report[4] !== 'M' || Number(report[1]) !== 0)
665
- continue;
666
- const { row, column } = getComposerAnchor();
667
- if (Number(report[3]) !== row)
668
- continue;
669
- caret = Math.max(0, Math.min(input.length, Number(report[2]) - column));
670
- positionCursorOnInput();
671
- }
672
- if (reported) {
673
- // readline turns the same bytes into keypress events ("0", ";", "5", "M").
674
- ignoreComposerKeypress = true;
675
- setImmediate(() => { ignoreComposerKeypress = false; });
676
- }
677
- };
678
- // Registered before readline's decoder so a report is consumed before it is
679
- // mistaken for typed text.
680
- stdin.prependListener('data', onMouseData);
681
- stdout.on('resize', onResize);
682
- // Initial paint
683
- if (options.isFirstTurn) {
684
- stdout.write('\x1b]0;Moderado\x07');
685
- stdout.write('\x1b[2J\x1b[3J\x1b[H');
686
- }
687
- else {
688
- // After an AI response the screen has content below — clear and repaint the full TUI.
689
- stdout.write('\x1b[H\x1b[J');
690
- }
691
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
692
- positionCursorOnInput();
693
- return new Promise((resolve) => {
694
- const cleanup = () => {
695
- unbindComposerInput();
696
- stdin.removeListener('data', onMouseData);
697
- stdout.removeListener('resize', onResize);
698
- stdout.write(`${MOUSE_REPORT_OFF}\x1b[0 q\x1b[?25h`);
699
- if (stdin.isTTY) {
700
- try {
701
- stdin.setRawMode(false);
702
- }
703
- catch { /* ignore */ }
704
- }
705
- try {
706
- stdin.pause();
707
- }
708
- catch { /* ignore */ }
709
- };
710
- const onAbort = () => {
711
- cleanup();
712
- resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
713
- };
714
- if (options.signal) {
715
- options.signal.addEventListener('abort', onAbort, { once: true });
716
- }
717
- // ── Async keypress handler ────────────────────────────────────────────────
718
- // Using a void async IIFE so we can await async operations (model selector)
719
- // inside a synchronous event listener. For any async branch we first
720
- // remove ourselves from stdin so no duplicate events fire during the await.
721
- const onKeypress = (str, key) => {
722
- void (async () => {
723
- // Keystrokes decoded from a mouse report were already handled above.
724
- if (ignoreComposerKeypress)
725
- return;
726
- if (options.signal?.aborted) {
727
- cleanup();
728
- resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
729
- return;
730
- }
731
- // ── Ctrl+C ────────────────────────────────────────────────────────────
732
- if (key && key.ctrl && key.name === 'c') {
733
- cleanup();
734
- exitCleanly('\x1b[33mSession cancelled.\x1b[0m');
735
- }
736
- // ── /help overlay popup ───────────────────────────────────────────────
737
- // Triggered when Enter is pressed with "/help" already in the input box.
738
- // We keep the main TUI as background and layer the help box on top.
739
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/help') {
740
- setInput('');
741
- unbindComposerInput(); // pause main handler
742
- // Paint: full welcome TUI (background) + help box on top
743
- const helpLines = renderHelpPopupBox(options.version ?? 'v0.1', options.workspace);
744
- stdout.write('\x1b[H\x1b[J');
745
- stdout.write(renderWelcomePopupLayer(getOptions(), helpLines, stdout.columns, stdout.rows));
746
- // Wait for Esc / Enter / q to dismiss
747
- await new Promise((dismissResolve) => {
748
- const onDismiss = (s, k) => {
749
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
750
- s === 'q' || s === 'Q' ||
751
- (k && k.ctrl && k.name === 'c')) {
752
- stdin.removeListener('keypress', onDismiss);
753
- dismissResolve();
754
- }
755
- };
756
- stdin.on('keypress', onDismiss);
757
- });
758
- // Restore: repaint the full welcome TUI, show cursor, position on input line
759
- stdout.write('\x1b[H\x1b[J');
760
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
761
- positionCursorOnInput();
762
- bindComposerInput(); // re-attach main handler
763
- return;
764
- }
765
- // ── /model overlay popup ──────────────────────────────────────────────
766
- // Triggered when Enter is pressed with "/model" in the input box.
767
- // We temporarily detach the main keypress listener and hand the model
768
- // selector a drawFrame callback. drawFrame repaints the background TUI
769
- // exactly as-is and composites the popup content as a centered floating
770
- // layer on top of it (Cline/OpenCode style) — the popup never scrolls
771
- // the background because every step redraws the whole frame.
772
- // After it returns, we repaint the full welcome TUI and reposition cursor.
773
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/model') {
774
- setInput('');
775
- unbindComposerInput(); // pause main handler
776
- if (options.onModelSelect) {
777
- const drawFrame = (popupLines) => {
778
- stdout.write('\x1b[H\x1b[J');
779
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
780
- };
781
- const newId = await options.onModelSelect(drawFrame);
782
- if (newId && newId !== currentModel) {
783
- currentModel = newId;
784
- }
785
- const autoContinuePrompt = options.takeAutoContinuePrompt?.();
786
- if (autoContinuePrompt) {
787
- options.signal?.removeEventListener('abort', onAbort);
788
- cleanup();
789
- resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
790
- return;
791
- }
792
- }
793
- // Selection flow left stdin in non-raw mode — restore it for our TUI
794
- readline.emitKeypressEvents(stdin);
795
- stdin.resume();
796
- stdin.setRawMode(true);
797
- stdout.write('\x1b[H\x1b[J');
798
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
799
- positionCursorOnInput();
800
- bindComposerInput(); // re-attach main handler
801
- return;
802
- }
803
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/connect') {
804
- setInput('');
805
- unbindComposerInput();
806
- const drawFrame = (popupLines) => {
807
- stdout.write('\x1b[H\x1b[J');
808
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
809
- };
810
- if (options.onConnect) {
811
- const newModel = await options.onConnect(drawFrame);
812
- if (newModel)
813
- currentModel = newModel;
814
- }
815
- readline.emitKeypressEvents(stdin);
816
- stdin.resume();
817
- stdin.setRawMode(true);
818
- stdout.write('\x1b[H\x1b[J');
819
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
820
- positionCursorOnInput();
821
- bindComposerInput();
822
- return;
823
- }
824
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/session')) {
825
- const command = input.trim();
826
- setInput('');
827
- unbindComposerInput();
828
- if (options.onSession) {
829
- await options.onSession(command, (popupLines) => {
830
- stdout.write('\x1b[H\x1b[J');
831
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
832
- });
833
- }
834
- const autoContinuePrompt = options.takeAutoContinuePrompt?.();
835
- if (autoContinuePrompt) {
836
- options.signal?.removeEventListener('abort', onAbort);
837
- cleanup();
838
- resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
839
- return;
840
- }
841
- readline.emitKeypressEvents(stdin);
842
- stdin.resume();
843
- stdin.setRawMode(true);
844
- stdout.write('\x1b[H\x1b[J');
845
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
846
- positionCursorOnInput();
847
- bindComposerInput();
848
- return;
849
- }
850
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/queue')) {
851
- const command = input.trim();
852
- setInput('');
853
- unbindComposerInput();
854
- if (options.onQueue) {
855
- await options.onQueue(command, async (popupLines) => {
856
- stdout.write('\x1b[H\x1b[J');
857
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
858
- await new Promise((dismissResolve) => {
859
- const onDismiss = (s, k) => {
860
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
861
- s === 'q' || s === 'Q' || s === '\x1b') {
862
- stdin.removeListener('keypress', onDismiss);
863
- dismissResolve();
864
- }
865
- };
866
- stdin.on('keypress', onDismiss);
867
- });
868
- });
869
- }
870
- readline.emitKeypressEvents(stdin);
871
- stdin.resume();
872
- stdin.setRawMode(true);
873
- stdout.write('\x1b[H\x1b[J');
874
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
875
- positionCursorOnInput();
876
- bindComposerInput();
877
- return;
878
- }
879
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/btw')) {
880
- const command = input.trim();
881
- setInput('');
882
- unbindComposerInput();
883
- if (options.onBtw) {
884
- const ac = new AbortController();
885
- const onBtwKey = (s, k) => {
886
- if ((k && k.name === 'escape') || s === '\x1b') {
887
- ac.abort();
888
- }
889
- };
890
- stdin.on('keypress', onBtwKey);
891
- try {
892
- await options.onBtw(command, async (popupLines, frameOpts) => {
893
- stdout.write('\x1b[H\x1b[J');
894
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
895
- if (frameOpts?.waitDismiss !== false && !ac.signal.aborted) {
896
- await new Promise((dismissResolve) => {
897
- const onDismiss = (s, k) => {
898
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
899
- s === 'q' || s === 'Q' || s === '\x1b') {
900
- stdin.removeListener('keypress', onDismiss);
901
- dismissResolve();
902
- }
903
- };
904
- stdin.on('keypress', onDismiss);
905
- });
906
- }
907
- }, ac.signal);
908
- }
909
- finally {
910
- stdin.removeListener('keypress', onBtwKey);
911
- }
912
- }
913
- readline.emitKeypressEvents(stdin);
914
- stdin.resume();
915
- stdin.setRawMode(true);
916
- stdout.write('\x1b[H\x1b[J');
917
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
918
- positionCursorOnInput();
919
- bindComposerInput();
920
- return;
921
- }
922
- if (key && (key.name === 'return' || key.name === 'enter') && isMcpCommandInput(input)) {
923
- const command = input.trim();
924
- setInput('');
925
- unbindComposerInput();
926
- if (options.onMcp) {
927
- await options.onMcp(command, (popupLines) => {
928
- stdout.write('\x1b[H\x1b[J');
929
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
930
- });
931
- }
932
- if (options.signal?.aborted)
933
- return;
934
- readline.emitKeypressEvents(stdin);
935
- stdin.resume();
936
- stdin.setRawMode(true);
937
- redrawFull();
938
- bindComposerInput();
939
- return;
940
- }
941
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/init') {
942
- setInput('');
943
- unbindComposerInput();
944
- if (options.onInit) {
945
- await options.onInit((popupLines) => {
946
- stdout.write('\x1b[H\x1b[J');
947
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
948
- });
949
- }
950
- if (options.signal?.aborted)
951
- return;
952
- readline.emitKeypressEvents(stdin);
953
- stdin.resume();
954
- stdin.setRawMode(true);
955
- redrawFull();
956
- bindComposerInput();
957
- return;
958
- }
959
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/workflow')) {
960
- const command = input.trim();
961
- setInput('');
962
- unbindComposerInput();
963
- const action = options.onWorkflow ? await options.onWorkflow(command, (popupLines) => { stdout.write('\x1b[H\x1b[J'); stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows)); }) : undefined;
964
- if (action === 'build') {
965
- cleanup();
966
- resolve({ text: '', mode: 'Execute', autoApprove: currentAutoApprove, workflowAction: 'build' });
967
- return;
968
- }
969
- readline.emitKeypressEvents(stdin);
970
- stdin.resume();
971
- stdin.setRawMode(true);
972
- redrawFull();
973
- bindComposerInput();
974
- return;
975
- }
976
- // ── /clear ────────────────────────────────────────────────────────────
977
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/clear') {
978
- setInput('');
979
- options.onClear?.();
980
- redrawFull();
981
- return;
982
- }
983
- // ── /exit ─────────────────────────────────────────────────────────────
984
- if (key && (key.name === 'return' || key.name === 'enter') &&
985
- (input.trim() === '/exit' || input.trim() === '/quit')) {
986
- cleanup();
987
- if (options.signal)
988
- options.signal.removeEventListener('abort', onAbort);
989
- exitCleanly(options.onExit?.() ?? '\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m');
990
- }
991
- // ── Normal keys ───────────────────────────────────────────────────────
992
- if (!key) {
993
- if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
994
- input = input.slice(0, caret) + str + input.slice(caret);
995
- caret += 1;
996
- updateMentions();
997
- redrawCard();
998
- }
999
- return;
1000
- }
1001
- // Shift+Tab — toggle auto-approve
1002
- if ((key.name === 'tab' && key.shift) || key.sequence === '\x1b[Z') {
1003
- currentAutoApprove = !currentAutoApprove;
1004
- redrawCard();
1005
- return;
1006
- }
1007
- // Tab — autocomplete mention, autocomplete slash command or toggle mode
1008
- if (key.name === 'tab' && !key.shift) {
1009
- const mention = activeMentionToken(input, caret);
1010
- if (mention && mentionFiles.length > 0) {
1011
- const chosen = mentionFiles[Math.min(mentionSelection, mentionFiles.length - 1)];
1012
- const before = input.slice(0, mention.start);
1013
- const after = input.slice(caret);
1014
- const insert = `@${chosen} `;
1015
- input = before + insert + after;
1016
- caret = before.length + insert.length;
1017
- updateMentions();
1018
- redrawCard();
1019
- return;
1020
- }
1021
- if (input.startsWith('/')) {
1022
- const matching = getMatchingCommands(input);
1023
- if (matching.length > 0) {
1024
- setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
1025
- commandSelection = 0;
1026
- redrawCard();
1027
- return;
1028
- }
1029
- }
1030
- currentMode = currentMode === 'Plan' ? 'Execute' : 'Plan';
1031
- redrawCard();
1032
- return;
1033
- }
1034
- if (mentionFiles.length > 0 && (key.name === 'up' || key.name === 'down')) {
1035
- mentionSelection = ((mentionSelection + (key.name === 'up' ? -1 : 1)) % mentionFiles.length + mentionFiles.length) % mentionFiles.length;
1036
- redrawCard();
1037
- return;
1038
- }
1039
- if (input.startsWith('/') && (key.name === 'up' || key.name === 'down')) {
1040
- const matching = getMatchingCommands(input);
1041
- if (matching.length > 0) {
1042
- commandSelection = ((commandSelection + (key.name === 'up' ? -1 : 1)) % matching.length + matching.length) % matching.length;
1043
- redrawCard();
1044
- return;
1045
- }
1046
- }
1047
- if (!input.startsWith('/') && (key.name === 'up' || key.name === 'down') && questionHistory.length > 0) {
1048
- if (questionHistoryIndex === questionHistory.length)
1049
- questionHistoryDraft = input;
1050
- const recalled = navigateQuestionHistory(questionHistory, key.name === 'up' ? -1 : 1, questionHistoryIndex, questionHistoryDraft);
1051
- setInput(recalled.input);
1052
- questionHistoryIndex = recalled.index;
1053
- redrawCard();
1054
- return;
1055
- }
1056
- // Enter — submit real user message
1057
- if (key.name === 'return' || key.name === 'enter') {
1058
- const matching = getMatchingCommands(input);
1059
- if (input.startsWith('/') && matching.length > 0 && !matching.some((command) => command.name === input.trim())) {
1060
- setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
1061
- commandSelection = 0;
1062
- redrawCard();
1063
- return;
1064
- }
1065
- const trimmed = input.trim();
1066
- cleanup();
1067
- if (options.signal)
1068
- options.signal.removeEventListener('abort', onAbort);
1069
- const moveDown = 4 + getExtraLines();
1070
- stdout.write(`\x1b[${moveDown}B\r\n\x1b[0 q\x1b[?25h`);
1071
- resolve({ text: trimmed, mode: currentMode, autoApprove: currentAutoApprove });
1072
- return;
1073
- }
1074
- // Caret movement (cursor-only hop) and forward delete (repaint: the
1075
- // line shrank, so the old trailing character must be cleared).
1076
- if (key.name === 'left' || key.name === 'right' || key.name === 'delete') {
1077
- if (key.name === 'left') {
1078
- caret = Math.max(0, caret - 1);
1079
- updateMentions();
1080
- redrawCard();
1081
- }
1082
- else if (key.name === 'right') {
1083
- caret = Math.min(input.length, caret + 1);
1084
- updateMentions();
1085
- redrawCard();
1086
- }
1087
- else if (caret < input.length) {
1088
- input = input.slice(0, caret) + input.slice(caret + 1);
1089
- updateMentions();
1090
- redrawCard();
1091
- }
1092
- return;
1093
- }
1094
- // Backspace
1095
- if (key.name === 'backspace') {
1096
- if (caret > 0) {
1097
- input = input.slice(0, caret - 1) + input.slice(caret);
1098
- caret = Math.max(0, caret - 1);
1099
- commandSelection = 0;
1100
- updateMentions();
1101
- redrawCard();
1102
- }
1103
- return;
1104
- }
1105
- // Printable character
1106
- if (str && str.length === 1 && str.charCodeAt(0) >= 32 && !key.ctrl && !key.meta) {
1107
- input = input.slice(0, caret) + str + input.slice(caret);
1108
- caret += 1;
1109
- commandSelection = 0;
1110
- questionHistoryIndex = questionHistory.length;
1111
- updateMentions();
1112
- redrawCard();
1113
- }
1114
- })();
1115
- };
1116
- /**
1117
- * Mouse reporting is only useful while the composer owns the keyboard: a
1118
- * click on the composer line places the caret. Popups and sub-prompts take
1119
- * the keyboard over with mouse reporting off, so they keep the terminal's
1120
- * normal text selection, and clicks never leak into their filter fields.
1121
- */
1122
- const bindComposerInput = () => {
1123
- stdout.write(MOUSE_REPORT_ON);
1124
- stdin.on('keypress', onKeypress);
1125
- };
1126
- const unbindComposerInput = () => {
1127
- stdin.removeListener('keypress', onKeypress);
1128
- stdout.write(MOUSE_REPORT_OFF);
1129
- };
1130
- bindComposerInput();
1131
- });
1023
+ process.exitCode = 0;
1024
+ await closeHttpConnections();
1025
+ setTimeout(() => process.exit(0), 2000).unref();
1026
+ return new Promise(() => { });
1132
1027
  }
1133
1028
  /** A cumulative task snapshot; a request ending does not mean the task ended. */
1134
1029
  export function formatTokenUsage(event) {
@@ -1160,7 +1055,7 @@ export function renderGatewayFallbackUpdate(event, width = process.stdout.column
1160
1055
  const area = getChatAnswerArea(width);
1161
1056
  const destination = event.toProvider ? `${event.toProvider}/${event.toModel}` : event.toModel;
1162
1057
  const reason = event.reason === 'rate_limited_or_unavailable' ? 'after an upstream route was rate limited or unavailable' : 'after a fallback';
1163
- const text = `Gateway route changed to ${destination} ${reason}`.slice(0, area.cardWidth);
1058
+ const text = truncateToWidth(`Gateway route changed to ${destination} ${reason}`, area.cardWidth);
1164
1059
  const row = renderModeradoHeader().split('\n').length + 3;
1165
1060
  return `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${text}\x1b[0m\x1b8`;
1166
1061
  }