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
@@ -0,0 +1,883 @@
1
+ /**
2
+ * The interactive composer: raw-mode keypress handling, the prompt line, the
3
+ * suggestion and mention boxes, popup delegation, and history navigation.
4
+ *
5
+ * Split out of `ui/welcome.ts`, which now owns only layout and painting.
6
+ */
7
+ import readline from 'node:readline';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { activeMentionToken, filterMentionCandidates } from './file_mentions.js';
10
+ import { KEY_MODIFIER_REPORT_OFF, KEY_MODIFIER_REPORT_ON, MOUSE_REPORT_OFF, MOUSE_REPORT_ON, MOUSE_REPORT_PATTERN, composerCaretIndex, composerView, exitCleanly, getChatComposerRows, getChatLayout, getChatCardWidth, getComposerInputMaxRows, getMatchingCommands, getWelcomeBottomPadding, getWelcomeCardWidth, getWelcomeIndent, navigateQuestionHistory, renderCenteredWelcomeScreen, renderFullWelcomeScreen, renderHelpPopupBox, renderAboutPopupBox, renderWelcomePopupLayer, windowChatTranscriptAt, buildChatTranscriptRows, } from './welcome.js';
11
+ async function animateChatWordmark(stdout) {
12
+ const wordmark = 'MODERADO';
13
+ const frames = 6;
14
+ for (let frame = 0; frame <= frames; frame += 1) {
15
+ const columns = stdout.columns || 80;
16
+ const rows = stdout.rows || 24;
17
+ const width = Math.min(columns, wordmark.length);
18
+ const startColumn = Math.max(1, Math.floor((columns - width) / 2) + 1);
19
+ const endColumn = Math.max(1, columns - width + 1);
20
+ const progress = frame / frames;
21
+ const column = Math.round(startColumn + (endColumn - startColumn) * progress);
22
+ const startRow = Math.max(1, Math.floor(rows / 2) - 5);
23
+ const row = Math.round(startRow + (1 - startRow) * progress);
24
+ stdout.write(`\x1b[H\x1b[J\x1b[${row};${column}H\x1b[1;38;5;75m${wordmark.slice(0, width)}\x1b[0m`);
25
+ if (frame < frames)
26
+ await new Promise((resolve) => setTimeout(resolve, 30));
27
+ }
28
+ }
29
+ /**
30
+ * Open a file in the user's pager. Returns false when no pager could be run, so
31
+ * the caller can print the path instead of pretending something was shown.
32
+ */
33
+ function openInPager(file) {
34
+ const configured = process.env.PAGER?.trim();
35
+ const command = configured
36
+ ? configured.split(/\s+/).filter(Boolean)
37
+ : process.platform === 'win32' ? [] : ['less', '-R'];
38
+ if (command.length === 0)
39
+ return false;
40
+ const result = spawnSync(command[0], [...command.slice(1), file], { stdio: 'inherit' });
41
+ return !result.error;
42
+ }
43
+ function isMcpCommandInput(value) {
44
+ const command = value.trim();
45
+ return command === '/mcp' || command.startsWith('/mcp ');
46
+ }
47
+ /**
48
+ * Modified keys the terminal reports as escape sequences once XTMODKEYS is on:
49
+ * `CSI 27;modifier;key~` (xterm family) or `CSI key;modifier u` (kitty
50
+ * protocol). readline decodes those byte by byte, so the raw chunk is matched
51
+ * here instead and the derived keypresses are dropped.
52
+ */
53
+ const MODIFIED_KEY_PATTERN = /\x1b\[(?:27;(\d+);(\d+)~|(\d+);(\d+)u)/;
54
+ function decodeModifiedKey(text) {
55
+ const match = MODIFIED_KEY_PATTERN.exec(text);
56
+ if (!match)
57
+ return undefined;
58
+ const modifier = Number(match[1] ?? match[4]);
59
+ const keyCode = Number(match[2] ?? match[3]);
60
+ const bits = modifier - 1; // 1 = Shift, 2 = Alt, 4 = Ctrl
61
+ if (keyCode === 13 && (bits & 1))
62
+ return 'newline';
63
+ if (keyCode === 9 && (bits & 1))
64
+ return 'toggle-auto-approve';
65
+ if (keyCode === 99 && (bits & 4))
66
+ return 'cancel';
67
+ return undefined;
68
+ }
69
+ export async function promptInteractiveTurn(options) {
70
+ const stdin = process.stdin;
71
+ const stdout = process.stdout;
72
+ let currentModel = options.model;
73
+ let currentMode = options.initialMode ?? 'Execute';
74
+ let currentAutoApprove = options.initialAutoApprove ?? true;
75
+ let input = '';
76
+ /** Caret index inside `input` (0 .. input.length) - the composer is editable. */
77
+ let caret = 0;
78
+ /** True while keypress events derived from a mouse report are being dropped. */
79
+ let ignoreComposerKeypress = false;
80
+ /** Terminal height, with the same fallback the renderer uses. */
81
+ const getRows = () => stdout.rows || 24;
82
+ let commandSelection = 0;
83
+ const questionHistory = options.questionHistory ?? [];
84
+ let questionHistoryIndex = questionHistory.length;
85
+ let questionHistoryDraft = '';
86
+ let cachedFiles = [];
87
+ if (options.onMentionComplete) {
88
+ Promise.resolve(options.onMentionComplete('')).then((files) => {
89
+ cachedFiles = Array.isArray(files) ? files : [];
90
+ updateMentions();
91
+ }).catch(() => { cachedFiles = []; });
92
+ }
93
+ let mentionFiles = [];
94
+ let mentionSelection = 0;
95
+ /**
96
+ * Rows the answer is scrolled up from its tail. 0 follows the newest line;
97
+ * PgUp/PgDn and the mouse wheel move it, and every new turn starts at 0.
98
+ */
99
+ let answerOffset = 0;
100
+ const updateMentions = () => {
101
+ const mention = activeMentionToken(input, caret);
102
+ if (mention && cachedFiles.length > 0) {
103
+ mentionFiles = filterMentionCandidates(cachedFiles, mention.token, 5);
104
+ mentionSelection = Math.min(mentionSelection, Math.max(0, mentionFiles.length - 1));
105
+ }
106
+ else {
107
+ mentionFiles = [];
108
+ mentionSelection = 0;
109
+ }
110
+ };
111
+ /** Replace the composer text, leaving the caret at the end of the new value. */
112
+ const setInput = (value) => {
113
+ input = value;
114
+ caret = value.length;
115
+ };
116
+ const getOptions = () => ({
117
+ model: currentModel,
118
+ provider: options.getProviderName?.(),
119
+ tokens: options.tokens,
120
+ cost: options.cost,
121
+ usageAvailable: options.usageAvailable,
122
+ usageEstimated: options.usageEstimated,
123
+ workspace: options.workspace,
124
+ mode: currentMode,
125
+ autoApprove: currentAutoApprove,
126
+ input,
127
+ inputCaret: caret,
128
+ commandSelection,
129
+ chatQuestion: options.chatQuestion,
130
+ chatAnswer: options.chatAnswer,
131
+ chatAnswerOffset: answerOffset,
132
+ // Live activity for the running turn, so a composer-driven repaint shows
133
+ // the transcript the loop is painting rather than an empty answer area.
134
+ chatActivity: options.getChatActivity?.(),
135
+ chatThoughtTime: options.chatThoughtTime,
136
+ chatTaskDone: options.chatTaskDone,
137
+ outputTokenRate: options.outputTokenRate,
138
+ tokenUsage: options.tokenUsage,
139
+ queuedCommands: options.queuedCommands,
140
+ // The @ completion list lives in the card, so the composer must be told
141
+ // about it — without these the keypress handler maintained a selection that
142
+ // was never painted.
143
+ mentionFiles,
144
+ mentionSelection,
145
+ isTurnSettled: true,
146
+ });
147
+ // ── Non-TTY fallback ──────────────────────────────────────────────────────
148
+ if (!stdin.isTTY) {
149
+ stdout.write('\x1b]0;Moderado\x07');
150
+ stdout.write(renderFullWelcomeScreen(getOptions()));
151
+ return new Promise((resolve) => {
152
+ const rl = readline.createInterface({ input: stdin, output: stdout });
153
+ rl.question('\x1b[1;38;5;75m❯\x1b[0m ', (answer) => {
154
+ rl.close();
155
+ resolve({ text: answer.trim(), mode: currentMode, autoApprove: currentAutoApprove });
156
+ });
157
+ });
158
+ }
159
+ // ── TTY Interactive Loop ───────────────────────────────────────────────────
160
+ readline.emitKeypressEvents(stdin);
161
+ // A previous turn's cleanup() pauses stdin — resume it so keystrokes keep
162
+ // flowing after an answer (the main app must never appear dead post-answer).
163
+ stdin.resume();
164
+ stdin.setRawMode(true);
165
+ const getExtraLines = () => mentionFiles.length > 0 ? mentionFiles.length + 2 : 0;
166
+ /** Columns of the card the input is painted in. */
167
+ const getCardWidth = () => getOptions().chatQuestion !== undefined
168
+ ? getChatCardWidth(stdout.columns)
169
+ : getWelcomeCardWidth(stdout.columns);
170
+ /** Painted rows of the input box plus where the caret sits in them. */
171
+ const getInputView = () => composerView(input, caret, getCardWidth(), getComposerInputMaxRows(getRows()));
172
+ /** Rows between the input's first row and the terminal's last row. */
173
+ const getComposerOffset = () => getWelcomeBottomPadding(getOptions(), getRows()) + 3 + getInputView().rows.length + getExtraLines();
174
+ /** Screen row and 1-based column of the caret's cell in the input box. */
175
+ const getComposerAnchor = (caretRow = getInputView().caretRow) => {
176
+ const options = getOptions();
177
+ const column = getWelcomeIndent(getCardWidth(), stdout.columns) + 3;
178
+ if (options.chatQuestion !== undefined) {
179
+ // Chat frames place the composer at a computed row. Measure the card that
180
+ // is actually painted — a queued-commands box or a wrapped prompt makes it
181
+ // taller — so the caret never lands on the mode row or inside the box.
182
+ const layout = getChatLayout(stdout.columns, getRows(), options.chatQuestion, getChatComposerRows(options));
183
+ return { row: layout.composerTop + 1 + caretRow, column };
184
+ }
185
+ return { row: getRows() - getComposerOffset() + caretRow, column };
186
+ };
187
+ /**
188
+ * Move cursor to the ❯ input line (Line 2 inside the centered card).
189
+ * Absolute addressing (`row;column H`) works no matter where the cursor
190
+ * currently sits — relative "move up N" escapes the input line when
191
+ * pressed repeatedly, e.g. browsing history with Left/Right.
192
+ */
193
+ const positionCursorOnInput = () => {
194
+ const view = getInputView();
195
+ const { row, column } = getComposerAnchor(view.caretRow);
196
+ stdout.write(`\x1b[1 q\x1b[?25h\x1b[${row};${column + view.caretColumn}H`);
197
+ };
198
+ /** Full-screen redraw: clear everything, repaint welcome TUI, reposition cursor. */
199
+ const redrawFull = () => {
200
+ stdout.write('\x1b[H\x1b[J');
201
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
202
+ positionCursorOnInput();
203
+ };
204
+ /** Repaint the welcome view so it remains centered after every edit or resize. */
205
+ const redrawCard = () => {
206
+ redrawFull();
207
+ };
208
+ const onResize = () => redrawFull();
209
+ /** Layout of the chat frame as it is painted right now. */
210
+ const currentChatLayout = () => getChatLayout(stdout.columns, getRows(), options.chatQuestion ?? '', getChatComposerRows(getOptions()));
211
+ /**
212
+ * How many rows of transcript sit above the tail view — the scroll range.
213
+ *
214
+ * The range covers the whole transcript, not just the answer: the activity log
215
+ * and the answer are one list, so scrolling up reaches what the agent did as
216
+ * well as what it said.
217
+ */
218
+ const answerScrollLimit = () => {
219
+ const answer = options.chatAnswer;
220
+ const activity = options.getChatActivity?.();
221
+ if ((!answer || answer.trim().length === 0) && !activity)
222
+ return 0;
223
+ const layout = currentChatLayout();
224
+ if (layout.answerRows <= 0)
225
+ return 0;
226
+ return windowChatTranscriptAt(buildChatTranscriptRows(activity, answer ?? '', layout.area.textWidth).rows, layout.answerRows, 0).hiddenAbove;
227
+ };
228
+ /**
229
+ * Move the transcript view; positive scrolls up towards older lines.
230
+ *
231
+ * A long turn is taller than the transcript area, so the tail view hides its
232
+ * opening rows. This is the way back: the whole turn stays in the buffer, the
233
+ * status row reports the position, and the turn loop is told so its next
234
+ * incremental paint does not yank the reader back to the bottom.
235
+ */
236
+ const scrollAnswer = (delta) => {
237
+ const limit = answerScrollLimit();
238
+ if (limit <= 0)
239
+ return;
240
+ const next = Math.max(0, Math.min(limit, answerOffset + delta));
241
+ if (next === answerOffset)
242
+ return;
243
+ answerOffset = next;
244
+ options.onTranscriptScroll?.(next);
245
+ redrawFull();
246
+ };
247
+ /** One page is one screenful of answer rows, minus the overflow note. */
248
+ const answerPage = () => Math.max(1, currentChatLayout().answerRows - 1);
249
+ /** Insert a line break at the caret — Shift+Enter and Ctrl+J land here. */
250
+ const insertLineFeed = () => {
251
+ input = input.slice(0, caret) + '\n' + input.slice(caret);
252
+ caret += 1;
253
+ commandSelection = 0;
254
+ questionHistoryIndex = questionHistory.length;
255
+ updateMentions();
256
+ redrawCard();
257
+ };
258
+ /** Toggle Plan/Execute; kept callable from a decoded escape sequence too. */
259
+ const toggleMode = () => {
260
+ currentMode = currentMode === 'Plan' ? 'Execute' : 'Plan';
261
+ redrawCard();
262
+ };
263
+ /**
264
+ * Cancel the session from a decoded escape sequence (Ctrl+C reported as a
265
+ * modified key). Assigned once the cleanup path exists.
266
+ */
267
+ let cancelSession = () => { };
268
+ /**
269
+ * Place the caret where the user clicked inside the input box. Only a primary
270
+ * press is honoured; releases, drags, wheel and other buttons are ignored.
271
+ */
272
+ const onComposerData = (chunk) => {
273
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
274
+ let reported = false;
275
+ // Shift+Enter arrives as one of these sequences once the terminal reports
276
+ // modified keys; readline would otherwise type it out as literal garbage.
277
+ const modified = decodeModifiedKey(text);
278
+ if (modified === 'newline')
279
+ insertLineFeed();
280
+ else if (modified === 'toggle-auto-approve')
281
+ toggleMode();
282
+ else if (modified === 'cancel')
283
+ cancelSession();
284
+ if (modified)
285
+ reported = true;
286
+ for (const report of text.matchAll(MOUSE_REPORT_PATTERN)) {
287
+ reported = true;
288
+ // Wheel up (64) / down (65): three rows a notch, like every other pager.
289
+ if (report[4] === 'M' && (Number(report[1]) === 64 || Number(report[1]) === 65)) {
290
+ scrollAnswer(Number(report[1]) === 64 ? 3 : -3);
291
+ continue;
292
+ }
293
+ if (report[4] !== 'M' || Number(report[1]) !== 0)
294
+ continue;
295
+ const view = getInputView();
296
+ const { row, column } = getComposerAnchor(0);
297
+ const rowOffset = Number(report[3]) - row;
298
+ if (rowOffset < 0 || rowOffset >= view.rows.length)
299
+ continue;
300
+ caret = composerCaretIndex(input, view.rows, rowOffset, Number(report[2]) - column);
301
+ positionCursorOnInput();
302
+ }
303
+ if (reported) {
304
+ // readline turns the same bytes into keypress events ("0", ";", "5", "M").
305
+ ignoreComposerKeypress = true;
306
+ setImmediate(() => { ignoreComposerKeypress = false; });
307
+ }
308
+ };
309
+ // Registered before readline's decoder so a report is consumed before it is
310
+ // mistaken for typed text.
311
+ stdin.prependListener('data', onComposerData);
312
+ stdout.on('resize', onResize);
313
+ // Initial paint
314
+ if (options.isFirstTurn) {
315
+ stdout.write('\x1b]0;Moderado\x07');
316
+ stdout.write('\x1b[2J\x1b[3J\x1b[H');
317
+ }
318
+ else {
319
+ // After an AI response the screen has content below — clear and repaint the full TUI.
320
+ stdout.write('\x1b[H\x1b[J');
321
+ }
322
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
323
+ positionCursorOnInput();
324
+ return new Promise((resolve) => {
325
+ const cleanup = () => {
326
+ unbindComposerInput();
327
+ stdin.removeListener('data', onComposerData);
328
+ stdout.removeListener('resize', onResize);
329
+ stdout.write(`${KEY_MODIFIER_REPORT_OFF}${MOUSE_REPORT_OFF}\x1b[0 q\x1b[?25h`);
330
+ if (stdin.isTTY) {
331
+ try {
332
+ stdin.setRawMode(false);
333
+ }
334
+ catch { /* ignore */ }
335
+ }
336
+ try {
337
+ stdin.pause();
338
+ }
339
+ catch { /* ignore */ }
340
+ };
341
+ cancelSession = () => {
342
+ cleanup();
343
+ void exitCleanly('\x1b[33mSession cancelled.\x1b[0m');
344
+ };
345
+ const onAbort = () => {
346
+ cleanup();
347
+ resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
348
+ };
349
+ if (options.signal) {
350
+ options.signal.addEventListener('abort', onAbort, { once: true });
351
+ }
352
+ // ── Async keypress handler ────────────────────────────────────────────────
353
+ // Using a void async IIFE so we can await async operations (model selector)
354
+ // inside a synchronous event listener. For any async branch we first
355
+ // remove ourselves from stdin so no duplicate events fire during the await.
356
+ const onKeypress = (str, key) => {
357
+ void (async () => {
358
+ // Keystrokes decoded from a mouse report were already handled above.
359
+ if (ignoreComposerKeypress)
360
+ return;
361
+ // Intercept XTMODKEYS modified keys that readline cannot decode.
362
+ // The raw byte chunk `str` carries the escape sequence (e.g. CSI 27;1;13~
363
+ // for Shift+Enter); readline decodes those byte-by-byte and drops them,
364
+ // so we match the pattern directly and derive the keypress ourselves.
365
+ const modified = decodeModifiedKey(str);
366
+ if (modified === 'newline') {
367
+ insertLineFeed();
368
+ return;
369
+ }
370
+ if (modified === 'toggle-auto-approve') {
371
+ currentAutoApprove = !currentAutoApprove;
372
+ redrawCard();
373
+ return;
374
+ }
375
+ if (modified === 'cancel') {
376
+ cleanup();
377
+ return await exitCleanly('\\x1b[33mSession cancelled.\\x1b[0m');
378
+ }
379
+ // Enter submits; a line feed (Ctrl+J, or Shift+Enter where the terminal
380
+ // reports it) breaks the line instead.
381
+ const isEnter = Boolean(key) && (key.name === 'return' || key.name === 'enter');
382
+ const isLineFeed = isEnter && (key.shift === true || key.meta === true || key.ctrl === true);
383
+ const isSubmit = isEnter && !isLineFeed;
384
+ if (options.signal?.aborted) {
385
+ cleanup();
386
+ resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
387
+ return;
388
+ }
389
+ // ── Ctrl+C ────────────────────────────────────────────────────────────
390
+ if (key && key.ctrl && key.name === 'c') {
391
+ cleanup();
392
+ return await exitCleanly('\x1b[33mSession cancelled.\x1b[0m');
393
+ }
394
+ // ── Answer scrollback ─────────────────────────────────────────────────
395
+ // PgUp/PgDn browse the answer without leaving the composer. The turn
396
+ // summary is often taller than the answer area, and the terminal's own
397
+ // scrollback is not available to a full-screen TUI.
398
+ if (key && (key.name === 'pageup' || key.name === 'pagedown')) {
399
+ scrollAnswer(key.name === 'pageup' ? answerPage() : -answerPage());
400
+ return;
401
+ }
402
+ // ── /help overlay popup ───────────────────────────────────────────────
403
+ // Triggered when Enter is pressed with "/help" already in the input box.
404
+ // We keep the main TUI as background and layer the help box on top.
405
+ if (isSubmit && input.trim() === '/help') {
406
+ setInput('');
407
+ unbindComposerInput(); // pause main handler
408
+ // Paint: full welcome TUI (background) + help box on top
409
+ const helpLines = renderHelpPopupBox(options.version ?? 'v0.1', options.workspace);
410
+ const paintHelp = () => {
411
+ stdout.write('\x1b[H\x1b[J');
412
+ stdout.write(renderWelcomePopupLayer(getOptions(), helpLines, stdout.columns, stdout.rows));
413
+ };
414
+ // The composer's own resize repaint clears the box off screen, so the
415
+ // overlay has to repaint itself at the new geometry.
416
+ const onHelpResize = () => paintHelp();
417
+ stdout.on('resize', onHelpResize);
418
+ paintHelp();
419
+ // Wait for Esc / Enter / q to dismiss
420
+ await new Promise((dismissResolve) => {
421
+ const onDismiss = (s, k) => {
422
+ if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
423
+ s === 'q' || s === 'Q' ||
424
+ (k && k.ctrl && k.name === 'c')) {
425
+ stdin.removeListener('keypress', onDismiss);
426
+ stdout.removeListener('resize', onHelpResize);
427
+ dismissResolve();
428
+ }
429
+ };
430
+ stdin.on('keypress', onDismiss);
431
+ });
432
+ // Restore: repaint the full welcome TUI, show cursor, position on input line
433
+ stdout.write('\x1b[H\x1b[J');
434
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
435
+ positionCursorOnInput();
436
+ bindComposerInput(); // re-attach main handler
437
+ return;
438
+ }
439
+ // ── /about overlay popup ──────────────────────────────────────────────
440
+ // Triggered when Enter is pressed with "/about" already in the input box.
441
+ if (isSubmit && input.trim() === '/about') {
442
+ setInput('');
443
+ unbindComposerInput();
444
+ const aboutLines = renderAboutPopupBox(options.version ?? 'v0.1');
445
+ const paintAbout = () => {
446
+ stdout.write('\x1b[H\x1b[J');
447
+ stdout.write(renderWelcomePopupLayer(getOptions(), aboutLines, stdout.columns, stdout.rows));
448
+ };
449
+ const onAboutResize = () => paintAbout();
450
+ stdout.on('resize', onAboutResize);
451
+ paintAbout();
452
+ await new Promise((dismissResolve) => {
453
+ const onDismiss = (s, k) => {
454
+ if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
455
+ s === 'q' || s === 'Q' ||
456
+ (k && k.ctrl && k.name === 'c')) {
457
+ stdin.removeListener('keypress', onDismiss);
458
+ stdout.removeListener('resize', onAboutResize);
459
+ dismissResolve();
460
+ }
461
+ };
462
+ stdin.on('keypress', onDismiss);
463
+ });
464
+ stdout.write('\x1b[H\x1b[J');
465
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
466
+ positionCursorOnInput();
467
+ bindComposerInput();
468
+ return;
469
+ }
470
+ // ── /model overlay popup ──────────────────────────────────────────────
471
+ // Triggered when Enter is pressed with "/model" in the input box.
472
+ // We temporarily detach the main keypress listener and hand the model
473
+ // selector a drawFrame callback. drawFrame repaints the background TUI
474
+ // exactly as-is and composites the popup content as a centered floating
475
+ // layer on top of it (Cline/OpenCode style) — the popup never scrolls
476
+ // the background because every step redraws the whole frame.
477
+ // After it returns, we repaint the full welcome TUI and reposition cursor.
478
+ if (isSubmit && input.trim() === '/model') {
479
+ setInput('');
480
+ unbindComposerInput(); // pause main handler
481
+ if (options.onModelSelect) {
482
+ const drawFrame = (popupLines) => {
483
+ stdout.write('\x1b[H\x1b[J');
484
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
485
+ };
486
+ const newId = await options.onModelSelect(drawFrame);
487
+ if (newId && newId !== currentModel) {
488
+ currentModel = newId;
489
+ }
490
+ const autoContinuePrompt = options.takeAutoContinuePrompt?.();
491
+ if (autoContinuePrompt) {
492
+ options.signal?.removeEventListener('abort', onAbort);
493
+ cleanup();
494
+ resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
495
+ return;
496
+ }
497
+ }
498
+ // Selection flow left stdin in non-raw mode — restore it for our TUI
499
+ readline.emitKeypressEvents(stdin);
500
+ stdin.resume();
501
+ stdin.setRawMode(true);
502
+ stdout.write('\x1b[H\x1b[J');
503
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
504
+ positionCursorOnInput();
505
+ bindComposerInput(); // re-attach main handler
506
+ return;
507
+ }
508
+ if (isSubmit && input.trim() === '/connect') {
509
+ setInput('');
510
+ unbindComposerInput();
511
+ const drawFrame = (popupLines) => {
512
+ stdout.write('\x1b[H\x1b[J');
513
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
514
+ };
515
+ if (options.onConnect) {
516
+ const newModel = await options.onConnect(drawFrame);
517
+ if (newModel)
518
+ currentModel = newModel;
519
+ }
520
+ readline.emitKeypressEvents(stdin);
521
+ stdin.resume();
522
+ stdin.setRawMode(true);
523
+ stdout.write('\x1b[H\x1b[J');
524
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
525
+ positionCursorOnInput();
526
+ bindComposerInput();
527
+ return;
528
+ }
529
+ if (isSubmit && input.trim() === '/transcript') {
530
+ setInput('');
531
+ unbindComposerInput();
532
+ stdout.write('\x1b[H\x1b[J');
533
+ const file = await options.onTranscript?.();
534
+ if (file) {
535
+ // The pager needs the terminal in cooked mode and no mouse reporting;
536
+ // both are process state, so they are restored before repainting.
537
+ try {
538
+ stdin.setRawMode(false);
539
+ }
540
+ catch { /* ignore */ }
541
+ stdout.write(MOUSE_REPORT_OFF);
542
+ const opened = openInPager(file);
543
+ stdout.write(MOUSE_REPORT_ON);
544
+ try {
545
+ stdin.setRawMode(true);
546
+ }
547
+ catch { /* ignore */ }
548
+ if (!opened)
549
+ stdout.write(`\x1b[38;5;244mTranscript written to ${file}\x1b[0m\n`);
550
+ }
551
+ else {
552
+ stdout.write('\x1b[38;5;244mNo transcript to show yet — finish a turn first.\x1b[0m\n');
553
+ }
554
+ readline.emitKeypressEvents(stdin);
555
+ stdin.resume();
556
+ stdout.write('\x1b[H\x1b[J');
557
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
558
+ positionCursorOnInput();
559
+ bindComposerInput();
560
+ return;
561
+ }
562
+ if (isSubmit && input.trim().startsWith('/session')) {
563
+ const command = input.trim();
564
+ setInput('');
565
+ unbindComposerInput();
566
+ if (options.onSession) {
567
+ await options.onSession(command, (popupLines) => {
568
+ stdout.write('\x1b[H\x1b[J');
569
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
570
+ });
571
+ }
572
+ const autoContinuePrompt = options.takeAutoContinuePrompt?.();
573
+ if (autoContinuePrompt) {
574
+ options.signal?.removeEventListener('abort', onAbort);
575
+ cleanup();
576
+ resolve({ text: autoContinuePrompt, mode: currentMode, autoApprove: currentAutoApprove });
577
+ return;
578
+ }
579
+ readline.emitKeypressEvents(stdin);
580
+ stdin.resume();
581
+ stdin.setRawMode(true);
582
+ stdout.write('\x1b[H\x1b[J');
583
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
584
+ positionCursorOnInput();
585
+ bindComposerInput();
586
+ return;
587
+ }
588
+ if (isSubmit && input.trim().startsWith('/queue')) {
589
+ const command = input.trim();
590
+ setInput('');
591
+ unbindComposerInput();
592
+ if (options.onQueue) {
593
+ await options.onQueue(command, async (popupLines) => {
594
+ stdout.write('\x1b[H\x1b[J');
595
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
596
+ await new Promise((dismissResolve) => {
597
+ const onDismiss = (s, k) => {
598
+ if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
599
+ s === 'q' || s === 'Q' || s === '\x1b') {
600
+ stdin.removeListener('keypress', onDismiss);
601
+ dismissResolve();
602
+ }
603
+ };
604
+ stdin.on('keypress', onDismiss);
605
+ });
606
+ });
607
+ }
608
+ readline.emitKeypressEvents(stdin);
609
+ stdin.resume();
610
+ stdin.setRawMode(true);
611
+ stdout.write('\x1b[H\x1b[J');
612
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
613
+ positionCursorOnInput();
614
+ bindComposerInput();
615
+ return;
616
+ }
617
+ if (isSubmit && input.trim().startsWith('/btw')) {
618
+ const command = input.trim();
619
+ setInput('');
620
+ unbindComposerInput();
621
+ if (options.onBtw) {
622
+ const ac = new AbortController();
623
+ const onBtwKey = (s, k) => {
624
+ if ((k && k.name === 'escape') || s === '\x1b') {
625
+ ac.abort();
626
+ }
627
+ };
628
+ stdin.on('keypress', onBtwKey);
629
+ try {
630
+ await options.onBtw(command, async (popupLines, frameOpts) => {
631
+ stdout.write('\x1b[H\x1b[J');
632
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
633
+ if (frameOpts?.waitDismiss !== false && !ac.signal.aborted) {
634
+ await new Promise((dismissResolve) => {
635
+ const onDismiss = (s, k) => {
636
+ if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
637
+ s === 'q' || s === 'Q' || s === '\x1b') {
638
+ stdin.removeListener('keypress', onDismiss);
639
+ dismissResolve();
640
+ }
641
+ };
642
+ stdin.on('keypress', onDismiss);
643
+ });
644
+ }
645
+ }, ac.signal);
646
+ }
647
+ finally {
648
+ stdin.removeListener('keypress', onBtwKey);
649
+ }
650
+ }
651
+ readline.emitKeypressEvents(stdin);
652
+ stdin.resume();
653
+ stdin.setRawMode(true);
654
+ stdout.write('\x1b[H\x1b[J');
655
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
656
+ positionCursorOnInput();
657
+ bindComposerInput();
658
+ return;
659
+ }
660
+ if (isSubmit && isMcpCommandInput(input)) {
661
+ const command = input.trim();
662
+ setInput('');
663
+ unbindComposerInput();
664
+ if (options.onMcp) {
665
+ await options.onMcp(command, (popupLines) => {
666
+ stdout.write('\x1b[H\x1b[J');
667
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
668
+ });
669
+ }
670
+ if (options.signal?.aborted)
671
+ return;
672
+ readline.emitKeypressEvents(stdin);
673
+ stdin.resume();
674
+ stdin.setRawMode(true);
675
+ redrawFull();
676
+ bindComposerInput();
677
+ return;
678
+ }
679
+ if (isSubmit && input.trim() === '/init') {
680
+ setInput('');
681
+ unbindComposerInput();
682
+ if (options.onInit) {
683
+ await options.onInit((popupLines) => {
684
+ stdout.write('\x1b[H\x1b[J');
685
+ stdout.write(renderWelcomePopupLayer(getOptions(), popupLines, stdout.columns, stdout.rows));
686
+ });
687
+ }
688
+ if (options.signal?.aborted)
689
+ return;
690
+ readline.emitKeypressEvents(stdin);
691
+ stdin.resume();
692
+ stdin.setRawMode(true);
693
+ redrawFull();
694
+ bindComposerInput();
695
+ return;
696
+ }
697
+ if (isSubmit && input.trim().startsWith('/workflow')) {
698
+ const command = input.trim();
699
+ setInput('');
700
+ unbindComposerInput();
701
+ 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;
702
+ if (action === 'build') {
703
+ cleanup();
704
+ resolve({ text: '', mode: 'Execute', autoApprove: currentAutoApprove, workflowAction: 'build' });
705
+ return;
706
+ }
707
+ readline.emitKeypressEvents(stdin);
708
+ stdin.resume();
709
+ stdin.setRawMode(true);
710
+ redrawFull();
711
+ bindComposerInput();
712
+ return;
713
+ }
714
+ // ── /clear ────────────────────────────────────────────────────────────
715
+ if (isSubmit && input.trim() === '/clear') {
716
+ setInput('');
717
+ options.onClear?.();
718
+ redrawFull();
719
+ return;
720
+ }
721
+ // ── /exit ─────────────────────────────────────────────────────────────
722
+ if (isSubmit &&
723
+ (input.trim() === '/exit' || input.trim() === '/quit')) {
724
+ cleanup();
725
+ if (options.signal)
726
+ options.signal.removeEventListener('abort', onAbort);
727
+ return await exitCleanly(options.onExit?.() ?? '\x1b[32mGoodbye! Stay Tuned with Moderado!\x1b[0m');
728
+ }
729
+ // ── Normal keys ───────────────────────────────────────────────────────
730
+ if (!key) {
731
+ if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
732
+ input = input.slice(0, caret) + str + input.slice(caret);
733
+ caret += 1;
734
+ updateMentions();
735
+ redrawCard();
736
+ }
737
+ return;
738
+ }
739
+ // Shift+Tab — toggle auto-approve
740
+ if ((key.name === 'tab' && key.shift) || key.sequence === '\x1b[Z') {
741
+ currentAutoApprove = !currentAutoApprove;
742
+ redrawCard();
743
+ return;
744
+ }
745
+ // Tab — autocomplete mention, autocomplete slash command or toggle mode
746
+ if (key.name === 'tab' && !key.shift) {
747
+ const mention = activeMentionToken(input, caret);
748
+ if (mention && mentionFiles.length > 0) {
749
+ const chosen = mentionFiles[Math.min(mentionSelection, mentionFiles.length - 1)];
750
+ const before = input.slice(0, mention.start);
751
+ const after = input.slice(caret);
752
+ const insert = `@${chosen} `;
753
+ input = before + insert + after;
754
+ caret = before.length + insert.length;
755
+ updateMentions();
756
+ redrawCard();
757
+ return;
758
+ }
759
+ if (input.startsWith('/')) {
760
+ const matching = getMatchingCommands(input);
761
+ if (matching.length > 0) {
762
+ setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
763
+ commandSelection = 0;
764
+ redrawCard();
765
+ return;
766
+ }
767
+ }
768
+ currentMode = currentMode === 'Plan' ? 'Execute' : 'Plan';
769
+ redrawCard();
770
+ return;
771
+ }
772
+ if (mentionFiles.length > 0 && (key.name === 'up' || key.name === 'down')) {
773
+ mentionSelection = ((mentionSelection + (key.name === 'up' ? -1 : 1)) % mentionFiles.length + mentionFiles.length) % mentionFiles.length;
774
+ redrawCard();
775
+ return;
776
+ }
777
+ if (input.startsWith('/') && (key.name === 'up' || key.name === 'down')) {
778
+ const matching = getMatchingCommands(input);
779
+ if (matching.length > 0) {
780
+ commandSelection = ((commandSelection + (key.name === 'up' ? -1 : 1)) % matching.length + matching.length) % matching.length;
781
+ redrawCard();
782
+ return;
783
+ }
784
+ }
785
+ if (!input.startsWith('/') && (key.name === 'up' || key.name === 'down') && questionHistory.length > 0) {
786
+ if (questionHistoryIndex === questionHistory.length)
787
+ questionHistoryDraft = input;
788
+ const recalled = navigateQuestionHistory(questionHistory, key.name === 'up' ? -1 : 1, questionHistoryIndex, questionHistoryDraft);
789
+ setInput(recalled.input);
790
+ questionHistoryIndex = recalled.index;
791
+ redrawCard();
792
+ return;
793
+ }
794
+ // Enter — submit real user message
795
+ if (isLineFeed) {
796
+ insertLineFeed();
797
+ return;
798
+ }
799
+ if (isSubmit) {
800
+ const matching = getMatchingCommands(input);
801
+ if (input.startsWith('/') && matching.length > 0 && !matching.some((command) => command.name === input.trim())) {
802
+ setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
803
+ commandSelection = 0;
804
+ redrawCard();
805
+ return;
806
+ }
807
+ const trimmed = input.trim();
808
+ const animateFirstChat = options.chatQuestion === undefined && trimmed.length > 0;
809
+ cleanup();
810
+ if (options.signal)
811
+ options.signal.removeEventListener('abort', onAbort);
812
+ if (animateFirstChat)
813
+ await animateChatWordmark(stdout);
814
+ // Move past the rows below the caret's row, so the frame that follows
815
+ // starts under the card however tall the input box grew.
816
+ const inputView = getInputView();
817
+ const moveDown = (inputView.rows.length - 1 - inputView.caretRow) + 4 + getExtraLines();
818
+ stdout.write(`\x1b[${moveDown}B\r\n\x1b[0 q\x1b[?25h`);
819
+ resolve({ text: trimmed, mode: currentMode, autoApprove: currentAutoApprove });
820
+ return;
821
+ }
822
+ // Caret movement (cursor-only hop) and forward delete (repaint: the
823
+ // line shrank, so the old trailing character must be cleared).
824
+ if (key.name === 'left' || key.name === 'right' || key.name === 'delete') {
825
+ if (key.name === 'left') {
826
+ caret = Math.max(0, caret - 1);
827
+ updateMentions();
828
+ redrawCard();
829
+ }
830
+ else if (key.name === 'right') {
831
+ caret = Math.min(input.length, caret + 1);
832
+ updateMentions();
833
+ redrawCard();
834
+ }
835
+ else if (caret < input.length) {
836
+ input = input.slice(0, caret) + input.slice(caret + 1);
837
+ updateMentions();
838
+ redrawCard();
839
+ }
840
+ return;
841
+ }
842
+ // Backspace
843
+ if (key.name === 'backspace') {
844
+ if (caret > 0) {
845
+ input = input.slice(0, caret - 1) + input.slice(caret);
846
+ caret = Math.max(0, caret - 1);
847
+ commandSelection = 0;
848
+ updateMentions();
849
+ redrawCard();
850
+ }
851
+ return;
852
+ }
853
+ // Printable character
854
+ if (str && str.length === 1 && str.charCodeAt(0) >= 32 && !key.ctrl && !key.meta) {
855
+ input = input.slice(0, caret) + str + input.slice(caret);
856
+ caret += 1;
857
+ commandSelection = 0;
858
+ questionHistoryIndex = questionHistory.length;
859
+ updateMentions();
860
+ redrawCard();
861
+ }
862
+ })();
863
+ };
864
+ /**
865
+ * Mouse reporting is only useful while the composer owns the keyboard: a
866
+ * click on the composer line places the caret. Popups and sub-prompts take
867
+ * the keyboard over with mouse reporting off, so they keep the terminal's
868
+ * normal text selection, and clicks never leak into their filter fields.
869
+ */
870
+ const bindComposerInput = () => {
871
+ stdout.write(MOUSE_REPORT_ON + KEY_MODIFIER_REPORT_ON);
872
+ stdin.on('keypress', onKeypress);
873
+ };
874
+ const unbindComposerInput = () => {
875
+ stdin.removeListener('keypress', onKeypress);
876
+ // Key-modifier reporting is scoped to the composer: popups read plain
877
+ // legacy keys, and leaving XTMODKEYS on would change what they receive.
878
+ stdout.write(KEY_MODIFIER_REPORT_OFF + MOUSE_REPORT_OFF);
879
+ };
880
+ bindComposerInput();
881
+ });
882
+ }
883
+ //# sourceMappingURL=composer.js.map