@pellux/goodvibes-tui 0.19.23 → 0.19.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.24] — 2026-04-22
8
+
9
+ Four correctness and honesty fixes from an external architecture review. No SDK change (pinned at 0.23.2).
10
+
11
+ ### Fixed
12
+
13
+ - **`/clear` now actually clears the display** (`src/core/conversation.ts`). The original `clearDisplay()` immediately rebuilt the display buffer from `this.messages`, so `getDisplayBlocks().length` was unchanged before and after the call — `/clear` was a no-op. The fix introduces a `_displayFromMessageIndex` watermark: `clearDisplay()` records the current message count and clears the buffer without re-rendering; `rebuildHistory()` now only renders messages added *after* the watermark, so the screen is blank until the next message arrives. The full LLM context (`getMessagesForLLM()`, `getMessageSnapshot()`) is untouched. `resetAll()` zeroes the watermark so a full reset continues to work. Regression tests added to `src/test/core/conversation.test.ts` (4 new tests).
14
+
15
+ - **Workspace tab bar shows active state on the unfocused pane** (`src/panels/panel-manager.ts`, `src/renderer/panel-workspace-bar.ts`). `getWorkspaceTabs()` previously set both `active` and `focused` from the single globally-focused panel, so the unfocused pane's selected tab lost its active marker entirely when focus moved to the other pane. The fix splits the two semantics: `active` is now true for the currently-selected tab in *its own pane* (derived from `pane.activeIndex`, independent of keyboard focus); `focused` is true only for the one tab in the globally-focused pane. The workspace bar renderer uses the `focused` flag to append a `▸` glyph to the focused tab's label, making keyboard-focus distinct from pane-level selection. Both panes now show their selected tab highlighted regardless of which has focus. Regression tests added to `src/test/panels/panel-manager.test.ts` (2 new tests).
16
+
17
+ - **Panel render cache race guard** (`src/renderer/panel-composite.ts`). The documented hazard in the R2 cache — where a mid-render `invalidate()` call would be silently clobbered by the trailing `markRendered()` — has been resolved. The fix wraps each panel's `invalidate()` the first time it enters `renderPanel()` to maintain a per-panel generation counter (stored in a module-level `WeakMap`). The generation is snapshotted before `render()` and compared after: `markRendered()` is only called when the generation is unchanged, meaning no concurrent invalidation fired. If the generation changed, `needsRender` stays `true` and the next frame re-renders with the fresh state. Backward-compatible: existing panels that don't invalidate during render are unaffected. New test file `src/test/renderer/panel-composite.test.ts` (4 tests).
18
+
19
+ - **Release gate filenames renamed to match what the tests actually verify** (`src/test/release-gates/`). `runtime-certification-gate.test.ts` was renamed to `runtime-contract-shape-gate.test.ts` and its `describe()` block updated to `'runtime contract shape gate'`. `foundation-surfaces-gate.test.ts` was renamed to `foundation-surface-stability-gate.test.ts` and its `describe()` block updated to `'foundation surface stability gate'`. The test content (assertions, helpers) is unchanged — this is an honesty rename only, removing the implication that the tests verify runtime behavior certification when they actually verify structural shape preservation.
20
+
21
+ ---
22
+
7
23
  ## [0.19.23] — 2026-04-22
8
24
 
9
25
  SDK 0.23.x constraint-propagation reconciliation. Surfaces the new per-chain constraint data across the WRFC panel, process modal, agent inspector, and agent detail modal; adds system-message notifications for constraint enumeration and violations; exposes the WRFC-injected `systemPromptAddendum` so operators can see the engineer addendum was applied.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.23-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.24-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.23",
3
+ "version": "0.19.24",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -74,6 +74,13 @@ export class ConversationManager extends SdkConversationManager {
74
74
  private errorLineRegistry: number[] = [];
75
75
  /** Streaming block start line in history buffer (for incremental streaming update). */
76
76
  private streamingStartLine = -1;
77
+ /**
78
+ * Message index at the time of the last clearDisplay() call.
79
+ * rebuildHistory() renders only messages at or after this index, so the
80
+ * display stays blank for messages added before the clear while LLM history
81
+ * is fully preserved. Reset to 0 on resetAll() or rebuildHistory() width change.
82
+ */
83
+ private _displayFromMessageIndex = 0;
77
84
 
78
85
  public suppressSplash: boolean = false;
79
86
  public splashOptions: SplashOptions = {};
@@ -215,6 +222,7 @@ export class ConversationManager extends SdkConversationManager {
215
222
  this.messageLineRegistry = [];
216
223
  this.errorLineRegistry = [];
217
224
  this.streamingStartLine = -1;
225
+ this._displayFromMessageIndex = 0; // full reset — show everything on next render
218
226
  }
219
227
 
220
228
  /**
@@ -293,20 +301,27 @@ export class ConversationManager extends SdkConversationManager {
293
301
  this.lastRenderedWidth = width;
294
302
  this.dirty = false;
295
303
 
304
+ const snapshot = this.getMessageSnapshot();
305
+ // When _displayFromMessageIndex > 0, clearDisplay() was called. Only render
306
+ // messages added after the clear — the pre-clear history stays off-screen.
307
+ // On a full rebuild (e.g. width change), reset the display-start to 0 so the
308
+ // user can scroll back to the full history if needed.
309
+ const displayStart = this._displayFromMessageIndex;
310
+ const visibleSnapshot = displayStart > 0 ? snapshot.slice(displayStart) : snapshot;
311
+
296
312
  // Tool messages ARE rendered (as collapsed blocks); this filter is only
297
313
  // for determining whether to show the splash screen (tool-only messages
298
314
  // don't count as visible conversation content for splash purposes).
299
- const snapshot = this.getMessageSnapshot();
300
- const displayMessages = snapshot.filter(
315
+ const displayMessages = visibleSnapshot.filter(
301
316
  (m) => m.role !== 'tool' && m.role !== 'system',
302
317
  );
303
318
 
304
- if (displayMessages.length === 0 && !this.suppressSplash) {
319
+ if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
305
320
  this.addSplashScreen(width);
306
321
  return;
307
322
  }
308
323
 
309
- this.appendMessages(snapshot, width);
324
+ this.appendMessages(visibleSnapshot, width);
310
325
  this.appendedUpTo = snapshot.length;
311
326
  }
312
327
 
@@ -509,19 +524,27 @@ export class ConversationManager extends SdkConversationManager {
509
524
 
510
525
  /**
511
526
  * clearDisplay - Clear the visual history buffer without touching the LLM context messages.
512
- * The next render will show a blank conversation area.
527
+ * The next render will show a blank conversation area. Subsequent message additions
528
+ * rebuild the display incrementally from that point forward.
529
+ *
530
+ * Contract:
531
+ * - getDisplayBlocks() returns an empty array immediately after this call.
532
+ * - getMessageSnapshot() is unaffected — full LLM history is preserved.
533
+ * - resetAll() (which clears both display and messages) continues to work.
534
+ * - rebuildHistory() can be called by callers that need a full display rebuild.
513
535
  */
514
536
  public clearDisplay(): void {
515
537
  this.history.clear();
516
- this.appendedUpTo = 0;
517
- this.dirty = true;
518
- // Re-render from existing messages to rebuild buffer
519
- const width = this._getWidth();
520
- this.lastRenderedWidth = width;
538
+ this.blockRegistry = [];
539
+ this.messageLineRegistry = [];
540
+ this.errorLineRegistry = [];
541
+ // Advance _displayFromMessageIndex to exclude all current messages from display.
542
+ // rebuildHistory() will only render messages added AFTER this point.
543
+ this._displayFromMessageIndex = this.getMessageSnapshot().length;
544
+ this.appendedUpTo = this._displayFromMessageIndex;
521
545
  this.dirty = false;
522
- const snapshot = this.getMessageSnapshot();
523
- this.appendMessages(snapshot, width);
524
- this.appendedUpTo = snapshot.length;
546
+ // Do NOT re-render here — display stays blank until the next message is added.
547
+ // The lastRenderedWidth is kept so subsequent appends use the correct width.
525
548
  }
526
549
  }
527
550
 
@@ -347,13 +347,17 @@ export class PanelManager {
347
347
 
348
348
  getWorkspaceTabs(): readonly WorkspaceTab[] {
349
349
  if (this._cachedWorkspaceTabs !== null) return this._cachedWorkspaceTabs;
350
+ // `active` = the currently selected tab in its own pane (independent of focus).
351
+ // `focused` = true only for the one tab in the globally focused pane that is active.
350
352
  const focusedPanelId = this.getActivePanel()?.id;
353
+ const topActivePanelId = this.topPane.panels[this.topPane.activeIndex]?.id;
354
+ const bottomActivePanelId = this.bottomPane.panels[this.bottomPane.activeIndex]?.id;
351
355
  const topTabs = this.topPane.panels.map((panel) => ({
352
356
  id: panel.id,
353
357
  name: panel.name,
354
358
  icon: panel.icon,
355
359
  pane: 'top' as const,
356
- active: panel.id === focusedPanelId,
360
+ active: panel.id === topActivePanelId,
357
361
  focused: panel.id === focusedPanelId,
358
362
  }));
359
363
  const bottomTabs = this.bottomPane.panels.map((panel) => ({
@@ -361,7 +365,7 @@ export class PanelManager {
361
365
  name: panel.name,
362
366
  icon: panel.icon,
363
367
  pane: 'bottom' as const,
364
- active: panel.id === focusedPanelId,
368
+ active: panel.id === bottomActivePanelId,
365
369
  focused: panel.id === focusedPanelId,
366
370
  }));
367
371
  const tabs = [...topTabs, ...bottomTabs] as WorkspaceTab[];
@@ -24,9 +24,9 @@ import { renderPanelWorkspaceBar } from './panel-workspace-bar.ts';
24
24
  * path (e.g. deferred lazy-load) should call `this.invalidate()` AFTER the
25
25
  * trailing work so the next frame picks it up.
26
26
  *
27
- * If this becomes a real bug, the fix is to snapshot `needsRender` before
28
- * calling `render()` and only `markRendered()` if the snapshot was true
29
- * preserving any concurrent invalidation. Deferred pending more evidence.
27
+ * The fix (now applied): snapshot `needsRender` before calling `render()` and
28
+ * only call `markRendered()` when no concurrent invalidation occurred during
29
+ * the render pass preserving any mid-render invalidation.
30
30
  */
31
31
  interface PanelRenderCache {
32
32
  lines: Line[];
@@ -34,15 +34,52 @@ interface PanelRenderCache {
34
34
  height: number;
35
35
  }
36
36
  const panelRenderCache = new WeakMap<Panel, PanelRenderCache>();
37
+ /**
38
+ * Per-panel render-generation counter. Incremented whenever invalidate() fires
39
+ * on the panel (tracked here externally since Panel does not expose a version).
40
+ * We monkey-patch each panel the first time it enters renderPanel() by wrapping
41
+ * its invalidate() to bump the counter stored in this map.
42
+ *
43
+ * Race-guard: snapshot the generation before render(), compare after. If it
44
+ * changed, a mid-render invalidation occurred — leave needsRender=true.
45
+ */
46
+ const panelRenderGen = new WeakMap<Panel, { gen: number }>();
47
+
48
+ function getRenderGenState(panel: Panel): { gen: number } {
49
+ let state = panelRenderGen.get(panel);
50
+ if (!state) {
51
+ state = { gen: 0 };
52
+ panelRenderGen.set(panel, state);
53
+ // Wrap invalidate() to bump generation counter.
54
+ const origInvalidate = panel.invalidate.bind(panel);
55
+ panel.invalidate = () => {
56
+ state!.gen++;
57
+ origInvalidate();
58
+ };
59
+ }
60
+ return state;
61
+ }
37
62
 
38
63
  /** R2: Render a panel, skipping if nothing changed. Returns cached lines on a skip. */
39
- function renderPanel(panel: Panel, width: number, height: number): Line[] {
64
+ export function renderPanel(panel: Panel, width: number, height: number): Line[] {
40
65
  const cached = panelRenderCache.get(panel);
41
66
  if (cached && !panel.needsRender && cached.width === width && cached.height === height) {
42
67
  return cached.lines;
43
68
  }
69
+ // Snapshot render-generation counter BEFORE calling render(). If an event
70
+ // listener fires during render() and calls panel.invalidate(), the generation
71
+ // counter will be bumped. We compare after render to detect mid-render races.
72
+ const genState = getRenderGenState(panel);
73
+ const genBefore = genState.gen;
44
74
  const lines = panel.render(width, height);
45
- panel.markRendered();
75
+ // Only call markRendered() when no mid-render invalidation occurred.
76
+ // If the generation changed during render(), a concurrent invalidate() fired —
77
+ // leave needsRender=true so the next frame re-renders with the new state.
78
+ if (genState.gen === genBefore) {
79
+ panel.markRendered();
80
+ }
81
+ // If gen changed, needsRender is already true (invalidate() set it); do not
82
+ // call markRendered() — the next frame will pick it up.
46
83
  panelRenderCache.set(panel, { lines, width, height });
47
84
  return lines;
48
85
  }
@@ -17,7 +17,11 @@ export function renderPanelWorkspaceBar(
17
17
  return renderTabStrip({
18
18
  width,
19
19
  tabs: tabs.map((tab) => ({
20
- label: `${tab.pane === 'bottom' ? 'v' : '^'} ${tab.icon} ${tab.name}`,
20
+ // tab.active = selected in its own pane (drives highlighted background).
21
+ // tab.focused = has keyboard focus (drives brighter text / focus indicator).
22
+ // A tab can be active-but-not-focused (selected in the unfocused pane) or
23
+ // active-and-focused (selected in the focused pane).
24
+ label: `${tab.pane === 'bottom' ? 'v' : '^'} ${tab.icon} ${tab.name}${tab.focused ? ' ▸' : ''}`,
21
25
  active: tab.active,
22
26
  })),
23
27
  prefixLabel: ' PANELS ',
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.23';
9
+ let _version = '0.19.24';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;