@pellux/goodvibes-tui 0.20.2 → 0.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.
Files changed (120) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/platform-sandbox-qemu.ts +60 -16
  39. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  40. package/src/input/commands/recall-review.ts +26 -2
  41. package/src/input/commands/services-runtime.ts +2 -2
  42. package/src/input/commands/session-workflow.ts +3 -3
  43. package/src/input/commands/share-runtime.ts +99 -12
  44. package/src/input/commands/tts-runtime.ts +30 -4
  45. package/src/input/commands.ts +2 -2
  46. package/src/input/delete-key-policy.ts +46 -0
  47. package/src/input/feed-context-factory.ts +2 -0
  48. package/src/input/handler-feed.ts +3 -0
  49. package/src/input/handler-interactions.ts +2 -15
  50. package/src/input/handler-modal-routes.ts +91 -12
  51. package/src/input/handler-modal-token-routes.ts +3 -0
  52. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  53. package/src/input/handler-onboarding.ts +55 -69
  54. package/src/input/handler-types.ts +163 -0
  55. package/src/input/handler.ts +5 -2
  56. package/src/input/input-history.ts +76 -6
  57. package/src/input/model-picker-filter.ts +265 -0
  58. package/src/input/model-picker-items.ts +208 -0
  59. package/src/input/model-picker.ts +92 -325
  60. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  61. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  62. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  63. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  64. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  65. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  66. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  67. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  68. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  69. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  70. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  71. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  72. package/src/input/settings-modal-data.ts +304 -0
  73. package/src/input/settings-modal-mutations.ts +154 -0
  74. package/src/input/settings-modal.ts +182 -220
  75. package/src/main.ts +57 -57
  76. package/src/panels/builtin/agent.ts +4 -1
  77. package/src/panels/builtin/development.ts +4 -1
  78. package/src/panels/confirm-state.ts +27 -12
  79. package/src/panels/cost-tracker-panel.ts +23 -67
  80. package/src/panels/eval-panel.ts +10 -9
  81. package/src/panels/knowledge-panel.ts +3 -5
  82. package/src/panels/local-auth-panel.ts +124 -4
  83. package/src/panels/project-planning-panel.ts +42 -4
  84. package/src/panels/search-focus.ts +11 -5
  85. package/src/panels/subscription-panel.ts +33 -25
  86. package/src/panels/types.ts +28 -1
  87. package/src/panels/wrfc-panel.ts +224 -41
  88. package/src/renderer/agent-detail-modal.ts +11 -10
  89. package/src/renderer/code-block.ts +10 -2
  90. package/src/renderer/compositor.ts +18 -4
  91. package/src/renderer/context-inspector.ts +1 -5
  92. package/src/renderer/diff.ts +94 -21
  93. package/src/renderer/markdown.ts +29 -13
  94. package/src/renderer/settings-modal-helpers.ts +1 -1
  95. package/src/renderer/settings-modal.ts +77 -8
  96. package/src/renderer/syntax-highlighter.ts +10 -3
  97. package/src/renderer/term-caps.ts +318 -0
  98. package/src/renderer/theme.ts +158 -0
  99. package/src/renderer/tool-call.ts +12 -2
  100. package/src/renderer/ui-factory.ts +50 -6
  101. package/src/runtime/bootstrap-command-context.ts +1 -0
  102. package/src/runtime/bootstrap-command-parts.ts +14 -0
  103. package/src/runtime/bootstrap-core.ts +121 -13
  104. package/src/runtime/bootstrap.ts +2 -0
  105. package/src/runtime/onboarding/apply.ts +4 -6
  106. package/src/runtime/onboarding/index.ts +1 -0
  107. package/src/runtime/onboarding/markers.ts +42 -49
  108. package/src/runtime/onboarding/progress.ts +148 -0
  109. package/src/runtime/onboarding/state.ts +133 -55
  110. package/src/runtime/onboarding/types.ts +20 -0
  111. package/src/runtime/sandbox-qemu-templates.ts +15 -0
  112. package/src/runtime/services.ts +21 -0
  113. package/src/runtime/wrfc-persistence.ts +237 -0
  114. package/src/shell/blocking-input.ts +20 -5
  115. package/src/tools/wrfc-agent-guard.ts +64 -3
  116. package/src/utils/format-elapsed.ts +30 -0
  117. package/src/utils/terminal-width.ts +45 -0
  118. package/src/version.ts +1 -1
  119. package/src/work-plans/work-plan-store.ts +4 -6
  120. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -1,8 +1,18 @@
1
1
  import { TerminalBuffer } from './buffer.ts';
2
2
  import { type Cell } from '../types/grid.ts';
3
+ import {
4
+ type TermColorCaps,
5
+ downsampleColor,
6
+ wrapSynced,
7
+ } from './term-caps.ts';
3
8
 
4
9
  /**
5
10
  * DiffEngine - Generates minimal ANSI updates between two buffers.
11
+ *
12
+ * Accepts a TermColorCaps probe result so that color sequences are
13
+ * downsampled to the terminal's actual capability level (truecolor /
14
+ * ansi256 / basic16 / none), and frames are wrapped in DEC 2026
15
+ * synchronized-output markers when supported.
6
16
  */
7
17
  export class DiffEngine {
8
18
  private lastFg = '';
@@ -13,6 +23,19 @@ export class DiffEngine {
13
23
  private lastItalic = false;
14
24
  private lastStrikethrough = false;
15
25
  private lastLink = '';
26
+ private caps: TermColorCaps;
27
+ /**
28
+ * Run-coalescing state: tracks the last cell position emitted.
29
+ * When the next cell is at (lastEmitX+1, lastEmitY) and SGR is unchanged,
30
+ * we skip cursor re-addressing and append the char directly.
31
+ * Reset per diff() call to avoid stale state across frames.
32
+ */
33
+ private lastEmitX = -1;
34
+ private lastEmitY = -1;
35
+
36
+ constructor(caps: TermColorCaps = { capability: 'truecolor', syncedOutput: true }) {
37
+ this.caps = caps;
38
+ }
16
39
 
17
40
  public reset(): void {
18
41
  this.lastFg = '';
@@ -27,7 +50,10 @@ export class DiffEngine {
27
50
 
28
51
  public diff(oldBuffer: TerminalBuffer | null, newBuffer: TerminalBuffer): string {
29
52
  let output = '';
30
-
53
+ // Reset run-coalescing state per frame: last emitted cursor position.
54
+ this.lastEmitX = -1;
55
+ this.lastEmitY = -1;
56
+
31
57
  for (let y = 0; y < newBuffer.height; y++) {
32
58
  // Skip rows that were not written in either the old or new buffer.
33
59
  // If neither side touched the row, both must match the prior frame:
@@ -43,9 +69,20 @@ export class DiffEngine {
43
69
  if (!newCell || newCell.char === '') continue;
44
70
 
45
71
  if (this.isCellDifferent(oldCell, newCell)) {
46
- output += `\x1b[${y + 1};${x + 1}H`;
47
- output += this.applyStyles(newCell);
48
- output += newCell.char;
72
+ const sgrOutput = this.applyStyles(newCell);
73
+ // Run-coalescing: when the previous emitted cell was (x-1, y) and
74
+ // the SGR state did not change, skip cursor re-addressing.
75
+ // Emit a cursor move only on run breaks (new row, gap, or style change).
76
+ if (sgrOutput === '' && this.lastEmitY === y && this.lastEmitX === x - 1) {
77
+ // Contiguous run on same row, same SGR — just append the char.
78
+ output += newCell.char;
79
+ } else {
80
+ output += `\x1b[${y + 1};${x + 1}H`;
81
+ output += sgrOutput;
82
+ output += newCell.char;
83
+ }
84
+ this.lastEmitX = x;
85
+ this.lastEmitY = y;
49
86
  }
50
87
  }
51
88
  }
@@ -56,7 +93,7 @@ export class DiffEngine {
56
93
  this.lastLink = '';
57
94
  }
58
95
 
59
- return output;
96
+ return wrapSynced(output, this.caps);
60
97
  }
61
98
 
62
99
  private isCellDifferent(a: Cell | undefined, b: Cell): boolean {
@@ -66,6 +103,13 @@ export class DiffEngine {
66
103
  (a.link ?? '') !== (b.link ?? '');
67
104
  }
68
105
 
106
+ /**
107
+ * Convert a raw color string (hex or r;g;b) to the "r;g;b" form expected
108
+ * by applyStyles. For non-RGB palette indices the value is returned as-is.
109
+ * This mirrors the original sanitizeColor contract but is now capability-aware
110
+ * only in the sense of normalizing hex → "r;g;b" — downsampling happens in
111
+ * applyStyles via downsampleColor.
112
+ */
69
113
  private sanitizeColor(color: string): string {
70
114
  if (color.startsWith('#')) {
71
115
  const r = parseInt(color.slice(1, 3), 16);
@@ -77,7 +121,7 @@ export class DiffEngine {
77
121
  }
78
122
 
79
123
  private applyStyles(cell: Cell): string {
80
- let style = '';
124
+ // Normalize hex → r;g;b (for change-detection against lastFg/lastBg)
81
125
  const fg = this.sanitizeColor(cell.fg);
82
126
  const bg = this.sanitizeColor(cell.bg);
83
127
  const link = cell.link ?? '';
@@ -87,21 +131,45 @@ export class DiffEngine {
87
131
  cell.underline !== this.lastUnderline || cell.italic !== this.lastItalic ||
88
132
  cell.strikethrough !== this.lastStrikethrough;
89
133
 
134
+ let style = '';
135
+
90
136
  if (changed) {
91
- style += '\x1b[0m'; // Reset all attributes
92
- if (cell.bold) style += '\x1b[1m';
93
- if (cell.dim) style += '\x1b[2m';
94
- if (cell.italic) style += '\x1b[3m';
95
- if (cell.underline) style += '\x1b[4m';
96
- if (cell.strikethrough) style += '\x1b[9m';
97
-
98
- if (fg) {
99
- const isRgb = fg.includes(';');
100
- style += isRgb ? `\x1b[38;2;${fg}m` : `\x1b[38;5;${fg}m`;
137
+ // Reset all attributes first
138
+ if (this.caps.capability !== 'none') {
139
+ style += '\x1b[0m';
101
140
  }
102
- if (bg) {
103
- const isRgb = bg.includes(';');
104
- style += isRgb ? `\x1b[48;2;${bg}m` : `\x1b[48;5;${bg}m`;
141
+
142
+ // Text attributes are always emitted when capability > none
143
+ if (this.caps.capability !== 'none') {
144
+ if (cell.bold) style += '\x1b[1m';
145
+ if (cell.dim) style += '\x1b[2m';
146
+ if (cell.italic) style += '\x1b[3m';
147
+ if (cell.underline) style += '\x1b[4m';
148
+ if (cell.strikethrough) style += '\x1b[9m';
149
+ }
150
+
151
+ // Foreground color — capability-downsampled
152
+ const fgOut = downsampleColor(fg, this.caps, 'fg');
153
+ if (fgOut !== null) {
154
+ if (this.caps.capability === 'basic16') {
155
+ // fgOut is already the numeric SGR code (e.g. "31", "92")
156
+ style += `\x1b[${fgOut}m`;
157
+ } else {
158
+ const isRgb = fgOut.includes(';');
159
+ style += isRgb ? `\x1b[38;2;${fgOut}m` : `\x1b[38;5;${fgOut}m`;
160
+ }
161
+ }
162
+
163
+ // Background color — capability-downsampled
164
+ const bgOut = downsampleColor(bg, this.caps, 'bg');
165
+ if (bgOut !== null) {
166
+ if (this.caps.capability === 'basic16') {
167
+ // bgOut is already the numeric SGR code (e.g. "41", "102")
168
+ style += `\x1b[${bgOut}m`;
169
+ } else {
170
+ const isRgb = bgOut.includes(';');
171
+ style += isRgb ? `\x1b[48;2;${bgOut}m` : `\x1b[48;5;${bgOut}m`;
172
+ }
105
173
  }
106
174
 
107
175
  this.lastFg = fg;
@@ -114,15 +182,20 @@ export class DiffEngine {
114
182
  }
115
183
 
116
184
  // OSC 8 hyperlink: emit open/close/change sequences only when link changes
117
- if (link !== this.lastLink) {
185
+ // Hyperlinks are suppressed in no-color mode (dumb/no-color terminals
186
+ // cannot render them and the OSC sequence is just noise).
187
+ if (link !== this.lastLink && this.caps.capability !== 'none') {
118
188
  if (link) {
119
189
  // Open new hyperlink (close previous if any was open)
120
190
  style += `\x1b]8;;${link}\x1b\\`;
121
191
  } else {
122
192
  // Close hyperlink
123
- style += `\x1b]8;;\x1b\\`;
193
+ style += '\x1b]8;;\x1b\\';
124
194
  }
125
195
  this.lastLink = link;
196
+ } else if (link !== this.lastLink) {
197
+ // capability === 'none': track state but emit nothing
198
+ this.lastLink = link;
126
199
  }
127
200
 
128
201
  return style;
@@ -3,9 +3,15 @@ import { UIFactory } from './ui-factory.ts';
3
3
  import { renderCodeBlock } from './code-block.ts';
4
4
  import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import { LAYOUT } from './layout.ts';
6
+ import { DARK_THEME } from './theme.ts';
7
+
8
+ /** Module-level resolved token set (dark default; replace with resolveTheme(mode) when mode detection lands). */
9
+ const T = DARK_THEME;
6
10
 
7
11
  export interface MarkdownRenderOptions {
8
12
  codeBlockLineNumbers?: boolean;
13
+ /** When true, suppresses tree-sitter parse scheduling for streaming code blocks. */
14
+ isStreaming?: boolean;
9
15
  }
10
16
 
11
17
  /** Module-level set of inline markdown special characters (hoisted out of hot loop). */
@@ -63,24 +69,31 @@ export function renderMarkdownTracked(
63
69
  let inCodeBlock = false;
64
70
  let codeBlockLang = '';
65
71
  let codeBlockLines: string[] = [];
72
+ let fenceChar = '`';
73
+ let fenceIndent = 0;
66
74
  const indent = LAYOUT.LEFT_MARGIN;
67
75
  const contentWidth = LAYOUT.contentWidth(width);
68
76
 
69
77
  for (let i = 0; i < rawLines.length; i++) {
70
78
  const raw = rawLines[i];
71
79
 
72
- const fenceMatch = raw.match(/^```(\w*)/);
80
+ const fenceMatch = raw.match(/^(\s*)(```|~~~)\s*([\w-]*)/);
73
81
  if (fenceMatch && !inCodeBlock) {
74
82
  inCodeBlock = true;
75
- codeBlockLang = fenceMatch[1] || '';
83
+ fenceIndent = fenceMatch[1].length;
84
+ fenceChar = fenceMatch[2][0]; // '`' or '~'
85
+ codeBlockLang = fenceMatch[3] || '';
76
86
  codeBlockLines = [];
77
87
  continue;
78
88
  }
79
89
  if (inCodeBlock) {
80
- if (raw.trimStart().startsWith('```')) {
90
+ // Close fence: same char, same or less indentation, at least 3 of that char
91
+ const closeFenceRe = new RegExp(`^\\s{0,${fenceIndent}}${fenceChar === '`' ? '```' : '~~~'}`);
92
+ if (closeFenceRe.test(raw)) {
81
93
  const blockStart = lines.length;
82
94
  const rendered = renderCodeBlock(codeBlockLines, codeBlockLang, width, {
83
95
  showLineNumbers: options.codeBlockLineNumbers ?? true,
96
+ isStreaming: options.isStreaming ?? false,
84
97
  });
85
98
  codeBlocks.push({
86
99
  startOffset: blockStart,
@@ -91,6 +104,8 @@ export function renderMarkdownTracked(
91
104
  inCodeBlock = false;
92
105
  codeBlockLang = '';
93
106
  codeBlockLines = [];
107
+ fenceChar = '`';
108
+ fenceIndent = 0;
94
109
  } else {
95
110
  codeBlockLines.push(raw);
96
111
  }
@@ -106,17 +121,17 @@ export function renderMarkdownTracked(
106
121
  const h2 = raw.match(/^## (.+)/);
107
122
  const h1 = raw.match(/^# (.+)/);
108
123
  if (h1) {
109
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h1[1].toUpperCase(), width, { fg: '#00ffff', bold: true }));
124
+ lines.push(UIFactory.stringToLine(' '.repeat(indent) + h1[1].toUpperCase(), width, { fg: T.heading1, bold: true }));
110
125
  lines.push(UIFactory.stringToLine(' '.repeat(indent) + '━'.repeat(Math.min(getDisplayWidth(h1[1]), contentWidth)), width, { fg: '244' }));
111
126
  continue;
112
127
  }
113
128
  if (h2) {
114
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h2[1], width, { fg: '#00ffff', bold: true }));
129
+ lines.push(UIFactory.stringToLine(' '.repeat(indent) + h2[1], width, { fg: T.heading2, bold: true }));
115
130
  lines.push(UIFactory.stringToLine(' '.repeat(indent) + '─'.repeat(Math.min(getDisplayWidth(h2[1]), contentWidth)), width, { fg: '240' }));
116
131
  continue;
117
132
  }
118
133
  if (h3) {
119
- lines.push(UIFactory.stringToLine(' '.repeat(indent) + h3[1], width, { fg: '111', bold: true }));
134
+ lines.push(UIFactory.stringToLine(' '.repeat(indent) + h3[1], width, { fg: T.heading3, bold: true }));
120
135
  continue;
121
136
  }
122
137
 
@@ -130,7 +145,7 @@ export function renderMarkdownTracked(
130
145
  const rendered = renderInlineMarkdown(taskMatch[3]);
131
146
  const prefix = ' '.repeat(bulletX) + checkbox;
132
147
  const style = checked ? { fg: '244', strikethrough: true } : {};
133
- lines.push(...compositeInlineLine(prefix, rendered, width, { fg: checked ? '#22c55e' : '252', ...style }, textStartX));
148
+ lines.push(...compositeInlineLine(prefix, rendered, width, { fg: checked ? T.checkboxChecked : '252', ...style }, textStartX));
134
149
  continue;
135
150
  }
136
151
 
@@ -190,6 +205,7 @@ export function renderMarkdownTracked(
190
205
  const blockStart = lines.length;
191
206
  const rendered = renderCodeBlock(codeBlockLines, codeBlockLang, width, {
192
207
  showLineNumbers: options.codeBlockLineNumbers ?? true,
208
+ isStreaming: options.isStreaming ?? false,
193
209
  });
194
210
  codeBlocks.push({
195
211
  startOffset: blockStart,
@@ -300,14 +316,14 @@ function renderTable(rows: string[], width: number, indent: number): Line[] {
300
316
  }
301
317
  let style: Partial<Cell> = {};
302
318
  if (token.type === 'code') {
303
- style = { fg: '#ffcc00', bg: '#1a1a1a' };
319
+ style = { fg: T.inlineCodeFg, bold: true };
304
320
  } else if (token.type === 'link') {
305
- style = { fg: '#00aaff', underline: true };
321
+ style = { fg: T.link, underline: true };
306
322
  } else {
307
323
  style = { ...token.style };
308
324
  }
309
325
  if (isHdr) {
310
- style.fg = style.fg || '#00ffff';
326
+ style.fg = style.fg || T.heading1;
311
327
  style.bold = true;
312
328
  } else {
313
329
  style.fg = style.fg || '252';
@@ -320,7 +336,7 @@ function renderTable(rows: string[], width: number, indent: number): Line[] {
320
336
 
321
337
  // Pad remaining space
322
338
  while (w < maxW) {
323
- cells.push(createStyledCell(' ', isHdr ? { fg: '#00ffff' } : { fg: '252' }));
339
+ cells.push(createStyledCell(' ', isHdr ? { fg: T.heading1 } : { fg: '252' }));
324
340
  w++;
325
341
  }
326
342
  return cells;
@@ -528,14 +544,14 @@ function compositeInlineLine(
528
544
  if (token.type === 'text') {
529
545
  for (const ch of token.text) chars.push({ char: ch, style: token.style });
530
546
  } else if (token.type === 'code') {
531
- for (const ch of token.text) chars.push({ char: ch, style: { fg: '#ffcc00', bg: '#1a1a1a' } });
547
+ for (const ch of token.text) chars.push({ char: ch, style: { fg: T.inlineCodeFg, bold: true } });
532
548
  } else if (token.type === 'link') {
533
549
  // Resolve URL: if url is empty or relative, treat as text; if it's a file path, use file:// protocol
534
550
  let resolvedUrl = token.url;
535
551
  if (resolvedUrl && !resolvedUrl.startsWith('http') && !resolvedUrl.startsWith('file://') && resolvedUrl.startsWith('/')) {
536
552
  resolvedUrl = `file://${resolvedUrl}`;
537
553
  }
538
- for (const ch of token.text) chars.push({ char: ch, style: { fg: '#00aaff', underline: true, link: resolvedUrl || undefined } });
554
+ for (const ch of token.text) chars.push({ char: ch, style: { fg: T.link, underline: true, link: resolvedUrl || undefined } });
539
555
  }
540
556
  }
541
557
 
@@ -111,7 +111,7 @@ export const SETTING_LABELS: Partial<Record<string, string>> = {
111
111
  'ui.systemMessages': 'System Message Target',
112
112
  'ui.operationalMessages': 'Operational Message Target',
113
113
  'ui.wrfcMessages': 'WRFC Message Target',
114
- 'ui.voiceEnabled': 'Voice Surface',
114
+ 'ui.voiceEnabled': 'Always Speak',
115
115
  'behavior.autoCompactThreshold': 'Auto-Compact %',
116
116
  'behavior.staleContextWarnings': 'Context Warnings',
117
117
  'behavior.returnContextMode': 'Return Context',
@@ -248,8 +248,8 @@ function buildSubscriptionContext(modal: SettingsModal, entry: SubscriptionEntry
248
248
  const routeReason = inferSubscriptionRouteReason(entry);
249
249
  const logout = entry.state === 'active' || entry.state === 'pending'
250
250
  ? modal.subscriptionLogoutConfirmationTarget === entry.provider
251
- ? `Press Enter again to sign out ${entry.provider}. Move selection or close config to cancel.`
252
- : 'Press Enter to review sign-out for this provider session.'
251
+ ? `Sign out ${entry.provider}? Enter/y to confirm, n/Esc to cancel.`
252
+ : 'Press Enter to begin sign-out for this provider session.'
253
253
  : `Use /subscription login ${entry.provider} start to begin OAuth sign-in for this provider.`;
254
254
  return [
255
255
  entry.provider,
@@ -268,6 +268,28 @@ function buildSubscriptionContext(modal: SettingsModal, entry: SubscriptionEntry
268
268
 
269
269
  function buildContextLines(modal: SettingsModal, width: number): string[] {
270
270
  const category = modal.currentCategory;
271
+
272
+ // Search mode: show context for the selected search result, or a help blurb
273
+ if (modal.searchFocused) {
274
+ const selected = modal.getSelected();
275
+ const lines: string[] = ['Search Results'];
276
+ if (selected) {
277
+ lines.push(...buildSettingContext(modal, selected));
278
+ } else {
279
+ lines.push(
280
+ modal.searchQuery.trim().length === 0
281
+ ? 'Type a query to search across all settings categories.'
282
+ : 'No settings matched the search query.',
283
+ );
284
+ }
285
+ const wrapped: string[] = [];
286
+ for (const line of lines) {
287
+ if (line === '') { wrapped.push(''); continue; }
288
+ wrapped.push(...paddedWrapped(line, width));
289
+ }
290
+ return wrapped;
291
+ }
292
+
271
293
  const lines: string[] = [
272
294
  `${CATEGORY_LABELS[category]} configuration`,
273
295
  ];
@@ -458,7 +480,51 @@ function renderSubscriptionRows(modal: SettingsModal, width: number, height: num
458
480
  return rows.slice(0, height);
459
481
  }
460
482
 
483
+ function renderSearchRows(modal: SettingsModal, width: number, height: number): string[] {
484
+ const rows: string[] = [];
485
+ const query = modal.searchQuery;
486
+ // Search-prompt row shows the current query
487
+ const promptRow = `/ ${query}${GLYPHS.surface.cursor}`;
488
+ rows.push(promptRow);
489
+
490
+ const results = modal.searchResults;
491
+ if (query.trim().length === 0 || results.length === 0) {
492
+ rows.push(query.trim().length === 0 ? 'Type to search across all categories.' : 'No results.');
493
+ return rows.slice(0, height);
494
+ }
495
+
496
+ const selectedIndex = clamp(modal.selectedIndex, 0, results.length - 1);
497
+ const typeWidth = 9;
498
+ const sourceWidth = 12;
499
+ const categoryWidth = 14;
500
+ const available = Math.max(24, width - typeWidth - sourceWidth - categoryWidth - 16);
501
+ const keyWidth = clamp(Math.floor(available * 0.56), 18, 52);
502
+ const valueWidth = Math.max(10, available - keyWidth);
503
+ rows.push(` ${padDisplay('Setting', keyWidth)} ${padDisplay('Value', valueWidth)} ${padDisplay('Type', typeWidth)} ${padDisplay('Category', categoryWidth)} ${padDisplay('Source', sourceWidth)}`);
504
+
505
+ const visibleCount = Math.max(1, height - 3);
506
+ const window = stableWindow(results.length, selectedIndex, visibleCount);
507
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more result(s) above`);
508
+
509
+ for (let index = window.start; index < window.end; index += 1) {
510
+ const entry = results[index]!;
511
+ const selected = index === selectedIndex;
512
+ const marker = selected ? GLYPHS.navigation.selected : entry.isDefault ? ' ' : '◇';
513
+ const value = currentSettingValue(modal, entry, selected);
514
+ const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
515
+ const label = getSettingLabel(entry);
516
+ // Derive category label from setting key prefix
517
+ const keyPrefix = entry.setting.key.split('.')[0] ?? '';
518
+ const categoryLabel = CATEGORY_LABELS[keyPrefix as SettingsCategory] ?? keyPrefix;
519
+ rows.push(`${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(categoryLabel, categoryWidth)} ${padDisplay(source, sourceWidth)}`);
520
+ }
521
+
522
+ if (window.end < results.length) rows.push(`${GLYPHS.navigation.moreBelow} ${results.length - window.end} more result(s) below`);
523
+ return rows.slice(0, height);
524
+ }
525
+
461
526
  function renderControlRows(modal: SettingsModal, width: number, height: number): string[] {
527
+ if (modal.searchFocused) return renderSearchRows(modal, width, height);
462
528
  if (modal.currentCategory === 'flags') return renderFlagRows(modal, width, height);
463
529
  if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
464
530
  if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
@@ -474,12 +540,13 @@ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
474
540
  }
475
541
 
476
542
  function footerText(modal: SettingsModal): string {
543
+ if (modal.searchFocused) return 'Search · type to filter · Up/Down navigate results · Enter select · Esc exit search';
477
544
  if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
478
- if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · Esc close';
479
- if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · Enter review/sign out · Esc close';
480
- if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · Enter edit trust · Esc close';
481
- if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · Enter/Space toggle · Esc close';
482
- return 'Focus settings · Up/Down setting · Left categories · Tab pane · Enter/Space edit/toggle · R reset · Esc close';
545
+ if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · / search · Esc close';
546
+ if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · / search · Enter review/sign out · Esc close';
547
+ if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · / search · Enter edit trust · Esc close';
548
+ if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · / search · Enter/Space toggle · Esc close';
549
+ return 'Focus settings · Up/Down setting · Left categories · Tab pane · / search · Enter/Space edit/toggle · R reset · Esc close';
483
550
  }
484
551
 
485
552
  export function renderSettingsModal(
@@ -523,7 +590,9 @@ export function renderSettingsModal(
523
590
  height: viewportHeight,
524
591
  title: 'Configuration Workspace / Settings',
525
592
  leftHeader: 'Categories',
526
- mainHeader: `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`,
593
+ mainHeader: modal.searchFocused
594
+ ? `Search: ${modal.searchQuery || '…'} (${modal.searchResults.length} result${modal.searchResults.length === 1 ? '' : 's'})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`
595
+ : `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`,
527
596
  leftRows: categoryRows.map((row): WorkspaceRow => ({
528
597
  text: row.text,
529
598
  selected: row.selected,
@@ -466,8 +466,13 @@ export class SyntaxHighlighter {
466
466
  * If the highlight cache has a result for this code+language, returns it.
467
467
  * Otherwise, schedules an async parse in background and returns null.
468
468
  * Callers should fall back to regex-based tokenization when null is returned.
469
+ *
470
+ * @param isStreaming - When true, suppresses background parse scheduling.
471
+ * The regex tokenizer serves during streaming; tree-sitter is scheduled only
472
+ * when the block is finalized (isStreaming=false or omitted) to avoid ~50
473
+ * wasted async parses per streamed block that thrash the FIFO cache.
469
474
  */
470
- highlight(code: string, fenceTag: string): HighlightedLine[] | null {
475
+ highlight(code: string, fenceTag: string, isStreaming = false): HighlightedLine[] | null {
471
476
  const langId = this.fenceToLangId(fenceTag);
472
477
  if (!langId) return null; // unsupported language
473
478
 
@@ -475,8 +480,10 @@ export class SyntaxHighlighter {
475
480
  const cached = this.cache.get(key);
476
481
  if (cached) return cached;
477
482
 
478
- // Schedule background parse if not already pending
479
- if (!this.pending.has(key)) {
483
+ // Do not schedule background parse while the block is still being streamed.
484
+ // The regex tokenizer serves during streaming (as designed). Schedule parse
485
+ // only when isStreaming=false, i.e., the block has been finalized.
486
+ if (!isStreaming && !this.pending.has(key)) {
480
487
  this.scheduleParse(code, langId, key);
481
488
  }
482
489