@pellux/goodvibes-tui 0.18.13 → 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 +139 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -2
- package/src/daemon/cli.ts +82 -6
- package/src/input/command-registry.ts +2 -0
- package/src/input/commands/control-room-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/platform-access-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +20 -0
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed.ts +6 -0
- package/src/input/handler-modal-routes.ts +19 -2
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-picker-routes.ts +4 -2
- package/src/input/model-picker.ts +11 -0
- package/src/input/settings-modal.ts +31 -3
- package/src/panels/agent-logs-panel.ts +23 -24
- package/src/panels/base-panel.ts +6 -0
- package/src/panels/builtin/session.ts +66 -0
- package/src/panels/builtin/shared.ts +1 -1
- package/src/panels/provider-account-snapshot.ts +1 -1
- package/src/panels/provider-accounts-panel.ts +23 -27
- package/src/panels/qr-panel.ts +182 -0
- package/src/panels/scrollable-list-panel.ts +407 -0
- package/src/panels/services-panel.ts +1 -1
- package/src/panels/subscription-panel.ts +1 -1
- package/src/panels/types.ts +6 -0
- package/src/panels/worktree-panel.ts +20 -19
- package/src/renderer/buffer.ts +19 -0
- package/src/renderer/compositor.ts +19 -6
- package/src/renderer/panel-composite.ts +24 -3
- package/src/renderer/qr-renderer.ts +117 -0
- package/src/renderer/settings-modal-helpers.ts +122 -0
- package/src/renderer/settings-modal.ts +147 -111
- package/src/runtime/bootstrap-command-context.ts +1 -1
- package/src/runtime/bootstrap-command-parts.ts +31 -15
- package/src/runtime/bootstrap-core.ts +23 -1
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/diagnostics/panels/index.ts +5 -5
- package/src/runtime/services.ts +1 -1
- package/src/runtime/store/domains/domain-read-matrix.ts +0 -2
- package/src/runtime/ui-events.ts +1 -46
- package/src/runtime/ui-read-model-helpers.ts +1 -32
- package/src/runtime/ui-read-models-observability-maintenance.ts +1 -81
- package/src/runtime/ui-read-models-observability-options.ts +1 -5
- package/src/runtime/ui-read-models-observability-remote.ts +1 -73
- package/src/runtime/ui-read-models-observability-security.ts +1 -172
- package/src/runtime/ui-read-models-observability-system.ts +1 -217
- package/src/runtime/ui-read-models-observability.ts +1 -59
- package/src/runtime/ui-service-queries.ts +1 -114
- package/src/version.ts +1 -1
- package/src/config/service-registry.ts +0 -1
- package/src/config/subscription-providers.ts +0 -1
- package/src/runtime/diagnostics/actions.ts +0 -776
- package/src/runtime/diagnostics/index.ts +0 -99
- package/src/runtime/diagnostics/panels/agents.ts +0 -252
- package/src/runtime/diagnostics/panels/events.ts +0 -188
- package/src/runtime/diagnostics/panels/health.ts +0 -242
- package/src/runtime/diagnostics/panels/tasks.ts +0 -251
- package/src/runtime/diagnostics/panels/tool-calls.ts +0 -267
- package/src/runtime/diagnostics/provider.ts +0 -262
- package/src/runtime/store/domains/conversation.ts +0 -1
- package/src/runtime/store/domains/permissions.ts +0 -1
- package/src/runtime/store/helpers/reducers/conversation.ts +0 -1
- package/src/runtime/store/helpers/reducers/lifecycle.ts +0 -1
- package/src/runtime/store/helpers/reducers/shared.ts +0 -60
- package/src/runtime/store/helpers/reducers/sync.ts +0 -555
- package/src/runtime/store/helpers/reducers.ts +0 -30
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import type { Line } from '../types/grid.ts';
|
|
2
|
+
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
+
import { BasePanel } from './base-panel.ts';
|
|
4
|
+
import type { PanelCategory } from './types.ts';
|
|
5
|
+
import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
|
|
6
|
+
import {
|
|
7
|
+
buildEmptyState,
|
|
8
|
+
buildPanelWorkspace,
|
|
9
|
+
DEFAULT_PANEL_PALETTE,
|
|
10
|
+
resolveScrollablePanelSection,
|
|
11
|
+
type PanelPalette,
|
|
12
|
+
} from './polish.ts';
|
|
13
|
+
import {
|
|
14
|
+
isPanelSearchBackspace,
|
|
15
|
+
isPanelSearchCancel,
|
|
16
|
+
isPanelSearchPrintable,
|
|
17
|
+
} from './search-focus.ts';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// ScrollableListPanel<T>
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base class for all list-based panels that require scroll/cursor navigation.
|
|
25
|
+
*
|
|
26
|
+
* Subclasses implement:
|
|
27
|
+
* - `getItems()` — the ordered list of items to display
|
|
28
|
+
* - `renderItem(item, index, selected, width)` — one `Line` per item
|
|
29
|
+
*
|
|
30
|
+
* Optionally override:
|
|
31
|
+
* - `getEmptyStateMessage()` / `getEmptyStateActions()` — empty-state copy
|
|
32
|
+
* - `onSelect(item)` — called when the user presses Enter
|
|
33
|
+
* - `onAction(item, action)` — for secondary key bindings
|
|
34
|
+
* - `getPalette()` — colour palette (defaults to `DEFAULT_PANEL_PALETTE`)
|
|
35
|
+
* - `getPageSize()` — rows per page-up/page-down (default 10)
|
|
36
|
+
*
|
|
37
|
+
* `renderList()` produces the full `Line[]` output that a trivial panel's
|
|
38
|
+
* `render()` can return directly:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* render(width: number, height: number): Line[] {
|
|
42
|
+
* return this.renderList(width, height, { header: this.buildHeader(width) });
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
47
|
+
protected selectedIndex = 0;
|
|
48
|
+
/** Tracks the first visible row index; kept in sync with resolveScrollablePanelSection. */
|
|
49
|
+
protected scrollStart = 0;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
id: string,
|
|
53
|
+
name: string,
|
|
54
|
+
icon: string,
|
|
55
|
+
category: PanelCategory,
|
|
56
|
+
componentHealthMonitor?: ComponentHealthMonitor,
|
|
57
|
+
) {
|
|
58
|
+
super(id, name, icon, category, componentHealthMonitor);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
// Abstract — subclasses must implement
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Return the full ordered list of items to display. */
|
|
66
|
+
protected abstract getItems(): readonly T[];
|
|
67
|
+
|
|
68
|
+
/** Render a single item as one terminal `Line`. */
|
|
69
|
+
protected abstract renderItem(
|
|
70
|
+
item: T,
|
|
71
|
+
index: number,
|
|
72
|
+
selected: boolean,
|
|
73
|
+
width: number,
|
|
74
|
+
): Line;
|
|
75
|
+
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
// Optional overrides
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** Short label shown in the empty-state title. */
|
|
81
|
+
protected getEmptyStateMessage(): string {
|
|
82
|
+
return 'No items';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Suggested actions shown in the empty state. */
|
|
86
|
+
protected getEmptyStateActions(): Array<{ command: string; summary: string }> {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Called when the user presses Enter on the selected item. */
|
|
91
|
+
protected onSelect(_item: T): void {}
|
|
92
|
+
|
|
93
|
+
/** Called for secondary key bindings (e.g. 'd' for delete). */
|
|
94
|
+
protected onAction(_item: T, _action: string): void {}
|
|
95
|
+
|
|
96
|
+
/** Colour palette used by `renderList()`. */
|
|
97
|
+
protected getPalette(): PanelPalette {
|
|
98
|
+
return DEFAULT_PANEL_PALETTE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rows to jump on pageup / pagedown.
|
|
103
|
+
* Override in `render()` to pass the actual visible row count:
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* this._pageSize = Math.max(1, visibleRows - 2);
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
protected getPageSize(): number {
|
|
110
|
+
return 10;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -------------------------------------------------------------------------
|
|
114
|
+
// Navigation — consistent across ALL panels
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
handleInput(key: string): boolean {
|
|
118
|
+
const items = this.getItems();
|
|
119
|
+
const total = items.length;
|
|
120
|
+
|
|
121
|
+
switch (key) {
|
|
122
|
+
case 'up':
|
|
123
|
+
case 'k':
|
|
124
|
+
if (total === 0) return false;
|
|
125
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
126
|
+
this.needsRender = true;
|
|
127
|
+
return true;
|
|
128
|
+
|
|
129
|
+
case 'down':
|
|
130
|
+
case 'j':
|
|
131
|
+
if (total === 0) return false;
|
|
132
|
+
this.selectedIndex = Math.min(total - 1, this.selectedIndex + 1);
|
|
133
|
+
this.needsRender = true;
|
|
134
|
+
return true;
|
|
135
|
+
|
|
136
|
+
case 'pageup':
|
|
137
|
+
if (total === 0) return false;
|
|
138
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - this.getPageSize());
|
|
139
|
+
this.needsRender = true;
|
|
140
|
+
return true;
|
|
141
|
+
|
|
142
|
+
case 'pagedown':
|
|
143
|
+
if (total === 0) return false;
|
|
144
|
+
this.selectedIndex = Math.min(total - 1, this.selectedIndex + this.getPageSize());
|
|
145
|
+
this.needsRender = true;
|
|
146
|
+
return true;
|
|
147
|
+
|
|
148
|
+
case 'home':
|
|
149
|
+
case 'g':
|
|
150
|
+
if (total === 0) return false;
|
|
151
|
+
this.selectedIndex = 0;
|
|
152
|
+
this.needsRender = true;
|
|
153
|
+
return true;
|
|
154
|
+
|
|
155
|
+
case 'end':
|
|
156
|
+
case 'G':
|
|
157
|
+
if (total === 0) return false;
|
|
158
|
+
this.selectedIndex = total - 1;
|
|
159
|
+
this.needsRender = true;
|
|
160
|
+
return true;
|
|
161
|
+
|
|
162
|
+
case 'return':
|
|
163
|
+
case 'enter': {
|
|
164
|
+
if (total === 0) return false;
|
|
165
|
+
const item = items[this.selectedIndex];
|
|
166
|
+
if (item !== undefined) this.onSelect(item);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
// Scroll state helpers
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Clamp `selectedIndex` to the current item count.
|
|
181
|
+
* Must be called after any data refresh that may shrink the list.
|
|
182
|
+
*/
|
|
183
|
+
protected clampSelection(): void {
|
|
184
|
+
const total = this.getItems().length;
|
|
185
|
+
if (total === 0) {
|
|
186
|
+
this.selectedIndex = 0;
|
|
187
|
+
} else {
|
|
188
|
+
this.selectedIndex = Math.min(this.selectedIndex, total - 1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------------------
|
|
193
|
+
// Render helper — the main convenience entry point
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Render the full panel including optional header/footer and an empty state.
|
|
198
|
+
*
|
|
199
|
+
* Uses `resolveScrollablePanelSection` + `buildPanelWorkspace` internally,
|
|
200
|
+
* keeping `scrollStart` in sync after each call.
|
|
201
|
+
*
|
|
202
|
+
* @param width Panel width in columns.
|
|
203
|
+
* @param height Panel height in rows.
|
|
204
|
+
* @param options.header Lines prepended as the first workspace section.
|
|
205
|
+
* @param options.footer Lines appended as the last workspace section.
|
|
206
|
+
* @param options.emptyMessage Override for the empty-state title text.
|
|
207
|
+
* @param options.title Workspace title (defaults to `this.name`).
|
|
208
|
+
*/
|
|
209
|
+
protected renderList(
|
|
210
|
+
width: number,
|
|
211
|
+
height: number,
|
|
212
|
+
options: {
|
|
213
|
+
readonly header?: readonly Line[];
|
|
214
|
+
readonly footer?: readonly Line[];
|
|
215
|
+
readonly emptyMessage?: string;
|
|
216
|
+
readonly title?: string;
|
|
217
|
+
} = {},
|
|
218
|
+
): Line[] {
|
|
219
|
+
this.needsRender = false;
|
|
220
|
+
const palette = this.getPalette();
|
|
221
|
+
const items = this.getItems();
|
|
222
|
+
const title = options.title ?? this.name;
|
|
223
|
+
|
|
224
|
+
// Build all item lines (pre-render for resolveScrollablePanelSection)
|
|
225
|
+
const scrollableLines: Line[] = items.map((item, index) =>
|
|
226
|
+
this.renderItem(item, index, index === this.selectedIndex, width),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Empty state
|
|
230
|
+
if (scrollableLines.length === 0) {
|
|
231
|
+
const emptyLines = buildEmptyState(
|
|
232
|
+
width,
|
|
233
|
+
options.emptyMessage ?? this.getEmptyStateMessage(),
|
|
234
|
+
'',
|
|
235
|
+
this.getEmptyStateActions(),
|
|
236
|
+
palette,
|
|
237
|
+
);
|
|
238
|
+
const lines = buildPanelWorkspace(width, height, {
|
|
239
|
+
title,
|
|
240
|
+
sections: [
|
|
241
|
+
...(options.header ? [{ lines: options.header as Line[] }] : []),
|
|
242
|
+
{ lines: emptyLines },
|
|
243
|
+
...(options.footer ? [{ lines: options.footer as Line[] }] : []),
|
|
244
|
+
],
|
|
245
|
+
palette,
|
|
246
|
+
});
|
|
247
|
+
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
248
|
+
return lines.slice(0, height);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Resolve scrollable section (updates scrollStart)
|
|
252
|
+
const beforeSections = options.header ? [{ lines: options.header as Line[] }] : [];
|
|
253
|
+
const afterSections = options.footer ? [{ lines: options.footer as Line[] }] : [];
|
|
254
|
+
|
|
255
|
+
const resolved = resolveScrollablePanelSection(width, height, {
|
|
256
|
+
palette,
|
|
257
|
+
beforeSections,
|
|
258
|
+
afterSections,
|
|
259
|
+
section: {
|
|
260
|
+
scrollableLines,
|
|
261
|
+
selectedIndex: this.selectedIndex,
|
|
262
|
+
scrollOffset: this.scrollStart,
|
|
263
|
+
guardRows: 1,
|
|
264
|
+
appendWindowSummary: scrollableLines.length > 5 ? { dimColor: palette.dim } : undefined,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
this.scrollStart = resolved.scrollOffset;
|
|
268
|
+
|
|
269
|
+
const sections = [
|
|
270
|
+
...beforeSections,
|
|
271
|
+
resolved.section,
|
|
272
|
+
...afterSections,
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const lines = buildPanelWorkspace(width, height, {
|
|
276
|
+
title,
|
|
277
|
+
sections,
|
|
278
|
+
palette,
|
|
279
|
+
});
|
|
280
|
+
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
281
|
+
return lines.slice(0, height);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// SearchableListPanel<T>
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Extends `ScrollableListPanel<T>` with inline search/filter support.
|
|
291
|
+
*
|
|
292
|
+
* Subclasses implement:
|
|
293
|
+
* - `getAllItems()` — the full (unfiltered) item list
|
|
294
|
+
* - `matchesSearch(item, query)` — case-insensitive filter predicate
|
|
295
|
+
*
|
|
296
|
+
* `getItems()` is implemented here and returns filtered results. Do NOT
|
|
297
|
+
* override `getItems()` in subclasses — override `getAllItems()` instead.
|
|
298
|
+
*
|
|
299
|
+
* Search state:
|
|
300
|
+
* - Printable characters append to `searchQuery`.
|
|
301
|
+
* - Backspace/Delete removes the last character.
|
|
302
|
+
* - Escape clears the query.
|
|
303
|
+
* - Navigation keys (up/down/etc.) are forwarded to the parent.
|
|
304
|
+
*
|
|
305
|
+
* Render the search input line by calling `buildSearchInput(width)` from
|
|
306
|
+
* your panel's header builder.
|
|
307
|
+
*/
|
|
308
|
+
export abstract class SearchableListPanel<T> extends ScrollableListPanel<T> {
|
|
309
|
+
protected searchQuery = '';
|
|
310
|
+
|
|
311
|
+
private _filteredItems: readonly T[] = [];
|
|
312
|
+
private _filterDirty = true;
|
|
313
|
+
|
|
314
|
+
// -------------------------------------------------------------------------
|
|
315
|
+
// Abstract — subclasses must implement
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** Return the full unfiltered item list. */
|
|
319
|
+
protected abstract getAllItems(): readonly T[];
|
|
320
|
+
|
|
321
|
+
/** Return true if `item` matches the search `query`. */
|
|
322
|
+
protected abstract matchesSearch(item: T, query: string): boolean;
|
|
323
|
+
|
|
324
|
+
// -------------------------------------------------------------------------
|
|
325
|
+
// getItems — returns filtered list (do NOT override in subclasses)
|
|
326
|
+
// -------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
protected getItems(): readonly T[] {
|
|
329
|
+
if (this._filterDirty) {
|
|
330
|
+
const all = this.getAllItems();
|
|
331
|
+
this._filteredItems = this.searchQuery
|
|
332
|
+
? all.filter((item) => this.matchesSearch(item, this.searchQuery))
|
|
333
|
+
: all;
|
|
334
|
+
this._filterDirty = false;
|
|
335
|
+
// Clamp after filter to keep selection in bounds
|
|
336
|
+
this.clampSelection();
|
|
337
|
+
}
|
|
338
|
+
return this._filteredItems;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Mark the filter cache as stale.
|
|
343
|
+
* Call this whenever `getAllItems()` returns new data.
|
|
344
|
+
*/
|
|
345
|
+
protected invalidateFilter(): void {
|
|
346
|
+
this._filterDirty = true;
|
|
347
|
+
this.needsRender = true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -------------------------------------------------------------------------
|
|
351
|
+
// Input — search first, navigation second
|
|
352
|
+
// -------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
handleInput(key: string): boolean {
|
|
355
|
+
// Backspace: trim query
|
|
356
|
+
if (isPanelSearchBackspace(key)) {
|
|
357
|
+
if (this.searchQuery.length > 0) {
|
|
358
|
+
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
359
|
+
this._filterDirty = true;
|
|
360
|
+
this.needsRender = true;
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Escape: clear query
|
|
367
|
+
if (isPanelSearchCancel(key)) {
|
|
368
|
+
if (this.searchQuery.length > 0) {
|
|
369
|
+
this.searchQuery = '';
|
|
370
|
+
this._filterDirty = true;
|
|
371
|
+
this.needsRender = true;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Printable characters: append to query
|
|
378
|
+
if (isPanelSearchPrintable(key)) {
|
|
379
|
+
this.searchQuery += key;
|
|
380
|
+
this._filterDirty = true;
|
|
381
|
+
this.needsRender = true;
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Navigation and Enter: delegate to parent
|
|
386
|
+
return super.handleInput(key);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Build the search input `Line` suitable for use in a panel header.
|
|
391
|
+
*
|
|
392
|
+
* Import `buildSearchInputLine` from `./polish.ts` and call it with
|
|
393
|
+
* `this.searchQuery`. Convenience wrapper:
|
|
394
|
+
*
|
|
395
|
+
* ```ts
|
|
396
|
+
* import { buildSearchInputLine } from './polish.ts';
|
|
397
|
+
*
|
|
398
|
+
* private buildHeader(width: number): Line[] {
|
|
399
|
+
* return [buildSearchInputLine(width, 'Filter', this.searchQuery, this.getPalette(), {})];
|
|
400
|
+
* }
|
|
401
|
+
* ```
|
|
402
|
+
*
|
|
403
|
+
* This method is intentionally left as a documentation reference rather
|
|
404
|
+
* than a concrete implementation to avoid coupling the base class to a
|
|
405
|
+
* specific label or search-input layout.
|
|
406
|
+
*/
|
|
407
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ServiceConfig,
|
|
6
6
|
type ServiceInspection,
|
|
7
7
|
type ServiceConnectionTestResult,
|
|
8
|
-
} from '
|
|
8
|
+
} from '@pellux/goodvibes-sdk/platform/config/service-registry';
|
|
9
9
|
import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
|
|
10
10
|
import {
|
|
11
11
|
buildEmptyState,
|
|
@@ -2,7 +2,7 @@ import type { Line } from '../types/grid.ts';
|
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
3
|
import { BasePanel } from './base-panel.ts';
|
|
4
4
|
import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
|
|
5
|
-
import { listBuiltinSubscriptionProviders } from '
|
|
5
|
+
import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
|
|
6
6
|
import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
|
|
7
7
|
import {
|
|
8
8
|
buildDetailBlock,
|
package/src/panels/types.ts
CHANGED
|
@@ -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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
4
|
import { buildKeyValueLine, buildPanelLine, buildPanelWorkspace, DEFAULT_PANEL_PALETTE, resolvePrimaryScrollableSection, type PanelWorkspaceSection } from './polish.ts';
|
|
5
5
|
import { summarizeWorktreeOwnership, type WorktreeRegistry, type WorktreeStatusRecord } from '@pellux/goodvibes-sdk/platform/runtime/worktree/registry';
|
|
6
6
|
|
|
@@ -22,10 +22,8 @@ function stateColor(state: WorktreeStatusRecord['state']): string {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export class WorktreePanel extends
|
|
25
|
+
export class WorktreePanel extends ScrollableListPanel<WorktreeStatusRecord> {
|
|
26
26
|
private rows: WorktreeStatusRecord[] = [];
|
|
27
|
-
private selectedIndex = 0;
|
|
28
|
-
private scrollOffset = 0;
|
|
29
27
|
private loading = false;
|
|
30
28
|
private readonly worktreeRegistry: WorktreeRegistry;
|
|
31
29
|
|
|
@@ -45,18 +43,21 @@ export class WorktreePanel extends BasePanel {
|
|
|
45
43
|
void this.refresh();
|
|
46
44
|
return true;
|
|
47
45
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
return super.handleInput(key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
protected getItems(): readonly WorktreeStatusRecord[] {
|
|
50
|
+
return this.rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected renderItem(row: WorktreeStatusRecord, index: number, _selected: boolean, width: number): Line {
|
|
54
|
+
const bg = index === this.selectedIndex ? C.headerBg : undefined;
|
|
55
|
+
return buildPanelLine(width, [
|
|
56
|
+
[` ${row.kind}`.padEnd(14), C.info, bg],
|
|
57
|
+
[` ${row.state}`.padEnd(16), stateColor(row.state), bg],
|
|
58
|
+
[` ${row.branch}`.padEnd(24), C.value, bg],
|
|
59
|
+
[` ${row.path}`.slice(0, Math.max(0, width - 56)), C.dim, bg],
|
|
60
|
+
]);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
private async refresh(): Promise<void> {
|
|
@@ -64,7 +65,7 @@ export class WorktreePanel extends BasePanel {
|
|
|
64
65
|
this.markDirty();
|
|
65
66
|
try {
|
|
66
67
|
this.rows = await this.worktreeRegistry.list();
|
|
67
|
-
this.
|
|
68
|
+
this.clampSelection();
|
|
68
69
|
} finally {
|
|
69
70
|
this.loading = false;
|
|
70
71
|
this.markDirty();
|
|
@@ -155,14 +156,14 @@ export class WorktreePanel extends BasePanel {
|
|
|
155
156
|
]);
|
|
156
157
|
}),
|
|
157
158
|
selectedIndex: this.selectedIndex,
|
|
158
|
-
scrollOffset: this.
|
|
159
|
+
scrollOffset: this.scrollStart,
|
|
159
160
|
guardRows: 1,
|
|
160
161
|
minRows: 4,
|
|
161
162
|
appendWindowSummary: { dimColor: C.dim },
|
|
162
163
|
},
|
|
163
164
|
afterSections: [detailSection],
|
|
164
165
|
});
|
|
165
|
-
this.
|
|
166
|
+
this.scrollStart = resolvedWorktreesSection.scrollOffset;
|
|
166
167
|
sections.push(resolvedWorktreesSection.section);
|
|
167
168
|
sections.push(detailSection);
|
|
168
169
|
}
|
package/src/renderer/buffer.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
66
|
+
return this.frontBuffer;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
public resetDiff(): void {
|
|
68
70
|
this.diffEngine.reset();
|
|
69
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
81
|
+
bottomContent = bottomActivePanel ? renderPanel(bottomActivePanel, panelWidth, bottomH) : [];
|
|
61
82
|
} else {
|
|
62
83
|
const topH = Math.max(0, panelHeight - 1);
|
|
63
|
-
topContent = topActivePanel ? topActivePanel
|
|
84
|
+
topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
return {
|