@pellux/goodvibes-tui 1.20.0 → 1.21.0
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/CHANGELOG.md +20 -1
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/doctor.ts +7 -1
- package/src/cli/management.ts +1 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +43 -40
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +298 -17
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +31 -38
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +295 -0
- package/src/runtime/recovery-prompt.ts +66 -6
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +334 -16
package/src/renderer/markdown.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { renderCodeBlock } from './code-block.ts';
|
|
|
4
4
|
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
5
5
|
import { LAYOUT } from './layout.ts';
|
|
6
6
|
import { activeTheme } from './theme.ts';
|
|
7
|
+
import { renderInlineMarkdown, type InlineToken } from './markdown-inline.ts';
|
|
8
|
+
import { isLikelyTableHeaderRow, isLikelyTableSeparatorRow, renderTable } from './markdown-table.ts';
|
|
9
|
+
|
|
10
|
+
// Re-exported so existing importers of renderInlineMarkdown keep working after
|
|
11
|
+
// the tokenizer moved to markdown-inline.ts (see that module's header).
|
|
12
|
+
export { renderInlineMarkdown, type InlineToken };
|
|
7
13
|
|
|
8
14
|
// Transcript tokens are read live per render (const T = activeTheme() at the top
|
|
9
15
|
// of each render function below) so a dark→light repaint re-resolves with no
|
|
@@ -15,27 +21,6 @@ export interface MarkdownRenderOptions {
|
|
|
15
21
|
isStreaming?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
/** Module-level set of inline markdown special characters (hoisted out of hot loop). */
|
|
19
|
-
const INLINE_SPECIAL_CHARS = new Set(['[', '`', '*', '_', '~']);
|
|
20
|
-
|
|
21
|
-
function splitTableCells(row: string): string[] {
|
|
22
|
-
const cells = row.trim().split('|').map((c) => c.trim());
|
|
23
|
-
if (cells.length > 0 && cells[0] === '') cells.shift();
|
|
24
|
-
if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
|
|
25
|
-
return cells;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isLikelyTableSeparatorRow(row: string): boolean {
|
|
29
|
-
if (!row.includes('|')) return false;
|
|
30
|
-
const cells = splitTableCells(row);
|
|
31
|
-
if (cells.length === 0) return false;
|
|
32
|
-
return cells.every((cell) => /:?-{3,}:?/.test(cell));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isLikelyTableHeaderRow(row: string): boolean {
|
|
36
|
-
return splitTableCells(row).length >= 2;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
24
|
/**
|
|
40
25
|
* renderMarkdown - Parse markdown text into styled Line[].
|
|
41
26
|
* Thin wrapper over renderMarkdownTracked for callers that don't need code-block metadata.
|
|
@@ -220,320 +205,6 @@ export function renderMarkdownTracked(
|
|
|
220
205
|
return { lines, codeBlocks };
|
|
221
206
|
}
|
|
222
207
|
|
|
223
|
-
/**
|
|
224
|
-
* Strip markdown formatting from text for width measurement.
|
|
225
|
-
* Removes **, *, `, ~~ markers but keeps the inner text.
|
|
226
|
-
*/
|
|
227
|
-
function stripMarkdown(text: string): string {
|
|
228
|
-
return text
|
|
229
|
-
.replace(/\*\*(.+?)\*\*/g, '$1') // bold
|
|
230
|
-
.replace(/\*(.+?)\*/g, '$1') // italic
|
|
231
|
-
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
|
232
|
-
.replace(/`(.+?)`/g, '$1'); // inline code
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Render a markdown table with box-drawing borders.
|
|
237
|
-
* Parses inline markdown in cells, fits to terminal width,
|
|
238
|
-
* truncates long content, and renders with proper styling.
|
|
239
|
-
*/
|
|
240
|
-
function renderTable(rows: string[], width: number, indent: number): Line[] {
|
|
241
|
-
const T = activeTheme();
|
|
242
|
-
const lines: Line[] = [];
|
|
243
|
-
|
|
244
|
-
// Parse rows into cells, skip separator
|
|
245
|
-
const parsedRows: string[][] = [];
|
|
246
|
-
let hasSeparator = false;
|
|
247
|
-
for (const row of rows) {
|
|
248
|
-
const trimmed = row.trim();
|
|
249
|
-
if (isLikelyTableSeparatorRow(trimmed)) {
|
|
250
|
-
hasSeparator = true;
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
const cells = splitTableCells(trimmed);
|
|
254
|
-
if (cells.length > 0) parsedRows.push(cells);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (parsedRows.length === 0) return lines;
|
|
258
|
-
|
|
259
|
-
const colCount = Math.max(...parsedRows.map(r => r.length));
|
|
260
|
-
const availW = width - indent;
|
|
261
|
-
|
|
262
|
-
// Measure column widths using stripped text (no markdown markers)
|
|
263
|
-
const naturalWidths: number[] = new Array(colCount).fill(0);
|
|
264
|
-
for (const row of parsedRows) {
|
|
265
|
-
for (let c = 0; c < row.length; c++) {
|
|
266
|
-
naturalWidths[c] = Math.max(naturalWidths[c], getDisplayWidth(stripMarkdown(row[c])));
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Budget: availW minus borders (colCount+1) minus padding (2 per col)
|
|
271
|
-
const overhead = (colCount + 1) + (colCount * 2);
|
|
272
|
-
const contentBudget = Math.max(colCount, availW - overhead);
|
|
273
|
-
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
|
|
274
|
-
|
|
275
|
-
// Compute final column widths
|
|
276
|
-
const colWidths: number[] = new Array(colCount).fill(0);
|
|
277
|
-
if (totalNatural <= contentBudget) {
|
|
278
|
-
// Everything fits
|
|
279
|
-
for (let c = 0; c < colCount; c++) colWidths[c] = naturalWidths[c];
|
|
280
|
-
} else {
|
|
281
|
-
// Proportionally shrink, with minimum of 4 chars per column
|
|
282
|
-
const minW = 4;
|
|
283
|
-
for (let c = 0; c < colCount; c++) {
|
|
284
|
-
colWidths[c] = Math.max(minW, Math.floor((naturalWidths[c] / totalNatural) * contentBudget));
|
|
285
|
-
}
|
|
286
|
-
// Distribute leftover from rounding
|
|
287
|
-
let used = colWidths.reduce((a, b) => a + b, 0);
|
|
288
|
-
for (let c = 0; c < colCount && used < contentBudget; c++) {
|
|
289
|
-
colWidths[c]++;
|
|
290
|
-
used++;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const bc = '240'; // border color
|
|
295
|
-
|
|
296
|
-
// Helper: build a border line
|
|
297
|
-
const makeBorder = (left: string, mid: string, right: string, horiz: string): Line => {
|
|
298
|
-
let s = ' '.repeat(indent) + left;
|
|
299
|
-
for (let c = 0; c < colCount; c++) {
|
|
300
|
-
s += horiz.repeat(colWidths[c] + 2) + (c < colCount - 1 ? mid : right);
|
|
301
|
-
}
|
|
302
|
-
return UIFactory.stringToLine(s, width, { fg: bc });
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
// Helper: render a cell's content with inline markdown into Cell[]
|
|
306
|
-
const renderCellContent = (raw: string, maxW: number, isHdr: boolean): Cell[] => {
|
|
307
|
-
const cells: Cell[] = [];
|
|
308
|
-
const tokens = renderInlineMarkdown(raw);
|
|
309
|
-
let w = 0;
|
|
310
|
-
|
|
311
|
-
for (const token of tokens) {
|
|
312
|
-
const text = token.text;
|
|
313
|
-
for (const ch of text) {
|
|
314
|
-
const cw = getDisplayWidth(ch);
|
|
315
|
-
if (w + cw > maxW) {
|
|
316
|
-
// Truncate with ellipsis. If the last cell pushed is a wide-glyph
|
|
317
|
-
// placeholder (the empty second column of a 2-wide character),
|
|
318
|
-
// overwriting it in place would leave the wide glyph's first
|
|
319
|
-
// column dangling next to the ellipsis with no placeholder cell \u2014
|
|
320
|
-
// step back one more cell so the ellipsis replaces the actual
|
|
321
|
-
// glyph, not its placeholder.
|
|
322
|
-
if (cells.length > 0 && cells[cells.length - 1].char === '' && cells.length > 1) {
|
|
323
|
-
cells.pop();
|
|
324
|
-
}
|
|
325
|
-
if (cells.length > 0) cells[cells.length - 1] = createStyledCell('\u2026', cells[cells.length - 1]);
|
|
326
|
-
return cells;
|
|
327
|
-
}
|
|
328
|
-
let style: Partial<Cell> = {};
|
|
329
|
-
if (token.type === 'code') {
|
|
330
|
-
style = { fg: T.inlineCodeFg, bold: true };
|
|
331
|
-
} else if (token.type === 'link') {
|
|
332
|
-
style = { fg: T.link, underline: true };
|
|
333
|
-
} else {
|
|
334
|
-
style = { ...token.style };
|
|
335
|
-
}
|
|
336
|
-
if (isHdr) {
|
|
337
|
-
style.fg = style.fg || T.heading1;
|
|
338
|
-
style.bold = true;
|
|
339
|
-
} else {
|
|
340
|
-
style.fg = style.fg || '252';
|
|
341
|
-
}
|
|
342
|
-
cells.push(createStyledCell(ch, style));
|
|
343
|
-
if (cw === 2) cells.push(createStyledCell('', style)); // wide char placeholder
|
|
344
|
-
w += cw;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Pad remaining space
|
|
349
|
-
while (w < maxW) {
|
|
350
|
-
cells.push(createStyledCell(' ', isHdr ? { fg: T.heading1 } : { fg: '252' }));
|
|
351
|
-
w++;
|
|
352
|
-
}
|
|
353
|
-
return cells;
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
// Top border
|
|
357
|
-
lines.push(makeBorder('\u250c', '\u252c', '\u2510', '\u2500'));
|
|
358
|
-
|
|
359
|
-
// Rows
|
|
360
|
-
for (let r = 0; r < parsedRows.length; r++) {
|
|
361
|
-
const row = parsedRows[r];
|
|
362
|
-
const isHeader = hasSeparator && r === 0;
|
|
363
|
-
|
|
364
|
-
// Build the row line cell-by-cell
|
|
365
|
-
const line = new Array(width).fill(null).map(() => createStyledCell(' ')) as Cell[];
|
|
366
|
-
let x = indent;
|
|
367
|
-
|
|
368
|
-
// Left border
|
|
369
|
-
if (x < width) line[x] = createStyledCell('\u2502', { fg: bc });
|
|
370
|
-
x++;
|
|
371
|
-
|
|
372
|
-
for (let c = 0; c < colCount; c++) {
|
|
373
|
-
// Space before content
|
|
374
|
-
if (x < width) line[x] = createStyledCell(' ');
|
|
375
|
-
x++;
|
|
376
|
-
|
|
377
|
-
// Cell content
|
|
378
|
-
const raw = c < row.length ? row[c] : '';
|
|
379
|
-
const cellContent = renderCellContent(raw, colWidths[c], isHeader);
|
|
380
|
-
for (const cell of cellContent) {
|
|
381
|
-
if (x < width) line[x] = cell;
|
|
382
|
-
x++;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Space after content
|
|
386
|
-
if (x < width) line[x] = createStyledCell(' ');
|
|
387
|
-
x++;
|
|
388
|
-
|
|
389
|
-
// Column separator
|
|
390
|
-
if (x < width) line[x] = createStyledCell('\u2502', { fg: bc });
|
|
391
|
-
x++;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
lines.push(line);
|
|
395
|
-
|
|
396
|
-
// Header separator
|
|
397
|
-
if (isHeader) {
|
|
398
|
-
lines.push(makeBorder('\u251c', '\u253c', '\u2524', '\u2500'));
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Bottom border
|
|
403
|
-
lines.push(makeBorder('\u2514', '\u2534', '\u2518', '\u2500'));
|
|
404
|
-
|
|
405
|
-
return lines;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Inline markdown token types.
|
|
410
|
-
*/
|
|
411
|
-
type InlineToken =
|
|
412
|
-
| { type: 'text'; text: string; style: Partial<Cell> }
|
|
413
|
-
| { type: 'code'; text: string }
|
|
414
|
-
| { type: 'link'; text: string; url: string };
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* renderInlineMarkdown - Parse inline markdown (bold, italic, inline code, links)
|
|
418
|
-
* and return a flat array of tokens with style info.
|
|
419
|
-
*/
|
|
420
|
-
export function renderInlineMarkdown(text: string): InlineToken[] {
|
|
421
|
-
const tokens: InlineToken[] = [];
|
|
422
|
-
let i = 0;
|
|
423
|
-
|
|
424
|
-
while (i < text.length) {
|
|
425
|
-
// Link: [text](url)
|
|
426
|
-
if (text[i] === '[') {
|
|
427
|
-
const closeB = text.indexOf(']', i);
|
|
428
|
-
if (closeB !== -1 && text[closeB + 1] === '(') {
|
|
429
|
-
const closeP = text.indexOf(')', closeB + 2);
|
|
430
|
-
if (closeP !== -1) {
|
|
431
|
-
const linkText = text.slice(i + 1, closeB);
|
|
432
|
-
const url = text.slice(closeB + 2, closeP);
|
|
433
|
-
tokens.push({ type: 'link', text: linkText, url });
|
|
434
|
-
i = closeP + 1;
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Inline code: `code`
|
|
441
|
-
if (text[i] === '`') {
|
|
442
|
-
const end = text.indexOf('`', i + 1);
|
|
443
|
-
if (end !== -1) {
|
|
444
|
-
tokens.push({ type: 'code', text: text.slice(i + 1, end) });
|
|
445
|
-
i = end + 1;
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Bold+italic: ***text***
|
|
451
|
-
if (text.slice(i, i + 3) === '***') {
|
|
452
|
-
const end = text.indexOf('***', i + 3);
|
|
453
|
-
if (end !== -1) {
|
|
454
|
-
tokens.push({ type: 'text', text: text.slice(i + 3, end), style: { bold: true, italic: true } });
|
|
455
|
-
i = end + 3;
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
// No closing *** found — emit the leading * as plain text so the ** bold
|
|
459
|
-
// check can handle the remaining ** on the next iteration.
|
|
460
|
-
tokens.push({ type: 'text', text: '*', style: {} });
|
|
461
|
-
i += 1;
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Bold: **text**
|
|
466
|
-
if (text.slice(i, i + 2) === '**') {
|
|
467
|
-
const end = text.indexOf('**', i + 2);
|
|
468
|
-
if (end !== -1) {
|
|
469
|
-
tokens.push({ type: 'text', text: text.slice(i + 2, end), style: { bold: true } });
|
|
470
|
-
i = end + 2;
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Italic: *text* or _text_
|
|
476
|
-
// Guard: text[i - 1] !== '*' prevents the second * of ** from starting italic.
|
|
477
|
-
// Guard: text[i + 1] !== text[i] prevents the first * of ** (or _ of __) from
|
|
478
|
-
// starting italic when bold/underscore-bold detection failed (e.g. unclosed **).
|
|
479
|
-
if (
|
|
480
|
-
(text[i] === '*' || text[i] === '_') &&
|
|
481
|
-
text[i - 1] !== text[i] &&
|
|
482
|
-
text[i + 1] !== text[i]
|
|
483
|
-
) {
|
|
484
|
-
const closer = text[i];
|
|
485
|
-
const end = text.indexOf(closer, i + 1);
|
|
486
|
-
if (end !== -1 && end > i + 1) {
|
|
487
|
-
tokens.push({ type: 'text', text: text.slice(i + 1, end), style: { italic: true } });
|
|
488
|
-
i = end + 1;
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Strikethrough: ~~text~~
|
|
494
|
-
if (text.slice(i, i + 2) === '~~') {
|
|
495
|
-
const end = text.indexOf('~~', i + 2);
|
|
496
|
-
if (end !== -1) {
|
|
497
|
-
tokens.push({ type: 'text', text: text.slice(i + 2, end), style: { strikethrough: true, fg: '244' } });
|
|
498
|
-
i = end + 2;
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Plain text — accumulate until next special char, detect bare URLs and file paths
|
|
504
|
-
let end = i + 1;
|
|
505
|
-
while (end < text.length && !INLINE_SPECIAL_CHARS.has(text[end])) {
|
|
506
|
-
const code = text.charCodeAt(end);
|
|
507
|
-
end += (code >= 0xD800 && code <= 0xDBFF) ? 2 : 1;
|
|
508
|
-
}
|
|
509
|
-
const plainText = text.slice(i, end);
|
|
510
|
-
|
|
511
|
-
// Detect http/https URLs in plain text
|
|
512
|
-
const urlMatch = plainText.match(/^(https?:\/\/[^\s,)>"]+)/);
|
|
513
|
-
if (urlMatch) {
|
|
514
|
-
const url = urlMatch[1];
|
|
515
|
-
tokens.push({ type: 'link', text: url, url });
|
|
516
|
-
i += url.length;
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Detect absolute file paths in plain text (require at least one directory separator)
|
|
521
|
-
const fileMatch = plainText.match(/^(\/[^\s,)>"]+\/[^\s,)>"]+(?:\.[a-zA-Z0-9]+)?)/);
|
|
522
|
-
if (fileMatch) {
|
|
523
|
-
const filePath = fileMatch[1];
|
|
524
|
-
const fileUrl = `file://${filePath}`;
|
|
525
|
-
tokens.push({ type: 'link', text: filePath, url: fileUrl });
|
|
526
|
-
i += filePath.length;
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
tokens.push({ type: 'text', text: plainText, style: {} });
|
|
531
|
-
i = end;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
return tokens;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
208
|
/**
|
|
538
209
|
* compositeInlineLine - Convert a prefix + InlineTokens into Line[], applying word wrap.
|
|
539
210
|
* Builds cells directly from token styles.
|
|
@@ -280,14 +280,49 @@ function renderModelRows(picker: ModelPickerModal, lines: Line[], rows: number,
|
|
|
280
280
|
if (end < models.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${models.length - end} more model(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The effort step of the model picker.
|
|
285
|
+
*
|
|
286
|
+
* The list is exactly the levels the chosen model resolved to — not a fixed
|
|
287
|
+
* four — and it is introduced by a headline naming what this model does with
|
|
288
|
+
* the setting (named levels, a thinking-token budget, an on/off toggle, or
|
|
289
|
+
* nothing at all). A best-guess spec prints its caveat under the list rather
|
|
290
|
+
* than presenting unverified levels as if the provider had confirmed them.
|
|
291
|
+
*/
|
|
283
292
|
function renderEffortRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
293
|
+
const presentation = picker.effortPresentation;
|
|
294
|
+
let row = 0;
|
|
295
|
+
|
|
296
|
+
if (presentation && rows > 0) {
|
|
297
|
+
fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
|
|
298
|
+
writeText(lines[row]!, startX + 1, width - 2, presentation.headline, { fg: PALETTE.subtitle, bg: PALETTE.bodyBg });
|
|
299
|
+
row += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// A model with nothing to configure gets a statement, not an empty list.
|
|
303
|
+
if (presentation && !presentation.configurable) {
|
|
304
|
+
if (row < rows) {
|
|
305
|
+
fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
|
|
306
|
+
writeText(lines[row]!, startX + 1, width - 2, 'Esc returns to the model list.', { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const firstLevelRow = row;
|
|
312
|
+
for (let i = 0; i < picker.effortLevels.length && row < rows; i += 1, row += 1) {
|
|
313
|
+
const effort = picker.effortLevels[i]!;
|
|
314
|
+
const selected = i === picker.selectedIndex;
|
|
287
315
|
const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
|
|
288
316
|
fillRange(lines[row]!, startX, startX + width - 1, bg);
|
|
289
317
|
const marker = selected ? GLYPHS.navigation.selected : ' ';
|
|
290
|
-
|
|
318
|
+
const detail = presentation?.choices[i]?.description ?? '';
|
|
319
|
+
const text = detail ? `${marker} ${effort.padEnd(8)}${detail}` : `${marker} ${effort}`;
|
|
320
|
+
writeText(lines[row]!, startX + 1, width - 2, text, { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (presentation?.caveat && row < rows && row > firstLevelRow) {
|
|
324
|
+
fillRange(lines[row]!, startX, startX + width - 1, PALETTE.bodyBg);
|
|
325
|
+
writeText(lines[row]!, startX + 1, width - 2, presentation.caveat, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
|
|
291
326
|
}
|
|
292
327
|
}
|
|
293
328
|
|
|
@@ -489,7 +524,15 @@ function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeig
|
|
|
489
524
|
const filteredProviders = picker.getFilteredProviders();
|
|
490
525
|
base.push(objectId(picker.providers), objectId(filteredProviders), filteredProviders.length);
|
|
491
526
|
} else if (picker.mode === 'effort') {
|
|
492
|
-
base.push(
|
|
527
|
+
base.push(
|
|
528
|
+
objectId(picker.effortLevels),
|
|
529
|
+
picker.effortLevels.join('\u001f'),
|
|
530
|
+
picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '',
|
|
531
|
+
// The headline and caveat are rendered content too: two models with the
|
|
532
|
+
// same level list but different spec kinds must not share a cache entry.
|
|
533
|
+
picker.effortPresentation?.headline ?? '',
|
|
534
|
+
picker.effortPresentation?.caveat ?? '',
|
|
535
|
+
);
|
|
493
536
|
} else if (picker.mode === 'contextCap') {
|
|
494
537
|
base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
|
|
495
538
|
} else if (picker.mode === 'embeddingProvider') {
|
|
@@ -19,6 +19,13 @@ export interface ShellFooterBuildOptions {
|
|
|
19
19
|
readonly showExitNotice: boolean;
|
|
20
20
|
readonly lastCopyTime: number;
|
|
21
21
|
readonly model?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Divergence marker appended to the model segment while the serving backend
|
|
24
|
+
* is not the user's configured selection (core/active-model-identity.ts),
|
|
25
|
+
* e.g. "failover from abacusai:route-llm". Absent/empty in the normal case,
|
|
26
|
+
* where the context-info line renders exactly as it always has.
|
|
27
|
+
*/
|
|
28
|
+
readonly modelNote?: string;
|
|
22
29
|
readonly toolCount?: number;
|
|
23
30
|
readonly workingDir?: string;
|
|
24
31
|
readonly provider?: string;
|
|
@@ -112,6 +119,34 @@ const DIM_STATUS_FG = '#64748b';
|
|
|
112
119
|
*/
|
|
113
120
|
let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
|
|
114
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The wrapped-prompt shape the composer reports each frame — the subset
|
|
124
|
+
* promptCursorOffset needs (see InputHandler.getWrappedPromptInfo).
|
|
125
|
+
*/
|
|
126
|
+
export interface WrappedPromptCursorInfo {
|
|
127
|
+
readonly visibleLines: readonly string[];
|
|
128
|
+
readonly visibleCursorLine: number;
|
|
129
|
+
readonly visibleCursorCol: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Flatten a wrapped-prompt cursor position (line + column) into the single
|
|
134
|
+
* character offset createFooter's `cursorPos` expects, counting one separator
|
|
135
|
+
* per wrapped line. Returns undefined when the cursor is not on a visible line
|
|
136
|
+
* (scrolled out of the composer's window), which suppresses the cursor overlay
|
|
137
|
+
* rather than drawing it in the wrong place.
|
|
138
|
+
*
|
|
139
|
+
* Lives here rather than inline at the render site so main.ts (a composition
|
|
140
|
+
* root already at the source-line gate) stays a wiring file.
|
|
141
|
+
*/
|
|
142
|
+
export function promptCursorOffset(info: WrappedPromptCursorInfo): number | undefined {
|
|
143
|
+
if (info.visibleCursorLine < 0) return undefined;
|
|
144
|
+
const precedingChars = info.visibleLines
|
|
145
|
+
.slice(0, info.visibleCursorLine)
|
|
146
|
+
.reduce((sum: number, line: string) => sum + line.length + 1, 0);
|
|
147
|
+
return precedingChars + info.visibleCursorCol;
|
|
148
|
+
}
|
|
149
|
+
|
|
115
150
|
export function estimateShellFooterHeight(
|
|
116
151
|
promptLineCount: number,
|
|
117
152
|
contextWindow?: number,
|
|
@@ -158,6 +193,7 @@ export function buildShellFooter(
|
|
|
158
193
|
options.permissionMode,
|
|
159
194
|
options.webSurfaceUrl,
|
|
160
195
|
options.powerKeepAwake,
|
|
196
|
+
options.modelNote,
|
|
161
197
|
);
|
|
162
198
|
// Compact posture drops the process indicator and context hint entirely so
|
|
163
199
|
// the footer fits within ~5 rows on short terminals.
|
|
@@ -3,6 +3,7 @@ import { LAYOUT } from './layout.ts';
|
|
|
3
3
|
import { VERSION } from '../version.ts';
|
|
4
4
|
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } from '../utils/terminal-width.ts';
|
|
5
5
|
import type { GitHeaderInfo } from './git-status.ts';
|
|
6
|
+
import { renderHeaderLine } from './header-line.ts';
|
|
6
7
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
8
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
9
|
import { activeUiTones } from './theme.ts';
|
|
@@ -42,22 +43,6 @@ export interface ThinkingStallInfo {
|
|
|
42
43
|
readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
46
|
-
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
47
|
-
const branch = ` git:${gitInfo.branch}`;
|
|
48
|
-
if (gitInfo.dirty) {
|
|
49
|
-
const text = `${branch} * `;
|
|
50
|
-
return { text, width: getDisplayWidth(text) };
|
|
51
|
-
}
|
|
52
|
-
if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
53
|
-
const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
|
|
54
|
-
const text = `${branch}${arrows} `;
|
|
55
|
-
return { text, width: getDisplayWidth(text) };
|
|
56
|
-
}
|
|
57
|
-
const text = `${branch} `;
|
|
58
|
-
return { text, width: getDisplayWidth(text) };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
46
|
/** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
|
|
62
47
|
function fmtNum(n: number): string {
|
|
63
48
|
return abbreviateCount(n, { bSuffix: true });
|
|
@@ -75,68 +60,19 @@ function fmtCost(usd: number): string {
|
|
|
75
60
|
* UIFactory - Generates standard UI fragments without needing Ink/React overhead.
|
|
76
61
|
*/
|
|
77
62
|
export class UIFactory {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const brand = ` GoodVibes `;
|
|
92
|
-
const ver = `v${version} `;
|
|
93
|
-
const stats = ` ${model} `;
|
|
94
|
-
const prov = `(${provider}) `;
|
|
95
|
-
const line = createEmptyLine(width);
|
|
96
|
-
let curX = 0;
|
|
97
|
-
for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
98
|
-
for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
99
|
-
// Optional conversation title — shown after brand/ver, truncated to fit
|
|
100
|
-
if (title) {
|
|
101
|
-
const titleStr = `│ ${title} `;
|
|
102
|
-
// Reserve space for git info (if present) + model/provider on the right
|
|
103
|
-
const gitReserved = gitInfo ? buildGitSegment(gitInfo).width : 0;
|
|
104
|
-
const rightReserved = getDisplayWidth(stats + prov) + gitReserved;
|
|
105
|
-
const maxTitleW = width - curX - rightReserved - 1;
|
|
106
|
-
let displayTitle: string;
|
|
107
|
-
if (getDisplayWidth(titleStr) <= maxTitleW) {
|
|
108
|
-
displayTitle = titleStr;
|
|
109
|
-
} else {
|
|
110
|
-
let truncated = '';
|
|
111
|
-
let w = 0;
|
|
112
|
-
for (const ch of titleStr) {
|
|
113
|
-
const cw = getDisplayWidth(ch);
|
|
114
|
-
if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
|
|
115
|
-
truncated += ch;
|
|
116
|
-
w += cw;
|
|
117
|
-
}
|
|
118
|
-
displayTitle = truncated;
|
|
119
|
-
}
|
|
120
|
-
for (const char of displayTitle) { if (curX < width) line[curX++] = { char, fg: TITLE_COLOR, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
121
|
-
}
|
|
122
|
-
// Build git info segment
|
|
123
|
-
let gitStr = '';
|
|
124
|
-
let gitFg: string = t.chrome.faint;
|
|
125
|
-
if (gitInfo) {
|
|
126
|
-
gitStr = buildGitSegment(gitInfo).text;
|
|
127
|
-
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
128
|
-
gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const rightSideText = stats + prov;
|
|
132
|
-
const rightSideW = getDisplayWidth(rightSideText) + getDisplayWidth(gitStr);
|
|
133
|
-
let rightX = width - rightSideW;
|
|
134
|
-
for (const char of gitStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: gitFg, bg: '', bold: false, dim: !gitInfo?.dirty && !(gitInfo?.ahead || gitInfo?.behind), underline: false, italic: false, strikethrough: false }; }
|
|
135
|
-
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
136
|
-
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
137
|
-
lines.push(line);
|
|
138
|
-
lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
|
|
139
|
-
return lines;
|
|
63
|
+
/**
|
|
64
|
+
* The header row. Delegates to renderHeaderLine (header-line.ts) — extracted
|
|
65
|
+
* when the header gained the failover divergence marker and this file was
|
|
66
|
+
* already on the 800-line source gate.
|
|
67
|
+
*
|
|
68
|
+
* `model`/`provider` are the SERVING backend and `note` the divergence
|
|
69
|
+
* marker, both resolved by core/active-model-identity.ts so the header and
|
|
70
|
+
* the footer answer "who is serving?" from one place. `version` defaults to
|
|
71
|
+
* the live build VERSION; tests pass a pinned fixture so golden snapshots
|
|
72
|
+
* don't break on every release bump.
|
|
73
|
+
*/
|
|
74
|
+
public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION, note: string = ''): Line[] {
|
|
75
|
+
return renderHeaderLine(width, model, provider, title, gitInfo, version, note);
|
|
140
76
|
}
|
|
141
77
|
|
|
142
78
|
/**
|
|
@@ -221,6 +157,11 @@ export class UIFactory {
|
|
|
221
157
|
// "sleep disabled" chip in the posture block (the danger-mode idiom: a
|
|
222
158
|
// persistent, colored surface indicator, the safety mechanism itself).
|
|
223
159
|
powerKeepAwake?: boolean,
|
|
160
|
+
// Divergence marker for the model segment (core/active-model-identity.ts):
|
|
161
|
+
// set only while the serving backend is not the user's configured
|
|
162
|
+
// selection, e.g. "failover from abacusai:route-llm". Empty/undefined
|
|
163
|
+
// renders the context-info line exactly as it did before the marker existed.
|
|
164
|
+
modelNote?: string,
|
|
224
165
|
): Line[] {
|
|
225
166
|
const lines: Line[] = [];
|
|
226
167
|
const promptLines = prompt.split('\n');
|
|
@@ -499,6 +440,12 @@ export class UIFactory {
|
|
|
499
440
|
if (model) {
|
|
500
441
|
ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
|
|
501
442
|
}
|
|
443
|
+
// Divergence marker — its own segment so a narrow line drops it WHOLE
|
|
444
|
+
// (joinPrioritizedSegments breaks priority ties toward the later
|
|
445
|
+
// segment) rather than truncating it into a half-word. Priority 0: it
|
|
446
|
+
// outranks every decorative segment, and what survives without it is
|
|
447
|
+
// still the SERVING backend, never a claim about the configured one.
|
|
448
|
+
if (modelNote) ctxParts.push({ text: modelNote, priority: 0 });
|
|
502
449
|
// Cross-surface spine posture — plain words, no blame. Adopted mode only.
|
|
503
450
|
if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
|
|
504
451
|
// Web surface reachability — plain, persistent; dropped first under width pressure.
|