@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,5 +1,6 @@
1
1
  import { UIFactory } from '../renderer/ui-factory.ts';
2
2
  import { renderMarkdownTracked } from '../renderer/markdown.ts';
3
+ import { DARK_THEME } from '../renderer/theme.ts';
3
4
  import { renderToolCallBlock } from '../renderer/tool-call.ts';
4
5
  import { renderThinkingBlock } from '../renderer/thinking.ts';
5
6
  import { renderSystemMessage } from '../renderer/system-message.ts';
@@ -13,6 +14,23 @@ import { GLYPHS } from '../renderer/ui-primitives.ts';
13
14
  import type { BlockMeta, ConversationMessageSnapshot } from './conversation';
14
15
  import { parseDiffForApply } from '@pellux/goodvibes-sdk/platform/core';
15
16
  import { extractUserDisplayText } from '@pellux/goodvibes-sdk/platform/core';
17
+ import type { SystemMessageKind } from './system-message-router.ts';
18
+
19
+ const T = DARK_THEME;
20
+
21
+ /**
22
+ * Navigable system message kinds for error-navigation (nextErrorLine/prevErrorLine).
23
+ *
24
+ * Kind → navigable mapping:
25
+ * - 'system' YES — generic/catch-all messages (provider failures, session
26
+ * events, user-visible errors). Default for un-prefixed messages.
27
+ * - 'wrfc' YES — WRFC chain events are important and worth navigating to.
28
+ * - 'operational' NO — tool/scan/plugin/MCP status noise; not useful to jump to.
29
+ *
30
+ * When a message has no recorded kind (added via bare addSystemMessage), it
31
+ * defaults to 'system' and is therefore navigable.
32
+ */
33
+ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc']);
16
34
 
17
35
  type Message = ConversationMessageSnapshot;
18
36
 
@@ -29,6 +47,8 @@ interface ConversationRenderContext {
29
47
  readonly blockRegistry: BlockMeta[];
30
48
  readonly collapseState: Map<string, boolean>;
31
49
  readonly errorLineRegistry: number[];
50
+ /** Maps message index → SystemMessageKind for typed system messages. */
51
+ readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
32
52
  readonly configManager: ConfigManager | null;
33
53
  readonly splashOptions: SplashOptions;
34
54
  }
@@ -40,7 +60,7 @@ export function renderConversationUserMessage(
40
60
  ): void {
41
61
  const displayText = extractUserDisplayText(message.content);
42
62
  if (message.cancelled) {
43
- context.history.addLines(UIFactory.createMessageBar(width, displayText, '#3a1a1a', '196', ' x ', true));
63
+ context.history.addLines(UIFactory.createMessageBar(width, displayText, T.errorBarBg, '196', ' x ', true));
44
64
  return;
45
65
  }
46
66
  context.history.addLines(UIFactory.createMessageBar(width, displayText));
@@ -56,20 +76,20 @@ export function renderConversationAssistantMessage(
56
76
  ): void {
57
77
  const assistantHeaderDetails = [];
58
78
  if (message.model) {
59
- assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: '#94a3b8', dim: true });
79
+ assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
60
80
  }
61
81
  if (message.toolCalls && message.toolCalls.length > 0) {
62
- assistantHeaderDetails.push({ text: ` ${GLYPHS.status.pending} tools:${message.toolCalls.length} `, fg: '#38bdf8' });
82
+ assistantHeaderDetails.push({ text: ` ${GLYPHS.status.pending} tools:${message.toolCalls.length} `, fg: T.toolAccent });
63
83
  }
64
84
  if (message.reasoningContent || message.reasoningSummary) {
65
- assistantHeaderDetails.push({ text: ` ${GLYPHS.status.active} reasoning `, fg: '#a855f7', dim: true });
85
+ assistantHeaderDetails.push({ text: ` ${GLYPHS.status.active} reasoning `, fg: T.reasoningAccent, dim: true });
66
86
  }
67
87
  if (assistantHeaderDetails.length > 0) {
68
88
  context.history.addLine(renderConversationEventLine(width, {
69
89
  marker: GLYPHS.status.active,
70
- markerFg: '#22d3ee',
90
+ markerFg: T.assistantHeader,
71
91
  label: 'assistant',
72
- labelFg: '#22d3ee',
92
+ labelFg: T.assistantHeader,
73
93
  detailFg: '244',
74
94
  }, assistantHeaderDetails));
75
95
  }
@@ -180,11 +200,15 @@ export function renderConversationSystemMessage(
180
200
  context: ConversationRenderContext,
181
201
  message: Extract<Message, { role: 'system' }>,
182
202
  width: number,
203
+ msgIdx: number,
183
204
  ): void {
184
205
  const sysStartLine = context.history.getLineCount();
185
206
  const sysLines = renderSystemMessage(message.content, width);
186
207
  context.history.addLines(sysLines);
187
- if (/error/i.test(message.content)) {
208
+ // Resolve navigability from the stored kind, defaulting to 'system'
209
+ // (navigable) for messages added without an explicit kind tag.
210
+ const kind: SystemMessageKind = context.messageKindRegistry.get(msgIdx) ?? 'system';
211
+ if (NAVIGABLE_KINDS.has(kind)) {
188
212
  context.errorLineRegistry.push(sysStartLine);
189
213
  }
190
214
  }
@@ -218,13 +242,13 @@ export function renderConversationToolMessage(
218
242
 
219
243
  context.history.addLine(renderConversationEventLine(width, {
220
244
  marker: blockType === 'diff' ? GLYPHS.status.dualPane : GLYPHS.status.active,
221
- markerFg: blockType === 'diff' ? '#f59e0b' : '#38bdf8',
245
+ markerFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
222
246
  label: blockType === 'diff' ? 'diff' : 'tool result',
223
- labelFg: blockType === 'diff' ? '#f59e0b' : '#38bdf8',
247
+ labelFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
224
248
  detailFg: '244',
225
249
  }, [
226
250
  ...(message.toolName
227
- ? [{ text: ` ${message.toolName} `, fg: '#e2e8f0' as const }]
251
+ ? [{ text: ` ${message.toolName} `, fg: T.toolNameFg }]
228
252
  : [{ text: ` ${summarizeCallId(message.callId || 'standalone')} `, fg: '244' as const, dim: true }]),
229
253
  { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
230
254
  ]));
@@ -238,9 +262,9 @@ export function renderConversationToolMessage(
238
262
  : preview;
239
263
  const rendered = renderConversationCollapsedFragment(collapsedText, width, {
240
264
  prefix: blockType === 'diff' ? ` ${GLYPHS.status.dualPane} ` : ` ${GLYPHS.navigation.collapsed} `,
241
- prefixFg: blockType === 'diff' ? '#f59e0b' : '#38bdf8',
265
+ prefixFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
242
266
  text: '244',
243
- bodyBg: '#1a1a1a',
267
+ bodyBg: T.collapsedBodyBg,
244
268
  dim: true,
245
269
  });
246
270
  context.history.addLines(rendered);
@@ -280,6 +304,13 @@ export function appendConversationMessages(
280
304
  messages: Message[],
281
305
  width: number,
282
306
  messageLineRegistry: number[],
307
+ /**
308
+ * Absolute index of messages[0] in the full (unsliced) conversation snapshot.
309
+ * Required to align slice-relative loop indices with the absolute keys stored
310
+ * in messageKindRegistry, which is keyed at add-time (before any slice).
311
+ * Defaults to 0 when the full snapshot is rendered (no clearDisplay in effect).
312
+ */
313
+ msgIndexOffset = 0,
283
314
  ): void {
284
315
  const lineNumberMode = context.configManager?.get('display.lineNumbers') ?? 'off';
285
316
  const collapseThreshold = context.configManager?.get('display.collapseThreshold') ?? 30;
@@ -287,14 +318,17 @@ export function appendConversationMessages(
287
318
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
288
319
  const message = messages[msgIdx];
289
320
  messageLineRegistry[msgIdx] = context.history.getLineCount();
321
+ // absoluteIdx aligns the slice-relative loop counter with the absolute
322
+ // message index used as the key in messageKindRegistry.
323
+ const absoluteIdx = msgIndexOffset + msgIdx;
290
324
  if (message.role === 'user') {
291
325
  renderConversationUserMessage(context, message, width);
292
326
  } else if (message.role === 'assistant') {
293
- renderConversationAssistantMessage(context, message, width, lineNumberMode, collapseThreshold, msgIdx);
327
+ renderConversationAssistantMessage(context, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
294
328
  } else if (message.role === 'system') {
295
- renderConversationSystemMessage(context, message, width);
329
+ renderConversationSystemMessage(context, message, width, absoluteIdx);
296
330
  } else if (message.role === 'tool') {
297
- renderConversationToolMessage(context, message, width, msgIdx);
331
+ renderConversationToolMessage(context, message, width, absoluteIdx);
298
332
  }
299
333
  context.history.addLine(createEmptyLine(width));
300
334
  }
@@ -5,6 +5,7 @@ import type { ToolCall, ToolResult } from '@pellux/goodvibes-sdk/platform/types'
5
5
  import type { ProviderMessage, ContentPart } from '@pellux/goodvibes-sdk/platform/providers';
6
6
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
7
7
  import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
8
+ import type { SystemMessageKind } from './system-message-router.ts';
8
9
  import {
9
10
  ConversationManager as SdkConversationManager,
10
11
  type BlockMeta as SdkBlockMeta,
@@ -70,10 +71,31 @@ export class ConversationManager extends SdkConversationManager {
70
71
  protected blockRegistry: BlockMeta[] = [];
71
72
  /** Message index -> first rendered line index in the history buffer. */
72
73
  private messageLineRegistry: number[] = [];
73
- /** Registry of rendered line indices for system messages matching /error/i. */
74
+ /**
75
+ * Registry of rendered line indices for system messages whose kind is
76
+ * navigable (error-navigation worthy).
77
+ *
78
+ * Kind → navigable mapping:
79
+ * - 'system' YES — generic/catch-all messages (failures, provider errors,
80
+ * session events); the default for any un-prefixed message
81
+ * - 'wrfc' YES — WRFC chain events (failures matter for navigation)
82
+ * - 'operational' NO — noisy tool/scan/plugin/MCP status; not worth jumping to
83
+ *
84
+ * This replaces the old /error/i substring test, which missed failure phrases
85
+ * like "request failed" / "rate limited" and false-positived on benign info
86
+ * text that happened to contain the word "error".
87
+ */
74
88
  private errorLineRegistry: number[] = [];
89
+ /** Maps message index → SystemMessageKind for system-role messages. */
90
+ private messageKindRegistry: Map<number, SystemMessageKind> = new Map();
75
91
  /** Streaming block start line in history buffer (for incremental streaming update). */
76
92
  private streamingStartLine = -1;
93
+ /**
94
+ * Timestamp of the last renderMarkdown call during streaming.
95
+ * Used to throttle full re-parses to the 16ms render coalescer cadence.
96
+ * Reset to 0 on startStreamingBlock and finalizeStreamingBlock.
97
+ */
98
+ private _lastStreamRenderMs = 0;
77
99
  /**
78
100
  * Message index at the time of the last clearDisplay() call.
79
101
  * rebuildHistory() renders only messages at or after this index, so the
@@ -135,13 +157,49 @@ export class ConversationManager extends SdkConversationManager {
135
157
  }
136
158
 
137
159
  public override addSystemMessage(content: string): void {
160
+ // Clear any stale kind entry at the index this message will occupy.
161
+ // undo() splices the tail of this.messages, freeing indices that ARE reused
162
+ // by subsequent adds. Without this delete, a recycled index could carry a
163
+ // stale kind (e.g. 'operational') and silently mis-classify the new message.
164
+ const nextIndex = this.getMessageSnapshot().length;
165
+ this.messageKindRegistry.delete(nextIndex);
166
+ super.addSystemMessage(content);
167
+ this.markDirty();
168
+ }
169
+
170
+ /**
171
+ * addTypedSystemMessage - Add a system message with an explicit kind tag.
172
+ * The kind is stored in messageKindRegistry (keyed by the message index that
173
+ * will be assigned after the push) so that renderConversationSystemMessage
174
+ * can use it instead of text-based pattern matching.
175
+ *
176
+ * Called by SystemMessageRouter.routeTypedSystemMessage when routing to the
177
+ * conversation surface. Falls back to addSystemMessage for callers that do
178
+ * not have kind information.
179
+ */
180
+ public addTypedSystemMessage(content: string, kind: SystemMessageKind): void {
181
+ // getMessageSnapshot().length is the index this message will receive after
182
+ // addSystemMessage appends it to the messages array.
183
+ const nextIndex = this.getMessageSnapshot().length;
184
+ this.messageKindRegistry.set(nextIndex, kind);
138
185
  super.addSystemMessage(content);
139
186
  this.markDirty();
140
187
  }
141
188
 
142
189
  public override undo(): boolean {
143
190
  const result = super.undo();
144
- if (result) this.markDirty();
191
+ if (result) {
192
+ // undo() splices the messages tail at the last user-message index, meaning
193
+ // freed indices CAN be reused by subsequent adds. Purge all registry entries
194
+ // at or after the new message count so a recycled index cannot carry a stale
195
+ // kind from the evicted turn (e.g. 'operational' mis-classifying a bare add
196
+ // as non-navigable, or 'wrfc' wrongly making an operational message navigable).
197
+ const postUndoCount = this.getMessageSnapshot().length;
198
+ for (const key of this.messageKindRegistry.keys()) {
199
+ if (key >= postUndoCount) this.messageKindRegistry.delete(key);
200
+ }
201
+ this.markDirty();
202
+ }
145
203
  return result;
146
204
  }
147
205
 
@@ -172,9 +230,11 @@ export class ConversationManager extends SdkConversationManager {
172
230
  public override startStreamingBlock(): void {
173
231
  super.startStreamingBlock();
174
232
  this.markDirty();
175
- // Record the line where the streaming block starts so updates can be incremental
233
+ // Record the line where the streaming block starts so updates can be incremental.
234
+ // Reset the render throttle so the first delta always renders immediately.
176
235
  this.flushHistory();
177
236
  this.streamingStartLine = this.history.getLineCount();
237
+ this._lastStreamRenderMs = 0;
178
238
  }
179
239
 
180
240
  /**
@@ -184,12 +244,18 @@ export class ConversationManager extends SdkConversationManager {
184
244
  */
185
245
  public override updateStreamingBlock(content: string): void {
186
246
  super.updateStreamingBlock(content);
187
- // Incrementally update the history buffer instead of full rebuild
247
+ // Incrementally update the history buffer instead of full rebuild.
248
+ // Throttle renderMarkdown to the 16ms render-coalescer cadence to avoid
249
+ // O(n) parse overhead on every delta token during streaming.
188
250
  if (this.streamingStartLine >= 0) {
189
- const width = this._getWidth();
190
- this.history.truncateToLine(this.streamingStartLine);
191
- const rendered = renderMarkdown(content, width);
192
- this.history.addLines(rendered);
251
+ const now = Date.now();
252
+ if (now - this._lastStreamRenderMs >= 16) {
253
+ this._lastStreamRenderMs = now;
254
+ const width = this._getWidth();
255
+ this.history.truncateToLine(this.streamingStartLine);
256
+ const rendered = renderMarkdown(content, width, { isStreaming: true });
257
+ this.history.addLines(rendered);
258
+ }
193
259
  }
194
260
  }
195
261
 
@@ -200,6 +266,7 @@ export class ConversationManager extends SdkConversationManager {
200
266
  public override finalizeStreamingBlock(): void {
201
267
  super.finalizeStreamingBlock();
202
268
  this.streamingStartLine = -1;
269
+ this._lastStreamRenderMs = 0;
203
270
  this.markDirty();
204
271
  }
205
272
 
@@ -221,6 +288,7 @@ export class ConversationManager extends SdkConversationManager {
221
288
  this.blockRegistry = [];
222
289
  this.messageLineRegistry = [];
223
290
  this.errorLineRegistry = [];
291
+ this.messageKindRegistry = new Map();
224
292
  this.streamingStartLine = -1;
225
293
  this._displayFromMessageIndex = 0; // full reset — show everything on next render
226
294
  }
@@ -297,6 +365,8 @@ export class ConversationManager extends SdkConversationManager {
297
365
  this.blockRegistry = [];
298
366
  this.messageLineRegistry = [];
299
367
  this.errorLineRegistry = [];
368
+ // messageKindRegistry is NOT cleared here: kind info is set at add-time
369
+ // and must survive width-change rebuilds.
300
370
  const width = this._getWidth();
301
371
  this.lastRenderedWidth = width;
302
372
  this.dirty = false;
@@ -321,7 +391,7 @@ export class ConversationManager extends SdkConversationManager {
321
391
  return;
322
392
  }
323
393
 
324
- this.appendMessages(visibleSnapshot, width);
394
+ this.appendMessages(visibleSnapshot, width, displayStart);
325
395
  this.appendedUpTo = snapshot.length;
326
396
  }
327
397
 
@@ -345,6 +415,7 @@ export class ConversationManager extends SdkConversationManager {
345
415
  blockRegistry: this.blockRegistry,
346
416
  collapseState: this.collapseState,
347
417
  errorLineRegistry: this.errorLineRegistry,
418
+ messageKindRegistry: this.messageKindRegistry as ReadonlyMap<number, SystemMessageKind>,
348
419
  configManager: this._configManager,
349
420
  splashOptions: this.splashOptions,
350
421
  };
@@ -364,17 +435,24 @@ export class ConversationManager extends SdkConversationManager {
364
435
  renderConversationAssistantMessage(this.renderingContext(), message, width, lineNumberMode, collapseThreshold, msgIdx);
365
436
  }
366
437
 
367
- private renderSystemMessage(message: Extract<Message, { role: 'system' }>, width: number): void {
368
- renderConversationSystemMessage(this.renderingContext(), message, width);
438
+ private renderSystemMessage(message: Extract<Message, { role: 'system' }>, width: number, msgIdx: number): void {
439
+ renderConversationSystemMessage(this.renderingContext(), message, width, msgIdx);
369
440
  }
370
441
 
371
442
  private renderToolMessage(message: Extract<Message, { role: 'tool' }>, width: number, msgIdx: number): void {
372
443
  renderConversationToolMessage(this.renderingContext(), message, width, msgIdx);
373
444
  }
374
445
 
375
- /** Render a slice of messages into the history buffer. */
376
- private appendMessages(messages: Message[], width: number): void {
377
- appendConversationMessages(this.renderingContext(), messages, width, this.messageLineRegistry);
446
+ /**
447
+ * Render a slice of messages into the history buffer.
448
+ *
449
+ * @param msgIndexOffset - Absolute index of messages[0] in the full snapshot.
450
+ * Must equal displayStart when rendering a post-clearDisplay slice so that
451
+ * the renderer can resolve messageKindRegistry keys (which are absolute)
452
+ * from its slice-relative loop counter.
453
+ */
454
+ private appendMessages(messages: Message[], width: number, msgIndexOffset = 0): void {
455
+ appendConversationMessages(this.renderingContext(), messages, width, this.messageLineRegistry, msgIndexOffset);
378
456
  }
379
457
 
380
458
  /** Find the nearest block to a given line index, optionally filtered by type. */
@@ -446,8 +524,9 @@ export class ConversationManager extends SdkConversationManager {
446
524
 
447
525
  /**
448
526
  * getErrorLines - Returns line indices in the rendered history buffer for
449
- * system messages that contain 'error' (case-insensitive).
450
- * Triggers a history flush if dirty.
527
+ * system messages whose kind is navigable (see NAVIGABLE_KINDS in
528
+ * conversation-rendering.ts: 'system' and 'wrfc'). Operational messages
529
+ * are excluded regardless of message text. Triggers a history flush if dirty.
451
530
  */
452
531
  public getErrorLines(): number[] {
453
532
  this.flushHistory();
@@ -538,6 +617,12 @@ export class ConversationManager extends SdkConversationManager {
538
617
  this.blockRegistry = [];
539
618
  this.messageLineRegistry = [];
540
619
  this.errorLineRegistry = [];
620
+ // messageKindRegistry is NOT cleared here. The underlying messages array is
621
+ // preserved by clearDisplay(); kind entries for pre-clear messages are harmless
622
+ // because those messages are hidden by _displayFromMessageIndex and never rendered.
623
+ // Clearing the registry would cause kind loss for pre-clear messages that become
624
+ // visible again after a subsequent width-change rebuild (which resets displayStart
625
+ // to 0), incorrectly making operational messages navigable.
541
626
  // Advance _displayFromMessageIndex to exclude all current messages from display.
542
627
  // rebuildHistory() will only render messages added AFTER this point.
543
628
  this._displayFromMessageIndex = this.getMessageSnapshot().length;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * formatUserFacingError — classifies provider/network errors into plain-language
3
+ * one-liners with a suggested action.
4
+ *
5
+ * Classification order (most-specific first):
6
+ * auth → 401, 'invalid' + 'key'/'token', 'Unauthorized', 'forbidden'
7
+ * rate-limit → 429, 'rate limit', 'rate_limit', 'too many requests', 'quota'
8
+ * context-overflow → 'context length', 'maximum context', 'too many tokens',
9
+ * 'context window'
10
+ * network → ECONNREFUSED, ETIMEDOUT, 'fetch failed', 'socket hang up',
11
+ * 'network', ENOTFOUND
12
+ * generic → summarizeError fallback
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type ErrorClass =
24
+ | 'auth'
25
+ | 'rate-limit'
26
+ | 'context-overflow'
27
+ | 'network'
28
+ | 'generic';
29
+
30
+ export interface UserFacingError {
31
+ /** Short, plain-language description of what went wrong. */
32
+ message: string;
33
+ /** Suggested recovery action (slash-command or brief instruction). */
34
+ action: string;
35
+ /** Which classifier matched. */
36
+ kind: ErrorClass;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Classifier
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Extract a canonical string probe from an unknown error value.
45
+ * Concatenates status code (if present), error name, and message so that a
46
+ * single regex can match across all shapes.
47
+ */
48
+ function probeString(err: unknown): string {
49
+ if (err == null) return '';
50
+ const parts: string[] = [];
51
+
52
+ if (typeof err === 'object') {
53
+ // HTTP-style status fields
54
+ const asRecord = err as Record<string, unknown>;
55
+ const status = asRecord['status'] ?? asRecord['statusCode'] ?? asRecord['code'];
56
+ if (status != null) parts.push(String(status));
57
+
58
+ // name and message
59
+ if ('name' in asRecord && typeof asRecord['name'] === 'string') parts.push(asRecord['name']);
60
+ if ('message' in asRecord && typeof asRecord['message'] === 'string') parts.push(asRecord['message']);
61
+
62
+ // cause chain (one level)
63
+ if ('cause' in asRecord && asRecord['cause'] != null) {
64
+ const cause = asRecord['cause'] as Record<string, unknown>;
65
+ if (typeof cause['message'] === 'string') parts.push(cause['message']);
66
+ if (typeof cause['code'] === 'string') parts.push(cause['code']);
67
+ }
68
+ } else if (typeof err === 'string') {
69
+ parts.push(err);
70
+ } else {
71
+ parts.push(String(err));
72
+ }
73
+
74
+ return parts.join(' ').toLowerCase();
75
+ }
76
+
77
+ export function classifyError(err: unknown): ErrorClass {
78
+ const probe = probeString(err);
79
+
80
+ // Auth: 401 status, key/token invalidity, Unauthorized
81
+ if (
82
+ /\b401\b/.test(probe) ||
83
+ /invalid.{0,10}(api.?key|key|token)/i.test(probe) ||
84
+ /unauthorized/i.test(probe) ||
85
+ /authentication/i.test(probe) ||
86
+ /api.?key.{0,20}(missing|not.?set|required|invalid)/i.test(probe)
87
+ ) {
88
+ return 'auth';
89
+ }
90
+
91
+ // Rate-limit: 429 status, quota exceeded
92
+ if (
93
+ /\b429\b/.test(probe) ||
94
+ /rate.?limit/i.test(probe) ||
95
+ /too many requests/i.test(probe) ||
96
+ /quota.{0,20}exceeded/i.test(probe) ||
97
+ /request.{0,20}limit/i.test(probe)
98
+ ) {
99
+ return 'rate-limit';
100
+ }
101
+
102
+ // Context overflow: provider context-window exceeded messages
103
+ if (
104
+ /context.{0,10}(length|window|limit)/i.test(probe) ||
105
+ /maximum context/i.test(probe) ||
106
+ /too many tokens/i.test(probe) ||
107
+ /token.{0,10}limit/i.test(probe) ||
108
+ /context.{0,20}exceeded/i.test(probe) ||
109
+ /input.{0,10}too.{0,10}long/i.test(probe)
110
+ ) {
111
+ return 'context-overflow';
112
+ }
113
+
114
+ // Network: connection/transport errors
115
+ if (
116
+ /econnrefused/i.test(probe) ||
117
+ /etimedout/i.test(probe) ||
118
+ /enotfound/i.test(probe) ||
119
+ /fetch failed/i.test(probe) ||
120
+ /socket hang up/i.test(probe) ||
121
+ /network.{0,20}(error|failure|timeout)/i.test(probe) ||
122
+ /connection.{0,20}(refused|reset|timeout|closed)/i.test(probe) ||
123
+ /timeout/i.test(probe)
124
+ ) {
125
+ return 'network';
126
+ }
127
+
128
+ return 'generic';
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Formatter
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Classifies `err` and returns a plain-language one-liner with a suggested
137
+ * recovery action.
138
+ *
139
+ * Uses `summarizeError` from the SDK as the detail fallback for generic errors
140
+ * so stack traces never leak into the UI.
141
+ */
142
+ export function formatUserFacingError(err: unknown): UserFacingError {
143
+ const kind = classifyError(err);
144
+
145
+ switch (kind) {
146
+ case 'auth':
147
+ return {
148
+ kind,
149
+ message: 'Authentication failed — the provider rejected your API key.',
150
+ action: 'Run /login to re-authenticate or check your API key.',
151
+ };
152
+
153
+ case 'rate-limit':
154
+ return {
155
+ kind,
156
+ message: 'Rate limit reached — the provider is throttling requests.',
157
+ action: 'Wait a moment and retry, or switch models with /model.',
158
+ };
159
+
160
+ case 'context-overflow':
161
+ return {
162
+ kind,
163
+ message: 'Context window exceeded — the conversation is too long for this model.',
164
+ action: 'Run /compact to summarise the conversation and free context.',
165
+ };
166
+
167
+ case 'network':
168
+ return {
169
+ kind,
170
+ message: 'Network error — could not reach the provider.',
171
+ action: 'Check your connection and retry, or switch models with /model.',
172
+ };
173
+
174
+ default: {
175
+ const detail = summarizeError(err);
176
+ return {
177
+ kind: 'generic',
178
+ message: `Provider error: ${detail}`,
179
+ action: 'Retry your last message, or switch models with /model.',
180
+ };
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Convenience helper: returns the full formatted string for use in a single
187
+ * system message. Formats as "<message> <action>"
188
+ */
189
+ export function formatUserFacingErrorLine(err: unknown): string {
190
+ const { message, action } = formatUserFacingError(err);
191
+ return `${message} ${action}`;
192
+ }