moderado 0.4.34 → 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 (97) hide show
  1. package/dist/commands/chat.d.ts +7 -70
  2. package/dist/commands/chat.d.ts.map +1 -1
  3. package/dist/commands/chat.js +279 -453
  4. package/dist/commands/chat.js.map +1 -1
  5. package/dist/commands/chat_meta.d.ts +36 -0
  6. package/dist/commands/chat_meta.d.ts.map +1 -0
  7. package/dist/commands/chat_meta.js +102 -0
  8. package/dist/commands/chat_meta.js.map +1 -0
  9. package/dist/commands/chat_outcome.d.ts +31 -0
  10. package/dist/commands/chat_outcome.d.ts.map +1 -0
  11. package/dist/commands/chat_outcome.js +70 -0
  12. package/dist/commands/chat_outcome.js.map +1 -0
  13. package/dist/commands/chat_search.d.ts +24 -0
  14. package/dist/commands/chat_search.d.ts.map +1 -0
  15. package/dist/commands/chat_search.js +61 -0
  16. package/dist/commands/chat_search.js.map +1 -0
  17. package/dist/commands/chat_slash.d.ts +17 -0
  18. package/dist/commands/chat_slash.d.ts.map +1 -0
  19. package/dist/commands/chat_slash.js +108 -0
  20. package/dist/commands/chat_slash.js.map +1 -0
  21. package/dist/commands/run.d.ts.map +1 -1
  22. package/dist/commands/run.js +25 -1
  23. package/dist/commands/run.js.map +1 -1
  24. package/dist/config.d.ts +1 -1
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +18 -2
  27. package/dist/config.js.map +1 -1
  28. package/dist/diagnostic_log.d.ts.map +1 -1
  29. package/dist/diagnostic_log.js +1 -0
  30. package/dist/diagnostic_log.js.map +1 -1
  31. package/dist/sessions.d.ts.map +1 -1
  32. package/dist/sessions.js +5 -1
  33. package/dist/sessions.js.map +1 -1
  34. package/dist/transcript.d.ts +19 -0
  35. package/dist/transcript.d.ts.map +1 -0
  36. package/dist/transcript.js +76 -0
  37. package/dist/transcript.js.map +1 -0
  38. package/dist/turn_summary.d.ts +76 -0
  39. package/dist/turn_summary.d.ts.map +1 -0
  40. package/dist/turn_summary.js +305 -0
  41. package/dist/turn_summary.js.map +1 -0
  42. package/dist/ui/box.d.ts +19 -0
  43. package/dist/ui/box.d.ts.map +1 -1
  44. package/dist/ui/box.js +119 -12
  45. package/dist/ui/box.js.map +1 -1
  46. package/dist/ui/chart.d.ts +2 -0
  47. package/dist/ui/chart.d.ts.map +1 -0
  48. package/dist/ui/chart.js +97 -0
  49. package/dist/ui/chart.js.map +1 -0
  50. package/dist/ui/composer.d.ts +80 -0
  51. package/dist/ui/composer.d.ts.map +1 -0
  52. package/dist/ui/composer.js +762 -0
  53. package/dist/ui/composer.js.map +1 -0
  54. package/dist/ui/help_content.d.ts +1 -1
  55. package/dist/ui/help_content.d.ts.map +1 -1
  56. package/dist/ui/help_content.js +4 -2
  57. package/dist/ui/help_content.js.map +1 -1
  58. package/dist/ui/model_selector.d.ts +1 -0
  59. package/dist/ui/model_selector.d.ts.map +1 -1
  60. package/dist/ui/model_selector.js +61 -18
  61. package/dist/ui/model_selector.js.map +1 -1
  62. package/dist/ui/popup.d.ts.map +1 -1
  63. package/dist/ui/popup.js +7 -0
  64. package/dist/ui/popup.js.map +1 -1
  65. package/dist/ui/provider_connect.d.ts +8 -0
  66. package/dist/ui/provider_connect.d.ts.map +1 -1
  67. package/dist/ui/provider_connect.js +81 -23
  68. package/dist/ui/provider_connect.js.map +1 -1
  69. package/dist/ui/renderer.d.ts +49 -4
  70. package/dist/ui/renderer.d.ts.map +1 -1
  71. package/dist/ui/renderer.js +167 -31
  72. package/dist/ui/renderer.js.map +1 -1
  73. package/dist/ui/terminal_approval.d.ts +21 -0
  74. package/dist/ui/terminal_approval.d.ts.map +1 -1
  75. package/dist/ui/terminal_approval.js +145 -47
  76. package/dist/ui/terminal_approval.js.map +1 -1
  77. package/dist/ui/welcome.d.ts +223 -68
  78. package/dist/ui/welcome.d.ts.map +1 -1
  79. package/dist/ui/welcome.js +564 -687
  80. package/dist/ui/welcome.js.map +1 -1
  81. package/dist/vendor/providers/presets.d.ts +16 -0
  82. package/dist/vendor/providers/presets.d.ts.map +1 -1
  83. package/dist/vendor/providers/presets.js +13 -0
  84. package/dist/vendor/providers/presets.js.map +1 -1
  85. package/dist/vendor/tools/checkpoints.js +5 -5
  86. package/dist/vendor/tools/checkpoints.js.map +1 -1
  87. package/dist/vendor/tools/jail.d.ts +8 -0
  88. package/dist/vendor/tools/jail.d.ts.map +1 -1
  89. package/dist/vendor/tools/jail.js +13 -2
  90. package/dist/vendor/tools/jail.js.map +1 -1
  91. package/dist/vendor/tools/tools/apply_patch.d.ts.map +1 -1
  92. package/dist/vendor/tools/tools/apply_patch.js +6 -3
  93. package/dist/vendor/tools/tools/apply_patch.js.map +1 -1
  94. package/dist/vendor/tools/tools/git_diff.d.ts.map +1 -1
  95. package/dist/vendor/tools/tools/git_diff.js +5 -3
  96. package/dist/vendor/tools/tools/git_diff.js.map +1 -1
  97. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import fs from 'node:fs';
2
- import readline from 'node:readline';
3
- import { displayWidth } from './box.js';
2
+ import { displayWidth, sliceTailToWidth, sliceToWidth, truncateToWidth } from './box.js';
3
+ import { renderChartBlock } from './chart.js';
4
+ import { SUMMARY_FENCE } from '../turn_summary.js';
4
5
  import { closeHttpConnections } from '../http_shutdown.js';
5
6
  import { overlayCentered, dimLines, shadowUnder } from './popup.js';
6
- import { activeMentionToken, filterMentionCandidates } from './file_mentions.js';
7
7
  import { getHelpCommandGroups } from './help_content.js';
8
8
  export const MODERADO_ASCII_LOGO = [
9
9
  '\x1b[38;5;75m __ _______ ____ __________ ___ ____ ____ \x1b[0m',
@@ -20,7 +20,18 @@ export function stripAnsi(str) {
20
20
  function visibleLen(str) {
21
21
  return displayWidth(str);
22
22
  }
23
- 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) {
24
35
  const capacity = Math.max(1, width - 3);
25
36
  const start = Math.max(0, Math.min(caret, input.length - capacity));
26
37
  return { text: input.slice(start, start + capacity), caretColumn: caret - start };
@@ -30,6 +41,11 @@ export function getWelcomeCardWidth(width) {
30
41
  const terminalWidth = width ?? (process.stdout.columns || 80);
31
42
  return Math.max(70, Math.min(terminalWidth - 2, 120));
32
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
+ }
33
49
  /** Left indent that centers a block of `blockWidth` visible columns on the terminal. */
34
50
  export function getWelcomeIndent(blockWidth, width) {
35
51
  const terminalWidth = width ?? (process.stdout.columns || 80);
@@ -52,11 +68,156 @@ function centerBlock(lines, width) {
52
68
  */
53
69
  export function getChatAnswerArea(width) {
54
70
  const terminalWidth = width ?? (process.stdout.columns || 80);
55
- const cardWidth = getWelcomeCardWidth(terminalWidth);
71
+ const cardWidth = getChatCardWidth(terminalWidth);
56
72
  const indent = getWelcomeIndent(cardWidth, terminalWidth);
57
- const textWidth = Math.max(20, cardWidth - 2);
73
+ const textWidth = Math.max(1, cardWidth - 2);
58
74
  return { indent, cardWidth, textWidth, startColumn: indent + 1, wrapWidth: indent + textWidth };
59
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
+ }
60
221
  export function renderQueuedCommandsBox(commands, width) {
61
222
  if (!commands || commands.length === 0)
62
223
  return [];
@@ -84,7 +245,9 @@ export function renderQueuedCommandsBox(commands, width) {
84
245
  return lines;
85
246
  }
86
247
  export function renderWelcomeCard(options) {
87
- const width = getWelcomeCardWidth(options.width);
248
+ const width = options.chatQuestion === undefined
249
+ ? getWelcomeCardWidth(options.width)
250
+ : getChatCardWidth(options.width);
88
251
  const indent = ' '.repeat(getWelcomeIndent(width, options.width));
89
252
  const displayInput = options.input && options.input.length > 0
90
253
  ? composerInputWindow(options.input, options.inputCaret ?? options.input.length, width).text
@@ -110,16 +273,26 @@ export function renderWelcomeCard(options) {
110
273
  : processUsage;
111
274
  const processInfo = options.chatQuestion ? `${processPrefix}${processSuffix}` : details.slice(0, modeSpace);
112
275
  const left4 = processInfo ? `\x1b[38;5;244m${processInfo}\x1b[0m` : '';
113
- const right4 = options.mode === 'Plan'
114
- ? '\x1b[1;38;5;75m[Plan]\x1b[0m / Execute (Tab)'
115
- : 'Plan / \x1b[1;38;5;75m[Execute]\x1b[0m (Tab)';
116
- const right4Raw = options.mode === 'Plan' ? '[Plan] / Execute (Tab)' : 'Plan / [Execute] (Tab)';
117
- const spaces4Count = Math.max(1, width - visibleLen(left4) - right4Raw.length);
118
- 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;
119
292
  // Line 5: Working directory (left) ... provider, model, and Auto-approve (right)
120
- const approvalLabelRaw = options.autoApprove
121
- ? 'Auto-approve enabled (Shift+Tab)'
122
- : '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)';
123
296
  const modelSpace = Math.max(8, width - approvalLabelRaw.length - 10);
124
297
  const provider = options.provider?.trim() ?? '';
125
298
  const providerSpace = Math.max(1, modelSpace - 6);
@@ -140,7 +313,23 @@ export function renderWelcomeCard(options) {
140
313
  : '\x1b[38;5;242mAuto-approve off (Shift+Tab)\x1b[0m';
141
314
  const modelLabel = `\x1b[38;5;180m${modelText}\x1b[0m`;
142
315
  const spaces5Count = Math.max(1, width - shortWs.length - modelText.length - approvalLabelRaw.length - 1);
143
- 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;
144
333
  const cardLines = [];
145
334
  const queuedBox = renderQueuedCommandsBox(options.queuedCommands ?? [], width);
146
335
  if (queuedBox.length > 0) {
@@ -261,6 +450,12 @@ export function renderModeradoHeader(width) {
261
450
  ...centerBlock([COMMAND_HINT], width),
262
451
  ].join('\n');
263
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
+ }
264
459
  /** Word-wrap plain text to `maxWidth` visible columns, hard-breaking long words. */
265
460
  export function wrapText(text, maxWidth) {
266
461
  const out = [];
@@ -318,104 +513,388 @@ export function wrapText(text, maxWidth) {
318
513
  export function renderChatScreen(options, height) {
319
514
  const terminalWidth = options.width ?? (process.stdout.columns || 80);
320
515
  const terminalHeight = height ?? process.stdout.rows ?? 24;
321
- 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;
322
523
  const indent = ' '.repeat(area.indent);
323
524
  const lines = [];
324
- lines.push(...renderModeradoHeader(terminalWidth).split('\n'));
525
+ lines.push(renderChatHeader(terminalWidth));
325
526
  lines.push('');
326
527
  // Row 1: the user's question, wrapped onto the same columns as the composer card.
327
528
  const questionMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
328
- for (const [index, questionLine] of wrapText(options.chatQuestion ?? '', area.textWidth - 2).entries()) {
329
- 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}`;
330
536
  lines.push(indent + String.fromCharCode(27) + '[48;5;236m' + questionText + ' '.repeat(Math.max(0, area.cardWidth - visibleLen(questionText))) + String.fromCharCode(27) + '[0m');
331
537
  }
332
538
  const thoughtTime = options.chatThoughtTime ?? 0;
333
539
  const thoughtTimeLabel = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
334
- const chatStatus = !options.chatAnswer?.trim()
335
- ? ''
336
- : options.chatTaskDone
337
- ? `✔ Done · Thought for ${thoughtTimeLabel}`
338
- : 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()
339
564
  ? ''
340
- : `Thought for ${thoughtTimeLabel}`;
341
- lines.push(chatStatus ? indent + `\x1b[38;5;244m${chatStatus.slice(0, Math.max(0, area.cardWidth - 1))}\x1b[0m` : '');
342
- // Answer section: multi-line model answer
343
- 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) {
344
577
  lines.push('');
345
- for (const answerLine of wrapText(options.chatAnswer.trim(), area.textWidth)) {
346
- const heading = answerLine.match(/^\s*(?:#{1,6}\s+([^#]+?)|\*\*(.+?)\*\*)\s*$/);
347
- lines.push(indent + (heading
348
- ? `\x1b[1;38;5;25m${(heading[1] ?? heading[2]).trim()}\x1b[0m`
349
- : `\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)));
350
583
  }
351
584
  }
352
- const composer = renderWelcomeCard(options).split('\n');
353
- 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;
354
593
  lines.push(...composer);
355
594
  return lines.join('\n') + '\n';
356
595
  }
357
- /** Place the terminal cursor on the first row of the bottom composer. */
358
- export function renderChatComposerCursor(options, inputLength) {
359
- const width = getWelcomeCardWidth(options.width);
360
- const column = getWelcomeIndent(width, options.width) + 2 + Math.min(inputLength, width - 3);
361
- 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`;
362
608
  }
363
609
  export function navigateQuestionHistory(history, direction, index, draft) {
364
610
  const nextIndex = Math.max(0, Math.min(history.length, index + direction));
365
611
  return { input: nextIndex === history.length ? draft : history[nextIndex], index: nextIndex, draft };
366
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
+ }
367
755
  /**
368
756
  * Update the fixed chat elapsed-time row without repainting the terminal.
369
757
  * With an activity, the row is a live "still running" indicator that stays
370
758
  * visible after text starts streaming and during silent tool execution.
371
759
  */
372
- export function renderChatThoughtTimeUpdate(thoughtTime, taskStep, width, activity) {
760
+ export function renderChatThoughtTimeUpdate(thoughtTime, width, activity, row = 13) {
373
761
  const area = getChatAnswerArea(width);
374
- const label = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
375
- const stepText = taskStep && taskStep.maxSteps > 0 && taskStep.step >= 0
376
- ? `Step ${taskStep.step}/${taskStep.maxSteps}`
377
- : undefined;
378
- const status = (activity
379
- ? [`${activity.glyph} ${activity.label}`, stepText, label].filter(Boolean).join(' · ')
380
- : stepText ? `Processing task… ${stepText} · ${label}` : `Thought for ${label}`).slice(0, area.cardWidth);
381
- 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}`);
382
786
  }
383
787
  /**
384
- * Write one streamed fragment into the answer area without repainting the chat
385
- * frame. Wrapped lines return to `startColumn` so the answer keeps the same
386
- * 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.
387
792
  */
388
- export function renderChatAnswerDelta(delta, start, width, startColumn = 1) {
389
- let row = start.row;
390
- let column = start.column;
391
- let writeColumn = start.column;
392
- let sequence = '';
393
- let line = '';
394
- const flush = () => {
395
- if (line.length === 0)
396
- return;
397
- sequence += `\x1b[${row};${writeColumn}H\x1b[38;5;253m${line}\x1b[0m`;
398
- line = '';
399
- };
400
- for (const character of delta) {
401
- if (character === '\n') {
402
- flush();
403
- row++;
404
- column = startColumn;
405
- writeColumn = startColumn;
406
- continue;
407
- }
408
- line += character;
409
- column++;
410
- if (column > width) {
411
- flush();
412
- row++;
413
- column = startColumn;
414
- writeColumn = startColumn;
415
- }
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}`);
832
+ }
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] ?? ''}`;
416
896
  }
417
- flush();
418
- return { sequence, row, column };
897
+ return sequence + '\x1b8';
419
898
  }
420
899
  export function renderFullWelcomeScreen(options, height) {
421
900
  if (options.chatQuestion !== undefined) {
@@ -546,608 +1025,6 @@ export async function exitCleanly(message) {
546
1025
  setTimeout(() => process.exit(0), 2000).unref();
547
1026
  return new Promise(() => { });
548
1027
  }
549
- function isMcpCommandInput(value) {
550
- const command = value.trim();
551
- return command === '/mcp' || command.startsWith('/mcp ');
552
- }
553
- /**
554
- * SGR mouse report, e.g. "\x1b[<0;12;5M" for a primary-button press on column 12
555
- * of row 5. readline splits that escape into several keypress events ("0", ";",
556
- * "5", "M"), so the raw data chunk is parsed instead of the derived keystrokes.
557
- */
558
- const MOUSE_REPORT_PATTERN = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
559
- const MOUSE_REPORT_ON = '\x1b[?1000h\x1b[?1006h';
560
- const MOUSE_REPORT_OFF = '\x1b[?1000l\x1b[?1006l';
561
- export async function promptInteractiveTurn(options) {
562
- const stdin = process.stdin;
563
- const stdout = process.stdout;
564
- let currentModel = options.model;
565
- let currentMode = options.initialMode ?? 'Execute';
566
- let currentAutoApprove = options.initialAutoApprove ?? true;
567
- let input = '';
568
- /** Caret index inside `input` (0 .. input.length) - the composer is editable. */
569
- let caret = 0;
570
- /** True while keypress events derived from a mouse report are being dropped. */
571
- let ignoreComposerKeypress = false;
572
- /** Terminal height, with the same fallback the renderer uses. */
573
- const getRows = () => stdout.rows || 24;
574
- let commandSelection = 0;
575
- const questionHistory = options.questionHistory ?? [];
576
- let questionHistoryIndex = questionHistory.length;
577
- let questionHistoryDraft = '';
578
- let cachedFiles = [];
579
- if (options.onMentionComplete) {
580
- Promise.resolve(options.onMentionComplete('')).then((files) => {
581
- cachedFiles = Array.isArray(files) ? files : [];
582
- updateMentions();
583
- }).catch(() => { cachedFiles = []; });
584
- }
585
- let mentionFiles = [];
586
- let mentionSelection = 0;
587
- const updateMentions = () => {
588
- const mention = activeMentionToken(input, caret);
589
- if (mention && cachedFiles.length > 0) {
590
- mentionFiles = filterMentionCandidates(cachedFiles, mention.token, 5);
591
- mentionSelection = Math.min(mentionSelection, Math.max(0, mentionFiles.length - 1));
592
- }
593
- else {
594
- mentionFiles = [];
595
- mentionSelection = 0;
596
- }
597
- };
598
- /** Replace the composer text, leaving the caret at the end of the new value. */
599
- const setInput = (value) => {
600
- input = value;
601
- caret = value.length;
602
- };
603
- const getOptions = () => ({
604
- model: currentModel,
605
- provider: options.getProviderName?.(),
606
- tokens: options.tokens,
607
- cost: options.cost,
608
- usageAvailable: options.usageAvailable,
609
- usageEstimated: options.usageEstimated,
610
- workspace: options.workspace,
611
- mode: currentMode,
612
- autoApprove: currentAutoApprove,
613
- input,
614
- inputCaret: caret,
615
- commandSelection,
616
- chatQuestion: options.chatQuestion,
617
- chatAnswer: options.chatAnswer,
618
- chatThoughtTime: options.chatThoughtTime,
619
- chatTaskDone: options.chatTaskDone,
620
- outputTokenRate: options.outputTokenRate,
621
- tokenUsage: options.tokenUsage,
622
- queuedCommands: options.queuedCommands,
623
- isTurnSettled: true,
624
- });
625
- // ── Non-TTY fallback ──────────────────────────────────────────────────────
626
- if (!stdin.isTTY) {
627
- stdout.write('\x1b]0;Moderado\x07');
628
- stdout.write(renderFullWelcomeScreen(getOptions()));
629
- return new Promise((resolve) => {
630
- const rl = readline.createInterface({ input: stdin, output: stdout });
631
- rl.question('\x1b[1;38;5;75m❯\x1b[0m ', (answer) => {
632
- rl.close();
633
- resolve({ text: answer.trim(), mode: currentMode, autoApprove: currentAutoApprove });
634
- });
635
- });
636
- }
637
- // ── TTY Interactive Loop ───────────────────────────────────────────────────
638
- readline.emitKeypressEvents(stdin);
639
- // A previous turn's cleanup() pauses stdin — resume it so keystrokes keep
640
- // flowing after an answer (the main app must never appear dead post-answer).
641
- stdin.resume();
642
- stdin.setRawMode(true);
643
- const getExtraLines = () => mentionFiles.length > 0 ? mentionFiles.length + 2 : 0;
644
- /** Rows between the painted composer line and the terminal's last row. */
645
- const getComposerOffset = () => getWelcomeBottomPadding(getOptions(), getRows()) + 4 + getExtraLines();
646
- /** Screen row and 1-based column of the composer's first text cell. */
647
- const getComposerAnchor = () => ({
648
- row: getRows() - getComposerOffset(),
649
- column: getWelcomeIndent(getWelcomeCardWidth()) + 3,
650
- });
651
- /**
652
- * Move cursor to the ❯ input line (Line 2 inside the centered card).
653
- * Absolute addressing (`row;column H`) works no matter where the cursor
654
- * currently sits — relative "move up N" escapes the input line when
655
- * pressed repeatedly, e.g. browsing history with Left/Right.
656
- */
657
- const positionCursorOnInput = () => {
658
- const { row, column } = getComposerAnchor();
659
- const { caretColumn } = composerInputWindow(input, caret, getWelcomeCardWidth());
660
- stdout.write(`\x1b[1 q\x1b[?25h\x1b[${row};${column + caretColumn}H`);
661
- };
662
- /** Full-screen redraw: clear everything, repaint welcome TUI, reposition cursor. */
663
- const redrawFull = () => {
664
- stdout.write('\x1b[H\x1b[J');
665
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
666
- positionCursorOnInput();
667
- };
668
- /** Repaint the welcome view so it remains centered after every edit or resize. */
669
- const redrawCard = () => {
670
- redrawFull();
671
- };
672
- const onResize = () => redrawFull();
673
- /**
674
- * Place the caret where the user clicked on the composer line. Only a primary
675
- * press is honoured; releases, drags, wheel and other buttons are ignored.
676
- */
677
- const onMouseData = (chunk) => {
678
- const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
679
- let reported = false;
680
- for (const report of text.matchAll(MOUSE_REPORT_PATTERN)) {
681
- reported = true;
682
- if (report[4] !== 'M' || Number(report[1]) !== 0)
683
- continue;
684
- const { row, column } = getComposerAnchor();
685
- if (Number(report[3]) !== row)
686
- continue;
687
- caret = Math.max(0, Math.min(input.length, Number(report[2]) - column));
688
- positionCursorOnInput();
689
- }
690
- if (reported) {
691
- // readline turns the same bytes into keypress events ("0", ";", "5", "M").
692
- ignoreComposerKeypress = true;
693
- setImmediate(() => { ignoreComposerKeypress = false; });
694
- }
695
- };
696
- // Registered before readline's decoder so a report is consumed before it is
697
- // mistaken for typed text.
698
- stdin.prependListener('data', onMouseData);
699
- stdout.on('resize', onResize);
700
- // Initial paint
701
- if (options.isFirstTurn) {
702
- stdout.write('\x1b]0;Moderado\x07');
703
- stdout.write('\x1b[2J\x1b[3J\x1b[H');
704
- }
705
- else {
706
- // After an AI response the screen has content below — clear and repaint the full TUI.
707
- stdout.write('\x1b[H\x1b[J');
708
- }
709
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
710
- positionCursorOnInput();
711
- return new Promise((resolve) => {
712
- const cleanup = () => {
713
- unbindComposerInput();
714
- stdin.removeListener('data', onMouseData);
715
- stdout.removeListener('resize', onResize);
716
- stdout.write(`${MOUSE_REPORT_OFF}\x1b[0 q\x1b[?25h`);
717
- if (stdin.isTTY) {
718
- try {
719
- stdin.setRawMode(false);
720
- }
721
- catch { /* ignore */ }
722
- }
723
- try {
724
- stdin.pause();
725
- }
726
- catch { /* ignore */ }
727
- };
728
- const onAbort = () => {
729
- cleanup();
730
- resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
731
- };
732
- if (options.signal) {
733
- options.signal.addEventListener('abort', onAbort, { once: true });
734
- }
735
- // ── Async keypress handler ────────────────────────────────────────────────
736
- // Using a void async IIFE so we can await async operations (model selector)
737
- // inside a synchronous event listener. For any async branch we first
738
- // remove ourselves from stdin so no duplicate events fire during the await.
739
- const onKeypress = (str, key) => {
740
- void (async () => {
741
- // Keystrokes decoded from a mouse report were already handled above.
742
- if (ignoreComposerKeypress)
743
- return;
744
- if (options.signal?.aborted) {
745
- cleanup();
746
- resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
747
- return;
748
- }
749
- // ── Ctrl+C ────────────────────────────────────────────────────────────
750
- if (key && key.ctrl && key.name === 'c') {
751
- cleanup();
752
- return await exitCleanly('\x1b[33mSession cancelled.\x1b[0m');
753
- }
754
- // ── /help overlay popup ───────────────────────────────────────────────
755
- // Triggered when Enter is pressed with "/help" already in the input box.
756
- // We keep the main TUI as background and layer the help box on top.
757
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/help') {
758
- setInput('');
759
- unbindComposerInput(); // pause main handler
760
- // Paint: full welcome TUI (background) + help box on top
761
- const helpLines = renderHelpPopupBox(options.version ?? 'v0.1', options.workspace);
762
- stdout.write('\x1b[H\x1b[J');
763
- stdout.write(renderWelcomePopupLayer(getOptions(), helpLines, stdout.columns, stdout.rows));
764
- // Wait for Esc / Enter / q to dismiss
765
- await new Promise((dismissResolve) => {
766
- const onDismiss = (s, k) => {
767
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
768
- s === 'q' || s === 'Q' ||
769
- (k && k.ctrl && k.name === 'c')) {
770
- stdin.removeListener('keypress', onDismiss);
771
- dismissResolve();
772
- }
773
- };
774
- stdin.on('keypress', onDismiss);
775
- });
776
- // Restore: repaint the full welcome TUI, show cursor, position on input line
777
- stdout.write('\x1b[H\x1b[J');
778
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
779
- positionCursorOnInput();
780
- bindComposerInput(); // re-attach main handler
781
- return;
782
- }
783
- // ── /model overlay popup ──────────────────────────────────────────────
784
- // Triggered when Enter is pressed with "/model" in the input box.
785
- // We temporarily detach the main keypress listener and hand the model
786
- // selector a drawFrame callback. drawFrame repaints the background TUI
787
- // exactly as-is and composites the popup content as a centered floating
788
- // layer on top of it (Cline/OpenCode style) — the popup never scrolls
789
- // the background because every step redraws the whole frame.
790
- // After it returns, we repaint the full welcome TUI and reposition cursor.
791
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/model') {
792
- setInput('');
793
- unbindComposerInput(); // pause main handler
794
- if (options.onModelSelect) {
795
- const drawFrame = (popupLines) => {
796
- stdout.write('\x1b[H\x1b[J');
797
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
798
- };
799
- const newId = await options.onModelSelect(drawFrame);
800
- if (newId && newId !== currentModel) {
801
- currentModel = newId;
802
- }
803
- const autoContinuePrompt = options.takeAutoContinuePrompt?.();
804
- if (autoContinuePrompt) {
805
- options.signal?.removeEventListener('abort', onAbort);
806
- cleanup();
807
- resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
808
- return;
809
- }
810
- }
811
- // Selection flow left stdin in non-raw mode — restore it for our TUI
812
- readline.emitKeypressEvents(stdin);
813
- stdin.resume();
814
- stdin.setRawMode(true);
815
- stdout.write('\x1b[H\x1b[J');
816
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
817
- positionCursorOnInput();
818
- bindComposerInput(); // re-attach main handler
819
- return;
820
- }
821
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/connect') {
822
- setInput('');
823
- unbindComposerInput();
824
- const drawFrame = (popupLines) => {
825
- stdout.write('\x1b[H\x1b[J');
826
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
827
- };
828
- if (options.onConnect) {
829
- const newModel = await options.onConnect(drawFrame);
830
- if (newModel)
831
- currentModel = newModel;
832
- }
833
- readline.emitKeypressEvents(stdin);
834
- stdin.resume();
835
- stdin.setRawMode(true);
836
- stdout.write('\x1b[H\x1b[J');
837
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
838
- positionCursorOnInput();
839
- bindComposerInput();
840
- return;
841
- }
842
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/session')) {
843
- const command = input.trim();
844
- setInput('');
845
- unbindComposerInput();
846
- if (options.onSession) {
847
- await options.onSession(command, (popupLines) => {
848
- stdout.write('\x1b[H\x1b[J');
849
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
850
- });
851
- }
852
- const autoContinuePrompt = options.takeAutoContinuePrompt?.();
853
- if (autoContinuePrompt) {
854
- options.signal?.removeEventListener('abort', onAbort);
855
- cleanup();
856
- resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
857
- return;
858
- }
859
- readline.emitKeypressEvents(stdin);
860
- stdin.resume();
861
- stdin.setRawMode(true);
862
- stdout.write('\x1b[H\x1b[J');
863
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
864
- positionCursorOnInput();
865
- bindComposerInput();
866
- return;
867
- }
868
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/queue')) {
869
- const command = input.trim();
870
- setInput('');
871
- unbindComposerInput();
872
- if (options.onQueue) {
873
- await options.onQueue(command, async (popupLines) => {
874
- stdout.write('\x1b[H\x1b[J');
875
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
876
- await new Promise((dismissResolve) => {
877
- const onDismiss = (s, k) => {
878
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
879
- s === 'q' || s === 'Q' || s === '\x1b') {
880
- stdin.removeListener('keypress', onDismiss);
881
- dismissResolve();
882
- }
883
- };
884
- stdin.on('keypress', onDismiss);
885
- });
886
- });
887
- }
888
- readline.emitKeypressEvents(stdin);
889
- stdin.resume();
890
- stdin.setRawMode(true);
891
- stdout.write('\x1b[H\x1b[J');
892
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
893
- positionCursorOnInput();
894
- bindComposerInput();
895
- return;
896
- }
897
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/btw')) {
898
- const command = input.trim();
899
- setInput('');
900
- unbindComposerInput();
901
- if (options.onBtw) {
902
- const ac = new AbortController();
903
- const onBtwKey = (s, k) => {
904
- if ((k && k.name === 'escape') || s === '\x1b') {
905
- ac.abort();
906
- }
907
- };
908
- stdin.on('keypress', onBtwKey);
909
- try {
910
- await options.onBtw(command, async (popupLines, frameOpts) => {
911
- stdout.write('\x1b[H\x1b[J');
912
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
913
- if (frameOpts?.waitDismiss !== false && !ac.signal.aborted) {
914
- await new Promise((dismissResolve) => {
915
- const onDismiss = (s, k) => {
916
- if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
917
- s === 'q' || s === 'Q' || s === '\x1b') {
918
- stdin.removeListener('keypress', onDismiss);
919
- dismissResolve();
920
- }
921
- };
922
- stdin.on('keypress', onDismiss);
923
- });
924
- }
925
- }, ac.signal);
926
- }
927
- finally {
928
- stdin.removeListener('keypress', onBtwKey);
929
- }
930
- }
931
- readline.emitKeypressEvents(stdin);
932
- stdin.resume();
933
- stdin.setRawMode(true);
934
- stdout.write('\x1b[H\x1b[J');
935
- stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
936
- positionCursorOnInput();
937
- bindComposerInput();
938
- return;
939
- }
940
- if (key && (key.name === 'return' || key.name === 'enter') && isMcpCommandInput(input)) {
941
- const command = input.trim();
942
- setInput('');
943
- unbindComposerInput();
944
- if (options.onMcp) {
945
- await options.onMcp(command, (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() === '/init') {
960
- setInput('');
961
- unbindComposerInput();
962
- if (options.onInit) {
963
- await options.onInit((popupLines) => {
964
- stdout.write('\x1b[H\x1b[J');
965
- stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
966
- });
967
- }
968
- if (options.signal?.aborted)
969
- return;
970
- readline.emitKeypressEvents(stdin);
971
- stdin.resume();
972
- stdin.setRawMode(true);
973
- redrawFull();
974
- bindComposerInput();
975
- return;
976
- }
977
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/workflow')) {
978
- const command = input.trim();
979
- setInput('');
980
- unbindComposerInput();
981
- 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;
982
- if (action === 'build') {
983
- cleanup();
984
- resolve({ text: '', mode: 'Execute', autoApprove: currentAutoApprove, workflowAction: 'build' });
985
- return;
986
- }
987
- readline.emitKeypressEvents(stdin);
988
- stdin.resume();
989
- stdin.setRawMode(true);
990
- redrawFull();
991
- bindComposerInput();
992
- return;
993
- }
994
- // ── /clear ────────────────────────────────────────────────────────────
995
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/clear') {
996
- setInput('');
997
- options.onClear?.();
998
- redrawFull();
999
- return;
1000
- }
1001
- // ── /exit ─────────────────────────────────────────────────────────────
1002
- if (key && (key.name === 'return' || key.name === 'enter') &&
1003
- (input.trim() === '/exit' || input.trim() === '/quit')) {
1004
- cleanup();
1005
- if (options.signal)
1006
- options.signal.removeEventListener('abort', onAbort);
1007
- return await exitCleanly(options.onExit?.() ?? '\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m');
1008
- }
1009
- // ── Normal keys ───────────────────────────────────────────────────────
1010
- if (!key) {
1011
- if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
1012
- input = input.slice(0, caret) + str + input.slice(caret);
1013
- caret += 1;
1014
- updateMentions();
1015
- redrawCard();
1016
- }
1017
- return;
1018
- }
1019
- // Shift+Tab — toggle auto-approve
1020
- if ((key.name === 'tab' && key.shift) || key.sequence === '\x1b[Z') {
1021
- currentAutoApprove = !currentAutoApprove;
1022
- redrawCard();
1023
- return;
1024
- }
1025
- // Tab — autocomplete mention, autocomplete slash command or toggle mode
1026
- if (key.name === 'tab' && !key.shift) {
1027
- const mention = activeMentionToken(input, caret);
1028
- if (mention && mentionFiles.length > 0) {
1029
- const chosen = mentionFiles[Math.min(mentionSelection, mentionFiles.length - 1)];
1030
- const before = input.slice(0, mention.start);
1031
- const after = input.slice(caret);
1032
- const insert = `@${chosen} `;
1033
- input = before + insert + after;
1034
- caret = before.length + insert.length;
1035
- updateMentions();
1036
- redrawCard();
1037
- return;
1038
- }
1039
- if (input.startsWith('/')) {
1040
- const matching = getMatchingCommands(input);
1041
- if (matching.length > 0) {
1042
- setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
1043
- commandSelection = 0;
1044
- redrawCard();
1045
- return;
1046
- }
1047
- }
1048
- currentMode = currentMode === 'Plan' ? 'Execute' : 'Plan';
1049
- redrawCard();
1050
- return;
1051
- }
1052
- if (mentionFiles.length > 0 && (key.name === 'up' || key.name === 'down')) {
1053
- mentionSelection = ((mentionSelection + (key.name === 'up' ? -1 : 1)) % mentionFiles.length + mentionFiles.length) % mentionFiles.length;
1054
- redrawCard();
1055
- return;
1056
- }
1057
- if (input.startsWith('/') && (key.name === 'up' || key.name === 'down')) {
1058
- const matching = getMatchingCommands(input);
1059
- if (matching.length > 0) {
1060
- commandSelection = ((commandSelection + (key.name === 'up' ? -1 : 1)) % matching.length + matching.length) % matching.length;
1061
- redrawCard();
1062
- return;
1063
- }
1064
- }
1065
- if (!input.startsWith('/') && (key.name === 'up' || key.name === 'down') && questionHistory.length > 0) {
1066
- if (questionHistoryIndex === questionHistory.length)
1067
- questionHistoryDraft = input;
1068
- const recalled = navigateQuestionHistory(questionHistory, key.name === 'up' ? -1 : 1, questionHistoryIndex, questionHistoryDraft);
1069
- setInput(recalled.input);
1070
- questionHistoryIndex = recalled.index;
1071
- redrawCard();
1072
- return;
1073
- }
1074
- // Enter — submit real user message
1075
- if (key.name === 'return' || key.name === 'enter') {
1076
- const matching = getMatchingCommands(input);
1077
- if (input.startsWith('/') && matching.length > 0 && !matching.some((command) => command.name === input.trim())) {
1078
- setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
1079
- commandSelection = 0;
1080
- redrawCard();
1081
- return;
1082
- }
1083
- const trimmed = input.trim();
1084
- cleanup();
1085
- if (options.signal)
1086
- options.signal.removeEventListener('abort', onAbort);
1087
- const moveDown = 4 + getExtraLines();
1088
- stdout.write(`\x1b[${moveDown}B\r\n\x1b[0 q\x1b[?25h`);
1089
- resolve({ text: trimmed, mode: currentMode, autoApprove: currentAutoApprove });
1090
- return;
1091
- }
1092
- // Caret movement (cursor-only hop) and forward delete (repaint: the
1093
- // line shrank, so the old trailing character must be cleared).
1094
- if (key.name === 'left' || key.name === 'right' || key.name === 'delete') {
1095
- if (key.name === 'left') {
1096
- caret = Math.max(0, caret - 1);
1097
- updateMentions();
1098
- redrawCard();
1099
- }
1100
- else if (key.name === 'right') {
1101
- caret = Math.min(input.length, caret + 1);
1102
- updateMentions();
1103
- redrawCard();
1104
- }
1105
- else if (caret < input.length) {
1106
- input = input.slice(0, caret) + input.slice(caret + 1);
1107
- updateMentions();
1108
- redrawCard();
1109
- }
1110
- return;
1111
- }
1112
- // Backspace
1113
- if (key.name === 'backspace') {
1114
- if (caret > 0) {
1115
- input = input.slice(0, caret - 1) + input.slice(caret);
1116
- caret = Math.max(0, caret - 1);
1117
- commandSelection = 0;
1118
- updateMentions();
1119
- redrawCard();
1120
- }
1121
- return;
1122
- }
1123
- // Printable character
1124
- if (str && str.length === 1 && str.charCodeAt(0) >= 32 && !key.ctrl && !key.meta) {
1125
- input = input.slice(0, caret) + str + input.slice(caret);
1126
- caret += 1;
1127
- commandSelection = 0;
1128
- questionHistoryIndex = questionHistory.length;
1129
- updateMentions();
1130
- redrawCard();
1131
- }
1132
- })();
1133
- };
1134
- /**
1135
- * Mouse reporting is only useful while the composer owns the keyboard: a
1136
- * click on the composer line places the caret. Popups and sub-prompts take
1137
- * the keyboard over with mouse reporting off, so they keep the terminal's
1138
- * normal text selection, and clicks never leak into their filter fields.
1139
- */
1140
- const bindComposerInput = () => {
1141
- stdout.write(MOUSE_REPORT_ON);
1142
- stdin.on('keypress', onKeypress);
1143
- };
1144
- const unbindComposerInput = () => {
1145
- stdin.removeListener('keypress', onKeypress);
1146
- stdout.write(MOUSE_REPORT_OFF);
1147
- };
1148
- bindComposerInput();
1149
- });
1150
- }
1151
1028
  /** A cumulative task snapshot; a request ending does not mean the task ended. */
1152
1029
  export function formatTokenUsage(event) {
1153
1030
  const marker = event.estimated ? '~' : '';
@@ -1178,7 +1055,7 @@ export function renderGatewayFallbackUpdate(event, width = process.stdout.column
1178
1055
  const area = getChatAnswerArea(width);
1179
1056
  const destination = event.toProvider ? `${event.toProvider}/${event.toModel}` : event.toModel;
1180
1057
  const reason = event.reason === 'rate_limited_or_unavailable' ? 'after an upstream route was rate limited or unavailable' : 'after a fallback';
1181
- const text = `Gateway route changed to ${destination} ${reason}`.slice(0, area.cardWidth);
1058
+ const text = truncateToWidth(`Gateway route changed to ${destination} ${reason}`, area.cardWidth);
1182
1059
  const row = renderModeradoHeader().split('\n').length + 3;
1183
1060
  return `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${text}\x1b[0m\x1b8`;
1184
1061
  }