@pellux/goodvibes-tui 0.18.17 → 0.18.18

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,23 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.18.18] — 2026-04-16
8
+
9
+ ### Performance
10
+
11
+ - **R1 — Render coalescing** (`src/runtime/bootstrap-core.ts`): `requestRender()` is now wrapped in a `setImmediate`-based coalescer. A burst of N synchronous `requestRender()` calls in a single microtask produces exactly one render pass. A 16ms minimum-interval gate is applied to cap rendering at ~60fps during streaming (prevents hundreds of full pipeline runs per second on LLM token bursts).
12
+ - **R2 — Panel dirty-flag activation** (`src/renderer/panel-composite.ts`, `src/panels/base-panel.ts`, `src/panels/types.ts`): The existing `needsRender` field is now enforced. Added `invalidate(): void` and `markRendered(): void` to the `Panel` interface and `BasePanel`. `buildPanelCompositeData` routes all panel renders through a new `renderPanel()` helper backed by a per-panel `WeakMap` cache — panels that have not changed and whose dimensions are unchanged are skipped entirely. `ScrollableListPanel` and all 40+ panels that write `needsRender = true` on state mutation are compatible without changes (the contract was already partially in place; this activates it).
13
+ - **R3 — Buffer reuse** (`src/renderer/buffer.ts`, `src/renderer/compositor.ts`): `TerminalBuffer` gains a `reset(width, height): void` method that overwrites cells in-place instead of reallocating. `Compositor` now holds two long-lived `TerminalBuffer` instances (front/back). Each `composite()` call resets the back buffer, composites into it, diffs against the front buffer (the last-rendered frame), writes the diff, then swaps front/back. The `clone()` call that doubled allocation cost every frame is eliminated. `TerminalBuffer` constructor is called twice per session (once per buffer), not once per frame.
14
+
15
+ ### Tests
16
+
17
+ - Added `src/test/renderer/render-perf.test.ts` with 10 new tests covering R1 coalescing logic, R2 dirty-flag skip/invalidate/markRendered contract, and R3 `TerminalBuffer.reset()` behavior.
18
+ - Extended `src/test/renderer/compositor.test.ts` with 3 new tests covering R3 double-buffer reuse correctness (buffer identity after swap, resetDiff clearing both buffers, resize handling).
19
+ - Updated mock `Panel` objects in `src/test/renderer/panel-navigation.test.ts`, `src/test/panels/panel-manager.test.ts`, `src/test/panels/panel-list-panel.test.ts`, and `src/test/daemon/server.test.ts` to implement the new `invalidate()`/`markRendered()` interface methods.
20
+ - Test suite: 438/438 passing, typecheck clean, architecture check green.
21
+
22
+ ---
23
+
7
24
  ## [0.18.17] — 2026-04-16
8
25
 
9
26
  ### Bug Fixes
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.18.17-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.18.18-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.18.17",
3
+ "version": "0.18.18",
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",
@@ -40,6 +40,12 @@ export abstract class BasePanel implements Panel {
40
40
 
41
41
  abstract render(width: number, height: number): Line[];
42
42
 
43
+ /** R2: Mark this panel dirty — it will be re-rendered on the next compositor frame. */
44
+ public invalidate(): void { this.needsRender = true; }
45
+
46
+ /** R2: Called by the compositor after a successful render to clear the dirty flag. */
47
+ public markRendered(): void { this.needsRender = false; }
48
+
43
49
  protected markDirty(): void { this.needsRender = true; }
44
50
 
45
51
  /**
@@ -22,6 +22,12 @@ export interface Panel {
22
22
  isPinned: boolean;
23
23
  needsRender: boolean;
24
24
 
25
+ // Dirty-flag contract (R2: activated panel render skipping)
26
+ /** Mark this panel as needing a re-render on the next frame. */
27
+ invalidate(): void;
28
+ /** Called by the compositor after a successful render to clear the dirty flag. */
29
+ markRendered(): void;
30
+
25
31
  // Resource contract (optional — panels may declare resource requirements)
26
32
  resourceContract?: Readonly<ComponentResourceContract>;
27
33
 
@@ -31,4 +31,23 @@ export class TerminalBuffer {
31
31
  newBuf.cells = this.cells.map(line => line.map(cell => ({ ...cell })));
32
32
  return newBuf;
33
33
  }
34
+
35
+ /**
36
+ * Reset all cells in-place to empty, reusing this buffer instance.
37
+ * If dimensions changed, reallocates cells array.
38
+ */
39
+ public reset(width: number, height: number): void {
40
+ if (width !== this.width || height !== this.height) {
41
+ this.width = width;
42
+ this.height = height;
43
+ this.cells = Array.from({ length: height }, () => createEmptyLine(width));
44
+ } else {
45
+ for (let y = 0; y < this.height; y++) {
46
+ const row = this.cells[y]!;
47
+ for (let x = 0; x < this.width; x++) {
48
+ row[x] = createEmptyCell();
49
+ }
50
+ }
51
+ }
52
+ }
34
53
  }
@@ -54,24 +54,33 @@ export interface CompositeRequest {
54
54
  * Decoupled from global state — all needed data is passed as parameters.
55
55
  */
56
56
  export class Compositor {
57
- private lastBuffer: TerminalBuffer | null = null;
57
+ /** Double-buffer reuse: back is written, front is the last-rendered reference. */
58
+ private frontBuffer: TerminalBuffer | null = null;
59
+ private backBuffer: TerminalBuffer | null = null;
58
60
  private diffEngine = new DiffEngine();
59
61
 
60
62
  constructor(private stdout: NodeJS.WriteStream) {}
61
63
 
62
64
  /** Exposed for unit tests — returns the last composited buffer. */
63
65
  public get lastBufferForTest(): TerminalBuffer | null {
64
- return this.lastBuffer;
66
+ return this.frontBuffer;
65
67
  }
66
68
 
67
69
  public resetDiff(): void {
68
70
  this.diffEngine.reset();
69
- this.lastBuffer = null;
71
+ this.frontBuffer = null;
72
+ this.backBuffer = null;
70
73
  }
71
74
 
72
75
  public composite(params: CompositeRequest): void {
73
76
  const { width, height, header, viewport, footer, selection, search, panel, panelWidth } = params;
74
- const newBuffer = new TerminalBuffer(width, height);
77
+ // R3: Reuse back-buffer instead of allocating each frame
78
+ if (!this.backBuffer) {
79
+ this.backBuffer = new TerminalBuffer(width, height);
80
+ } else {
81
+ this.backBuffer.reset(width, height);
82
+ }
83
+ const newBuffer = this.backBuffer;
75
84
 
76
85
  const hasPanel = panel !== undefined && panelWidth !== undefined && panelWidth > 0;
77
86
  const leftWidth = hasPanel ? Math.max(1, width - panelWidth - 1) : width;
@@ -251,11 +260,15 @@ export class Compositor {
251
260
  });
252
261
 
253
262
  // 4. Diff and Render
254
- const diff = this.diffEngine.diff(this.lastBuffer, newBuffer);
263
+ // R3: Diff against front-buffer (last-rendered), then swap front/back — no clone() needed
264
+ const diff = this.diffEngine.diff(this.frontBuffer, newBuffer);
255
265
  if (diff) {
256
266
  this.stdout.write(diff);
257
267
  }
258
268
 
259
- this.lastBuffer = newBuffer.clone();
269
+ // Swap: back (just written) becomes the new front reference; old front becomes the next back
270
+ const swap = this.frontBuffer;
271
+ this.frontBuffer = this.backBuffer;
272
+ this.backBuffer = swap;
260
273
  }
261
274
  }
@@ -1,4 +1,5 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import type { Panel } from '../panels/types.ts';
2
3
  import type { InputHandler } from '../input/handler.ts';
3
4
  import type { PanelManager } from '../panels/panel-manager.ts';
4
5
  import type { PanelCompositeData } from './compositor.ts';
@@ -6,6 +7,26 @@ import { createSplitPaneLayout } from './layout-engine.ts';
6
7
  import { renderPanelTabBar } from './panel-tab-bar.ts';
7
8
  import { renderPanelWorkspaceBar } from './panel-workspace-bar.ts';
8
9
 
10
+ /** R2: Per-panel render cache for dirty-flag skipping. */
11
+ interface PanelRenderCache {
12
+ lines: Line[];
13
+ width: number;
14
+ height: number;
15
+ }
16
+ const panelRenderCache = new WeakMap<Panel, PanelRenderCache>();
17
+
18
+ /** R2: Render a panel, skipping if nothing changed. Returns cached lines on a skip. */
19
+ function renderPanel(panel: Panel, width: number, height: number): Line[] {
20
+ const cached = panelRenderCache.get(panel);
21
+ if (cached && !panel.needsRender && cached.width === width && cached.height === height) {
22
+ return cached.lines;
23
+ }
24
+ const lines = panel.render(width, height);
25
+ panel.markRendered();
26
+ panelRenderCache.set(panel, { lines, width, height });
27
+ return lines;
28
+ }
29
+
9
30
  export interface PanelCompositeBuildResult {
10
31
  readonly panelData?: PanelCompositeData;
11
32
  readonly panelWidth?: number;
@@ -47,7 +68,7 @@ export function buildPanelCompositeData(
47
68
  const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio);
48
69
  const topH = paneLayout.topContentRows;
49
70
  const bottomH = paneLayout.bottomContentRows;
50
- topContent = topActivePanel ? topActivePanel.render(panelWidth, topH) : [];
71
+ topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
51
72
 
52
73
  const bottomActivePanel = bottomPane.panels[bottomPane.activeIndex] ?? null;
53
74
  bottomTabBar = renderPanelTabBar(
@@ -57,10 +78,10 @@ export function buildPanelCompositeData(
57
78
  input.panelFocused && focusedPane === 'bottom',
58
79
  'bottom',
59
80
  );
60
- bottomContent = bottomActivePanel ? bottomActivePanel.render(panelWidth, bottomH) : [];
81
+ bottomContent = bottomActivePanel ? renderPanel(bottomActivePanel, panelWidth, bottomH) : [];
61
82
  } else {
62
83
  const topH = Math.max(0, panelHeight - 1);
63
- topContent = topActivePanel ? topActivePanel.render(panelWidth, topH) : [];
84
+ topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
64
85
  }
65
86
 
66
87
  return {
@@ -226,8 +226,30 @@ export async function initializeBootstrapCore(
226
226
  });
227
227
 
228
228
  const renderRequestRef = { value: (): void => {} };
229
+ // R1: Coalescing render scheduler — collapses N same-microtask requestRender() calls into 1.
230
+ // Also enforces a 16ms minimum interval to cap at ~60fps during streaming.
231
+ let renderScheduled = false;
232
+ let lastRenderTime = 0;
233
+ const RENDER_INTERVAL_MS = 16;
229
234
  const requestRender = (): void => {
230
- renderRequestRef.value();
235
+ if (renderScheduled) return;
236
+ renderScheduled = true;
237
+ setImmediate(() => {
238
+ renderScheduled = false;
239
+ const now = Date.now();
240
+ const elapsed = now - lastRenderTime;
241
+ if (elapsed < RENDER_INTERVAL_MS) {
242
+ // Too soon — debounce to the tail of the current 16ms window
243
+ const delay = RENDER_INTERVAL_MS - elapsed;
244
+ setTimeout(() => {
245
+ lastRenderTime = Date.now();
246
+ renderRequestRef.value();
247
+ }, delay);
248
+ } else {
249
+ lastRenderTime = now;
250
+ renderRequestRef.value();
251
+ }
252
+ });
231
253
  };
232
254
  const permissionPromptRef = {
233
255
  requestPermission: (async () => ({ approved: false, remember: false })) as PermissionRequestHandler,
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.18.17';
9
+ let _version = '0.18.18';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;