@pellux/goodvibes-tui 1.20.1 → 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 +13 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -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/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.ts +23 -23
- 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 +141 -20
- 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 +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- 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/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 +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- 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') {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { getConfigSnapshot } from '../config/index.ts';
|
|
2
|
+
import {
|
|
3
|
+
describeServingEffort,
|
|
4
|
+
publishActiveEffortOptions,
|
|
5
|
+
resolveRequestedEffortForServingModel,
|
|
6
|
+
toEffortModel,
|
|
7
|
+
} from '../providers/reasoning-effort-surface.ts';
|
|
2
8
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
9
|
import type { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
|
|
4
10
|
import type { ConversationManager } from '../core/conversation';
|
|
@@ -227,7 +233,7 @@ export function createBootstrapCommandActions(
|
|
|
227
233
|
clearScreen: () => unwiredShellAction('clearScreen'),
|
|
228
234
|
activatePlan,
|
|
229
235
|
requestPermission: (request) => requestPermission(request),
|
|
230
|
-
completeModelSelection: ({ model, effort, contextCap, target }) => {
|
|
236
|
+
completeModelSelection: ({ model, effort, contextCap, target, effortChosenByUser }) => {
|
|
231
237
|
if (!model) return;
|
|
232
238
|
const def = model;
|
|
233
239
|
const key = def.registryKey ?? `${def.provider}:${def.id}`;
|
|
@@ -257,13 +263,39 @@ export function createBootstrapCommandActions(
|
|
|
257
263
|
providerRegistry.setCurrentModel(key);
|
|
258
264
|
runtime.model = key;
|
|
259
265
|
runtime.provider = def.provider;
|
|
260
|
-
|
|
266
|
+
// Two levels, kept apart on purpose. Config `provider.reasoningEffort`
|
|
267
|
+
// holds what the USER asked for; `runtime.reasoningEffort` holds what
|
|
268
|
+
// the model now serving will actually receive, which is the requested
|
|
269
|
+
// level snapped DOWN to this model's own levels.
|
|
270
|
+
//
|
|
271
|
+
// Only the picker's effort STEP is a user choice, and only it writes
|
|
272
|
+
// the preference. Every other route into this callback (model-only
|
|
273
|
+
// commit, context-cap commit) carries a level merely carried over
|
|
274
|
+
// from the previous model; storing that is what used to ratchet the
|
|
275
|
+
// preference down for good — one hop through a model that caps at
|
|
276
|
+
// 'medium' and 'xhigh' was gone from config, so hopping back could
|
|
277
|
+
// not restore it.
|
|
278
|
+
//
|
|
279
|
+
// A stored 'xhigh' that becomes 'high' on the wire would make a
|
|
280
|
+
// single-value display a lie, so the display carries BOTH values with
|
|
281
|
+
// their provenance instead (describeServingEffort) rather than the
|
|
282
|
+
// preference being corrupted to match.
|
|
283
|
+
const effortModel = toEffortModel(def);
|
|
284
|
+
// Publish this model's real levels first, so both the preference
|
|
285
|
+
// write below and a later `config set provider.reasoningEffort` are
|
|
286
|
+
// validated against them.
|
|
287
|
+
publishActiveEffortOptions(effortModel);
|
|
288
|
+
if (effortChosenByUser && effort) configManager.set('provider.reasoningEffort', effort);
|
|
289
|
+
const serving = resolveRequestedEffortForServingModel(configManager, effortModel);
|
|
290
|
+
runtime.reasoningEffort = serving.effective ?? '';
|
|
261
291
|
configManager.set('provider.model', key);
|
|
262
|
-
configManager.set('provider.reasoningEffort', effort as 'instant' | 'low' | 'medium' | 'high');
|
|
263
292
|
const ctxNote = contextCap != null && contextCap > 0
|
|
264
293
|
? `, context cap: ${contextCap.toLocaleString()}`
|
|
265
294
|
: '';
|
|
266
|
-
conversation.log(`Switched to model: ${def.displayName} (${def.provider}), effort: ${
|
|
295
|
+
conversation.log(`Switched to model: ${def.displayName} (${def.provider}), effort: ${describeServingEffort(serving, effortModel)}${ctxNote}`, { fg: '135' });
|
|
296
|
+
// The SDK's own sentence, printed verbatim so the explanation cannot
|
|
297
|
+
// drift from the resolution that produced it.
|
|
298
|
+
if (serving.note) conversation.log(serving.note, { fg: '214' });
|
|
267
299
|
}
|
|
268
300
|
} catch (e) {
|
|
269
301
|
conversation.log(`Error switching model: ${summarizeError(e)}`, { fg: '#ef4444' });
|
|
@@ -350,7 +350,7 @@ export async function initializeBootstrapCore(
|
|
|
350
350
|
|
|
351
351
|
const toolRegistry = new ToolRegistry();
|
|
352
352
|
const { fileCache, projectIndex } = registerAllTools(toolRegistry, {
|
|
353
|
-
surfaceRoot: services.surface.surfaceRoot,
|
|
353
|
+
resolveSessionId: () => runtimeSessionIdRef.value, surfaceRoot: services.surface.surfaceRoot, // task refs follow the LIVE runtime session (recovery reassigns it in place); without this they fall into the shared legacy namespace
|
|
354
354
|
localhostFetchApproval: services.localhostFetchApproval, // loopback-fetch ask, built once in the services composition
|
|
355
355
|
execPromptAnswerHandler: services.execPromptAnswerHandler, // terminal prompt-answer path, built once in the services composition
|
|
356
356
|
fileUndoManager: services.fileUndoManager,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* durability-housekeeping.ts — the reclaim half of every crash-durability
|
|
3
|
+
* artefact this product writes to disk.
|
|
4
|
+
*
|
|
5
|
+
* The standing rule this module implements: if something is persisted across
|
|
6
|
+
* restarts and crashes, recovery has to do the housekeeping. Each individual
|
|
7
|
+
* store already knew how to WRITE its artefact and how to retire it on a clean
|
|
8
|
+
* path; none of them reclaimed what a SIGKILL left behind. Four stores, four
|
|
9
|
+
* kinds of residue:
|
|
10
|
+
*
|
|
11
|
+
* - liveness markers `<home>/.goodvibes/<root>/liveness/<id>.json`
|
|
12
|
+
* removed only on a clean exit, so a crash leaks one
|
|
13
|
+
* file per session id ever opened.
|
|
14
|
+
* - transcript journals `<home>/.goodvibes/<root>/transcript-<id>.journal`
|
|
15
|
+
* deleted on snapshot or replay, so a session that
|
|
16
|
+
* crashes and is never resumed leaks its journal.
|
|
17
|
+
* - quarantine files `<path>.unrecognized` — written so a human can
|
|
18
|
+
* inspect a corrupt file, never removed afterwards.
|
|
19
|
+
* - anchor sidecars `<sessionsDir>/<id>.anchors.json` — outlives the
|
|
20
|
+
* session JSONL that owns it.
|
|
21
|
+
*
|
|
22
|
+
* Every reap here is bounded by BOTH an age window and a count cap, decides
|
|
23
|
+
* what to keep by reading content rather than by a file merely existing, never
|
|
24
|
+
* touches the current session, tolerates a competing sweeper deleting the same
|
|
25
|
+
* file first, and reports what it removed. `startDurabilityHousekeeping` runs
|
|
26
|
+
* the sweep at startup and then on a repeating timer, because a long-lived
|
|
27
|
+
* process that only sweeps at boot never sweeps.
|
|
28
|
+
*
|
|
29
|
+
* Best-effort throughout: a failure in one reap never aborts the others and
|
|
30
|
+
* nothing here ever throws to its caller.
|
|
31
|
+
*/
|
|
32
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { reapQuarantinedFiles } from '@/config/read-versioned.ts';
|
|
35
|
+
import { reapOrphanedAnchorSidecars } from '@/core/rewind-turn-anchors.ts';
|
|
36
|
+
import { reapOrphanedJournals } from '@/core/transcript-journal.ts';
|
|
37
|
+
import { checkSessionLiveness, reapStaleLivenessMarkers } from '@/runtime/session-liveness-marker.ts';
|
|
38
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How often the sweep repeats after startup: 6 hours. Hours, not minutes — the
|
|
42
|
+
* residue this reclaims accumulates per crashed session, not continuously, and
|
|
43
|
+
* a directory scan is not something a foreground TUI should be doing often.
|
|
44
|
+
*/
|
|
45
|
+
export const DURABILITY_HOUSEKEEPING_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
export interface DurabilityHousekeepingInput {
|
|
48
|
+
/** The app's declare-once storage handle; every swept path derives from it. */
|
|
49
|
+
readonly surface: SessionSurface;
|
|
50
|
+
/**
|
|
51
|
+
* The session this process is using right now, read at sweep time rather
|
|
52
|
+
* than captured once — a resume or fork can repoint it between sweeps.
|
|
53
|
+
* Returning null means "not known yet", which is safe: the age and liveness
|
|
54
|
+
* rules independently protect an actively written artefact.
|
|
55
|
+
*/
|
|
56
|
+
readonly currentSessionId?: () => string | null;
|
|
57
|
+
/** Extra directories to scan for `.unrecognized` quarantine files. */
|
|
58
|
+
readonly extraQuarantineDirs?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DurabilityHousekeepingOutcome {
|
|
62
|
+
readonly livenessMarkers: number;
|
|
63
|
+
readonly transcriptJournals: number;
|
|
64
|
+
readonly quarantineFiles: number;
|
|
65
|
+
readonly anchorSidecars: number;
|
|
66
|
+
/** Sum of the four counts above — zero means nothing was reclaimed. */
|
|
67
|
+
readonly total: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run all four reaps once and return what each reclaimed.
|
|
72
|
+
*
|
|
73
|
+
* Each reap is isolated: one throwing is logged at warn level and counted as
|
|
74
|
+
* zero, and the remaining reaps still run. The caller never sees an exception.
|
|
75
|
+
*/
|
|
76
|
+
export function runDurabilityHousekeeping(input: DurabilityHousekeepingInput): DurabilityHousekeepingOutcome {
|
|
77
|
+
const { surface } = input;
|
|
78
|
+
const currentSessionId = safely('current session id', () => input.currentSessionId?.() ?? null, null);
|
|
79
|
+
|
|
80
|
+
const livenessMarkers = safely('liveness markers', () =>
|
|
81
|
+
reapStaleLivenessMarkers(surface, {
|
|
82
|
+
keepSessionIds: currentSessionId ? [currentSessionId] : [],
|
|
83
|
+
}).reaped, 0);
|
|
84
|
+
|
|
85
|
+
const transcriptJournals = safely('transcript journals', () =>
|
|
86
|
+
reapOrphanedJournals(surface, {
|
|
87
|
+
currentSessionId,
|
|
88
|
+
// Composition happens here so transcript-journal.ts keeps no dependency
|
|
89
|
+
// on the liveness marker: a journal whose session is open in another
|
|
90
|
+
// running instance is live state, not residue.
|
|
91
|
+
isSessionLive: (sessionId) => checkSessionLiveness(surface, sessionId).live,
|
|
92
|
+
}).reaped, 0);
|
|
93
|
+
|
|
94
|
+
const quarantineFiles = safely('quarantine files', () =>
|
|
95
|
+
reapQuarantinedFiles(quarantineDirsFor(input)).reaped, 0);
|
|
96
|
+
|
|
97
|
+
const anchorSidecars = safely('anchor sidecars', () =>
|
|
98
|
+
reapOrphanedAnchorSidecars(surface, { currentSessionId }).reaped, 0);
|
|
99
|
+
|
|
100
|
+
const outcome: DurabilityHousekeepingOutcome = {
|
|
101
|
+
livenessMarkers,
|
|
102
|
+
transcriptJournals,
|
|
103
|
+
quarantineFiles,
|
|
104
|
+
anchorSidecars,
|
|
105
|
+
total: livenessMarkers + transcriptJournals + quarantineFiles + anchorSidecars,
|
|
106
|
+
};
|
|
107
|
+
discloseHousekeeping(outcome);
|
|
108
|
+
return outcome;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start durability housekeeping: one sweep now, then a repeating sweep every
|
|
113
|
+
* `DURABILITY_HOUSEKEEPING_INTERVAL_MS`. Returns a disposer that stops the
|
|
114
|
+
* timer; calling it more than once is harmless.
|
|
115
|
+
*
|
|
116
|
+
* The timer is `unref()`d, so an undisposed sweeper can never hold the process
|
|
117
|
+
* open — same convention the store-snapshot scheduler and the config-file
|
|
118
|
+
* watchers in durability-services.ts use.
|
|
119
|
+
*/
|
|
120
|
+
export function startDurabilityHousekeeping(
|
|
121
|
+
input: DurabilityHousekeepingInput,
|
|
122
|
+
options: { readonly intervalMs?: number } = {},
|
|
123
|
+
): () => void {
|
|
124
|
+
runDurabilityHousekeeping(input);
|
|
125
|
+
const timer = setInterval(
|
|
126
|
+
() => { runDurabilityHousekeeping(input); },
|
|
127
|
+
options.intervalMs ?? DURABILITY_HOUSEKEEPING_INTERVAL_MS,
|
|
128
|
+
);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
let stopped = false;
|
|
131
|
+
return () => {
|
|
132
|
+
if (stopped) return;
|
|
133
|
+
stopped = true;
|
|
134
|
+
clearInterval(timer);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Disclose what was reclaimed — silent deletion is indistinguishable from data
|
|
140
|
+
* loss. Paths and counts only; nothing about a reclaimed file's contents.
|
|
141
|
+
*
|
|
142
|
+
* A sweep that reclaimed nothing logs nothing: the overwhelmingly common boot
|
|
143
|
+
* has no residue at all, and a per-boot "reclaimed 0" line would train readers
|
|
144
|
+
* to ignore the line that actually matters.
|
|
145
|
+
*/
|
|
146
|
+
function discloseHousekeeping(outcome: DurabilityHousekeepingOutcome): void {
|
|
147
|
+
if (outcome.total === 0) return;
|
|
148
|
+
logger.info('durability housekeeping reclaimed crash residue', {
|
|
149
|
+
livenessMarkers: outcome.livenessMarkers,
|
|
150
|
+
transcriptJournals: outcome.transcriptJournals,
|
|
151
|
+
quarantineFiles: outcome.quarantineFiles,
|
|
152
|
+
anchorSidecars: outcome.anchorSidecars,
|
|
153
|
+
total: outcome.total,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every directory that can accumulate `.unrecognized` files: the home-scoped
|
|
159
|
+
* surface root (transcript-journal quarantines) and the per-project session,
|
|
160
|
+
* state, recovery and checkpoint directories (versioned-config quarantines),
|
|
161
|
+
* plus whatever the caller adds.
|
|
162
|
+
*/
|
|
163
|
+
function quarantineDirsFor(input: DurabilityHousekeepingInput): readonly string[] {
|
|
164
|
+
const { surface } = input;
|
|
165
|
+
return [
|
|
166
|
+
join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot),
|
|
167
|
+
surface.sessionsDir,
|
|
168
|
+
surface.stateDir,
|
|
169
|
+
surface.recoveryDir,
|
|
170
|
+
surface.checkpointsDir,
|
|
171
|
+
...(input.extraQuarantineDirs ?? []),
|
|
172
|
+
].filter((dir): dir is string => typeof dir === 'string' && dir.length > 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Run one reap in isolation: never throws, returns `fallback` if it would have. */
|
|
176
|
+
function safely<T>(label: string, run: () => T, fallback: T): T {
|
|
177
|
+
try {
|
|
178
|
+
return run();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.warn('durability housekeeping step failed', { step: label, error: summarizeError(error) });
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|