moderado 0.4.34 → 0.4.50

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 +109 -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 +883 -0
  53. package/dist/ui/composer.js.map +1 -0
  54. package/dist/ui/help_content.d.ts +2 -2
  55. package/dist/ui/help_content.d.ts.map +1 -1
  56. package/dist/ui/help_content.js +5 -3
  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 +274 -69
  78. package/dist/ui/welcome.d.ts.map +1 -1
  79. package/dist/ui/welcome.js +729 -700
  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,13 +1,13 @@
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',
10
- '\x1b[38;5;75m / |/ / __ \\/ __ \\/ ____/ __ \\/ | / __ \\/ __ \\\x1b[0m',
10
+ '\x1b[38;5;75m / |/ / __ \\/ __ \\/ ____/ __ \\/ | / __ \\/ __ \x1b[0m',
11
11
  '\x1b[38;5;81m / /|_/ / / / / / / / __/ / /_/ / /| | / / / / / / /\x1b[0m',
12
12
  '\x1b[38;5;81m / / / / /_/ / /_/ / /___/ _, _/ ___ |/ /_/ / /_/ / \x1b[0m',
13
13
  '\x1b[38;5;87m/_/ /_/\\____/_____/_____/_/ |_/_/ |_/_____/\\____/ \x1b[0m',
@@ -20,16 +20,147 @@ export function stripAnsi(str) {
20
20
  function visibleLen(str) {
21
21
  return displayWidth(str);
22
22
  }
23
- function composerInputWindow(input, caret, width) {
24
- const capacity = Math.max(1, width - 3);
25
- const start = Math.max(0, Math.min(caret, input.length - capacity));
26
- return { text: input.slice(start, start + capacity), caretColumn: caret - start };
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
+ /**
35
+ * Ask the terminal to report keys whose legacy encoding is ambiguous
36
+ * (XTMODKEYS level 1), which is the only way Shift+Enter can be told apart from
37
+ * Enter. Level 1 leaves unambiguous keys — plain Ctrl+C, Shift+Tab, shifted
38
+ * letters — on their legacy bytes, so nothing else changes behaviour.
39
+ */
40
+ export const KEY_MODIFIER_REPORT_ON = '\x1b[>4;1m';
41
+ export const KEY_MODIFIER_REPORT_OFF = '\x1b[>4;0m';
42
+ /**
43
+ * Cells a composer row spends on its `❯ ` marker, plus one spare so a full row
44
+ * never reaches the last column (a terminal that wraps on the final cell would
45
+ * push the whole card down a row).
46
+ */
47
+ const COMPOSER_ROW_OVERHEAD = 3;
48
+ /** Split the composer text into the rows the card paints, wrapping at words. */
49
+ export function wrapComposerInput(input, width) {
50
+ const capacity = Math.max(1, width - COMPOSER_ROW_OVERHEAD);
51
+ const rows = [];
52
+ let segmentStart = 0;
53
+ for (;;) {
54
+ const newline = input.indexOf('\n', segmentStart);
55
+ const segmentEnd = newline === -1 ? input.length : newline;
56
+ if (segmentStart === segmentEnd) {
57
+ // An empty line (a bare line feed, or the tail after a trailing one) is
58
+ // still a row: the caret has to have somewhere to sit.
59
+ rows.push({ text: '', start: segmentStart, end: segmentStart });
60
+ }
61
+ else {
62
+ let cursor = segmentStart;
63
+ while (cursor < segmentEnd) {
64
+ let end = cursor;
65
+ let cells = 0;
66
+ let lastSpace = -1;
67
+ while (end < segmentEnd) {
68
+ const char = String.fromCodePoint(input.codePointAt(end));
69
+ const charWidth = displayWidth(char);
70
+ if (cells + charWidth > capacity)
71
+ break;
72
+ cells += charWidth;
73
+ end += char.length;
74
+ if (char === ' ')
75
+ lastSpace = end;
76
+ }
77
+ if (end >= segmentEnd) {
78
+ rows.push({ text: input.slice(cursor, segmentEnd), start: cursor, end: segmentEnd });
79
+ break;
80
+ }
81
+ // Break after the last space so words stay whole; the space itself is
82
+ // swallowed by the break rather than trailing the row. A row that would
83
+ // come out empty (a space at the wrap point) falls through to a hard
84
+ // break instead of emitting a blank row.
85
+ if (lastSpace - 1 > cursor) {
86
+ rows.push({ text: input.slice(cursor, lastSpace - 1), start: cursor, end: lastSpace - 1 });
87
+ cursor = lastSpace;
88
+ }
89
+ else {
90
+ rows.push({ text: input.slice(cursor, end), start: cursor, end });
91
+ cursor = end;
92
+ }
93
+ }
94
+ }
95
+ if (newline === -1)
96
+ return rows;
97
+ segmentStart = newline + 1;
98
+ }
99
+ }
100
+ /**
101
+ * Resolve the composer text into painted rows plus the caret's place in them.
102
+ * The window follows the caret, so a long prompt scrolls inside the box instead
103
+ * of growing the card past the bottom of the terminal.
104
+ */
105
+ export function composerView(input, caret, width, maxRows = Number.POSITIVE_INFINITY) {
106
+ const all = wrapComposerInput(input, width);
107
+ const clampedCaret = Math.max(0, Math.min(caret, input.length));
108
+ // The last row whose slice contains the caret: at a hard wrap the caret
109
+ // belongs to the row that starts there, at a swallowed space to the row that
110
+ // ends there.
111
+ let caretRow = 0;
112
+ for (let index = 0; index < all.length; index += 1) {
113
+ if (all[index].start <= clampedCaret && clampedCaret <= all[index].end)
114
+ caretRow = index;
115
+ }
116
+ const limit = Math.max(1, Math.floor(maxRows));
117
+ const start = Math.max(0, Math.min(caretRow - limit + 1, all.length - limit));
118
+ const rows = all.slice(start, start + limit);
119
+ const row = all[caretRow];
120
+ return {
121
+ rows,
122
+ caretRow: caretRow - start,
123
+ caretColumn: Math.min(Math.max(1, width - COMPOSER_ROW_OVERHEAD), displayWidth(input.slice(row.start, clampedCaret))),
124
+ hiddenAbove: start,
125
+ hiddenBelow: all.length - start - rows.length,
126
+ };
127
+ }
128
+ /** Caret index for a click `cells` into a painted row. */
129
+ export function composerCaretIndex(input, rows, rowOffset, cells) {
130
+ const row = rows[Math.max(0, Math.min(rows.length - 1, rowOffset))];
131
+ let index = row.start;
132
+ let used = 0;
133
+ while (index < row.end) {
134
+ const char = String.fromCodePoint(input.codePointAt(index));
135
+ const charWidth = displayWidth(char);
136
+ if (used + charWidth > cells)
137
+ break;
138
+ used += charWidth;
139
+ index += char.length;
140
+ }
141
+ return index;
142
+ }
143
+ /**
144
+ * Input rows the composer may show before it scrolls in place.
145
+ *
146
+ * The chat frame needs eight rows above the card (header, question, status,
147
+ * answer) and the card keeps four rows of its own furniture, so anything taller
148
+ * than this would scroll the composer off the screen.
149
+ */
150
+ export function getComposerInputMaxRows(height) {
151
+ const terminalHeight = height ?? process.stdout.rows ?? 24;
152
+ return Math.max(1, terminalHeight - 13);
27
153
  }
28
154
  /** Card width: nearly full terminal width, like OpenCode/Cline. */
29
155
  export function getWelcomeCardWidth(width) {
30
156
  const terminalWidth = width ?? (process.stdout.columns || 80);
31
157
  return Math.max(70, Math.min(terminalWidth - 2, 120));
32
158
  }
159
+ /** Chat fills the available columns; the startup Welcome card keeps its own width. */
160
+ export function getChatCardWidth(width) {
161
+ const terminalWidth = width ?? (process.stdout.columns || 80);
162
+ return Math.max(1, terminalWidth - 2);
163
+ }
33
164
  /** Left indent that centers a block of `blockWidth` visible columns on the terminal. */
34
165
  export function getWelcomeIndent(blockWidth, width) {
35
166
  const terminalWidth = width ?? (process.stdout.columns || 80);
@@ -52,11 +183,156 @@ function centerBlock(lines, width) {
52
183
  */
53
184
  export function getChatAnswerArea(width) {
54
185
  const terminalWidth = width ?? (process.stdout.columns || 80);
55
- const cardWidth = getWelcomeCardWidth(terminalWidth);
186
+ const cardWidth = getChatCardWidth(terminalWidth);
56
187
  const indent = getWelcomeIndent(cardWidth, terminalWidth);
57
- const textWidth = Math.max(20, cardWidth - 2);
188
+ const textWidth = Math.max(1, cardWidth - 2);
58
189
  return { indent, cardWidth, textWidth, startColumn: indent + 1, wrapWidth: indent + textWidth };
59
190
  }
191
+ /** The chat header is a single compact brand row. */
192
+ export const CHAT_HEADER_ROWS = 1;
193
+ /** Rows the composer card occupies: blank surface, input, blank surface, mode, workspace. */
194
+ export const CHAT_COMPOSER_ROWS = 5;
195
+ /**
196
+ * Fixed row geometry for the chat window. The answer area is bounded by the
197
+ * composer: a long answer is windowed instead of pushing the input card, the
198
+ * mode row, and the workspace row off screen (or painting over them).
199
+ */
200
+ export function getChatLayout(width, height, question = '', composerRows = CHAT_COMPOSER_ROWS) {
201
+ const terminalWidth = width ?? (process.stdout.columns || 80);
202
+ // Honor the real viewport: a hard floor here would emit a frame taller than a
203
+ // short terminal and scroll the composer away. A very short terminal simply
204
+ // shows fewer sections (the composer always wins — see renderChatScreen).
205
+ const terminalHeight = Math.max(2, height ?? (process.stdout.rows || 24));
206
+ const area = getChatAnswerArea(terminalWidth);
207
+ const wrappedQuestion = wrapText(question, Math.max(1, area.textWidth - 2)).length;
208
+ const questionTop = CHAT_HEADER_ROWS + 2;
209
+ // Rows above the answer: header, blank, question rows, status, blank.
210
+ const answerTopOffset = CHAT_HEADER_ROWS + 4;
211
+ // The frame has to end on the second-to-last row: a trailing newline written
212
+ // on the bottom row scrolls the viewport and drags the composer out of place.
213
+ // So the composer is pinned to the viewport bottom and keeps every row it
214
+ // needs — a queued-commands box makes it taller, and the answer area gives up
215
+ // those rows instead of pushing the card off screen.
216
+ const composerTop = Math.max(2, terminalHeight - Math.max(CHAT_COMPOSER_ROWS, composerRows));
217
+ // A pathological question must never starve the answer or the composer; when
218
+ // a tall composer leaves no room at all the question is dropped, not the card.
219
+ const questionRows = Math.max(0, Math.min(wrappedQuestion, composerTop - answerTopOffset - 2));
220
+ const statusRow = questionTop + questionRows;
221
+ const answerTop = statusRow + 2;
222
+ return {
223
+ area,
224
+ questionTop,
225
+ questionRows,
226
+ statusRow,
227
+ answerTop,
228
+ answerRows: Math.max(0, composerTop - 1 - answerTop),
229
+ composerTop,
230
+ terminalWidth,
231
+ };
232
+ }
233
+ /** Rows the composer card occupies for a given layout, including any queued-commands box. */
234
+ export function getChatComposerRows(options) {
235
+ return renderWelcomeCard(options).split('\n').length;
236
+ }
237
+ /** Invisible prefix that marks a rendered line as part of the turn summary. */
238
+ const SUMMARY_MARK = '\u0001';
239
+ /**
240
+ * Wrap an answer for the card, marking the turn summary as it goes.
241
+ *
242
+ * The fence has to be resolved *before* wrapping, not after: a summary line that
243
+ * wraps would lose its marker on the continuation, and a block whose opening
244
+ * marker scrolled off the top would stop being styled exactly when it is
245
+ * longest. Every wrapped line inside the fence carries `SUMMARY_MARK`, so the
246
+ * renderer can tint any row on its own.
247
+ */
248
+ export function wrapAnswerLines(text, textWidth) {
249
+ const lines = [];
250
+ let inSummary = false;
251
+ const answerLines = text.split('\n');
252
+ for (let index = 0; index < answerLines.length; index += 1) {
253
+ const rawLine = answerLines[index];
254
+ const trimmed = rawLine.trim();
255
+ if (trimmed === '```chart') {
256
+ const closing = answerLines.findIndex((line, lineIndex) => lineIndex > index && line.trim() === '```');
257
+ if (closing !== -1) {
258
+ const chartLines = renderChartBlock(answerLines.slice(index + 1, closing).join('\n'), textWidth);
259
+ if (chartLines) {
260
+ lines.push(...chartLines);
261
+ index = closing;
262
+ continue;
263
+ }
264
+ }
265
+ }
266
+ if (!inSummary && trimmed === SUMMARY_FENCE) {
267
+ inSummary = true;
268
+ continue;
269
+ }
270
+ if (inSummary && trimmed === '```') {
271
+ inSummary = false;
272
+ continue;
273
+ }
274
+ if (!inSummary) {
275
+ lines.push(...wrapText(rawLine, textWidth));
276
+ continue;
277
+ }
278
+ // The rail and its gap cost two cells, so summary prose wraps two cells early.
279
+ for (const wrapped of wrapText(rawLine, Math.max(8, textWidth - 2)))
280
+ lines.push(SUMMARY_MARK + wrapped);
281
+ }
282
+ return lines;
283
+ }
284
+ /**
285
+ * Window the answer around an offset, measured in rows scrolled up from the
286
+ * tail. `offset = 0` is the live view (newest rows). One row is reserved for the
287
+ * "earlier lines hidden" note whenever anything is above the window.
288
+ */
289
+ export function windowChatAnswerAt(text, maxRows, textWidth, offsetFromBottom = 0) {
290
+ const wrapped = wrapAnswerLines(text.trimEnd(), textWidth);
291
+ if (wrapped.length <= maxRows)
292
+ return { lines: wrapped, hiddenAbove: 0, hiddenBelow: 0 };
293
+ // The note costs a row, so the content capacity is one row smaller — and the
294
+ // maximum offset follows from that capacity, not from the raw row count.
295
+ const capacity = Math.max(1, maxRows - 1);
296
+ const maxOffset = Math.max(0, wrapped.length - capacity);
297
+ const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
298
+ const end = wrapped.length - offset;
299
+ const start = Math.max(0, end - capacity);
300
+ return { lines: wrapped.slice(start, end), hiddenAbove: start, hiddenBelow: wrapped.length - end };
301
+ }
302
+ /**
303
+ * Style one answer row.
304
+ *
305
+ * Inside the turn-summary fence every row becomes a band: a rail in the accent
306
+ * colour, a tinted background that reaches the right edge of the card, and
307
+ * headings in warm bold. This is what makes the report read as a different kind
308
+ * of thing from the prose above it, rather than three more paragraphs.
309
+ */
310
+ export function renderAnswerLine(line, textWidth) {
311
+ // Activity-log rows share the window with the answer but read as a record of
312
+ // what happened: dim, no rail, no band. One function, two kinds of row.
313
+ if (line.startsWith(LOG_MARK)) {
314
+ return `\x1b[38;5;244m${truncateToWidth(line.slice(LOG_MARK.length), textWidth)}\x1b[0m`;
315
+ }
316
+ if (!line.startsWith(SUMMARY_MARK)) {
317
+ const heading = line.match(/^\s*(?:#{1,6}\s+([^#]+?)|\*\*(.+?)\*\*)\s*$/);
318
+ return heading
319
+ ? `\x1b[1;38;5;25m${(heading[1] ?? heading[2]).trim()}\x1b[0m`
320
+ : `\x1b[38;5;253m${line}\x1b[0m`;
321
+ }
322
+ const body = line.slice(SUMMARY_MARK.length);
323
+ const heading = body.match(/^#{1,6}\s+(.+)$/);
324
+ const bullet = body.match(/^[-*]\s+(.+)$/);
325
+ const text = heading ? heading[1].trim() : bullet ? `· ${bullet[1].trim()}` : body;
326
+ const tone = heading ? '1;38;5;222' : '38;5;253';
327
+ // Pad to the card so the tint is a solid band instead of a ragged highlight.
328
+ const padded = `${text}${' '.repeat(Math.max(0, textWidth - 2 - displayWidth(text)))}`;
329
+ return `\x1b[48;5;236m\x1b[38;5;75m▍\x1b[0;48;5;236m\x1b[${tone}m ${padded}\x1b[0m`;
330
+ }
331
+ export function chatAnswerOverflowNote(hidden) {
332
+ // Short on purpose: the status row carries the scroll position, and this row
333
+ // has to fit a 70-column card without wrapping.
334
+ return `… +${hidden} earlier line${hidden === 1 ? '' : 's'} hidden · PgUp`;
335
+ }
60
336
  export function renderQueuedCommandsBox(commands, width) {
61
337
  if (!commands || commands.length === 0)
62
338
  return [];
@@ -84,13 +360,18 @@ export function renderQueuedCommandsBox(commands, width) {
84
360
  return lines;
85
361
  }
86
362
  export function renderWelcomeCard(options) {
87
- const width = getWelcomeCardWidth(options.width);
363
+ const width = options.chatQuestion === undefined
364
+ ? getWelcomeCardWidth(options.width)
365
+ : getChatCardWidth(options.width);
88
366
  const indent = ' '.repeat(getWelcomeIndent(width, options.width));
89
- const displayInput = options.input && options.input.length > 0
90
- ? composerInputWindow(options.input, options.inputCaret ?? options.input.length, width).text
91
- : '\x1b[38;5;242mAsk anything, I am all ears...\x1b[0m';
367
+ const inputText = options.input ?? '';
368
+ const inputView = composerView(inputText, options.inputCaret ?? inputText.length, width, options.maxInputRows ?? getComposerInputMaxRows());
369
+ // The placeholder owns the first row only; a typed prompt is wrapped into as
370
+ // many rows as it needs, so the box grows instead of scrolling sideways.
371
+ const inputRows = inputText.length > 0
372
+ ? inputView.rows.map((row) => row.text)
373
+ : ['\x1b[38;5;242mAsk anything, I am all ears...\x1b[0m'];
92
374
  const promptMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
93
- const textBox = `${promptMarker} ${displayInput}`;
94
375
  const surface = '\x1b[48;5;236m';
95
376
  const surfaceLine = (content = '') => {
96
377
  const preserved = content.replace(/\x1b\[0m/g, '\x1b[0;48;5;236m');
@@ -110,16 +391,26 @@ export function renderWelcomeCard(options) {
110
391
  : processUsage;
111
392
  const processInfo = options.chatQuestion ? `${processPrefix}${processSuffix}` : details.slice(0, modeSpace);
112
393
  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;
394
+ const compactChatFooter = options.chatQuestion !== undefined && width < 72;
395
+ const right4Raw = compactChatFooter
396
+ ? options.mode === 'Plan' ? '[Plan]' : '[Execute]'
397
+ : options.mode === 'Plan' ? '[Plan] / Execute (Tab)' : 'Plan / [Execute] (Tab)';
398
+ const compactMode = right4Raw.length > width ? (options.mode === 'Plan' ? 'P' : 'E') : right4Raw;
399
+ const right4 = compactChatFooter
400
+ ? `\x1b[1;38;5;75m${compactMode}\x1b[0m`
401
+ : options.mode === 'Plan'
402
+ ? '\x1b[1;38;5;75m[Plan]\x1b[0m / Execute (Tab)'
403
+ : 'Plan / \x1b[1;38;5;75m[Execute]\x1b[0m (Tab)';
404
+ const right4Visible = compactChatFooter ? compactMode : right4Raw;
405
+ const left4Fit = compactChatFooter
406
+ ? truncateToWidth(processInfo, Math.max(0, width - right4Visible.length - 1))
407
+ : left4;
408
+ const spaces4Count = Math.max(0, width - visibleLen(left4Fit) - right4Visible.length);
409
+ const line4 = left4Fit + ' '.repeat(spaces4Count) + right4;
119
410
  // 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)';
411
+ const approvalLabelRaw = compactChatFooter
412
+ ? width < 20 ? (options.autoApprove ? 'On' : 'Off') : (options.autoApprove ? 'Auto-approve on' : 'Auto-approve off')
413
+ : options.autoApprove ? 'Auto-approve enabled (Shift+Tab)' : 'Auto-approve off (Shift+Tab)';
123
414
  const modelSpace = Math.max(8, width - approvalLabelRaw.length - 10);
124
415
  const provider = options.provider?.trim() ?? '';
125
416
  const providerSpace = Math.max(1, modelSpace - 6);
@@ -140,7 +431,23 @@ export function renderWelcomeCard(options) {
140
431
  : '\x1b[38;5;242mAuto-approve off (Shift+Tab)\x1b[0m';
141
432
  const modelLabel = `\x1b[38;5;180m${modelText}\x1b[0m`;
142
433
  const spaces5Count = Math.max(1, width - shortWs.length - modelText.length - approvalLabelRaw.length - 1);
143
- const line5 = left5 + ' '.repeat(spaces5Count) + modelLabel + ' ' + approvalLabel;
434
+ const line5 = compactChatFooter
435
+ ? (() => {
436
+ const fieldBudget = Math.max(0, width - approvalLabelRaw.length - 2);
437
+ const workspaceBudget = Math.ceil(fieldBudget / 2);
438
+ const modelBudget = fieldBudget - workspaceBudget;
439
+ const workspaceText = sliceTailToWidth(options.workspace, workspaceBudget);
440
+ const modelField = sliceTailToWidth(modelText, modelBudget);
441
+ if (fieldBudget === 0)
442
+ return truncateToWidth(approvalLabel, width);
443
+ const fields = [
444
+ workspaceText ? `\x1b[38;5;245m${workspaceText}\x1b[0m` : '',
445
+ modelField ? `\x1b[38;5;180m${modelField}\x1b[0m` : '',
446
+ `\x1b[38;5;242m${approvalLabelRaw}\x1b[0m`,
447
+ ].filter(Boolean);
448
+ return fields.join(' ');
449
+ })()
450
+ : left5 + ' '.repeat(spaces5Count) + modelLabel + ' ' + approvalLabel;
144
451
  const cardLines = [];
145
452
  const queuedBox = renderQueuedCommandsBox(options.queuedCommands ?? [], width);
146
453
  if (queuedBox.length > 0) {
@@ -152,7 +459,10 @@ export function renderWelcomeCard(options) {
152
459
  if (matching.length)
153
460
  cardLines.push(...renderSuggestionsBox(matching, width, options.commandSelection ?? 0).map(line => indent + line));
154
461
  }
155
- cardLines.push(indent + surfaceLine(), indent + surfaceLine(textBox), indent + surfaceLine(), indent + line4, indent + line5);
462
+ cardLines.push(indent + surfaceLine(),
463
+ // Continuation rows keep the marker's two cells, so wrapped text lines up
464
+ // under the first row's text instead of under the ❯.
465
+ ...inputRows.map((rowText, index) => indent + surfaceLine(`${index === 0 ? promptMarker : ' '} ${rowText}`)), indent + surfaceLine(), indent + line4, indent + line5);
156
466
  if (options.mentionFiles && options.mentionFiles.length > 0) {
157
467
  cardLines.push(...renderMentionSuggestionsBox(options.mentionFiles, options.mentionSelection ?? 0, width).map((line) => indent + line));
158
468
  }
@@ -172,7 +482,8 @@ export const SLASH_COMMANDS = [
172
482
  { name: '/budget', desc: 'Set a per-task token limit' },
173
483
  { name: '/skills', desc: 'List skills; enable or disable user skills' },
174
484
  { name: '/clear', desc: 'Reset conversation memory' },
175
- { name: '/help', desc: 'Display commands, shortcuts & version' },
485
+ { name: '/about', desc: 'Show version and copyright info' },
486
+ { name: '/help', desc: 'Display commands & shortcuts' },
176
487
  { name: '/exit', desc: 'Exit Moderado' },
177
488
  ];
178
489
  export function getMatchingCommands(input) {
@@ -261,6 +572,12 @@ export function renderModeradoHeader(width) {
261
572
  ...centerBlock([COMMAND_HINT], width),
262
573
  ].join('\n');
263
574
  }
575
+ /** Compact wordmark used after the welcome screen transitions into chat. */
576
+ export function renderChatHeader(width) {
577
+ const terminalWidth = width ?? (process.stdout.columns || 80);
578
+ const label = 'MODERADO'.slice(0, Math.max(1, terminalWidth));
579
+ return ' '.repeat(Math.max(0, terminalWidth - label.length)) + `\x1b[1;38;5;75m${label}\x1b[0m`;
580
+ }
264
581
  /** Word-wrap plain text to `maxWidth` visible columns, hard-breaking long words. */
265
582
  export function wrapText(text, maxWidth) {
266
583
  const out = [];
@@ -318,104 +635,393 @@ export function wrapText(text, maxWidth) {
318
635
  export function renderChatScreen(options, height) {
319
636
  const terminalWidth = options.width ?? (process.stdout.columns || 80);
320
637
  const terminalHeight = height ?? process.stdout.rows ?? 24;
321
- const area = getChatAnswerArea(terminalWidth);
638
+ const question = options.chatQuestion ?? '';
639
+ // The composer is measured before the layout: a queued-commands box or a
640
+ // mention box makes the card taller, and the answer area must give up those
641
+ // rows instead of pushing the card past the bottom of the viewport.
642
+ const cardOptions = options.maxInputRows === undefined
643
+ ? { ...options, maxInputRows: getComposerInputMaxRows(terminalHeight) }
644
+ : options;
645
+ const composer = renderWelcomeCard(cardOptions).split('\n');
646
+ const layout = getChatLayout(terminalWidth, terminalHeight, question, composer.length);
647
+ const area = layout.area;
322
648
  const indent = ' '.repeat(area.indent);
323
649
  const lines = [];
324
- lines.push(...renderModeradoHeader(terminalWidth).split('\n'));
650
+ lines.push(renderChatHeader(terminalWidth));
325
651
  lines.push('');
326
652
  // Row 1: the user's question, wrapped onto the same columns as the composer card.
327
653
  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}`;
654
+ const questionLines = wrapText(question, Math.max(1, area.textWidth - 2));
655
+ const visibleQuestion = questionLines.slice(0, layout.questionRows);
656
+ for (const [index, questionLine] of visibleQuestion.entries()) {
657
+ const clipped = index === layout.questionRows - 1 && questionLines.length > layout.questionRows
658
+ ? `${questionLine.slice(0, Math.max(0, area.textWidth - 3))}…`
659
+ : questionLine;
660
+ const questionText = `${index === 0 ? questionMarker : ' '} ${clipped}`;
330
661
  lines.push(indent + String.fromCharCode(27) + '[48;5;236m' + questionText + ' '.repeat(Math.max(0, area.cardWidth - visibleLen(questionText))) + String.fromCharCode(27) + '[0m');
331
662
  }
332
663
  const thoughtTime = options.chatThoughtTime ?? 0;
333
664
  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
665
+ const answerText = options.chatAnswer ?? '';
666
+ const answerOffset = options.chatAnswerOffset ?? 0;
667
+ // The activity log and the answer are one list, so both the scroll position
668
+ // and the window are computed over the merged rows.
669
+ const transcriptWidth = getChatTranscriptWidth(layout);
670
+ const transcript = buildChatTranscriptRows(options.chatActivity, answerText, transcriptWidth);
671
+ // While the reader is scrolled up, the status row belongs to the scroll
672
+ // position: the turn clock is not what they are looking for.
673
+ const scrollWindow = transcript.rows.length > 0 && layout.answerRows > 0 && answerOffset > 0
674
+ ? windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset)
675
+ : undefined;
676
+ const activityElapsed = options.chatActivity
677
+ ? (options.chatActivity.elapsedLabel ?? formatElapsed(thoughtTime))
678
+ : '';
679
+ // An offset that no longer hides anything (the answer shrank, or the viewport
680
+ // grew) must report the live view, not claim a scroll position that is gone.
681
+ // Sitting at the very top still counts: rows are hidden *below* the window.
682
+ const scrolled = scrollWindow !== undefined
683
+ && (scrollWindow.hiddenAbove > 0 || scrollWindow.hiddenBelow > 0);
684
+ const chatStatus = scrolled
685
+ ? `${scrollWindow.hiddenAbove === 0 ? 'Top of the turn' : `↑ ${scrollWindow.hiddenAbove} above`} · ↓ ${scrollWindow.hiddenBelow} below · PgUp/PgDn`
686
+ : options.chatActivity
687
+ ? truncateToWidth(`${options.chatActivity.glyph} ${options.chatActivity.label} · ${activityElapsed}`, transcriptWidth)
688
+ : !answerText.trim()
339
689
  ? ''
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) {
690
+ : options.chatTaskDone
691
+ ? `✔ Done · Thought for ${thoughtTimeLabel}`
692
+ : options.tokenUsage
693
+ ? ''
694
+ : `Thought for ${thoughtTimeLabel}`;
695
+ lines.push(chatStatus
696
+ ? indent + `\x1b[38;5;244m${chatStatus}\x1b[0m`
697
+ : '');
698
+ // Transcript section: the activity log followed by the answer, windowed to the
699
+ // rows the composer leaves free. The log is never dropped to make room for the
700
+ // answer — that trade is what produced the one-line view.
701
+ if (transcript.rows.length > 0 && layout.answerRows > 0) {
344
702
  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`));
703
+ const { lines: transcriptLines, hiddenAbove } = windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset);
704
+ if (hiddenAbove > 0)
705
+ lines.push(indent + `\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), transcriptWidth)}\x1b[0m`);
706
+ for (const transcriptLine of transcriptLines) {
707
+ lines.push(indent + renderAnswerLine(transcriptLine, getChatTranscriptWidth(layout)));
350
708
  }
351
709
  }
352
- const composer = renderWelcomeCard(options).split('\n');
353
- lines.push(...Array(Math.max(1, terminalHeight - lines.length - composer.length - 1)).fill(''));
710
+ // Hard guarantee: the composer starts at `composerTop` and the frame ends one
711
+ // row above the viewport bottom, whatever the content did. This holds for any
712
+ // composer the viewport can hold (rows >= card height + 2); a terminal too
713
+ // short to show the card at all cannot be contained by layout arithmetic.
714
+ while (lines.length < layout.composerTop - 1)
715
+ lines.push('');
716
+ if (lines.length > layout.composerTop - 1)
717
+ lines.length = layout.composerTop - 1;
354
718
  lines.push(...composer);
355
719
  return lines.join('\n') + '\n';
356
720
  }
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`;
721
+ /**
722
+ * Place the terminal cursor on the caret's row inside the input box. Passing
723
+ * the layout's `composerTop` addresses the row absolutely, which stays correct
724
+ * when the card grows (a queued-commands box or a wrapped prompt); the relative
725
+ * form is the legacy path.
726
+ */
727
+ export function renderChatComposerCursor(options, input, composerTop) {
728
+ const width = getChatCardWidth(options.width);
729
+ const indent = getWelcomeIndent(width, options.width);
730
+ const view = composerView(input, input.length, width, getComposerInputMaxRows());
731
+ const caretColumn = indent + 2 + view.caretColumn;
732
+ if (composerTop !== undefined)
733
+ return `\x1b[1 q\x1b[?25h\x1b[${composerTop + 1 + view.caretRow};${caretColumn + 1}H`;
734
+ return `\x1b[1 q\x1b[?25h\x1b[${4 + view.caretRow}A\r\x1b[${caretColumn}C`;
362
735
  }
363
736
  export function navigateQuestionHistory(history, direction, index, draft) {
364
737
  const nextIndex = Math.max(0, Math.min(history.length, index + direction));
365
738
  return { input: nextIndex === history.length ? draft : history[nextIndex], index: nextIndex, draft };
366
739
  }
740
+ /** Silence before the row admits it is waiting on the model. */
741
+ export const WAIT_NOTICE_AFTER_MS = 10_000;
742
+ /** How often that notice is refreshed while the wait continues. */
743
+ export const WAIT_NOTICE_REPEAT_MS = 20_000;
744
+ /**
745
+ * What to say while the model has produced nothing.
746
+ *
747
+ * Silent for the first ten seconds — that is normal inference. After that the
748
+ * row says it is waiting, and re-announces every twenty seconds with the length
749
+ * of the silence, so a long wait never looks like a hung process. The bucket is
750
+ * quantised so the line changes on that cadence instead of every second.
751
+ */
752
+ export function describeWait(silentForMs) {
753
+ if (!Number.isFinite(silentForMs) || silentForMs < WAIT_NOTICE_AFTER_MS)
754
+ return undefined;
755
+ const repeats = Math.floor((silentForMs - WAIT_NOTICE_AFTER_MS) / WAIT_NOTICE_REPEAT_MS);
756
+ if (repeats === 0)
757
+ return { label: 'Waiting on the model' };
758
+ const announcedAt = (WAIT_NOTICE_AFTER_MS + repeats * WAIT_NOTICE_REPEAT_MS) / 1000;
759
+ return { label: 'Waiting on the model', elapsedLabel: `no output for ${announcedAt}s` };
760
+ }
761
+ /**
762
+ * Elapsed time as a short label: `8s`, `1m 04s`.
763
+ *
764
+ * This replaced the step counter. `Step 1/25` was true for minutes at a time
765
+ * while one inference ran, so it measured the engine's internal loop rather
766
+ * than anything the person waiting could act on.
767
+ */
768
+ export function formatElapsed(seconds) {
769
+ if (!Number.isFinite(seconds) || seconds <= 0)
770
+ return '0s';
771
+ if (seconds < 1)
772
+ return '<1s';
773
+ const total = Math.round(seconds);
774
+ if (total < 60)
775
+ return `${total}s`;
776
+ const minutes = Math.floor(total / 60);
777
+ return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
778
+ }
779
+ /**
780
+ * Shorten the middle of a label, keeping the head (the verb) and the tail (the
781
+ * filename, which is the part a person actually reads). Measured in terminal
782
+ * cells, not code units, so a CJK path cannot overflow the card.
783
+ */
784
+ export function elideMiddle(text, max) {
785
+ if (max <= 0)
786
+ return '';
787
+ if (displayWidth(text) <= max)
788
+ return text;
789
+ if (max <= 3)
790
+ return truncateToWidth(text, max);
791
+ const budget = max - 1;
792
+ const head = Math.ceil(budget / 2);
793
+ const tail = budget - head;
794
+ return `${sliceToWidth(text, head)}…${sliceTailToWidth(text, tail)}`;
795
+ }
796
+ /**
797
+ * Shorten the engine's progress sentence into a phase the user can follow.
798
+ *
799
+ * The status row is the only thing moving during a long, silent inference — the
800
+ * reasoning stream is deliberately not displayed — so it names what the agent is
801
+ * doing instead of repeating the step counter.
802
+ */
803
+ export function describeActivity(status) {
804
+ const text = status
805
+ .trim()
806
+ .replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')
807
+ .replace(/\s*\.{3}$/, '')
808
+ .trim();
809
+ if (/^inferring\b/i.test(text))
810
+ return 'Thinking';
811
+ if (/^discovering and selecting models/i.test(text))
812
+ return 'Selecting model';
813
+ if (/^retrying\b/i.test(text))
814
+ return 'Retrying request';
815
+ if (/^delegating sub-task/i.test(text))
816
+ return 'Delegating a sub-task';
817
+ return text || 'Working';
818
+ }
819
+ const TOOL_VERBS = {
820
+ read_file: 'Reading',
821
+ write_file: 'Writing',
822
+ edit_file: 'Editing',
823
+ apply_patch: 'Editing',
824
+ list_files: 'Listing',
825
+ search_files: 'Searching',
826
+ run_command: 'Running',
827
+ run_diagnostics: 'Running',
828
+ };
829
+ /**
830
+ * Name the work a tool call is about to do, from its parameters.
831
+ *
832
+ * `Running read_file` told the person nothing they could not already see; the
833
+ * path does. This is the part of the status row that keeps moving during a long
834
+ * coding turn, so it carries the real target.
835
+ */
836
+ export function describeToolActivity(toolName, parameters = {}) {
837
+ const text = (key) => {
838
+ const value = parameters[key];
839
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
840
+ };
841
+ if (toolName === 'apply_patch') {
842
+ const edits = Array.isArray(parameters.edits) ? parameters.edits : [];
843
+ if (edits.length > 1)
844
+ return `Editing ${edits.length} files`;
845
+ const first = edits[0];
846
+ const path = typeof first?.path === 'string' ? first.path : undefined;
847
+ return path ? `Editing ${path}` : 'Editing files';
848
+ }
849
+ if (toolName === 'run_command') {
850
+ const command = text('command');
851
+ if (!command)
852
+ return 'Running a command';
853
+ const args = Array.isArray(parameters.args)
854
+ ? parameters.args.filter((arg) => typeof arg === 'string')
855
+ : [];
856
+ return `Running ${[command, ...args].join(' ')}`;
857
+ }
858
+ if (toolName === 'search_files') {
859
+ const query = text('query');
860
+ return query ? `Searching "${query}"` : 'Searching the workspace';
861
+ }
862
+ if (toolName === 'list_files') {
863
+ const subpath = text('subpath');
864
+ return subpath && subpath !== '.' ? `Listing ${subpath}` : 'Listing the workspace';
865
+ }
866
+ if (toolName === 'run_diagnostics') {
867
+ const script = text('script');
868
+ return script ? `Running ${script}` : 'Running diagnostics';
869
+ }
870
+ if (toolName === 'web_search')
871
+ return 'Searching the web';
872
+ if (toolName === 'git_diff')
873
+ return 'Checking changes';
874
+ const verb = TOOL_VERBS[toolName];
875
+ const path = text('path');
876
+ if (verb && path)
877
+ return `${verb} ${path}`;
878
+ if (verb)
879
+ return verb;
880
+ return `Running ${toolName}`;
881
+ }
367
882
  /**
368
883
  * Update the fixed chat elapsed-time row without repainting the terminal.
369
884
  * With an activity, the row is a live "still running" indicator that stays
370
885
  * visible after text starts streaming and during silent tool execution.
371
886
  */
372
- export function renderChatThoughtTimeUpdate(thoughtTime, taskStep, width, activity) {
887
+ export function renderChatThoughtTimeUpdate(thoughtTime, width, activity, row = 13) {
373
888
  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`;
889
+ // A wait phase reports silence, not the turn clock; two timers side by side
890
+ // would be ambiguous.
891
+ const elapsed = activity?.elapsedLabel ?? formatElapsed(thoughtTime);
892
+ const paint = (status) => `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${truncateToWidth(status, area.cardWidth)}\x1b[0m\x1b8`;
893
+ if (!activity)
894
+ return paint(`Thought for ${elapsed}`);
895
+ // The timer is reserved before the label gets any room, so a long path can
896
+ // never push the elapsed time off the row.
897
+ const timer = ` · ${elapsed}`;
898
+ const timerCells = displayWidth(timer);
899
+ const label = elideMiddle(
900
+ // The engine's sentence often carries its own step prefix; the row no longer
901
+ // prints a counter, so drop it rather than showing engine internals.
902
+ `${activity.glyph} ${activity.label.replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')}`, Math.max(12, area.cardWidth - timerCells - 2));
903
+ const reasoning = activity.reasoning?.replace(/\s+/g, ' ').trim() ?? '';
904
+ // Everything here is measured in cells: a CJK reasoning tail is twice as wide
905
+ // as its length suggests, and the overflow used to wrap outside the card.
906
+ const room = area.cardWidth - displayWidth(label) - timerCells - 3;
907
+ const excerpt = reasoning && room > 8
908
+ ? (displayWidth(reasoning) > room ? `…${sliceTailToWidth(reasoning, room - 1)}` : reasoning)
909
+ : '';
910
+ // Clock first, then the message: the timer belongs to the phase, so the
911
+ // trailing text can be cut without taking the time with it.
912
+ return paint(excerpt ? `${label}${timer} · ${excerpt}` : `${label}${timer}`);
382
913
  }
383
914
  /**
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.
915
+ * Invisible prefix marking a rendered row as part of the live activity log.
916
+ *
917
+ * A second mark next to `SUMMARY_MARK` lets one renderer style both kinds of
918
+ * row in the same scrollable list without them knowing about each other.
387
919
  */
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
- }
920
+ const LOG_MARK = '\u0002';
921
+ /**
922
+ * Cells the transcript may use on this layout.
923
+ *
924
+ * The card has a 70-column floor, the terminal does not: on a narrow window the
925
+ * transcript still has to stop at the screen edge, not at the card's. Wrapping
926
+ * to the card width and painting anyway is what let a long row spill past the
927
+ * right border of the frame, so the wrap width is the narrower of the two and
928
+ * every row is clamped to it.
929
+ */
930
+ export function getChatTranscriptWidth(layout) {
931
+ const { area } = layout;
932
+ return Math.min(area.textWidth, Math.min(area.cardWidth, Math.max(8, layout.terminalWidth - area.startColumn + 1)));
933
+ }
934
+ /**
935
+ * Rows of the live turn transcript: the activity log first, the answer after.
936
+ *
937
+ * This is the Cline model. Cline keeps one scrollable list where every action
938
+ * and every message is a row, the newest pinned to the bottom and the rest
939
+ * reachable by scrolling. Merging the two here is what fixes "the output
940
+ * becomes one line again": the activity log used to be a separate block that
941
+ * handed its rows back the moment the first token streamed, so a long fix
942
+ * explanation left nothing on screen but a single clipped status row — exactly
943
+ * when there was most to read.
944
+ *
945
+ * The log and the answer now share the window, so the record of what the agent
946
+ * did and the answer it produced are read in one place and scroll together.
947
+ */
948
+ export function buildChatTranscriptRows(activity, answer, textWidth) {
949
+ const rows = [];
950
+ for (const entry of activity?.history ?? []) {
951
+ rows.push(LOG_MARK + `${entry.glyph} ${entry.label}`);
416
952
  }
417
- flush();
418
- return { sequence, row, column };
953
+ const reasoning = activity?.reasoning?.replace(/\s+/g, ' ').trim();
954
+ if (reasoning) {
955
+ // Two columns of indent mark the thought as subordinate to the phase, and
956
+ // wrapping (not truncating) is what turns one clipped line into prose.
957
+ for (const line of wrapText(reasoning, Math.max(8, textWidth - 2)))
958
+ rows.push(LOG_MARK + ` ${line}`);
959
+ }
960
+ const answerStart = rows.length;
961
+ if (answer.trim().length > 0)
962
+ rows.push(...wrapAnswerLines(answer.trimEnd(), textWidth));
963
+ return { rows, answerStart };
964
+ }
965
+ /**
966
+ * Window the transcript around an offset, measured in rows scrolled up from the
967
+ * tail. `offset = 0` is the live view (newest rows). One row is reserved for
968
+ * the "earlier lines hidden" note whenever anything is above the window.
969
+ */
970
+ export function windowChatTranscriptAt(rows, maxRows, offsetFromBottom = 0) {
971
+ if (rows.length <= maxRows)
972
+ return { lines: [...rows], hiddenAbove: 0, hiddenBelow: 0 };
973
+ // The note costs a row, so the content capacity is one row smaller — and the
974
+ // maximum offset follows from that capacity, not from the raw row count.
975
+ const capacity = Math.max(1, maxRows - 1);
976
+ const maxOffset = Math.max(0, rows.length - capacity);
977
+ const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
978
+ const end = rows.length - offset;
979
+ const start = Math.max(0, end - capacity);
980
+ return { lines: [...rows.slice(start, end)], hiddenAbove: start, hiddenBelow: rows.length - end };
981
+ }
982
+ /** Rows the transcript occupies for a layout; 0 when the viewport is tiny. */
983
+ export function getChatTranscriptRows(layout) {
984
+ return Math.max(0, layout.answerRows);
985
+ }
986
+ /**
987
+ * Paint the pinned phase header on the status row.
988
+ *
989
+ * One row, always: the phase and its clock. Everything it used to squeeze into
990
+ * that row now lives in the transcript below it.
991
+ */
992
+ export function renderChatTranscriptHeader(activity, layout, thoughtTime) {
993
+ const { area } = layout;
994
+ const cells = getChatTranscriptWidth(layout);
995
+ const elapsed = activity.elapsedLabel ?? formatElapsed(thoughtTime);
996
+ const header = `\x1b[38;5;250m${truncateToWidth(`${activity.glyph} ${activity.label} · ${elapsed}`, cells)}\x1b[0m`;
997
+ return `\x1b7\x1b[${layout.statusRow};${area.startColumn}H\x1b[2K${header}\x1b8`;
998
+ }
999
+ /**
1000
+ * Repaint the transcript in place: exactly the rows the layout reserves,
1001
+ * between the status row and the composer. The window scrolls (oldest rows
1002
+ * leave first) instead of writing over the composer, so a long turn can never
1003
+ * damage the bottom rows. The cursor is saved and restored, so the caller keeps
1004
+ * its position on the input line.
1005
+ *
1006
+ * Unlike the answer window it replaced, this never gives its rows up: the log
1007
+ * and the answer are one list, so the newest rows stay on screen and the rest
1008
+ * is one PgUp away. Every row is clamped to the card in terminal cells, so a
1009
+ * wide CJK glyph or a long path cannot push a row past the right border.
1010
+ */
1011
+ export function renderChatTranscriptWindow(activity, answer, layout, offsetFromBottom = 0) {
1012
+ const { area } = layout;
1013
+ const cells = getChatTranscriptWidth(layout);
1014
+ const { rows } = buildChatTranscriptRows(activity, answer, cells);
1015
+ const { lines, hiddenAbove } = windowChatTranscriptAt(rows, layout.answerRows, offsetFromBottom);
1016
+ const visible = hiddenAbove > 0
1017
+ ? [`\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), cells)}\x1b[0m`, ...lines.map((line) => renderAnswerLine(line, cells))]
1018
+ : lines.map((line) => renderAnswerLine(line, cells));
1019
+ let sequence = '\x1b7';
1020
+ for (let index = 0; index < layout.answerRows; index++) {
1021
+ const row = layout.answerTop + index;
1022
+ sequence += `\x1b[${row};${area.startColumn}H\x1b[2K${visible[index] ?? ''}`;
1023
+ }
1024
+ return sequence + '\x1b8';
419
1025
  }
420
1026
  export function renderFullWelcomeScreen(options, height) {
421
1027
  if (options.chatQuestion !== undefined) {
@@ -455,7 +1061,7 @@ export function renderWelcomePopupLayer(options, popupLines, width, height) {
455
1061
  const lightPopup = popupLines.map((line) => surface + line.replace(/\x1b\[0m/g, `\x1b[0m${surface}`) + '\x1b[0m');
456
1062
  return background + shadowUnder(lightPopup, cols, rows) + overlayCentered(lightPopup, cols, rows);
457
1063
  }
458
- export function renderHelpPopupBox(version, workspace, width) {
1064
+ export function renderHelpPopupBox(_version, workspace, width) {
459
1065
  const terminalWidth = width ?? (process.stdout.columns || 80);
460
1066
  const targetWidth = 82;
461
1067
  const boxWidth = Math.max(76, Math.min(terminalWidth, targetWidth));
@@ -473,6 +1079,7 @@ export function renderHelpPopupBox(version, workspace, width) {
473
1079
  '\x1b[1;38;5;114mKeyboard Shortcuts:\x1b[0m',
474
1080
  '\x1b[1mTab\x1b[0m Toggle between [Plan] and [Execute] mode',
475
1081
  '\x1b[1mShift+Tab\x1b[0m Toggle Auto-approval on / off for actions',
1082
+ '\x1b[1mShift+Enter\x1b[0m Insert a line break (Ctrl+J works everywhere)',
476
1083
  '\x1b[1mCtrl+C\x1b[0m Cancel active inference or exit session',
477
1084
  '\x1b[1mEsc\x1b[0m Stop the active task',
478
1085
  '\x1b[1mLeft/Right\x1b[0m Move the caret inside the bottom input line',
@@ -480,7 +1087,6 @@ export function renderHelpPopupBox(version, workspace, width) {
480
1087
  '\x1b[1mMouse click\x1b[0m Place the caret on the input line',
481
1088
  '',
482
1089
  `\x1b[38;5;245mWorkspace:\x1b[0m \x1b[38;5;253m${shortWs}\x1b[0m`,
483
- `\x1b[38;5;245mVersion:\x1b[0m \x1b[1;38;5;75m${version}\x1b[0m`,
484
1090
  '',
485
1091
  '\x1b[38;5;244mPress \x1b[1;38;5;75m[Esc]\x1b[0;38;5;244m or \x1b[1;38;5;75m[Enter]\x1b[0;38;5;244m to close\x1b[0m',
486
1092
  ];
@@ -499,6 +1105,31 @@ export function renderHelpPopupBox(version, workspace, width) {
499
1105
  lines.push('\x1b[38;5;240m╰' + '─'.repeat(boxWidth - 2) + '╯\x1b[0m');
500
1106
  return lines;
501
1107
  }
1108
+ /** Render a centered "About" popup with version and copyright info. */
1109
+ export function renderAboutPopupBox(version) {
1110
+ const boxWidth = 52;
1111
+ const innerW = boxWidth - 4;
1112
+ const titleStr = 'About Moderado';
1113
+ const remainingDashes = Math.max(0, boxWidth - titleStr.length - 5);
1114
+ // Strip the Alfazen build suffix (`+261024h`) so only `v<major.minor.patch>` shows.
1115
+ const semverOnly = version.split('+')[0];
1116
+ const content = [
1117
+ 'Moderado CLI',
1118
+ semverOnly,
1119
+ '',
1120
+ '(c) 2026 Moderado AI, an Alfazen Product',
1121
+ ];
1122
+ const lines = [];
1123
+ lines.push('\x1b[38;5;240m╭─ \x1b[1;38;5;75m' + titleStr + '\x1b[0;38;5;240m ' + '─'.repeat(remainingDashes) + '╮\x1b[0m');
1124
+ for (const item of content) {
1125
+ const plain = item.replace(/\x1b\\[[0-9;]*[a-zA-Z]/g, '');
1126
+ const leftPad = Math.max(0, Math.floor((innerW - plain.length) / 2));
1127
+ const rightPad = Math.max(0, innerW - plain.length - leftPad);
1128
+ lines.push('\x1b[38;5;240m│\x1b[0m' + ' '.repeat(leftPad) + item + ' '.repeat(rightPad) + '\x1b[38;5;240m│\x1b[0m');
1129
+ }
1130
+ lines.push('\x1b[38;5;240m╰' + '─'.repeat(boxWidth - 2) + '╯\x1b[0m');
1131
+ return lines;
1132
+ }
502
1133
  export let terminalCleanExitDone = false;
503
1134
  /** Mark that a clean exit already restored + cleared the terminal (guards the process 'exit' hook). */
504
1135
  export function notifyCleanExit() {
@@ -526,7 +1157,7 @@ export async function exitCleanly(message) {
526
1157
  notifyCleanExit();
527
1158
  const stdout = process.stdout;
528
1159
  const payload = stdout.isTTY
529
- ? MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message)
1160
+ ? KEY_MODIFIER_REPORT_OFF + MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message)
530
1161
  : renderExitMessage(message);
531
1162
  try {
532
1163
  fs.writeSync(1, payload);
@@ -546,608 +1177,6 @@ export async function exitCleanly(message) {
546
1177
  setTimeout(() => process.exit(0), 2000).unref();
547
1178
  return new Promise(() => { });
548
1179
  }
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
1180
  /** A cumulative task snapshot; a request ending does not mean the task ended. */
1152
1181
  export function formatTokenUsage(event) {
1153
1182
  const marker = event.estimated ? '~' : '';
@@ -1178,7 +1207,7 @@ export function renderGatewayFallbackUpdate(event, width = process.stdout.column
1178
1207
  const area = getChatAnswerArea(width);
1179
1208
  const destination = event.toProvider ? `${event.toProvider}/${event.toModel}` : event.toModel;
1180
1209
  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);
1210
+ const text = truncateToWidth(`Gateway route changed to ${destination} ${reason}`, area.cardWidth);
1182
1211
  const row = renderModeradoHeader().split('\n').length + 3;
1183
1212
  return `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${text}\x1b[0m\x1b8`;
1184
1213
  }