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