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.
@@ -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 / |/ / __ \\/ __ \\/ ____/ __ \\/ | / __ \\/ __ \\\x1b[0m',
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
- export function composerInputWindow(input, caret, width) {
35
- const capacity = Math.max(1, width - 3);
36
- const start = Math.max(0, Math.min(caret, input.length - capacity));
37
- return { text: input.slice(start, start + capacity), caretColumn: caret - start };
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, question = '', composerRows = CHAT_COMPOSER_ROWS) {
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
- // 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;
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 ? '1;38;5;222' : '38;5;253';
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;5;236m\x1b[38;5;75m▍\x1b[0;48;5;236m\x1b[${tone}m ${padded}\x1b[0m`;
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 displayInput = options.input && options.input.length > 0
253
- ? composerInputWindow(options.input, options.inputCaret ?? options.input.length, width).text
254
- : '\x1b[38;5;242mAsk anything, I am all ears...\x1b[0m';
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(), indent + surfaceLine(textBox), indent + surfaceLine(), indent + line4, indent + line5);
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: '/help', desc: 'Display commands, shortcuts & version' },
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
- return ' '.repeat(Math.max(0, terminalWidth - label.length)) + `\x1b[1;38;5;75m${label}\x1b[0m`;
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 (after a question is submitted): no logo — the question is
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 composer = renderWelcomeCard(options).split('\n');
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 { 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)));
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 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.
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, inputLength, composerTop) {
714
+ export function renderChatComposerCursor(options, input, composerTop) {
602
715
  const width = getChatCardWidth(options.width);
603
716
  const indent = getWelcomeIndent(width, options.width);
604
- const caretColumn = indent + 3 + Math.min(inputLength, Math.max(0, width - 3));
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[4A\r\x1b[${indent + 2 + Math.min(inputLength, Math.max(0, width - 3))}C`;
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 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));
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(version, workspace, width) {
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);