moderado 0.4.46 → 0.4.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/chat.d.ts +3 -1
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +27 -5
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/chat_slash.d.ts +1 -1
- package/dist/commands/chat_slash.d.ts.map +1 -1
- package/dist/commands/chat_slash.js +1 -0
- package/dist/commands/chat_slash.js.map +1 -1
- package/dist/turn_summary.js +1 -1
- package/dist/turn_summary.js.map +1 -1
- package/dist/ui/composer.d.ts +2 -1
- package/dist/ui/composer.d.ts.map +1 -1
- package/dist/ui/composer.js +235 -48
- package/dist/ui/composer.js.map +1 -1
- package/dist/ui/help_content.d.ts +1 -1
- package/dist/ui/help_content.js +1 -1
- package/dist/ui/help_content.js.map +1 -1
- package/dist/ui/welcome.d.ts +69 -15
- package/dist/ui/welcome.d.ts.map +1 -1
- package/dist/ui/welcome.js +232 -67
- package/dist/ui/welcome.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/welcome.js
CHANGED
|
@@ -7,7 +7,7 @@ import { overlayCentered, dimLines, shadowUnder } from './popup.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',
|
|
@@ -31,10 +31,125 @@ function visibleLen(str) {
|
|
|
31
31
|
export const MOUSE_REPORT_PATTERN = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
32
32
|
export const MOUSE_REPORT_ON = '\x1b[?1000h\x1b[?1006h';
|
|
33
33
|
export const MOUSE_REPORT_OFF = '\x1b[?1000l\x1b[?1006l';
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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);
|
|
38
153
|
}
|
|
39
154
|
/** Card width: nearly full terminal width, like OpenCode/Cline. */
|
|
40
155
|
export function getWelcomeCardWidth(width) {
|
|
@@ -82,32 +197,23 @@ export const CHAT_COMPOSER_ROWS = 5;
|
|
|
82
197
|
* composer: a long answer is windowed instead of pushing the input card, the
|
|
83
198
|
* mode row, and the workspace row off screen (or painting over them).
|
|
84
199
|
*/
|
|
85
|
-
export function getChatLayout(width, height,
|
|
200
|
+
export function getChatLayout(width, height, _question = '', composerRows = CHAT_COMPOSER_ROWS) {
|
|
86
201
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
87
202
|
// Honor the real viewport: a hard floor here would emit a frame taller than a
|
|
88
203
|
// short terminal and scroll the composer away. A very short terminal simply
|
|
89
204
|
// shows fewer sections (the composer always wins — see renderChatScreen).
|
|
90
205
|
const terminalHeight = Math.max(2, height ?? (process.stdout.rows || 24));
|
|
91
206
|
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
207
|
// The frame has to end on the second-to-last row: a trailing newline written
|
|
97
208
|
// on the bottom row scrolls the viewport and drags the composer out of place.
|
|
98
209
|
// So the composer is pinned to the viewport bottom and keeps every row it
|
|
99
210
|
// needs — a queued-commands box makes it taller, and the answer area gives up
|
|
100
211
|
// those rows instead of pushing the card off screen.
|
|
101
212
|
const composerTop = Math.max(2, terminalHeight - Math.max(CHAT_COMPOSER_ROWS, composerRows));
|
|
102
|
-
|
|
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;
|
|
213
|
+
const statusRow = CHAT_HEADER_ROWS + 2;
|
|
106
214
|
const answerTop = statusRow + 2;
|
|
107
215
|
return {
|
|
108
216
|
area,
|
|
109
|
-
questionTop,
|
|
110
|
-
questionRows,
|
|
111
217
|
statusRow,
|
|
112
218
|
answerTop,
|
|
113
219
|
answerRows: Math.max(0, composerTop - 1 - answerTop),
|
|
@@ -121,6 +227,8 @@ export function getChatComposerRows(options) {
|
|
|
121
227
|
}
|
|
122
228
|
/** Invisible prefix that marks a rendered line as part of the turn summary. */
|
|
123
229
|
const SUMMARY_MARK = '\u0001';
|
|
230
|
+
/** Invisible prefix that marks a transcript row as the user's question. */
|
|
231
|
+
const QUESTION_MARK = '\u0003';
|
|
124
232
|
/**
|
|
125
233
|
* Wrap an answer for the card, marking the turn summary as it goes.
|
|
126
234
|
*
|
|
@@ -193,6 +301,14 @@ export function windowChatAnswerAt(text, maxRows, textWidth, offsetFromBottom =
|
|
|
193
301
|
* of thing from the prose above it, rather than three more paragraphs.
|
|
194
302
|
*/
|
|
195
303
|
export function renderAnswerLine(line, textWidth) {
|
|
304
|
+
// Question rows get the same surface background as the input box so they stand
|
|
305
|
+
// out from the answer without altering their text colour.
|
|
306
|
+
if (line.startsWith(QUESTION_MARK)) {
|
|
307
|
+
const text = line.slice(QUESTION_MARK.length);
|
|
308
|
+
const clipped = truncateToWidth(text, textWidth);
|
|
309
|
+
const padding = ' '.repeat(Math.max(0, textWidth - displayWidth(clipped)));
|
|
310
|
+
return `\x1b[48;5;24m${clipped}${padding}\x1b[0m`;
|
|
311
|
+
}
|
|
196
312
|
// Activity-log rows share the window with the answer but read as a record of
|
|
197
313
|
// what happened: dim, no rail, no band. One function, two kinds of row.
|
|
198
314
|
if (line.startsWith(LOG_MARK)) {
|
|
@@ -208,10 +324,12 @@ export function renderAnswerLine(line, textWidth) {
|
|
|
208
324
|
const heading = body.match(/^#{1,6}\s+(.+)$/);
|
|
209
325
|
const bullet = body.match(/^[-*]\s+(.+)$/);
|
|
210
326
|
const text = heading ? heading[1].trim() : bullet ? `· ${bullet[1].trim()}` : body;
|
|
211
|
-
const tone = heading
|
|
327
|
+
const tone = heading
|
|
328
|
+
? text === 'COMPLETED' || text === 'COMPLETED!' ? '1;38;5;40' : '1;38;5;222'
|
|
329
|
+
: '38;5;253';
|
|
212
330
|
// Pad to the card so the tint is a solid band instead of a ragged highlight.
|
|
213
331
|
const padded = `${text}${' '.repeat(Math.max(0, textWidth - 2 - displayWidth(text)))}`;
|
|
214
|
-
return `\x1b[48;
|
|
332
|
+
return `\x1b[48;2;35;44;37m\x1b[38;5;75m▍\x1b[0;48;2;35;44;37m\x1b[${tone}m ${padded}\x1b[0m`;
|
|
215
333
|
}
|
|
216
334
|
export function chatAnswerOverflowNote(hidden) {
|
|
217
335
|
// Short on purpose: the status row carries the scroll position, and this row
|
|
@@ -249,11 +367,14 @@ export function renderWelcomeCard(options) {
|
|
|
249
367
|
? getWelcomeCardWidth(options.width)
|
|
250
368
|
: getChatCardWidth(options.width);
|
|
251
369
|
const indent = ' '.repeat(getWelcomeIndent(width, options.width));
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
370
|
+
const inputText = options.input ?? '';
|
|
371
|
+
const inputView = composerView(inputText, options.inputCaret ?? inputText.length, width, options.maxInputRows ?? getComposerInputMaxRows());
|
|
372
|
+
// The placeholder owns the first row only; a typed prompt is wrapped into as
|
|
373
|
+
// many rows as it needs, so the box grows instead of scrolling sideways.
|
|
374
|
+
const inputRows = inputText.length > 0
|
|
375
|
+
? inputView.rows.map((row) => row.text)
|
|
376
|
+
: ['\x1b[38;5;242mAsk anything, I am all ears...\x1b[0m'];
|
|
255
377
|
const promptMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
|
|
256
|
-
const textBox = `${promptMarker} ${displayInput}`;
|
|
257
378
|
const surface = '\x1b[48;5;236m';
|
|
258
379
|
const surfaceLine = (content = '') => {
|
|
259
380
|
const preserved = content.replace(/\x1b\[0m/g, '\x1b[0;48;5;236m');
|
|
@@ -341,7 +462,10 @@ export function renderWelcomeCard(options) {
|
|
|
341
462
|
if (matching.length)
|
|
342
463
|
cardLines.push(...renderSuggestionsBox(matching, width, options.commandSelection ?? 0).map(line => indent + line));
|
|
343
464
|
}
|
|
344
|
-
cardLines.push(indent + surfaceLine(),
|
|
465
|
+
cardLines.push(indent + surfaceLine(),
|
|
466
|
+
// Continuation rows keep the marker's two cells, so wrapped text lines up
|
|
467
|
+
// under the first row's text instead of under the ❯.
|
|
468
|
+
...inputRows.map((rowText, index) => indent + surfaceLine(`${index === 0 ? promptMarker : ' '} ${rowText}`)), indent + surfaceLine(), indent + line4, indent + line5);
|
|
345
469
|
if (options.mentionFiles && options.mentionFiles.length > 0) {
|
|
346
470
|
cardLines.push(...renderMentionSuggestionsBox(options.mentionFiles, options.mentionSelection ?? 0, width).map((line) => indent + line));
|
|
347
471
|
}
|
|
@@ -361,7 +485,8 @@ export const SLASH_COMMANDS = [
|
|
|
361
485
|
{ name: '/budget', desc: 'Set a per-task token limit' },
|
|
362
486
|
{ name: '/skills', desc: 'List skills; enable or disable user skills' },
|
|
363
487
|
{ name: '/clear', desc: 'Reset conversation memory' },
|
|
364
|
-
{ name: '/
|
|
488
|
+
{ name: '/about', desc: 'Show version and copyright info' },
|
|
489
|
+
{ name: '/help', desc: 'Display commands & shortcuts' },
|
|
365
490
|
{ name: '/exit', desc: 'Exit Moderado' },
|
|
366
491
|
];
|
|
367
492
|
export function getMatchingCommands(input) {
|
|
@@ -454,7 +579,8 @@ export function renderModeradoHeader(width) {
|
|
|
454
579
|
export function renderChatHeader(width) {
|
|
455
580
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
456
581
|
const label = 'MODERADO'.slice(0, Math.max(1, terminalWidth));
|
|
457
|
-
|
|
582
|
+
const padding = Math.max(0, Math.floor((terminalWidth - label.length) / 2));
|
|
583
|
+
return ' '.repeat(padding) + `\x1b[1;38;5;75m${label}\x1b[0m`;
|
|
458
584
|
}
|
|
459
585
|
/** Word-wrap plain text to `maxWidth` visible columns, hard-breaking long words. */
|
|
460
586
|
export function wrapText(text, maxWidth) {
|
|
@@ -506,9 +632,7 @@ export function wrapText(text, maxWidth) {
|
|
|
506
632
|
return out;
|
|
507
633
|
}
|
|
508
634
|
/**
|
|
509
|
-
* Full chat window
|
|
510
|
-
* displayed on the first row, the multi-line model answer below it, then the
|
|
511
|
-
* same input card as the welcome window at the bottom.
|
|
635
|
+
* Full chat window: the transcript scrolls above a fixed input card.
|
|
512
636
|
*/
|
|
513
637
|
export function renderChatScreen(options, height) {
|
|
514
638
|
const terminalWidth = options.width ?? (process.stdout.columns || 80);
|
|
@@ -517,24 +641,16 @@ export function renderChatScreen(options, height) {
|
|
|
517
641
|
// The composer is measured before the layout: a queued-commands box or a
|
|
518
642
|
// mention box makes the card taller, and the answer area must give up those
|
|
519
643
|
// rows instead of pushing the card past the bottom of the viewport.
|
|
520
|
-
const
|
|
644
|
+
const cardOptions = options.maxInputRows === undefined
|
|
645
|
+
? { ...options, maxInputRows: getComposerInputMaxRows(terminalHeight) }
|
|
646
|
+
: options;
|
|
647
|
+
const composer = renderWelcomeCard(cardOptions).split('\n');
|
|
521
648
|
const layout = getChatLayout(terminalWidth, terminalHeight, question, composer.length);
|
|
522
649
|
const area = layout.area;
|
|
523
650
|
const indent = ' '.repeat(area.indent);
|
|
524
651
|
const lines = [];
|
|
525
652
|
lines.push(renderChatHeader(terminalWidth));
|
|
526
653
|
lines.push('');
|
|
527
|
-
// Row 1: the user's question, wrapped onto the same columns as the composer card.
|
|
528
|
-
const questionMarker = '\x1b[1;38;5;75m' + String.fromCodePoint(0x276F) + '\x1b[0;48;5;236m';
|
|
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}`;
|
|
536
|
-
lines.push(indent + String.fromCharCode(27) + '[48;5;236m' + questionText + ' '.repeat(Math.max(0, area.cardWidth - visibleLen(questionText))) + String.fromCharCode(27) + '[0m');
|
|
537
|
-
}
|
|
538
654
|
const thoughtTime = options.chatThoughtTime ?? 0;
|
|
539
655
|
const thoughtTimeLabel = thoughtTime > 0 && thoughtTime < 1 ? '<1s' : `${Math.round(thoughtTime)}s`;
|
|
540
656
|
const answerText = options.chatAnswer ?? '';
|
|
@@ -542,7 +658,7 @@ export function renderChatScreen(options, height) {
|
|
|
542
658
|
// The activity log and the answer are one list, so both the scroll position
|
|
543
659
|
// and the window are computed over the merged rows.
|
|
544
660
|
const transcriptWidth = getChatTranscriptWidth(layout);
|
|
545
|
-
const transcript = buildChatTranscriptRows(options.chatActivity, answerText, transcriptWidth);
|
|
661
|
+
const transcript = buildChatTranscriptRows(options.chatActivity, answerText, transcriptWidth, options.chatHistory, question);
|
|
546
662
|
// While the reader is scrolled up, the status row belongs to the scroll
|
|
547
663
|
// position: the turn clock is not what they are looking for.
|
|
548
664
|
const scrollWindow = transcript.rows.length > 0 && layout.answerRows > 0 && answerOffset > 0
|
|
@@ -557,7 +673,7 @@ export function renderChatScreen(options, height) {
|
|
|
557
673
|
const scrolled = scrollWindow !== undefined
|
|
558
674
|
&& (scrollWindow.hiddenAbove > 0 || scrollWindow.hiddenBelow > 0);
|
|
559
675
|
const chatStatus = scrolled
|
|
560
|
-
? `${scrollWindow.hiddenAbove === 0 ? 'Top of the turn' : `↑ ${scrollWindow.hiddenAbove} above`} · ↓ ${scrollWindow.hiddenBelow} below · PgUp/PgDn`
|
|
676
|
+
? `${scrollWindow.hiddenAbove === 0 ? (options.chatHistory?.length ? 'Top of history' : 'Top of the turn') : `↑ ${scrollWindow.hiddenAbove} above`} · ↓ ${scrollWindow.hiddenBelow} below · PgUp/PgDn`
|
|
561
677
|
: options.chatActivity
|
|
562
678
|
? truncateToWidth(`${options.chatActivity.glyph} ${options.chatActivity.label} · ${activityElapsed}`, transcriptWidth)
|
|
563
679
|
: !answerText.trim()
|
|
@@ -575,12 +691,8 @@ export function renderChatScreen(options, height) {
|
|
|
575
691
|
// answer — that trade is what produced the one-line view.
|
|
576
692
|
if (transcript.rows.length > 0 && layout.answerRows > 0) {
|
|
577
693
|
lines.push('');
|
|
578
|
-
const
|
|
579
|
-
|
|
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)));
|
|
583
|
-
}
|
|
694
|
+
for (const transcriptLine of renderTranscriptRows(transcript.rows, layout, answerOffset))
|
|
695
|
+
lines.push(indent + transcriptLine);
|
|
584
696
|
}
|
|
585
697
|
// Hard guarantee: the composer starts at `composerTop` and the frame ends one
|
|
586
698
|
// row above the viewport bottom, whatever the content did. This holds for any
|
|
@@ -594,17 +706,19 @@ export function renderChatScreen(options, height) {
|
|
|
594
706
|
return lines.join('\n') + '\n';
|
|
595
707
|
}
|
|
596
708
|
/**
|
|
597
|
-
* Place the terminal cursor on the
|
|
598
|
-
* `composerTop` addresses the row absolutely, which stays correct
|
|
599
|
-
* queued-commands box
|
|
709
|
+
* Place the terminal cursor on the caret's row inside the input box. Passing
|
|
710
|
+
* the layout's `composerTop` addresses the row absolutely, which stays correct
|
|
711
|
+
* when the card grows (a queued-commands box or a wrapped prompt); the relative
|
|
712
|
+
* form is the legacy path.
|
|
600
713
|
*/
|
|
601
|
-
export function renderChatComposerCursor(options,
|
|
714
|
+
export function renderChatComposerCursor(options, input, composerTop) {
|
|
602
715
|
const width = getChatCardWidth(options.width);
|
|
603
716
|
const indent = getWelcomeIndent(width, options.width);
|
|
604
|
-
const
|
|
717
|
+
const view = composerView(input, input.length, width, getComposerInputMaxRows());
|
|
718
|
+
const caretColumn = indent + 2 + view.caretColumn;
|
|
605
719
|
if (composerTop !== undefined)
|
|
606
|
-
return `\x1b[1 q\x1b[?25h\x1b[${composerTop + 1};${caretColumn}H`;
|
|
607
|
-
return `\x1b[1 q\x1b[?25h\x1b[
|
|
720
|
+
return `\x1b[1 q\x1b[?25h\x1b[${composerTop + 1 + view.caretRow};${caretColumn + 1}H`;
|
|
721
|
+
return `\x1b[1 q\x1b[?25h\x1b[${4 + view.caretRow}A\r\x1b[${caretColumn}C`;
|
|
608
722
|
}
|
|
609
723
|
export function navigateQuestionHistory(history, direction, index, draft) {
|
|
610
724
|
const nextIndex = Math.max(0, Math.min(history.length, index + direction));
|
|
@@ -818,8 +932,14 @@ export function getChatTranscriptWidth(layout) {
|
|
|
818
932
|
* The log and the answer now share the window, so the record of what the agent
|
|
819
933
|
* did and the answer it produced are read in one place and scroll together.
|
|
820
934
|
*/
|
|
821
|
-
export function buildChatTranscriptRows(activity, answer, textWidth) {
|
|
935
|
+
export function buildChatTranscriptRows(activity, answer, textWidth, history = [], question = '') {
|
|
822
936
|
const rows = [];
|
|
937
|
+
for (const turn of history) {
|
|
938
|
+
rows.push(...wrapText(`❯ ${turn.question}`, textWidth).map((line) => QUESTION_MARK + line));
|
|
939
|
+
rows.push(...wrapAnswerLines(turn.answer.trimEnd(), textWidth));
|
|
940
|
+
}
|
|
941
|
+
if (question)
|
|
942
|
+
rows.push(...wrapText(`❯ ${question}`, textWidth).map((line) => QUESTION_MARK + line));
|
|
823
943
|
for (const entry of activity?.history ?? []) {
|
|
824
944
|
rows.push(LOG_MARK + `${entry.glyph} ${entry.label}`);
|
|
825
945
|
}
|
|
@@ -845,13 +965,36 @@ export function windowChatTranscriptAt(rows, maxRows, offsetFromBottom = 0) {
|
|
|
845
965
|
return { lines: [...rows], hiddenAbove: 0, hiddenBelow: 0 };
|
|
846
966
|
// The note costs a row, so the content capacity is one row smaller — and the
|
|
847
967
|
// maximum offset follows from that capacity, not from the raw row count.
|
|
848
|
-
const capacity = Math.max(1, maxRows - 1);
|
|
968
|
+
const capacity = Math.max(1, maxRows - (maxRows > 1 ? 1 : 0));
|
|
849
969
|
const maxOffset = Math.max(0, rows.length - capacity);
|
|
850
970
|
const offset = Math.max(0, Math.min(Math.round(offsetFromBottom), maxOffset));
|
|
851
971
|
const end = rows.length - offset;
|
|
852
972
|
const start = Math.max(0, end - capacity);
|
|
853
973
|
return { lines: [...rows.slice(start, end)], hiddenAbove: start, hiddenBelow: rows.length - end };
|
|
854
974
|
}
|
|
975
|
+
/** A click at the top of the chat scrollbar jumps to the oldest transcript row. */
|
|
976
|
+
export function chatTranscriptOffsetForRow(totalRows, visibleRows, row) {
|
|
977
|
+
const maxOffset = Math.max(0, totalRows - Math.max(1, visibleRows - 1));
|
|
978
|
+
return visibleRows <= 1 ? maxOffset : Math.round(maxOffset * (1 - Math.max(0, Math.min(row, visibleRows - 1)) / (visibleRows - 1)));
|
|
979
|
+
}
|
|
980
|
+
function renderTranscriptRows(rows, layout, offset) {
|
|
981
|
+
const cells = getChatTranscriptWidth(layout);
|
|
982
|
+
const { lines, hiddenAbove, hiddenBelow } = windowChatTranscriptAt(rows, layout.answerRows, offset);
|
|
983
|
+
const visible = hiddenAbove > 0 && layout.answerRows > 1
|
|
984
|
+
? [`\x1b[38;5;244m${truncateToWidth(chatAnswerOverflowNote(hiddenAbove), cells)}\x1b[0m`, ...lines.map((line) => renderAnswerLine(line, cells))]
|
|
985
|
+
: lines.map((line) => renderAnswerLine(line, cells));
|
|
986
|
+
const overflowing = rows.length > layout.answerRows && layout.answerRows > 0;
|
|
987
|
+
const offsetFromBottom = hiddenBelow;
|
|
988
|
+
const maxOffset = Math.max(1, rows.length - Math.max(1, layout.answerRows - 1));
|
|
989
|
+
const thumbRow = Math.round((1 - offsetFromBottom / maxOffset) * Math.max(0, layout.answerRows - 1));
|
|
990
|
+
return Array.from({ length: layout.answerRows }, (_, index) => {
|
|
991
|
+
const line = visible[index] ?? '';
|
|
992
|
+
if (!overflowing)
|
|
993
|
+
return line;
|
|
994
|
+
const glyph = index === thumbRow ? '█' : '│';
|
|
995
|
+
return `${line}${' '.repeat(Math.max(0, cells - displayWidth(line)))}\x1b[38;5;244m${glyph}\x1b[0m`;
|
|
996
|
+
});
|
|
997
|
+
}
|
|
855
998
|
/** Rows the transcript occupies for a layout; 0 when the viewport is tiny. */
|
|
856
999
|
export function getChatTranscriptRows(layout) {
|
|
857
1000
|
return Math.max(0, layout.answerRows);
|
|
@@ -881,14 +1024,10 @@ export function renderChatTranscriptHeader(activity, layout, thoughtTime) {
|
|
|
881
1024
|
* is one PgUp away. Every row is clamped to the card in terminal cells, so a
|
|
882
1025
|
* wide CJK glyph or a long path cannot push a row past the right border.
|
|
883
1026
|
*/
|
|
884
|
-
export function renderChatTranscriptWindow(activity, answer, layout, offsetFromBottom = 0) {
|
|
1027
|
+
export function renderChatTranscriptWindow(activity, answer, layout, offsetFromBottom = 0, history = [], question = '') {
|
|
885
1028
|
const { area } = layout;
|
|
886
|
-
const
|
|
887
|
-
const
|
|
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));
|
|
1029
|
+
const { rows } = buildChatTranscriptRows(activity, answer, getChatTranscriptWidth(layout), history, question);
|
|
1030
|
+
const visible = renderTranscriptRows(rows, layout, offsetFromBottom);
|
|
892
1031
|
let sequence = '\x1b7';
|
|
893
1032
|
for (let index = 0; index < layout.answerRows; index++) {
|
|
894
1033
|
const row = layout.answerTop + index;
|
|
@@ -934,7 +1073,7 @@ export function renderWelcomePopupLayer(options, popupLines, width, height) {
|
|
|
934
1073
|
const lightPopup = popupLines.map((line) => surface + line.replace(/\x1b\[0m/g, `\x1b[0m${surface}`) + '\x1b[0m');
|
|
935
1074
|
return background + shadowUnder(lightPopup, cols, rows) + overlayCentered(lightPopup, cols, rows);
|
|
936
1075
|
}
|
|
937
|
-
export function renderHelpPopupBox(
|
|
1076
|
+
export function renderHelpPopupBox(_version, workspace, width) {
|
|
938
1077
|
const terminalWidth = width ?? (process.stdout.columns || 80);
|
|
939
1078
|
const targetWidth = 82;
|
|
940
1079
|
const boxWidth = Math.max(76, Math.min(terminalWidth, targetWidth));
|
|
@@ -952,14 +1091,15 @@ export function renderHelpPopupBox(version, workspace, width) {
|
|
|
952
1091
|
'\x1b[1;38;5;114mKeyboard Shortcuts:\x1b[0m',
|
|
953
1092
|
'\x1b[1mTab\x1b[0m Toggle between [Plan] and [Execute] mode',
|
|
954
1093
|
'\x1b[1mShift+Tab\x1b[0m Toggle Auto-approval on / off for actions',
|
|
1094
|
+
'\x1b[1mShift+Enter\x1b[0m Insert a line break (Ctrl+J works everywhere)',
|
|
955
1095
|
'\x1b[1mCtrl+C\x1b[0m Cancel active inference or exit session',
|
|
956
1096
|
'\x1b[1mEsc\x1b[0m Stop the active task',
|
|
957
1097
|
'\x1b[1mLeft/Right\x1b[0m Move the caret inside the bottom input line',
|
|
958
1098
|
'\x1b[1mUp/Down\x1b[0m Browse slash commands after "/", else recall history',
|
|
1099
|
+
'\x1b[1mPgUp/PgDn\x1b[0m Browse chat; click its right-side scroll track to jump',
|
|
959
1100
|
'\x1b[1mMouse click\x1b[0m Place the caret on the input line',
|
|
960
1101
|
'',
|
|
961
1102
|
`\x1b[38;5;245mWorkspace:\x1b[0m \x1b[38;5;253m${shortWs}\x1b[0m`,
|
|
962
|
-
`\x1b[38;5;245mVersion:\x1b[0m \x1b[1;38;5;75m${version}\x1b[0m`,
|
|
963
1103
|
'',
|
|
964
1104
|
'\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',
|
|
965
1105
|
];
|
|
@@ -978,6 +1118,31 @@ export function renderHelpPopupBox(version, workspace, width) {
|
|
|
978
1118
|
lines.push('\x1b[38;5;240m╰' + '─'.repeat(boxWidth - 2) + '╯\x1b[0m');
|
|
979
1119
|
return lines;
|
|
980
1120
|
}
|
|
1121
|
+
/** Render a centered "About" popup with version and copyright info. */
|
|
1122
|
+
export function renderAboutPopupBox(version) {
|
|
1123
|
+
const boxWidth = 52;
|
|
1124
|
+
const innerW = boxWidth - 4;
|
|
1125
|
+
const titleStr = 'About Moderado';
|
|
1126
|
+
const remainingDashes = Math.max(0, boxWidth - titleStr.length - 5);
|
|
1127
|
+
// Strip the Alfazen build suffix (`+261024h`) so only `v<major.minor.patch>` shows.
|
|
1128
|
+
const semverOnly = version.split('+')[0];
|
|
1129
|
+
const content = [
|
|
1130
|
+
'Moderado CLI',
|
|
1131
|
+
semverOnly,
|
|
1132
|
+
'',
|
|
1133
|
+
'(c) 2026 Moderado AI, an Alfazen Product',
|
|
1134
|
+
];
|
|
1135
|
+
const lines = [];
|
|
1136
|
+
lines.push('\x1b[38;5;240m╭─ \x1b[1;38;5;75m' + titleStr + '\x1b[0;38;5;240m ' + '─'.repeat(remainingDashes) + '╮\x1b[0m');
|
|
1137
|
+
for (const item of content) {
|
|
1138
|
+
const plain = item.replace(/\x1b\\[[0-9;]*[a-zA-Z]/g, '');
|
|
1139
|
+
const leftPad = Math.max(0, Math.floor((innerW - plain.length) / 2));
|
|
1140
|
+
const rightPad = Math.max(0, innerW - plain.length - leftPad);
|
|
1141
|
+
lines.push('\x1b[38;5;240m│\x1b[0m' + ' '.repeat(leftPad) + item + ' '.repeat(rightPad) + '\x1b[38;5;240m│\x1b[0m');
|
|
1142
|
+
}
|
|
1143
|
+
lines.push('\x1b[38;5;240m╰' + '─'.repeat(boxWidth - 2) + '╯\x1b[0m');
|
|
1144
|
+
return lines;
|
|
1145
|
+
}
|
|
981
1146
|
export let terminalCleanExitDone = false;
|
|
982
1147
|
/** Mark that a clean exit already restored + cleared the terminal (guards the process 'exit' hook). */
|
|
983
1148
|
export function notifyCleanExit() {
|
|
@@ -1005,7 +1170,7 @@ export async function exitCleanly(message) {
|
|
|
1005
1170
|
notifyCleanExit();
|
|
1006
1171
|
const stdout = process.stdout;
|
|
1007
1172
|
const payload = stdout.isTTY
|
|
1008
|
-
? MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message)
|
|
1173
|
+
? KEY_MODIFIER_REPORT_OFF + MOUSE_REPORT_OFF + '\x1b[?25h\x1b[?1049l\x1b[0 q\x1b[0m\x1b[2J\x1b[3J\x1b[H' + renderExitMessage(message)
|
|
1009
1174
|
: renderExitMessage(message);
|
|
1010
1175
|
try {
|
|
1011
1176
|
fs.writeSync(1, payload);
|