@pellux/goodvibes-tui 1.20.1 → 1.21.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/input/settings-modal-types.ts +2 -1
  36. package/src/main.ts +12 -20
  37. package/src/providers/reasoning-effort-surface.ts +399 -0
  38. package/src/renderer/compositor.ts +68 -18
  39. package/src/renderer/markdown-inline.ts +143 -0
  40. package/src/renderer/markdown-table.ts +459 -0
  41. package/src/renderer/markdown.ts +6 -335
  42. package/src/renderer/model-workspace.ts +48 -5
  43. package/src/renderer/settings-modal-helpers.ts +53 -1
  44. package/src/renderer/settings-modal.ts +1 -50
  45. package/src/runtime/bootstrap-command-parts.ts +36 -4
  46. package/src/runtime/bootstrap-core.ts +1 -1
  47. package/src/runtime/durability-housekeeping.ts +183 -0
  48. package/src/runtime/durability-services.ts +68 -11
  49. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  50. package/src/runtime/provider-fallback.ts +16 -2
  51. package/src/runtime/recovery-decisions.ts +180 -11
  52. package/src/runtime/recovery-prompt.ts +11 -1
  53. package/src/runtime/services.ts +9 -4
  54. package/src/runtime/session-liveness-marker.ts +142 -5
  55. package/src/runtime/workstream-draft-store.ts +198 -25
  56. package/src/runtime/workstream-services.ts +17 -2
  57. package/src/shell/ui-openers.ts +1 -1
  58. package/src/version.ts +1 -1
  59. package/src/work-plans/work-plan-store.ts +334 -16
@@ -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
- for (let row = 0; row < Math.min(rows, picker.effortLevels.length); row += 1) {
285
- const effort = picker.effortLevels[row]!;
286
- const selected = row === picker.selectedIndex;
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
- writeText(lines[row]!, startX + 1, width - 2, `${marker} ${effort}`, { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
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(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
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') {
@@ -4,7 +4,7 @@
4
4
  * architecture cap. No layout logic lives here.
5
5
  */
6
6
 
7
- import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
7
+ import type { SettingEntry, McpEntry, SubscriptionEntry, SettingsCategory } from '../input/settings-modal-types.ts';
8
8
  import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
10
  import { UI_TONES } from './ui-primitives.ts';
@@ -61,6 +61,57 @@ export function inferSubscriptionRouteReason(entry: SubscriptionEntry): string |
61
61
  return undefined;
62
62
  }
63
63
 
64
+ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
65
+ display: 'Presentation settings for the terminal transcript: streaming, line numbers, thinking visibility, reasoning summaries, token speed, and tool previews.',
66
+ ui: 'Controls where operational messages render and whether voice surfaces are enabled. These settings change visibility, not provider behavior.',
67
+ provider: 'Default model routing for normal chat turns, embeddings, reasoning effort, and persistent system prompt file.',
68
+ pricing: 'Manual model prices (USD per 1M tokens, keyed provider:model). Your price wins over provider-served and catalog prices in every cost display, immediately.',
69
+ update: 'Self-update posture: whether the daemon checks hourly and updates at an idle moment, the release source it checks, and the check cadence. Clients update at launch; every swap keeps the previous binary for one-command rollback.',
70
+ subscriptions: 'Provider subscription login state and routing posture. Active sessions can be reviewed or signed out here; API keys remain managed through secrets.',
71
+ behavior: 'Day-to-day shell behavior: approval posture, compaction, history, guidance, notifications, stale-context warnings, return context, and Human-in-the-Loop mode.',
72
+ storage: 'Local storage posture, including secret storage policy and maximum artifact size for knowledge/home graph/document ingestion.',
73
+ atRest: 'Data-at-rest policy for the on-disk transcript journal and execution ledger: whether secret/credential patterns are redacted at write time, and the age/size caps that trigger pruning.',
74
+ permissions: 'Permission mode and tool-class policy. These settings decide whether the shell prompts before read/write/exec/network/agent actions.',
75
+ orchestration: 'Agent orchestration limits and recursion controls.',
76
+ planner: 'How /workstream decomposes a goal into work items: agent-driven decomposition (with heuristic fallback) or the forced heuristic path, plus the planning agent\'s turn, token, and wall-clock bounds.',
77
+ wrfc: 'Work-review-fix-cycle thresholds, retry limits, and automatic commit behavior.',
78
+ helper: 'Helper model defaults used by helper subsystems when they do not use the main chat route.',
79
+ tts: 'Text-to-speech provider, voice, and optional spoken-turn LLM overrides.',
80
+ voice: 'Free, offline local voice engines (voice.local.*): the STT and TTS engine, binary, and model-path for whisper.cpp/faster-whisper and Piper/Kokoro. All empty by default — configurable-not-configured, nothing auto-downloads, and an unconfigured machine reports honestly (never an error). Once set, "Local engines" appears in the TTS provider picker beside elevenlabs, with no billing dimension.',
81
+ service: 'Background service posture: enabled state, autostart, restart behavior, service name, platform, and logs.',
82
+ daemon: 'Local session daemon. It hosts the shared session broker and companion chat so a session started here is visible and steerable from other surfaces. On by default, bound to loopback (127.0.0.1) only.',
83
+ controlPlane: 'Daemon control-plane settings for local admin/API access.',
84
+ httpListener: 'HTTP listener settings for webhook and integration ingress.',
85
+ web: 'Browser surface settings for the local or network web UI.',
86
+ batch: 'Batch execution settings, including local vs Cloudflare queue behavior.',
87
+ automation: 'Scheduled and automated run settings, concurrency, timeout, catch-up, cooldown, and retention behavior.',
88
+ checkin: 'Proactive check-in: on a cadence, a briefing is judged and you are contacted only when something warrants it. Off by default; every run — scheduled or manual — leaves a receipt (see /checkin) recording whether it stayed quiet, delivered a message, or was skipped.',
89
+ watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior.',
90
+ runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
91
+ telemetry: 'Telemetry payload policy.',
92
+ cache: 'Provider and model cache behavior, TTL, and hit-rate monitoring.',
93
+ diagnostics: 'Post-edit diagnostics: whether the shell runs language diagnostics on a file after the model edits or writes it, surfacing new problems inline.',
94
+ mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
95
+ sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
96
+ surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
97
+ conversationGate: 'What a message arriving from one of those channels does. By default it gets a conversational answer, and work is proposed and waits for your agreement rather than starting on its own; you can instead confirm every run, or restore the old behavior where a message starts work immediately. Also how long a pending proposal stays answerable and how many can wait at once. This governs messages from channels only — what you type here always runs, and schedules and triggers were authorized when created.',
98
+ cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
99
+ release: 'Release-channel preference.',
100
+ power: 'Host sleep ownership. Keep-awake holds the host awake (the always-visible "sleep disabled" chip is the safety mechanism — no timer, no AC-only option); automatic inhibition keeps the host awake while real work runs, with an honest hard cap on that work hold.',
101
+ memory: 'Memory provenance surfacing. Whether turns that drew on your standing memories show a small "used N memories" chip with a drill-in listing them. Off by default — when off, nothing is rendered and no context is added.',
102
+ danger: 'High-impact switches for daemon and HTTP listener behavior. These are operational overrides, not normal preferences.',
103
+ tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
104
+ network: 'Combined network view for daemon control-plane, HTTP listener, browser web surface, and general outbound network settings.',
105
+ fetch: 'Fetch response sanitization and host trust tiers for the fetch tool: sanitize mode plus default trusted/blocked host lists. Gated by the Fetch Response Sanitization feature.',
106
+ agents: 'Sub-agent context-window awareness and per-turn passive knowledge/code injection: token budget, relevance floor, code-chunk limit, and the compaction threshold. Gated by the agent context/injection features.',
107
+ security: 'API token scope and rotation auditing: rotation cadence, warning lead time, and whether overdue/over-scoped tokens are blocked or only reported. Gated by the Token Scope and Rotation Audit feature.',
108
+ integrations: 'Integration delivery reliability (Slack/Discord/webhook): retry counts, backoff bounds, dead-letter queue size, and SLO enforcement. Gated by the Integration Delivery SLO feature.',
109
+ policy: 'Policy-as-code bundle loading: where the startup policy bundle is loaded from and its file path. Gated by the Policy-as-Code feature.',
110
+ notifications: 'Notification router burst-suppression tuning: burst window, threshold, and cooldown. Gated by the Adaptive Notification Suppression feature.',
111
+ relay: 'Outbound zero-knowledge relay reachability, for reaching this daemon from outside the LAN without opening an inbound port. Off by default — relay.enabled is the switch. The relay operator sees only ciphertext and connection metadata — self-host your own relay for full control.',
112
+ learning: 'Idle-time memory consolidation: merging duplicate standing memory records and decaying/archiving stale ones. Off by default — nothing runs until enabled.',
113
+ };
114
+
64
115
  export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], string> = {
65
116
  display: 'Display',
66
117
  ui: 'UI',
@@ -94,6 +145,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
94
145
  mcp: 'MCP',
95
146
  sandbox: 'Sandbox',
96
147
  surfaces: 'Surfaces',
148
+ conversationGate: 'Channel Message Handling',
97
149
  cloudflare: 'Cloudflare',
98
150
  release: 'Release',
99
151
  power: 'Power',
@@ -10,7 +10,7 @@ import type { SettingsModal, SettingEntry, McpEntry, SubscriptionEntry, Settings
10
10
  import { SETTINGS_CATEGORIES, SETTINGS_CATEGORY_GROUPS } from '../input/settings-modal.ts';
11
11
  import { FEATURE_SETTINGS_BY_ID, isFeatureValueEnabled } from '../runtime/feature-settings.ts';
12
12
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
- import { CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
13
+ import { CATEGORY_INFO, CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
14
14
  import { isSecretConfigKey } from '../config/secret-config.ts';
15
15
  import { GLYPHS } from './ui-primitives.ts';
16
16
  import { formatHints, joinHints } from './hint-grammar.ts';
@@ -24,55 +24,6 @@ import {
24
24
  type WorkspaceRow,
25
25
  } from './fullscreen-workspace.ts';
26
26
 
27
- const CATEGORY_INFO: Record<SettingsCategory, string> = {
28
- display: 'Presentation settings for the terminal transcript: streaming, line numbers, thinking visibility, reasoning summaries, token speed, and tool previews.',
29
- ui: 'Controls where operational messages render and whether voice surfaces are enabled. These settings change visibility, not provider behavior.',
30
- provider: 'Default model routing for normal chat turns, embeddings, reasoning effort, and persistent system prompt file.',
31
- pricing: 'Manual model prices (USD per 1M tokens, keyed provider:model). Your price wins over provider-served and catalog prices in every cost display, immediately.',
32
- update: 'Self-update posture: whether the daemon checks hourly and updates at an idle moment, the release source it checks, and the check cadence. Clients update at launch; every swap keeps the previous binary for one-command rollback.',
33
- subscriptions: 'Provider subscription login state and routing posture. Active sessions can be reviewed or signed out here; API keys remain managed through secrets.',
34
- behavior: 'Day-to-day shell behavior: approval posture, compaction, history, guidance, notifications, stale-context warnings, return context, and Human-in-the-Loop mode.',
35
- storage: 'Local storage posture, including secret storage policy and maximum artifact size for knowledge/home graph/document ingestion.',
36
- atRest: 'Data-at-rest policy for the on-disk transcript journal and execution ledger: whether secret/credential patterns are redacted at write time, and the age/size caps that trigger pruning.',
37
- permissions: 'Permission mode and tool-class policy. These settings decide whether the shell prompts before read/write/exec/network/agent actions.',
38
- orchestration: 'Agent orchestration limits and recursion controls.',
39
- planner: 'How /workstream decomposes a goal into work items: agent-driven decomposition (with heuristic fallback) or the forced heuristic path, plus the planning agent\'s turn, token, and wall-clock bounds.',
40
- wrfc: 'Work-review-fix-cycle thresholds, retry limits, and automatic commit behavior.',
41
- helper: 'Helper model defaults used by helper subsystems when they do not use the main chat route.',
42
- tts: 'Text-to-speech provider, voice, and optional spoken-turn LLM overrides.',
43
- voice: 'Free, offline local voice engines (voice.local.*): the STT and TTS engine, binary, and model-path for whisper.cpp/faster-whisper and Piper/Kokoro. All empty by default — configurable-not-configured, nothing auto-downloads, and an unconfigured machine reports honestly (never an error). Once set, "Local engines" appears in the TTS provider picker beside elevenlabs, with no billing dimension.',
44
- service: 'Background service posture: enabled state, autostart, restart behavior, service name, platform, and logs.',
45
- daemon: 'Local session daemon. It hosts the shared session broker and companion chat so a session started here is visible and steerable from other surfaces. On by default, bound to loopback (127.0.0.1) only.',
46
- controlPlane: 'Daemon control-plane settings for local admin/API access.',
47
- httpListener: 'HTTP listener settings for webhook and integration ingress.',
48
- web: 'Browser surface settings for the local or network web UI.',
49
- batch: 'Batch execution settings, including local vs Cloudflare queue behavior.',
50
- automation: 'Scheduled and automated run settings, concurrency, timeout, catch-up, cooldown, and retention behavior.',
51
- checkin: 'Proactive check-in: on a cadence, a briefing is judged and you are contacted only when something warrants it. Off by default; every run — scheduled or manual — leaves a receipt (see /checkin) recording whether it stayed quiet, delivered a message, or was skipped.',
52
- watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior.',
53
- runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
54
- telemetry: 'Telemetry payload policy.',
55
- cache: 'Provider and model cache behavior, TTL, and hit-rate monitoring.',
56
- diagnostics: 'Post-edit diagnostics: whether the shell runs language diagnostics on a file after the model edits or writes it, surfacing new problems inline.',
57
- mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
58
- sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
59
- surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
60
- cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
61
- release: 'Release-channel preference.',
62
- power: 'Host sleep ownership. Keep-awake holds the host awake (the always-visible "sleep disabled" chip is the safety mechanism — no timer, no AC-only option); automatic inhibition keeps the host awake while real work runs, with an honest hard cap on that work hold.',
63
- memory: 'Memory provenance surfacing. Whether turns that drew on your standing memories show a small "used N memories" chip with a drill-in listing them. Off by default — when off, nothing is rendered and no context is added.',
64
- danger: 'High-impact switches for daemon and HTTP listener behavior. These are operational overrides, not normal preferences.',
65
- tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
66
- network: 'Combined network view for daemon control-plane, HTTP listener, browser web surface, and general outbound network settings.',
67
- fetch: 'Fetch response sanitization and host trust tiers for the fetch tool: sanitize mode plus default trusted/blocked host lists. Gated by the Fetch Response Sanitization feature.',
68
- agents: 'Sub-agent context-window awareness and per-turn passive knowledge/code injection: token budget, relevance floor, code-chunk limit, and the compaction threshold. Gated by the agent context/injection features.',
69
- security: 'API token scope and rotation auditing: rotation cadence, warning lead time, and whether overdue/over-scoped tokens are blocked or only reported. Gated by the Token Scope and Rotation Audit feature.',
70
- integrations: 'Integration delivery reliability (Slack/Discord/webhook): retry counts, backoff bounds, dead-letter queue size, and SLO enforcement. Gated by the Integration Delivery SLO feature.',
71
- policy: 'Policy-as-code bundle loading: where the startup policy bundle is loaded from and its file path. Gated by the Policy-as-Code feature.',
72
- notifications: 'Notification router burst-suppression tuning: burst window, threshold, and cooldown. Gated by the Adaptive Notification Suppression feature.',
73
- relay: 'Outbound zero-knowledge relay reachability, for reaching this daemon from outside the LAN without opening an inbound port. Off by default — relay.enabled is the switch. The relay operator sees only ciphertext and connection metadata — self-host your own relay for full control.',
74
- learning: 'Idle-time memory consolidation: merging duplicate standing memory records and decaying/archiving stale ones. Off by default — nothing runs until enabled.',
75
- };
76
27
 
77
28
  const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
78
29
  'display.themeMode': {
@@ -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
- runtime.reasoningEffort = effort as 'instant' | 'low' | 'medium' | 'high';
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: ${effort}${ctxNote}`, { fg: '135' });
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,