moderado 0.4.34 → 0.4.46
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 +108 -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 +762 -0
- package/dist/ui/composer.js.map +1 -0
- package/dist/ui/help_content.d.ts +1 -1
- package/dist/ui/help_content.d.ts.map +1 -1
- package/dist/ui/help_content.js +4 -2
- 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 +223 -68
- package/dist/ui/welcome.d.ts.map +1 -1
- package/dist/ui/welcome.js +564 -687
- 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,9 +1,9 @@
|
|
|
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',
|
|
@@ -20,7 +20,18 @@ export function stripAnsi(str) {
|
|
|
20
20
|
function visibleLen(str) {
|
|
21
21
|
return displayWidth(str);
|
|
22
22
|
}
|
|
23
|
-
|
|
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
|
+
export function composerInputWindow(input, caret, width) {
|
|
24
35
|
const capacity = Math.max(1, width - 3);
|
|
25
36
|
const start = Math.max(0, Math.min(caret, input.length - capacity));
|
|
26
37
|
return { text: input.slice(start, start + capacity), caretColumn: caret - start };
|
|
@@ -30,6 +41,11 @@ export function getWelcomeCardWidth(width) {
|
|
|
30
41
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
31
42
|
return Math.max(70, Math.min(terminalWidth - 2, 120));
|
|
32
43
|
}
|
|
44
|
+
/** Chat fills the available columns; the startup Welcome card keeps its own width. */
|
|
45
|
+
export function getChatCardWidth(width) {
|
|
46
|
+
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
47
|
+
return Math.max(1, terminalWidth - 2);
|
|
48
|
+
}
|
|
33
49
|
/** Left indent that centers a block of `blockWidth` visible columns on the terminal. */
|
|
34
50
|
export function getWelcomeIndent(blockWidth, width) {
|
|
35
51
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
@@ -52,11 +68,156 @@ function centerBlock(lines, width) {
|
|
|
52
68
|
*/
|
|
53
69
|
export function getChatAnswerArea(width) {
|
|
54
70
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
55
|
-
const cardWidth =
|
|
71
|
+
const cardWidth = getChatCardWidth(terminalWidth);
|
|
56
72
|
const indent = getWelcomeIndent(cardWidth, terminalWidth);
|
|
57
|
-
const textWidth = Math.max(
|
|
73
|
+
const textWidth = Math.max(1, cardWidth - 2);
|
|
58
74
|
return { indent, cardWidth, textWidth, startColumn: indent + 1, wrapWidth: indent + textWidth };
|
|
59
75
|
}
|
|
76
|
+
/** The chat header is a single compact brand row. */
|
|
77
|
+
export const CHAT_HEADER_ROWS = 1;
|
|
78
|
+
/** Rows the composer card occupies: blank surface, input, blank surface, mode, workspace. */
|
|
79
|
+
export const CHAT_COMPOSER_ROWS = 5;
|
|
80
|
+
/**
|
|
81
|
+
* Fixed row geometry for the chat window. The answer area is bounded by the
|
|
82
|
+
* composer: a long answer is windowed instead of pushing the input card, the
|
|
83
|
+
* mode row, and the workspace row off screen (or painting over them).
|
|
84
|
+
*/
|
|
85
|
+
export function getChatLayout(width, height, question = '', composerRows = CHAT_COMPOSER_ROWS) {
|
|
86
|
+
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
87
|
+
// Honor the real viewport: a hard floor here would emit a frame taller than a
|
|
88
|
+
// short terminal and scroll the composer away. A very short terminal simply
|
|
89
|
+
// shows fewer sections (the composer always wins — see renderChatScreen).
|
|
90
|
+
const terminalHeight = Math.max(2, height ?? (process.stdout.rows || 24));
|
|
91
|
+
const area = getChatAnswerArea(terminalWidth);
|
|
92
|
+
const wrappedQuestion = wrapText(question, Math.max(1, area.textWidth - 2)).length;
|
|
93
|
+
const questionTop = CHAT_HEADER_ROWS + 2;
|
|
94
|
+
// Rows above the answer: header, blank, question rows, status, blank.
|
|
95
|
+
const answerTopOffset = CHAT_HEADER_ROWS + 4;
|
|
96
|
+
// The frame has to end on the second-to-last row: a trailing newline written
|
|
97
|
+
// on the bottom row scrolls the viewport and drags the composer out of place.
|
|
98
|
+
// So the composer is pinned to the viewport bottom and keeps every row it
|
|
99
|
+
// needs — a queued-commands box makes it taller, and the answer area gives up
|
|
100
|
+
// those rows instead of pushing the card off screen.
|
|
101
|
+
const composerTop = Math.max(2, terminalHeight - Math.max(CHAT_COMPOSER_ROWS, composerRows));
|
|
102
|
+
// A pathological question must never starve the answer or the composer; when
|
|
103
|
+
// a tall composer leaves no room at all the question is dropped, not the card.
|
|
104
|
+
const questionRows = Math.max(0, Math.min(wrappedQuestion, composerTop - answerTopOffset - 2));
|
|
105
|
+
const statusRow = questionTop + questionRows;
|
|
106
|
+
const answerTop = statusRow + 2;
|
|
107
|
+
return {
|
|
108
|
+
area,
|
|
109
|
+
questionTop,
|
|
110
|
+
questionRows,
|
|
111
|
+
statusRow,
|
|
112
|
+
answerTop,
|
|
113
|
+
answerRows: Math.max(0, composerTop - 1 - answerTop),
|
|
114
|
+
composerTop,
|
|
115
|
+
terminalWidth,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Rows the composer card occupies for a given layout, including any queued-commands box. */
|
|
119
|
+
export function getChatComposerRows(options) {
|
|
120
|
+
return renderWelcomeCard(options).split('\n').length;
|
|
121
|
+
}
|
|
122
|
+
/** Invisible prefix that marks a rendered line as part of the turn summary. */
|
|
123
|
+
const SUMMARY_MARK = '\u0001';
|
|
124
|
+
/**
|
|
125
|
+
* Wrap an answer for the card, marking the turn summary as it goes.
|
|
126
|
+
*
|
|
127
|
+
* The fence has to be resolved *before* wrapping, not after: a summary line that
|
|
128
|
+
* wraps would lose its marker on the continuation, and a block whose opening
|
|
129
|
+
* marker scrolled off the top would stop being styled exactly when it is
|
|
130
|
+
* longest. Every wrapped line inside the fence carries `SUMMARY_MARK`, so the
|
|
131
|
+
* renderer can tint any row on its own.
|
|
132
|
+
*/
|
|
133
|
+
export function wrapAnswerLines(text, textWidth) {
|
|
134
|
+
const lines = [];
|
|
135
|
+
let inSummary = false;
|
|
136
|
+
const answerLines = text.split('\n');
|
|
137
|
+
for (let index = 0; index < answerLines.length; index += 1) {
|
|
138
|
+
const rawLine = answerLines[index];
|
|
139
|
+
const trimmed = rawLine.trim();
|
|
140
|
+
if (trimmed === '```chart') {
|
|
141
|
+
const closing = answerLines.findIndex((line, lineIndex) => lineIndex > index && line.trim() === '```');
|
|
142
|
+
if (closing !== -1) {
|
|
143
|
+
const chartLines = renderChartBlock(answerLines.slice(index + 1, closing).join('\n'), textWidth);
|
|
144
|
+
if (chartLines) {
|
|
145
|
+
lines.push(...chartLines);
|
|
146
|
+
index = closing;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!inSummary && trimmed === SUMMARY_FENCE) {
|
|
152
|
+
inSummary = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (inSummary && trimmed === '```') {
|
|
156
|
+
inSummary = false;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!inSummary) {
|
|
160
|
+
lines.push(...wrapText(rawLine, textWidth));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// The rail and its gap cost two cells, so summary prose wraps two cells early.
|
|
164
|
+
for (const wrapped of wrapText(rawLine, Math.max(8, textWidth - 2)))
|
|
165
|
+
lines.push(SUMMARY_MARK + wrapped);
|
|
166
|
+
}
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Window the answer around an offset, measured in rows scrolled up from the
|
|
171
|
+
* tail. `offset = 0` is the live view (newest rows). One row is reserved for the
|
|
172
|
+
* "earlier lines hidden" note whenever anything is above the window.
|
|
173
|
+
*/
|
|
174
|
+
export function windowChatAnswerAt(text, maxRows, textWidth, offsetFromBottom = 0) {
|
|
175
|
+
const wrapped = wrapAnswerLines(text.trimEnd(), textWidth);
|
|
176
|
+
if (wrapped.length <= maxRows)
|
|
177
|
+
return { lines: wrapped, hiddenAbove: 0, hiddenBelow: 0 };
|
|
178
|
+
// The note costs a row, so the content capacity is one row smaller — and the
|
|
179
|
+
// maximum offset follows from that capacity, not from the raw row count.
|
|
180
|
+
const capacity = Math.max(1, maxRows - 1);
|
|
181
|
+
const maxOffset = Math.max(0, wrapped.length - capacity);
|
|
182
|
+
const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
|
|
183
|
+
const end = wrapped.length - offset;
|
|
184
|
+
const start = Math.max(0, end - capacity);
|
|
185
|
+
return { lines: wrapped.slice(start, end), hiddenAbove: start, hiddenBelow: wrapped.length - end };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Style one answer row.
|
|
189
|
+
*
|
|
190
|
+
* Inside the turn-summary fence every row becomes a band: a rail in the accent
|
|
191
|
+
* colour, a tinted background that reaches the right edge of the card, and
|
|
192
|
+
* headings in warm bold. This is what makes the report read as a different kind
|
|
193
|
+
* of thing from the prose above it, rather than three more paragraphs.
|
|
194
|
+
*/
|
|
195
|
+
export function renderAnswerLine(line, textWidth) {
|
|
196
|
+
// Activity-log rows share the window with the answer but read as a record of
|
|
197
|
+
// what happened: dim, no rail, no band. One function, two kinds of row.
|
|
198
|
+
if (line.startsWith(LOG_MARK)) {
|
|
199
|
+
return `\x1b[38;5;244m${truncateToWidth(line.slice(LOG_MARK.length), textWidth)}\x1b[0m`;
|
|
200
|
+
}
|
|
201
|
+
if (!line.startsWith(SUMMARY_MARK)) {
|
|
202
|
+
const heading = line.match(/^\s*(?:#{1,6}\s+([^#]+?)|\*\*(.+?)\*\*)\s*$/);
|
|
203
|
+
return heading
|
|
204
|
+
? `\x1b[1;38;5;25m${(heading[1] ?? heading[2]).trim()}\x1b[0m`
|
|
205
|
+
: `\x1b[38;5;253m${line}\x1b[0m`;
|
|
206
|
+
}
|
|
207
|
+
const body = line.slice(SUMMARY_MARK.length);
|
|
208
|
+
const heading = body.match(/^#{1,6}\s+(.+)$/);
|
|
209
|
+
const bullet = body.match(/^[-*]\s+(.+)$/);
|
|
210
|
+
const text = heading ? heading[1].trim() : bullet ? `· ${bullet[1].trim()}` : body;
|
|
211
|
+
const tone = heading ? '1;38;5;222' : '38;5;253';
|
|
212
|
+
// Pad to the card so the tint is a solid band instead of a ragged highlight.
|
|
213
|
+
const padded = `${text}${' '.repeat(Math.max(0, textWidth - 2 - displayWidth(text)))}`;
|
|
214
|
+
return `\x1b[48;5;236m\x1b[38;5;75m▍\x1b[0;48;5;236m\x1b[${tone}m ${padded}\x1b[0m`;
|
|
215
|
+
}
|
|
216
|
+
export function chatAnswerOverflowNote(hidden) {
|
|
217
|
+
// Short on purpose: the status row carries the scroll position, and this row
|
|
218
|
+
// has to fit a 70-column card without wrapping.
|
|
219
|
+
return `… +${hidden} earlier line${hidden === 1 ? '' : 's'} hidden · PgUp`;
|
|
220
|
+
}
|
|
60
221
|
export function renderQueuedCommandsBox(commands, width) {
|
|
61
222
|
if (!commands || commands.length === 0)
|
|
62
223
|
return [];
|
|
@@ -84,7 +245,9 @@ export function renderQueuedCommandsBox(commands, width) {
|
|
|
84
245
|
return lines;
|
|
85
246
|
}
|
|
86
247
|
export function renderWelcomeCard(options) {
|
|
87
|
-
const width =
|
|
248
|
+
const width = options.chatQuestion === undefined
|
|
249
|
+
? getWelcomeCardWidth(options.width)
|
|
250
|
+
: getChatCardWidth(options.width);
|
|
88
251
|
const indent = ' '.repeat(getWelcomeIndent(width, options.width));
|
|
89
252
|
const displayInput = options.input && options.input.length > 0
|
|
90
253
|
? composerInputWindow(options.input, options.inputCaret ?? options.input.length, width).text
|
|
@@ -110,16 +273,26 @@ export function renderWelcomeCard(options) {
|
|
|
110
273
|
: processUsage;
|
|
111
274
|
const processInfo = options.chatQuestion ? `${processPrefix}${processSuffix}` : details.slice(0, modeSpace);
|
|
112
275
|
const left4 = processInfo ? `\x1b[38;5;244m${processInfo}\x1b[0m` : '';
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
276
|
+
const compactChatFooter = options.chatQuestion !== undefined && width < 72;
|
|
277
|
+
const right4Raw = compactChatFooter
|
|
278
|
+
? options.mode === 'Plan' ? '[Plan]' : '[Execute]'
|
|
279
|
+
: options.mode === 'Plan' ? '[Plan] / Execute (Tab)' : 'Plan / [Execute] (Tab)';
|
|
280
|
+
const compactMode = right4Raw.length > width ? (options.mode === 'Plan' ? 'P' : 'E') : right4Raw;
|
|
281
|
+
const right4 = compactChatFooter
|
|
282
|
+
? `\x1b[1;38;5;75m${compactMode}\x1b[0m`
|
|
283
|
+
: options.mode === 'Plan'
|
|
284
|
+
? '\x1b[1;38;5;75m[Plan]\x1b[0m / Execute (Tab)'
|
|
285
|
+
: 'Plan / \x1b[1;38;5;75m[Execute]\x1b[0m (Tab)';
|
|
286
|
+
const right4Visible = compactChatFooter ? compactMode : right4Raw;
|
|
287
|
+
const left4Fit = compactChatFooter
|
|
288
|
+
? truncateToWidth(processInfo, Math.max(0, width - right4Visible.length - 1))
|
|
289
|
+
: left4;
|
|
290
|
+
const spaces4Count = Math.max(0, width - visibleLen(left4Fit) - right4Visible.length);
|
|
291
|
+
const line4 = left4Fit + ' '.repeat(spaces4Count) + right4;
|
|
119
292
|
// Line 5: Working directory (left) ... provider, model, and Auto-approve (right)
|
|
120
|
-
const approvalLabelRaw =
|
|
121
|
-
? 'Auto-approve
|
|
122
|
-
: 'Auto-approve off (Shift+Tab)';
|
|
293
|
+
const approvalLabelRaw = compactChatFooter
|
|
294
|
+
? width < 20 ? (options.autoApprove ? 'On' : 'Off') : (options.autoApprove ? 'Auto-approve on' : 'Auto-approve off')
|
|
295
|
+
: options.autoApprove ? 'Auto-approve enabled (Shift+Tab)' : 'Auto-approve off (Shift+Tab)';
|
|
123
296
|
const modelSpace = Math.max(8, width - approvalLabelRaw.length - 10);
|
|
124
297
|
const provider = options.provider?.trim() ?? '';
|
|
125
298
|
const providerSpace = Math.max(1, modelSpace - 6);
|
|
@@ -140,7 +313,23 @@ export function renderWelcomeCard(options) {
|
|
|
140
313
|
: '\x1b[38;5;242mAuto-approve off (Shift+Tab)\x1b[0m';
|
|
141
314
|
const modelLabel = `\x1b[38;5;180m${modelText}\x1b[0m`;
|
|
142
315
|
const spaces5Count = Math.max(1, width - shortWs.length - modelText.length - approvalLabelRaw.length - 1);
|
|
143
|
-
const line5 =
|
|
316
|
+
const line5 = compactChatFooter
|
|
317
|
+
? (() => {
|
|
318
|
+
const fieldBudget = Math.max(0, width - approvalLabelRaw.length - 2);
|
|
319
|
+
const workspaceBudget = Math.ceil(fieldBudget / 2);
|
|
320
|
+
const modelBudget = fieldBudget - workspaceBudget;
|
|
321
|
+
const workspaceText = sliceTailToWidth(options.workspace, workspaceBudget);
|
|
322
|
+
const modelField = sliceTailToWidth(modelText, modelBudget);
|
|
323
|
+
if (fieldBudget === 0)
|
|
324
|
+
return truncateToWidth(approvalLabel, width);
|
|
325
|
+
const fields = [
|
|
326
|
+
workspaceText ? `\x1b[38;5;245m${workspaceText}\x1b[0m` : '',
|
|
327
|
+
modelField ? `\x1b[38;5;180m${modelField}\x1b[0m` : '',
|
|
328
|
+
`\x1b[38;5;242m${approvalLabelRaw}\x1b[0m`,
|
|
329
|
+
].filter(Boolean);
|
|
330
|
+
return fields.join(' ');
|
|
331
|
+
})()
|
|
332
|
+
: left5 + ' '.repeat(spaces5Count) + modelLabel + ' ' + approvalLabel;
|
|
144
333
|
const cardLines = [];
|
|
145
334
|
const queuedBox = renderQueuedCommandsBox(options.queuedCommands ?? [], width);
|
|
146
335
|
if (queuedBox.length > 0) {
|
|
@@ -261,6 +450,12 @@ export function renderModeradoHeader(width) {
|
|
|
261
450
|
...centerBlock([COMMAND_HINT], width),
|
|
262
451
|
].join('\n');
|
|
263
452
|
}
|
|
453
|
+
/** Compact wordmark used after the welcome screen transitions into chat. */
|
|
454
|
+
export function renderChatHeader(width) {
|
|
455
|
+
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
456
|
+
const label = 'MODERADO'.slice(0, Math.max(1, terminalWidth));
|
|
457
|
+
return ' '.repeat(Math.max(0, terminalWidth - label.length)) + `\x1b[1;38;5;75m${label}\x1b[0m`;
|
|
458
|
+
}
|
|
264
459
|
/** Word-wrap plain text to `maxWidth` visible columns, hard-breaking long words. */
|
|
265
460
|
export function wrapText(text, maxWidth) {
|
|
266
461
|
const out = [];
|
|
@@ -318,104 +513,388 @@ export function wrapText(text, maxWidth) {
|
|
|
318
513
|
export function renderChatScreen(options, height) {
|
|
319
514
|
const terminalWidth = options.width ?? (process.stdout.columns || 80);
|
|
320
515
|
const terminalHeight = height ?? process.stdout.rows ?? 24;
|
|
321
|
-
const
|
|
516
|
+
const question = options.chatQuestion ?? '';
|
|
517
|
+
// The composer is measured before the layout: a queued-commands box or a
|
|
518
|
+
// mention box makes the card taller, and the answer area must give up those
|
|
519
|
+
// rows instead of pushing the card past the bottom of the viewport.
|
|
520
|
+
const composer = renderWelcomeCard(options).split('\n');
|
|
521
|
+
const layout = getChatLayout(terminalWidth, terminalHeight, question, composer.length);
|
|
522
|
+
const area = layout.area;
|
|
322
523
|
const indent = ' '.repeat(area.indent);
|
|
323
524
|
const lines = [];
|
|
324
|
-
lines.push(
|
|
525
|
+
lines.push(renderChatHeader(terminalWidth));
|
|
325
526
|
lines.push('');
|
|
326
527
|
// Row 1: the user's question, wrapped onto the same columns as the composer card.
|
|
327
528
|
const questionMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
|
|
328
|
-
|
|
329
|
-
|
|
529
|
+
const questionLines = wrapText(question, Math.max(1, area.textWidth - 2));
|
|
530
|
+
const visibleQuestion = questionLines.slice(0, layout.questionRows);
|
|
531
|
+
for (const [index, questionLine] of visibleQuestion.entries()) {
|
|
532
|
+
const clipped = index === layout.questionRows - 1 && questionLines.length > layout.questionRows
|
|
533
|
+
? `${questionLine.slice(0, Math.max(0, area.textWidth - 3))}…`
|
|
534
|
+
: questionLine;
|
|
535
|
+
const questionText = `${index === 0 ? questionMarker : ' '} ${clipped}`;
|
|
330
536
|
lines.push(indent + String.fromCharCode(27) + '[48;5;236m' + questionText + ' '.repeat(Math.max(0, area.cardWidth - visibleLen(questionText))) + String.fromCharCode(27) + '[0m');
|
|
331
537
|
}
|
|
332
538
|
const thoughtTime = options.chatThoughtTime ?? 0;
|
|
333
539
|
const thoughtTimeLabel = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
540
|
+
const answerText = options.chatAnswer ?? '';
|
|
541
|
+
const answerOffset = options.chatAnswerOffset ?? 0;
|
|
542
|
+
// The activity log and the answer are one list, so both the scroll position
|
|
543
|
+
// and the window are computed over the merged rows.
|
|
544
|
+
const transcriptWidth = getChatTranscriptWidth(layout);
|
|
545
|
+
const transcript = buildChatTranscriptRows(options.chatActivity, answerText, transcriptWidth);
|
|
546
|
+
// While the reader is scrolled up, the status row belongs to the scroll
|
|
547
|
+
// position: the turn clock is not what they are looking for.
|
|
548
|
+
const scrollWindow = transcript.rows.length > 0 && layout.answerRows > 0 && answerOffset > 0
|
|
549
|
+
? windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset)
|
|
550
|
+
: undefined;
|
|
551
|
+
const activityElapsed = options.chatActivity
|
|
552
|
+
? (options.chatActivity.elapsedLabel ?? formatElapsed(thoughtTime))
|
|
553
|
+
: '';
|
|
554
|
+
// An offset that no longer hides anything (the answer shrank, or the viewport
|
|
555
|
+
// grew) must report the live view, not claim a scroll position that is gone.
|
|
556
|
+
// Sitting at the very top still counts: rows are hidden *below* the window.
|
|
557
|
+
const scrolled = scrollWindow !== undefined
|
|
558
|
+
&& (scrollWindow.hiddenAbove > 0 || scrollWindow.hiddenBelow > 0);
|
|
559
|
+
const chatStatus = scrolled
|
|
560
|
+
? `${scrollWindow.hiddenAbove === 0 ? 'Top of the turn' : `↑ ${scrollWindow.hiddenAbove} above`} · ↓ ${scrollWindow.hiddenBelow} below · PgUp/PgDn`
|
|
561
|
+
: options.chatActivity
|
|
562
|
+
? truncateToWidth(`${options.chatActivity.glyph} ${options.chatActivity.label} · ${activityElapsed}`, transcriptWidth)
|
|
563
|
+
: !answerText.trim()
|
|
339
564
|
? ''
|
|
340
|
-
:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
565
|
+
: options.chatTaskDone
|
|
566
|
+
? `✔ Done · Thought for ${thoughtTimeLabel}`
|
|
567
|
+
: options.tokenUsage
|
|
568
|
+
? ''
|
|
569
|
+
: `Thought for ${thoughtTimeLabel}`;
|
|
570
|
+
lines.push(chatStatus
|
|
571
|
+
? indent + `\x1b[38;5;244m${chatStatus}\x1b[0m`
|
|
572
|
+
: '');
|
|
573
|
+
// Transcript section: the activity log followed by the answer, windowed to the
|
|
574
|
+
// rows the composer leaves free. The log is never dropped to make room for the
|
|
575
|
+
// answer — that trade is what produced the one-line view.
|
|
576
|
+
if (transcript.rows.length > 0 && layout.answerRows > 0) {
|
|
344
577
|
lines.push('');
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
lines.push(indent + (
|
|
348
|
-
|
|
349
|
-
|
|
578
|
+
const { lines: transcriptLines, hiddenAbove } = windowChatTranscriptAt(transcript.rows, layout.answerRows, answerOffset);
|
|
579
|
+
if (hiddenAbove > 0)
|
|
580
|
+
lines.push(indent + `\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), transcriptWidth)}\x1b[0m`);
|
|
581
|
+
for (const transcriptLine of transcriptLines) {
|
|
582
|
+
lines.push(indent + renderAnswerLine(transcriptLine, getChatTranscriptWidth(layout)));
|
|
350
583
|
}
|
|
351
584
|
}
|
|
352
|
-
|
|
353
|
-
|
|
585
|
+
// Hard guarantee: the composer starts at `composerTop` and the frame ends one
|
|
586
|
+
// row above the viewport bottom, whatever the content did. This holds for any
|
|
587
|
+
// composer the viewport can hold (rows >= card height + 2); a terminal too
|
|
588
|
+
// short to show the card at all cannot be contained by layout arithmetic.
|
|
589
|
+
while (lines.length < layout.composerTop - 1)
|
|
590
|
+
lines.push('');
|
|
591
|
+
if (lines.length > layout.composerTop - 1)
|
|
592
|
+
lines.length = layout.composerTop - 1;
|
|
354
593
|
lines.push(...composer);
|
|
355
594
|
return lines.join('\n') + '\n';
|
|
356
595
|
}
|
|
357
|
-
/**
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
596
|
+
/**
|
|
597
|
+
* Place the terminal cursor on the input row. Passing the layout's
|
|
598
|
+
* `composerTop` addresses the row absolutely, which stays correct when a
|
|
599
|
+
* queued-commands box grows the card; the relative form is the legacy path.
|
|
600
|
+
*/
|
|
601
|
+
export function renderChatComposerCursor(options, inputLength, composerTop) {
|
|
602
|
+
const width = getChatCardWidth(options.width);
|
|
603
|
+
const indent = getWelcomeIndent(width, options.width);
|
|
604
|
+
const caretColumn = indent + 3 + Math.min(inputLength, Math.max(0, width - 3));
|
|
605
|
+
if (composerTop !== undefined)
|
|
606
|
+
return `\x1b[1 q\x1b[?25h\x1b[${composerTop + 1};${caretColumn}H`;
|
|
607
|
+
return `\x1b[1 q\x1b[?25h\x1b[4A\r\x1b[${indent + 2 + Math.min(inputLength, Math.max(0, width - 3))}C`;
|
|
362
608
|
}
|
|
363
609
|
export function navigateQuestionHistory(history, direction, index, draft) {
|
|
364
610
|
const nextIndex = Math.max(0, Math.min(history.length, index + direction));
|
|
365
611
|
return { input: nextIndex === history.length ? draft : history[nextIndex], index: nextIndex, draft };
|
|
366
612
|
}
|
|
613
|
+
/** Silence before the row admits it is waiting on the model. */
|
|
614
|
+
export const WAIT_NOTICE_AFTER_MS = 10_000;
|
|
615
|
+
/** How often that notice is refreshed while the wait continues. */
|
|
616
|
+
export const WAIT_NOTICE_REPEAT_MS = 20_000;
|
|
617
|
+
/**
|
|
618
|
+
* What to say while the model has produced nothing.
|
|
619
|
+
*
|
|
620
|
+
* Silent for the first ten seconds — that is normal inference. After that the
|
|
621
|
+
* row says it is waiting, and re-announces every twenty seconds with the length
|
|
622
|
+
* of the silence, so a long wait never looks like a hung process. The bucket is
|
|
623
|
+
* quantised so the line changes on that cadence instead of every second.
|
|
624
|
+
*/
|
|
625
|
+
export function describeWait(silentForMs) {
|
|
626
|
+
if (!Number.isFinite(silentForMs) || silentForMs < WAIT_NOTICE_AFTER_MS)
|
|
627
|
+
return undefined;
|
|
628
|
+
const repeats = Math.floor((silentForMs - WAIT_NOTICE_AFTER_MS) / WAIT_NOTICE_REPEAT_MS);
|
|
629
|
+
if (repeats === 0)
|
|
630
|
+
return { label: 'Waiting on the model' };
|
|
631
|
+
const announcedAt = (WAIT_NOTICE_AFTER_MS + repeats * WAIT_NOTICE_REPEAT_MS) / 1000;
|
|
632
|
+
return { label: 'Waiting on the model', elapsedLabel: `no output for ${announcedAt}s` };
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Elapsed time as a short label: `8s`, `1m 04s`.
|
|
636
|
+
*
|
|
637
|
+
* This replaced the step counter. `Step 1/25` was true for minutes at a time
|
|
638
|
+
* while one inference ran, so it measured the engine's internal loop rather
|
|
639
|
+
* than anything the person waiting could act on.
|
|
640
|
+
*/
|
|
641
|
+
export function formatElapsed(seconds) {
|
|
642
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
643
|
+
return '0s';
|
|
644
|
+
if (seconds < 1)
|
|
645
|
+
return '<1s';
|
|
646
|
+
const total = Math.round(seconds);
|
|
647
|
+
if (total < 60)
|
|
648
|
+
return `${total}s`;
|
|
649
|
+
const minutes = Math.floor(total / 60);
|
|
650
|
+
return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Shorten the middle of a label, keeping the head (the verb) and the tail (the
|
|
654
|
+
* filename, which is the part a person actually reads). Measured in terminal
|
|
655
|
+
* cells, not code units, so a CJK path cannot overflow the card.
|
|
656
|
+
*/
|
|
657
|
+
export function elideMiddle(text, max) {
|
|
658
|
+
if (max <= 0)
|
|
659
|
+
return '';
|
|
660
|
+
if (displayWidth(text) <= max)
|
|
661
|
+
return text;
|
|
662
|
+
if (max <= 3)
|
|
663
|
+
return truncateToWidth(text, max);
|
|
664
|
+
const budget = max - 1;
|
|
665
|
+
const head = Math.ceil(budget / 2);
|
|
666
|
+
const tail = budget - head;
|
|
667
|
+
return `${sliceToWidth(text, head)}…${sliceTailToWidth(text, tail)}`;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Shorten the engine's progress sentence into a phase the user can follow.
|
|
671
|
+
*
|
|
672
|
+
* The status row is the only thing moving during a long, silent inference — the
|
|
673
|
+
* reasoning stream is deliberately not displayed — so it names what the agent is
|
|
674
|
+
* doing instead of repeating the step counter.
|
|
675
|
+
*/
|
|
676
|
+
export function describeActivity(status) {
|
|
677
|
+
const text = status
|
|
678
|
+
.trim()
|
|
679
|
+
.replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')
|
|
680
|
+
.replace(/\s*\.{3}$/, '')
|
|
681
|
+
.trim();
|
|
682
|
+
if (/^inferring\b/i.test(text))
|
|
683
|
+
return 'Thinking';
|
|
684
|
+
if (/^discovering and selecting models/i.test(text))
|
|
685
|
+
return 'Selecting model';
|
|
686
|
+
if (/^retrying\b/i.test(text))
|
|
687
|
+
return 'Retrying request';
|
|
688
|
+
if (/^delegating sub-task/i.test(text))
|
|
689
|
+
return 'Delegating a sub-task';
|
|
690
|
+
return text || 'Working';
|
|
691
|
+
}
|
|
692
|
+
const TOOL_VERBS = {
|
|
693
|
+
read_file: 'Reading',
|
|
694
|
+
write_file: 'Writing',
|
|
695
|
+
edit_file: 'Editing',
|
|
696
|
+
apply_patch: 'Editing',
|
|
697
|
+
list_files: 'Listing',
|
|
698
|
+
search_files: 'Searching',
|
|
699
|
+
run_command: 'Running',
|
|
700
|
+
run_diagnostics: 'Running',
|
|
701
|
+
};
|
|
702
|
+
/**
|
|
703
|
+
* Name the work a tool call is about to do, from its parameters.
|
|
704
|
+
*
|
|
705
|
+
* `Running read_file` told the person nothing they could not already see; the
|
|
706
|
+
* path does. This is the part of the status row that keeps moving during a long
|
|
707
|
+
* coding turn, so it carries the real target.
|
|
708
|
+
*/
|
|
709
|
+
export function describeToolActivity(toolName, parameters = {}) {
|
|
710
|
+
const text = (key) => {
|
|
711
|
+
const value = parameters[key];
|
|
712
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
713
|
+
};
|
|
714
|
+
if (toolName === 'apply_patch') {
|
|
715
|
+
const edits = Array.isArray(parameters.edits) ? parameters.edits : [];
|
|
716
|
+
if (edits.length > 1)
|
|
717
|
+
return `Editing ${edits.length} files`;
|
|
718
|
+
const first = edits[0];
|
|
719
|
+
const path = typeof first?.path === 'string' ? first.path : undefined;
|
|
720
|
+
return path ? `Editing ${path}` : 'Editing files';
|
|
721
|
+
}
|
|
722
|
+
if (toolName === 'run_command') {
|
|
723
|
+
const command = text('command');
|
|
724
|
+
if (!command)
|
|
725
|
+
return 'Running a command';
|
|
726
|
+
const args = Array.isArray(parameters.args)
|
|
727
|
+
? parameters.args.filter((arg) => typeof arg === 'string')
|
|
728
|
+
: [];
|
|
729
|
+
return `Running ${[command, ...args].join(' ')}`;
|
|
730
|
+
}
|
|
731
|
+
if (toolName === 'search_files') {
|
|
732
|
+
const query = text('query');
|
|
733
|
+
return query ? `Searching "${query}"` : 'Searching the workspace';
|
|
734
|
+
}
|
|
735
|
+
if (toolName === 'list_files') {
|
|
736
|
+
const subpath = text('subpath');
|
|
737
|
+
return subpath && subpath !== '.' ? `Listing ${subpath}` : 'Listing the workspace';
|
|
738
|
+
}
|
|
739
|
+
if (toolName === 'run_diagnostics') {
|
|
740
|
+
const script = text('script');
|
|
741
|
+
return script ? `Running ${script}` : 'Running diagnostics';
|
|
742
|
+
}
|
|
743
|
+
if (toolName === 'web_search')
|
|
744
|
+
return 'Searching the web';
|
|
745
|
+
if (toolName === 'git_diff')
|
|
746
|
+
return 'Checking changes';
|
|
747
|
+
const verb = TOOL_VERBS[toolName];
|
|
748
|
+
const path = text('path');
|
|
749
|
+
if (verb && path)
|
|
750
|
+
return `${verb} ${path}`;
|
|
751
|
+
if (verb)
|
|
752
|
+
return verb;
|
|
753
|
+
return `Running ${toolName}`;
|
|
754
|
+
}
|
|
367
755
|
/**
|
|
368
756
|
* Update the fixed chat elapsed-time row without repainting the terminal.
|
|
369
757
|
* With an activity, the row is a live "still running" indicator that stays
|
|
370
758
|
* visible after text starts streaming and during silent tool execution.
|
|
371
759
|
*/
|
|
372
|
-
export function renderChatThoughtTimeUpdate(thoughtTime,
|
|
760
|
+
export function renderChatThoughtTimeUpdate(thoughtTime, width, activity, row = 13) {
|
|
373
761
|
const area = getChatAnswerArea(width);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
762
|
+
// A wait phase reports silence, not the turn clock; two timers side by side
|
|
763
|
+
// would be ambiguous.
|
|
764
|
+
const elapsed = activity?.elapsedLabel ?? formatElapsed(thoughtTime);
|
|
765
|
+
const paint = (status) => `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${truncateToWidth(status, area.cardWidth)}\x1b[0m\x1b8`;
|
|
766
|
+
if (!activity)
|
|
767
|
+
return paint(`Thought for ${elapsed}`);
|
|
768
|
+
// The timer is reserved before the label gets any room, so a long path can
|
|
769
|
+
// never push the elapsed time off the row.
|
|
770
|
+
const timer = ` · ${elapsed}`;
|
|
771
|
+
const timerCells = displayWidth(timer);
|
|
772
|
+
const label = elideMiddle(
|
|
773
|
+
// The engine's sentence often carries its own step prefix; the row no longer
|
|
774
|
+
// prints a counter, so drop it rather than showing engine internals.
|
|
775
|
+
`${activity.glyph} ${activity.label.replace(/^step\s+\d+\s*\/\s*\d+\s*:\s*/i, '')}`, Math.max(12, area.cardWidth - timerCells - 2));
|
|
776
|
+
const reasoning = activity.reasoning?.replace(/\s+/g, ' ').trim() ?? '';
|
|
777
|
+
// Everything here is measured in cells: a CJK reasoning tail is twice as wide
|
|
778
|
+
// as its length suggests, and the overflow used to wrap outside the card.
|
|
779
|
+
const room = area.cardWidth - displayWidth(label) - timerCells - 3;
|
|
780
|
+
const excerpt = reasoning && room > 8
|
|
781
|
+
? (displayWidth(reasoning) > room ? `…${sliceTailToWidth(reasoning, room - 1)}` : reasoning)
|
|
782
|
+
: '';
|
|
783
|
+
// Clock first, then the message: the timer belongs to the phase, so the
|
|
784
|
+
// trailing text can be cut without taking the time with it.
|
|
785
|
+
return paint(excerpt ? `${label}${timer} · ${excerpt}` : `${label}${timer}`);
|
|
382
786
|
}
|
|
383
787
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
788
|
+
* Invisible prefix marking a rendered row as part of the live activity log.
|
|
789
|
+
*
|
|
790
|
+
* A second mark next to `SUMMARY_MARK` lets one renderer style both kinds of
|
|
791
|
+
* row in the same scrollable list without them knowing about each other.
|
|
387
792
|
*/
|
|
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
|
-
|
|
793
|
+
const LOG_MARK = '\u0002';
|
|
794
|
+
/**
|
|
795
|
+
* Cells the transcript may use on this layout.
|
|
796
|
+
*
|
|
797
|
+
* The card has a 70-column floor, the terminal does not: on a narrow window the
|
|
798
|
+
* transcript still has to stop at the screen edge, not at the card's. Wrapping
|
|
799
|
+
* to the card width and painting anyway is what let a long row spill past the
|
|
800
|
+
* right border of the frame, so the wrap width is the narrower of the two and
|
|
801
|
+
* every row is clamped to it.
|
|
802
|
+
*/
|
|
803
|
+
export function getChatTranscriptWidth(layout) {
|
|
804
|
+
const { area } = layout;
|
|
805
|
+
return Math.min(area.textWidth, Math.min(area.cardWidth, Math.max(8, layout.terminalWidth - area.startColumn + 1)));
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Rows of the live turn transcript: the activity log first, the answer after.
|
|
809
|
+
*
|
|
810
|
+
* This is the Cline model. Cline keeps one scrollable list where every action
|
|
811
|
+
* and every message is a row, the newest pinned to the bottom and the rest
|
|
812
|
+
* reachable by scrolling. Merging the two here is what fixes "the output
|
|
813
|
+
* becomes one line again": the activity log used to be a separate block that
|
|
814
|
+
* handed its rows back the moment the first token streamed, so a long fix
|
|
815
|
+
* explanation left nothing on screen but a single clipped status row — exactly
|
|
816
|
+
* when there was most to read.
|
|
817
|
+
*
|
|
818
|
+
* The log and the answer now share the window, so the record of what the agent
|
|
819
|
+
* did and the answer it produced are read in one place and scroll together.
|
|
820
|
+
*/
|
|
821
|
+
export function buildChatTranscriptRows(activity, answer, textWidth) {
|
|
822
|
+
const rows = [];
|
|
823
|
+
for (const entry of activity?.history ?? []) {
|
|
824
|
+
rows.push(LOG_MARK + `${entry.glyph} ${entry.label}`);
|
|
825
|
+
}
|
|
826
|
+
const reasoning = activity?.reasoning?.replace(/\s+/g, ' ').trim();
|
|
827
|
+
if (reasoning) {
|
|
828
|
+
// Two columns of indent mark the thought as subordinate to the phase, and
|
|
829
|
+
// wrapping (not truncating) is what turns one clipped line into prose.
|
|
830
|
+
for (const line of wrapText(reasoning, Math.max(8, textWidth - 2)))
|
|
831
|
+
rows.push(LOG_MARK + ` ${line}`);
|
|
832
|
+
}
|
|
833
|
+
const answerStart = rows.length;
|
|
834
|
+
if (answer.trim().length > 0)
|
|
835
|
+
rows.push(...wrapAnswerLines(answer.trimEnd(), textWidth));
|
|
836
|
+
return { rows, answerStart };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Window the transcript around an offset, measured in rows scrolled up from the
|
|
840
|
+
* tail. `offset = 0` is the live view (newest rows). One row is reserved for
|
|
841
|
+
* the "earlier lines hidden" note whenever anything is above the window.
|
|
842
|
+
*/
|
|
843
|
+
export function windowChatTranscriptAt(rows, maxRows, offsetFromBottom = 0) {
|
|
844
|
+
if (rows.length <= maxRows)
|
|
845
|
+
return { lines: [...rows], hiddenAbove: 0, hiddenBelow: 0 };
|
|
846
|
+
// The note costs a row, so the content capacity is one row smaller — and the
|
|
847
|
+
// maximum offset follows from that capacity, not from the raw row count.
|
|
848
|
+
const capacity = Math.max(1, maxRows - 1);
|
|
849
|
+
const maxOffset = Math.max(0, rows.length - capacity);
|
|
850
|
+
const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
|
|
851
|
+
const end = rows.length - offset;
|
|
852
|
+
const start = Math.max(0, end - capacity);
|
|
853
|
+
return { lines: [...rows.slice(start, end)], hiddenAbove: start, hiddenBelow: rows.length - end };
|
|
854
|
+
}
|
|
855
|
+
/** Rows the transcript occupies for a layout; 0 when the viewport is tiny. */
|
|
856
|
+
export function getChatTranscriptRows(layout) {
|
|
857
|
+
return Math.max(0, layout.answerRows);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Paint the pinned phase header on the status row.
|
|
861
|
+
*
|
|
862
|
+
* One row, always: the phase and its clock. Everything it used to squeeze into
|
|
863
|
+
* that row now lives in the transcript below it.
|
|
864
|
+
*/
|
|
865
|
+
export function renderChatTranscriptHeader(activity, layout, thoughtTime) {
|
|
866
|
+
const { area } = layout;
|
|
867
|
+
const cells = getChatTranscriptWidth(layout);
|
|
868
|
+
const elapsed = activity.elapsedLabel ?? formatElapsed(thoughtTime);
|
|
869
|
+
const header = `\x1b[38;5;250m${truncateToWidth(`${activity.glyph} ${activity.label} · ${elapsed}`, cells)}\x1b[0m`;
|
|
870
|
+
return `\x1b7\x1b[${layout.statusRow};${area.startColumn}H\x1b[2K${header}\x1b8`;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Repaint the transcript in place: exactly the rows the layout reserves,
|
|
874
|
+
* between the status row and the composer. The window scrolls (oldest rows
|
|
875
|
+
* leave first) instead of writing over the composer, so a long turn can never
|
|
876
|
+
* damage the bottom rows. The cursor is saved and restored, so the caller keeps
|
|
877
|
+
* its position on the input line.
|
|
878
|
+
*
|
|
879
|
+
* Unlike the answer window it replaced, this never gives its rows up: the log
|
|
880
|
+
* and the answer are one list, so the newest rows stay on screen and the rest
|
|
881
|
+
* is one PgUp away. Every row is clamped to the card in terminal cells, so a
|
|
882
|
+
* wide CJK glyph or a long path cannot push a row past the right border.
|
|
883
|
+
*/
|
|
884
|
+
export function renderChatTranscriptWindow(activity, answer, layout, offsetFromBottom = 0) {
|
|
885
|
+
const { area } = layout;
|
|
886
|
+
const cells = getChatTranscriptWidth(layout);
|
|
887
|
+
const { rows } = buildChatTranscriptRows(activity, answer, cells);
|
|
888
|
+
const { lines, hiddenAbove } = windowChatTranscriptAt(rows, layout.answerRows, offsetFromBottom);
|
|
889
|
+
const visible = hiddenAbove > 0
|
|
890
|
+
? [`\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), cells)}\x1b[0m`, ...lines.map((line) => renderAnswerLine(line, cells))]
|
|
891
|
+
: lines.map((line) => renderAnswerLine(line, cells));
|
|
892
|
+
let sequence = '\x1b7';
|
|
893
|
+
for (let index = 0; index < layout.answerRows; index++) {
|
|
894
|
+
const row = layout.answerTop + index;
|
|
895
|
+
sequence += `\x1b[${row};${area.startColumn}H\x1b[2K${visible[index] ?? ''}`;
|
|
416
896
|
}
|
|
417
|
-
|
|
418
|
-
return { sequence, row, column };
|
|
897
|
+
return sequence + '\x1b8';
|
|
419
898
|
}
|
|
420
899
|
export function renderFullWelcomeScreen(options, height) {
|
|
421
900
|
if (options.chatQuestion !== undefined) {
|
|
@@ -546,608 +1025,6 @@ export async function exitCleanly(message) {
|
|
|
546
1025
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
547
1026
|
return new Promise(() => { });
|
|
548
1027
|
}
|
|
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
1028
|
/** A cumulative task snapshot; a request ending does not mean the task ended. */
|
|
1152
1029
|
export function formatTokenUsage(event) {
|
|
1153
1030
|
const marker = event.estimated ? '~' : '';
|
|
@@ -1178,7 +1055,7 @@ export function renderGatewayFallbackUpdate(event, width = process.stdout.column
|
|
|
1178
1055
|
const area = getChatAnswerArea(width);
|
|
1179
1056
|
const destination = event.toProvider ? `${event.toProvider}/${event.toModel}` : event.toModel;
|
|
1180
1057
|
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}
|
|
1058
|
+
const text = truncateToWidth(`Gateway route changed to ${destination} ${reason}`, area.cardWidth);
|
|
1182
1059
|
const row = renderModeradoHeader().split('\n').length + 3;
|
|
1183
1060
|
return `\x1b7\x1b[${row};${area.startColumn}H\x1b[K\x1b[38;5;244m${text}\x1b[0m\x1b8`;
|
|
1184
1061
|
}
|