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.
- package/dist/commands/chat.d.ts +7 -70
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +279 -453
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/chat_meta.d.ts +36 -0
- package/dist/commands/chat_meta.d.ts.map +1 -0
- package/dist/commands/chat_meta.js +102 -0
- package/dist/commands/chat_meta.js.map +1 -0
- package/dist/commands/chat_outcome.d.ts +31 -0
- package/dist/commands/chat_outcome.d.ts.map +1 -0
- package/dist/commands/chat_outcome.js +70 -0
- package/dist/commands/chat_outcome.js.map +1 -0
- package/dist/commands/chat_search.d.ts +24 -0
- package/dist/commands/chat_search.d.ts.map +1 -0
- package/dist/commands/chat_search.js +61 -0
- package/dist/commands/chat_search.js.map +1 -0
- package/dist/commands/chat_slash.d.ts +17 -0
- package/dist/commands/chat_slash.d.ts.map +1 -0
- package/dist/commands/chat_slash.js +109 -0
- package/dist/commands/chat_slash.js.map +1 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +25 -1
- package/dist/commands/run.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -2
- package/dist/config.js.map +1 -1
- package/dist/diagnostic_log.d.ts.map +1 -1
- package/dist/diagnostic_log.js +1 -0
- package/dist/diagnostic_log.js.map +1 -1
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +5 -1
- package/dist/sessions.js.map +1 -1
- package/dist/transcript.d.ts +19 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +76 -0
- package/dist/transcript.js.map +1 -0
- package/dist/turn_summary.d.ts +76 -0
- package/dist/turn_summary.d.ts.map +1 -0
- package/dist/turn_summary.js +305 -0
- package/dist/turn_summary.js.map +1 -0
- package/dist/ui/box.d.ts +19 -0
- package/dist/ui/box.d.ts.map +1 -1
- package/dist/ui/box.js +119 -12
- package/dist/ui/box.js.map +1 -1
- package/dist/ui/chart.d.ts +2 -0
- package/dist/ui/chart.d.ts.map +1 -0
- package/dist/ui/chart.js +97 -0
- package/dist/ui/chart.js.map +1 -0
- package/dist/ui/composer.d.ts +80 -0
- package/dist/ui/composer.d.ts.map +1 -0
- package/dist/ui/composer.js +883 -0
- package/dist/ui/composer.js.map +1 -0
- package/dist/ui/help_content.d.ts +2 -2
- package/dist/ui/help_content.d.ts.map +1 -1
- package/dist/ui/help_content.js +5 -3
- package/dist/ui/help_content.js.map +1 -1
- package/dist/ui/model_selector.d.ts +1 -0
- package/dist/ui/model_selector.d.ts.map +1 -1
- package/dist/ui/model_selector.js +61 -18
- package/dist/ui/model_selector.js.map +1 -1
- package/dist/ui/popup.d.ts.map +1 -1
- package/dist/ui/popup.js +7 -0
- package/dist/ui/popup.js.map +1 -1
- package/dist/ui/provider_connect.d.ts +8 -0
- package/dist/ui/provider_connect.d.ts.map +1 -1
- package/dist/ui/provider_connect.js +81 -23
- package/dist/ui/provider_connect.js.map +1 -1
- package/dist/ui/renderer.d.ts +49 -4
- package/dist/ui/renderer.d.ts.map +1 -1
- package/dist/ui/renderer.js +167 -31
- package/dist/ui/renderer.js.map +1 -1
- package/dist/ui/terminal_approval.d.ts +21 -0
- package/dist/ui/terminal_approval.d.ts.map +1 -1
- package/dist/ui/terminal_approval.js +145 -47
- package/dist/ui/terminal_approval.js.map +1 -1
- package/dist/ui/welcome.d.ts +274 -69
- package/dist/ui/welcome.d.ts.map +1 -1
- package/dist/ui/welcome.js +729 -700
- package/dist/ui/welcome.js.map +1 -1
- package/dist/vendor/providers/presets.d.ts +16 -0
- package/dist/vendor/providers/presets.d.ts.map +1 -1
- package/dist/vendor/providers/presets.js +13 -0
- package/dist/vendor/providers/presets.js.map +1 -1
- package/dist/vendor/tools/checkpoints.js +5 -5
- package/dist/vendor/tools/checkpoints.js.map +1 -1
- package/dist/vendor/tools/jail.d.ts +8 -0
- package/dist/vendor/tools/jail.d.ts.map +1 -1
- package/dist/vendor/tools/jail.js +13 -2
- package/dist/vendor/tools/jail.js.map +1 -1
- package/dist/vendor/tools/tools/apply_patch.d.ts.map +1 -1
- package/dist/vendor/tools/tools/apply_patch.js +6 -3
- package/dist/vendor/tools/tools/apply_patch.js.map +1 -1
- package/dist/vendor/tools/tools/git_diff.d.ts.map +1 -1
- package/dist/vendor/tools/tools/git_diff.js +5 -3
- package/dist/vendor/tools/tools/git_diff.js.map +1 -1
- 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
|