moderado 0.4.46 → 0.4.51

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.
@@ -1 +1 @@
1
- {"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../../src/ui/composer.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAuBtD,OAAO,KAAK,EAAE,iBAAiB,EAAwB,MAAM,cAAc,CAAC;AAmC5E,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,qGAAqG;IACrG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,iBAAiB,GAAG,SAAS,CAAC;IACtD;;;;;OAKG;IACH,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3F,kFAAkF;IAClF,sBAAsB,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAClD,4EAA4E;IAC5E,SAAS,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvF,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtF,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxF,KAAK,CAAC,EAAE,CACN,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EAC9F,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;IAC1G,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACzD,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAOD,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,qBAAqB,CAAC,CAgvBhC"}
1
+ {"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../../src/ui/composer.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,7 @@
7
7
  import readline from 'node:readline';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { activeMentionToken, filterMentionCandidates } from './file_mentions.js';
10
- import { MOUSE_REPORT_OFF, MOUSE_REPORT_ON, MOUSE_REPORT_PATTERN, composerInputWindow, exitCleanly, getChatComposerRows, getChatLayout, getChatCardWidth, getMatchingCommands, getWelcomeBottomPadding, getWelcomeCardWidth, getWelcomeIndent, navigateQuestionHistory, renderCenteredWelcomeScreen, renderFullWelcomeScreen, renderHelpPopupBox, renderWelcomePopupLayer, windowChatTranscriptAt, buildChatTranscriptRows, } from './welcome.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, chatTranscriptOffsetForRow, getChatTranscriptWidth, } from './welcome.js';
11
11
  async function animateChatWordmark(stdout) {
12
12
  const wordmark = 'MODERADO';
13
13
  const frames = 6;
@@ -19,7 +19,7 @@ async function animateChatWordmark(stdout) {
19
19
  const endColumn = Math.max(1, columns - width + 1);
20
20
  const progress = frame / frames;
21
21
  const column = Math.round(startColumn + (endColumn - startColumn) * progress);
22
- const startRow = Math.max(1, Math.floor(rows / 2) - 4);
22
+ const startRow = Math.max(1, Math.floor(rows / 2) - 5);
23
23
  const row = Math.round(startRow + (1 - startRow) * progress);
24
24
  stdout.write(`\x1b[H\x1b[J\x1b[${row};${column}H\x1b[1;38;5;75m${wordmark.slice(0, width)}\x1b[0m`);
25
25
  if (frame < frames)
@@ -44,6 +44,36 @@ function isMcpCommandInput(value) {
44
44
  const command = value.trim();
45
45
  return command === '/mcp' || command.startsWith('/mcp ');
46
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
+ const WIN32_KEY_PATTERN = /\x1b\[(\d+);\d+;(\d+);([01]);(\d+);(\d+)_/g;
55
+ const WIN32_INPUT_ON = '\x1b[?9001h';
56
+ const WIN32_INPUT_OFF = '\x1b[?9001l';
57
+ const WIN32_KEY_NAMES = {
58
+ 8: 'backspace', 9: 'tab', 13: 'return', 27: 'escape',
59
+ 33: 'pageup', 34: 'pagedown', 37: 'left', 38: 'up',
60
+ 39: 'right', 40: 'down', 46: 'delete',
61
+ };
62
+ function decodeModifiedKey(text) {
63
+ const match = MODIFIED_KEY_PATTERN.exec(text);
64
+ if (!match)
65
+ return undefined;
66
+ const modifier = Number(match[1] ?? match[4]);
67
+ const keyCode = Number(match[2] ?? match[3]);
68
+ const bits = modifier - 1; // 1 = Shift, 2 = Alt, 4 = Ctrl
69
+ if (keyCode === 13 && (bits & 1))
70
+ return 'newline';
71
+ if (keyCode === 9 && (bits & 1))
72
+ return 'toggle-auto-approve';
73
+ if (keyCode === 99 && (bits & 4))
74
+ return 'cancel';
75
+ return undefined;
76
+ }
47
77
  export async function promptInteractiveTurn(options) {
48
78
  const stdin = process.stdin;
49
79
  const stdout = process.stdout;
@@ -55,6 +85,10 @@ export async function promptInteractiveTurn(options) {
55
85
  let caret = 0;
56
86
  /** True while keypress events derived from a mouse report are being dropped. */
57
87
  let ignoreComposerKeypress = false;
88
+ const useWin32Input = process.platform === 'win32' && Boolean(process.env.WT_SESSION);
89
+ let win32InputBound = false;
90
+ let pendingWin32Key = '';
91
+ let handleWin32Keypress = () => { };
58
92
  /** Terminal height, with the same fallback the renderer uses. */
59
93
  const getRows = () => stdout.rows || 24;
60
94
  let commandSelection = 0;
@@ -106,6 +140,7 @@ export async function promptInteractiveTurn(options) {
106
140
  commandSelection,
107
141
  chatQuestion: options.chatQuestion,
108
142
  chatAnswer: options.chatAnswer,
143
+ chatHistory: options.chatHistory,
109
144
  chatAnswerOffset: answerOffset,
110
145
  // Live activity for the running turn, so a composer-driven repaint shows
111
146
  // the transcript the loop is painting rather than an empty answer area.
@@ -141,23 +176,26 @@ export async function promptInteractiveTurn(options) {
141
176
  stdin.resume();
142
177
  stdin.setRawMode(true);
143
178
  const getExtraLines = () => mentionFiles.length > 0 ? mentionFiles.length + 2 : 0;
144
- /** Rows between the painted composer line and the terminal's last row. */
145
- const getComposerOffset = () => getWelcomeBottomPadding(getOptions(), getRows()) + 4 + getExtraLines();
146
- /** Screen row and 1-based column of the composer's first text cell. */
147
- const getComposerAnchor = () => {
179
+ /** Columns of the card the input is painted in. */
180
+ const getCardWidth = () => getOptions().chatQuestion !== undefined
181
+ ? getChatCardWidth(stdout.columns)
182
+ : getWelcomeCardWidth(stdout.columns);
183
+ /** Painted rows of the input box plus where the caret sits in them. */
184
+ const getInputView = () => composerView(input, caret, getCardWidth(), getComposerInputMaxRows(getRows()));
185
+ /** Rows between the input's first row and the terminal's last row. */
186
+ const getComposerOffset = () => getWelcomeBottomPadding(getOptions(), getRows()) + 3 + getInputView().rows.length + getExtraLines();
187
+ /** Screen row and 1-based column of the caret's cell in the input box. */
188
+ const getComposerAnchor = (caretRow = getInputView().caretRow) => {
148
189
  const options = getOptions();
149
- const cardWidth = options.chatQuestion !== undefined
150
- ? getChatCardWidth(stdout.columns)
151
- : getWelcomeCardWidth(stdout.columns);
152
- const column = getWelcomeIndent(cardWidth, stdout.columns) + 3;
190
+ const column = getWelcomeIndent(getCardWidth(), stdout.columns) + 3;
153
191
  if (options.chatQuestion !== undefined) {
154
192
  // Chat frames place the composer at a computed row. Measure the card that
155
- // is actually painted — a queued-commands or mention box makes it taller —
156
- // so the caret never lands on the mode row or inside the box.
193
+ // is actually painted — a queued-commands box or a wrapped prompt makes it
194
+ // taller — so the caret never lands on the mode row or inside the box.
157
195
  const layout = getChatLayout(stdout.columns, getRows(), options.chatQuestion, getChatComposerRows(options));
158
- return { row: layout.composerTop + 1, column };
196
+ return { row: layout.composerTop + 1 + caretRow, column };
159
197
  }
160
- return { row: getRows() - getComposerOffset(), column };
198
+ return { row: getRows() - getComposerOffset() + caretRow, column };
161
199
  };
162
200
  /**
163
201
  * Move cursor to the ❯ input line (Line 2 inside the centered card).
@@ -166,20 +204,18 @@ export async function promptInteractiveTurn(options) {
166
204
  * pressed repeatedly, e.g. browsing history with Left/Right.
167
205
  */
168
206
  const positionCursorOnInput = () => {
169
- const { row, column } = getComposerAnchor();
170
- const cardWidth = getOptions().chatQuestion !== undefined
171
- ? getChatCardWidth(stdout.columns)
172
- : getWelcomeCardWidth(stdout.columns);
173
- const { caretColumn } = composerInputWindow(input, caret, cardWidth);
174
- stdout.write(`\x1b[1 q\x1b[?25h\x1b[${row};${column + caretColumn}H`);
207
+ const view = getInputView();
208
+ const { row, column } = getComposerAnchor(view.caretRow);
209
+ stdout.write(`\x1b[1 q\x1b[?25h\x1b[${row};${column + view.caretColumn}H`);
175
210
  };
176
211
  /** Full-screen redraw: clear everything, repaint welcome TUI, reposition cursor. */
177
212
  const redrawFull = () => {
213
+ stdout.write('\x1b[?2026h');
178
214
  stdout.write('\x1b[H\x1b[J');
179
215
  stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
180
216
  positionCursorOnInput();
217
+ stdout.write('\x1b[?2026l');
181
218
  };
182
- /** Repaint the welcome view so it remains centered after every edit or resize. */
183
219
  const redrawCard = () => {
184
220
  redrawFull();
185
221
  };
@@ -201,7 +237,7 @@ export async function promptInteractiveTurn(options) {
201
237
  const layout = currentChatLayout();
202
238
  if (layout.answerRows <= 0)
203
239
  return 0;
204
- return windowChatTranscriptAt(buildChatTranscriptRows(activity, answer ?? '', layout.area.textWidth).rows, layout.answerRows, 0).hiddenAbove;
240
+ return windowChatTranscriptAt(buildChatTranscriptRows(activity, answer ?? '', getChatTranscriptWidth(layout), options.chatHistory, options.chatQuestion).rows, layout.answerRows, 0).hiddenAbove;
205
241
  };
206
242
  /**
207
243
  * Move the transcript view; positive scrolls up towards older lines.
@@ -222,15 +258,84 @@ export async function promptInteractiveTurn(options) {
222
258
  options.onTranscriptScroll?.(next);
223
259
  redrawFull();
224
260
  };
261
+ const jumpAnswerToRow = (row) => {
262
+ const layout = currentChatLayout();
263
+ const rows = buildChatTranscriptRows(options.getChatActivity?.(), options.chatAnswer ?? '', getChatTranscriptWidth(layout), options.chatHistory, options.chatQuestion).rows;
264
+ if (rows.length <= layout.answerRows)
265
+ return;
266
+ const next = chatTranscriptOffsetForRow(rows.length, layout.answerRows, row);
267
+ if (next === answerOffset)
268
+ return;
269
+ answerOffset = next;
270
+ options.onTranscriptScroll?.(next);
271
+ redrawFull();
272
+ };
225
273
  /** One page is one screenful of answer rows, minus the overflow note. */
226
274
  const answerPage = () => Math.max(1, currentChatLayout().answerRows - 1);
275
+ /** Insert a line break at the caret — Shift+Enter and Ctrl+J land here. */
276
+ const insertLineFeed = () => {
277
+ input = input.slice(0, caret) + '\n' + input.slice(caret);
278
+ caret += 1;
279
+ commandSelection = 0;
280
+ questionHistoryIndex = questionHistory.length;
281
+ updateMentions();
282
+ redrawCard();
283
+ };
284
+ /** Toggle Plan/Execute; kept callable from a decoded escape sequence too. */
285
+ const toggleMode = () => {
286
+ currentMode = currentMode === 'Plan' ? 'Execute' : 'Plan';
287
+ redrawCard();
288
+ };
227
289
  /**
228
- * Place the caret where the user clicked on the composer line. Only a primary
290
+ * Cancel the session from a decoded escape sequence (Ctrl+C reported as a
291
+ * modified key). Assigned once the cleanup path exists.
292
+ */
293
+ let cancelSession = () => { };
294
+ /**
295
+ * Place the caret where the user clicked inside the input box. Only a primary
229
296
  * press is honoured; releases, drags, wheel and other buttons are ignored.
230
297
  */
231
- const onMouseData = (chunk) => {
298
+ const onComposerData = (chunk) => {
232
299
  const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
233
300
  let reported = false;
301
+ // Windows Terminal's Win32 input mode reports every key as a record.
302
+ // Translate those records through the same composer handler as legacy keys.
303
+ if (win32InputBound) {
304
+ const keyData = pendingWin32Key + text;
305
+ const records = [...keyData.matchAll(WIN32_KEY_PATTERN)];
306
+ pendingWin32Key = /\x1b\[[0-9;]*$/.exec(keyData)?.[0] ?? '';
307
+ if (records.length || pendingWin32Key)
308
+ reported = true;
309
+ for (const record of records) {
310
+ if (record[3] !== '1')
311
+ continue;
312
+ const virtualKey = Number(record[1]);
313
+ const unicode = Number(record[2]);
314
+ const state = Number(record[4]);
315
+ const repeat = Number(record[5]);
316
+ if (unicode > 65535 || repeat < 1 || repeat > 256)
317
+ continue;
318
+ const char = unicode ? String.fromCharCode(unicode) : '';
319
+ const name = WIN32_KEY_NAMES[virtualKey] ?? (virtualKey >= 65 && virtualKey <= 90 ? String.fromCharCode(virtualKey).toLowerCase() : char.toLowerCase());
320
+ if (!name)
321
+ continue;
322
+ const altGr = Boolean((state & 1) && (state & 8) && unicode >= 32);
323
+ const key = { name, shift: Boolean(state & 16), ctrl: Boolean(state & 12) && !altGr, meta: Boolean(state & 3) && !altGr };
324
+ for (let index = 0; index < repeat; index++)
325
+ handleWin32Keypress(virtualKey === 13 ? '\r' : char, key);
326
+ }
327
+ }
328
+ // Shift+Enter arrives as one of these sequences once the terminal reports
329
+ // modified keys; readline would otherwise type it out as literal garbage.
330
+ const modified = decodeModifiedKey(text);
331
+ if (modified === 'newline')
332
+ insertLineFeed();
333
+ else if (modified === 'toggle-auto-approve')
334
+ toggleMode();
335
+ else if (modified === 'cancel')
336
+ cancelSession();
337
+ if (modified)
338
+ reported = true;
234
339
  for (const report of text.matchAll(MOUSE_REPORT_PATTERN)) {
235
340
  reported = true;
236
341
  // Wheel up (64) / down (65): three rows a notch, like every other pager.
@@ -240,10 +345,21 @@ export async function promptInteractiveTurn(options) {
240
345
  }
241
346
  if (report[4] !== 'M' || Number(report[1]) !== 0)
242
347
  continue;
243
- const { row, column } = getComposerAnchor();
244
- if (Number(report[3]) !== row)
348
+ if (options.chatQuestion !== undefined) {
349
+ const layout = currentChatLayout();
350
+ const scrollbarColumn = layout.area.startColumn + getChatTranscriptWidth(layout);
351
+ const clickedRow = Number(report[3]) - layout.answerTop;
352
+ if (Number(report[2]) === scrollbarColumn && clickedRow >= 0 && clickedRow < layout.answerRows) {
353
+ jumpAnswerToRow(clickedRow);
354
+ continue;
355
+ }
356
+ }
357
+ const view = getInputView();
358
+ const { row, column } = getComposerAnchor(0);
359
+ const rowOffset = Number(report[3]) - row;
360
+ if (rowOffset < 0 || rowOffset >= view.rows.length)
245
361
  continue;
246
- caret = Math.max(0, Math.min(input.length, Number(report[2]) - column));
362
+ caret = composerCaretIndex(input, view.rows, rowOffset, Number(report[2]) - column);
247
363
  positionCursorOnInput();
248
364
  }
249
365
  if (reported) {
@@ -254,7 +370,7 @@ export async function promptInteractiveTurn(options) {
254
370
  };
255
371
  // Registered before readline's decoder so a report is consumed before it is
256
372
  // mistaken for typed text.
257
- stdin.prependListener('data', onMouseData);
373
+ stdin.prependListener('data', onComposerData);
258
374
  stdout.on('resize', onResize);
259
375
  // Initial paint
260
376
  if (options.isFirstTurn) {
@@ -270,9 +386,9 @@ export async function promptInteractiveTurn(options) {
270
386
  return new Promise((resolve) => {
271
387
  const cleanup = () => {
272
388
  unbindComposerInput();
273
- stdin.removeListener('data', onMouseData);
389
+ stdin.removeListener('data', onComposerData);
274
390
  stdout.removeListener('resize', onResize);
275
- stdout.write(`${MOUSE_REPORT_OFF}\x1b[0 q\x1b[?25h`);
391
+ stdout.write(`${KEY_MODIFIER_REPORT_OFF}${MOUSE_REPORT_OFF}\x1b[0 q\x1b[?25h`);
276
392
  if (stdin.isTTY) {
277
393
  try {
278
394
  stdin.setRawMode(false);
@@ -284,6 +400,10 @@ export async function promptInteractiveTurn(options) {
284
400
  }
285
401
  catch { /* ignore */ }
286
402
  };
403
+ cancelSession = () => {
404
+ cleanup();
405
+ void exitCleanly('\x1b[33mSession cancelled.\x1b[0m');
406
+ };
287
407
  const onAbort = () => {
288
408
  cleanup();
289
409
  resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
@@ -295,11 +415,34 @@ export async function promptInteractiveTurn(options) {
295
415
  // Using a void async IIFE so we can await async operations (model selector)
296
416
  // inside a synchronous event listener. For any async branch we first
297
417
  // remove ourselves from stdin so no duplicate events fire during the await.
298
- const onKeypress = (str, key) => {
418
+ const onKeypress = (str, key, fromWin32 = false) => {
299
419
  void (async () => {
300
420
  // Keystrokes decoded from a mouse report were already handled above.
301
- if (ignoreComposerKeypress)
421
+ if (!fromWin32 && ignoreComposerKeypress)
422
+ return;
423
+ // Intercept XTMODKEYS modified keys that readline cannot decode.
424
+ // The raw byte chunk `str` carries the escape sequence (e.g. CSI 27;1;13~
425
+ // for Shift+Enter); readline decodes those byte-by-byte and drops them,
426
+ // so we match the pattern directly and derive the keypress ourselves.
427
+ const modified = decodeModifiedKey(str);
428
+ if (modified === 'newline') {
429
+ insertLineFeed();
430
+ return;
431
+ }
432
+ if (modified === 'toggle-auto-approve') {
433
+ currentAutoApprove = !currentAutoApprove;
434
+ redrawCard();
302
435
  return;
436
+ }
437
+ if (modified === 'cancel') {
438
+ cleanup();
439
+ return await exitCleanly('\\x1b[33mSession cancelled.\\x1b[0m');
440
+ }
441
+ // Enter submits; a line feed (Ctrl+J, or Shift+Enter where the terminal
442
+ // reports it) breaks the line instead.
443
+ const isEnter = Boolean(key) && (key.name === 'return' || key.name === 'enter');
444
+ const isLineFeed = isEnter && (str === '\n' || key.shift === true || key.meta === true || key.ctrl === true);
445
+ const isSubmit = isEnter && !isLineFeed;
303
446
  if (options.signal?.aborted) {
304
447
  cleanup();
305
448
  resolve({ text: '', mode: currentMode, autoApprove: currentAutoApprove });
@@ -321,7 +464,7 @@ export async function promptInteractiveTurn(options) {
321
464
  // ── /help overlay popup ───────────────────────────────────────────────
322
465
  // Triggered when Enter is pressed with "/help" already in the input box.
323
466
  // We keep the main TUI as background and layer the help box on top.
324
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/help') {
467
+ if (isSubmit && input.trim() === '/help') {
325
468
  setInput('');
326
469
  unbindComposerInput(); // pause main handler
327
470
  // Paint: full welcome TUI (background) + help box on top
@@ -355,6 +498,37 @@ export async function promptInteractiveTurn(options) {
355
498
  bindComposerInput(); // re-attach main handler
356
499
  return;
357
500
  }
501
+ // ── /about overlay popup ──────────────────────────────────────────────
502
+ // Triggered when Enter is pressed with "/about" already in the input box.
503
+ if (isSubmit && input.trim() === '/about') {
504
+ setInput('');
505
+ unbindComposerInput();
506
+ const aboutLines = renderAboutPopupBox(options.version ?? 'v0.1');
507
+ const paintAbout = () => {
508
+ stdout.write('\x1b[H\x1b[J');
509
+ stdout.write(renderWelcomePopupLayer(getOptions(), aboutLines, stdout.columns, stdout.rows));
510
+ };
511
+ const onAboutResize = () => paintAbout();
512
+ stdout.on('resize', onAboutResize);
513
+ paintAbout();
514
+ await new Promise((dismissResolve) => {
515
+ const onDismiss = (s, k) => {
516
+ if ((k && (k.name === 'escape' || k.name === 'return' || k.name === 'enter')) ||
517
+ s === 'q' || s === 'Q' ||
518
+ (k && k.ctrl && k.name === 'c')) {
519
+ stdin.removeListener('keypress', onDismiss);
520
+ stdout.removeListener('resize', onAboutResize);
521
+ dismissResolve();
522
+ }
523
+ };
524
+ stdin.on('keypress', onDismiss);
525
+ });
526
+ stdout.write('\x1b[H\x1b[J');
527
+ stdout.write(renderCenteredWelcomeScreen(getOptions(), stdout.rows));
528
+ positionCursorOnInput();
529
+ bindComposerInput();
530
+ return;
531
+ }
358
532
  // ── /model overlay popup ──────────────────────────────────────────────
359
533
  // Triggered when Enter is pressed with "/model" in the input box.
360
534
  // We temporarily detach the main keypress listener and hand the model
@@ -363,7 +537,7 @@ export async function promptInteractiveTurn(options) {
363
537
  // layer on top of it (Cline/OpenCode style) — the popup never scrolls
364
538
  // the background because every step redraws the whole frame.
365
539
  // After it returns, we repaint the full welcome TUI and reposition cursor.
366
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/model') {
540
+ if (isSubmit && input.trim() === '/model') {
367
541
  setInput('');
368
542
  unbindComposerInput(); // pause main handler
369
543
  if (options.onModelSelect) {
@@ -393,7 +567,7 @@ export async function promptInteractiveTurn(options) {
393
567
  bindComposerInput(); // re-attach main handler
394
568
  return;
395
569
  }
396
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/connect') {
570
+ if (isSubmit && input.trim() === '/connect') {
397
571
  setInput('');
398
572
  unbindComposerInput();
399
573
  const drawFrame = (popupLines) => {
@@ -414,7 +588,7 @@ export async function promptInteractiveTurn(options) {
414
588
  bindComposerInput();
415
589
  return;
416
590
  }
417
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/transcript') {
591
+ if (isSubmit && input.trim() === '/transcript') {
418
592
  setInput('');
419
593
  unbindComposerInput();
420
594
  stdout.write('\x1b[H\x1b[J');
@@ -447,7 +621,7 @@ export async function promptInteractiveTurn(options) {
447
621
  bindComposerInput();
448
622
  return;
449
623
  }
450
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/session')) {
624
+ if (isSubmit && input.trim().startsWith('/session')) {
451
625
  const command = input.trim();
452
626
  setInput('');
453
627
  unbindComposerInput();
@@ -473,7 +647,7 @@ export async function promptInteractiveTurn(options) {
473
647
  bindComposerInput();
474
648
  return;
475
649
  }
476
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/queue')) {
650
+ if (isSubmit && input.trim().startsWith('/queue')) {
477
651
  const command = input.trim();
478
652
  setInput('');
479
653
  unbindComposerInput();
@@ -502,7 +676,7 @@ export async function promptInteractiveTurn(options) {
502
676
  bindComposerInput();
503
677
  return;
504
678
  }
505
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/btw')) {
679
+ if (isSubmit && input.trim().startsWith('/btw')) {
506
680
  const command = input.trim();
507
681
  setInput('');
508
682
  unbindComposerInput();
@@ -545,7 +719,7 @@ export async function promptInteractiveTurn(options) {
545
719
  bindComposerInput();
546
720
  return;
547
721
  }
548
- if (key && (key.name === 'return' || key.name === 'enter') && isMcpCommandInput(input)) {
722
+ if (isSubmit && isMcpCommandInput(input)) {
549
723
  const command = input.trim();
550
724
  setInput('');
551
725
  unbindComposerInput();
@@ -564,7 +738,7 @@ export async function promptInteractiveTurn(options) {
564
738
  bindComposerInput();
565
739
  return;
566
740
  }
567
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/init') {
741
+ if (isSubmit && input.trim() === '/init') {
568
742
  setInput('');
569
743
  unbindComposerInput();
570
744
  if (options.onInit) {
@@ -582,7 +756,7 @@ export async function promptInteractiveTurn(options) {
582
756
  bindComposerInput();
583
757
  return;
584
758
  }
585
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim().startsWith('/workflow')) {
759
+ if (isSubmit && input.trim().startsWith('/workflow')) {
586
760
  const command = input.trim();
587
761
  setInput('');
588
762
  unbindComposerInput();
@@ -600,14 +774,14 @@ export async function promptInteractiveTurn(options) {
600
774
  return;
601
775
  }
602
776
  // ── /clear ────────────────────────────────────────────────────────────
603
- if (key && (key.name === 'return' || key.name === 'enter') && input.trim() === '/clear') {
777
+ if (isSubmit && input.trim() === '/clear') {
604
778
  setInput('');
605
779
  options.onClear?.();
606
780
  redrawFull();
607
781
  return;
608
782
  }
609
783
  // ── /exit ─────────────────────────────────────────────────────────────
610
- if (key && (key.name === 'return' || key.name === 'enter') &&
784
+ if (isSubmit &&
611
785
  (input.trim() === '/exit' || input.trim() === '/quit')) {
612
786
  cleanup();
613
787
  if (options.signal)
@@ -680,7 +854,11 @@ export async function promptInteractiveTurn(options) {
680
854
  return;
681
855
  }
682
856
  // Enter — submit real user message
683
- if (key.name === 'return' || key.name === 'enter') {
857
+ if (isLineFeed) {
858
+ insertLineFeed();
859
+ return;
860
+ }
861
+ if (isSubmit) {
684
862
  const matching = getMatchingCommands(input);
685
863
  if (input.startsWith('/') && matching.length > 0 && !matching.some((command) => command.name === input.trim())) {
686
864
  setInput(matching[Math.min(commandSelection, matching.length - 1)].name);
@@ -695,7 +873,10 @@ export async function promptInteractiveTurn(options) {
695
873
  options.signal.removeEventListener('abort', onAbort);
696
874
  if (animateFirstChat)
697
875
  await animateChatWordmark(stdout);
698
- const moveDown = 4 + getExtraLines();
876
+ // Move past the rows below the caret's row, so the frame that follows
877
+ // starts under the card however tall the input box grew.
878
+ const inputView = getInputView();
879
+ const moveDown = (inputView.rows.length - 1 - inputView.caretRow) + 4 + getExtraLines();
699
880
  stdout.write(`\x1b[${moveDown}B\r\n\x1b[0 q\x1b[?25h`);
700
881
  resolve({ text: trimmed, mode: currentMode, autoApprove: currentAutoApprove });
701
882
  return;
@@ -742,6 +923,7 @@ export async function promptInteractiveTurn(options) {
742
923
  }
743
924
  })();
744
925
  };
926
+ handleWin32Keypress = (str, key) => onKeypress(str, key, true);
745
927
  /**
746
928
  * Mouse reporting is only useful while the composer owns the keyboard: a
747
929
  * click on the composer line places the caret. Popups and sub-prompts take
@@ -749,12 +931,17 @@ export async function promptInteractiveTurn(options) {
749
931
  * normal text selection, and clicks never leak into their filter fields.
750
932
  */
751
933
  const bindComposerInput = () => {
752
- stdout.write(MOUSE_REPORT_ON);
934
+ win32InputBound = useWin32Input;
935
+ stdout.write(MOUSE_REPORT_ON + KEY_MODIFIER_REPORT_ON + (useWin32Input ? WIN32_INPUT_ON : ''));
753
936
  stdin.on('keypress', onKeypress);
754
937
  };
755
938
  const unbindComposerInput = () => {
939
+ win32InputBound = false;
940
+ pendingWin32Key = '';
756
941
  stdin.removeListener('keypress', onKeypress);
757
- stdout.write(MOUSE_REPORT_OFF);
942
+ // Key-modifier reporting is scoped to the composer: popups read plain
943
+ // legacy keys, and leaving XTMODKEYS on would change what they receive.
944
+ stdout.write((useWin32Input ? WIN32_INPUT_OFF : '') + KEY_MODIFIER_REPORT_OFF + MOUSE_REPORT_OFF);
758
945
  };
759
946
  bindComposerInput();
760
947
  });