gentle-shell-bridge 0.3.0 → 0.3.2
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/LICENSE +202 -0
- package/README.md +91 -722
- package/docs/contract.md +92 -35
- package/package.json +13 -1
- package/src/api.ts +1 -1
- package/.codegraph/.gitignore +0 -5
- package/PRD.md +0 -144
- package/examples/bash-card.ts +0 -1694
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +0 -30
- package/odd/tasks/plugin-keymap.md +0 -32
- package/odd/tasks/plugin-surface-contract.md +0 -55
- package/test/api.test.ts +0 -395
- package/test/apply-on-move.test.ts +0 -477
- package/test/arm.test.ts +0 -220
- package/test/bridge-ready.test.ts +0 -309
- package/test/bus.test.ts +0 -165
- package/test/button-rows.test.ts +0 -409
- package/test/capture.test.ts +0 -356
- package/test/card-style-rows.test.ts +0 -510
- package/test/card-style-transform.test.ts +0 -379
- package/test/card-style-wiring.test.ts +0 -258
- package/test/card-styles-e2e.test.ts +0 -491
- package/test/card-styles.test.ts +0 -324
- package/test/card-transcript-path.test.ts +0 -178
- package/test/config.test.ts +0 -93
- package/test/e2e.test.ts +0 -443
- package/test/example-bash-identity.test.ts +0 -372
- package/test/example-bash-matrix.test.ts +0 -856
- package/test/example-designs.test.ts +0 -734
- package/test/example-neon-transform.test.ts +0 -283
- package/test/example-plain-style.test.ts +0 -1268
- package/test/extension.test.ts +0 -160
- package/test/fake-events.ts +0 -58
- package/test/fake-host.ts +0 -211
- package/test/fake-sidebar.ts +0 -516
- package/test/fake-transcript.ts +0 -486
- package/test/frame-telemetry.test.ts +0 -417
- package/test/hook.test.ts +0 -196
- package/test/manifest.test.ts +0 -105
- package/test/new-category.test.ts +0 -979
- package/test/notices.test.ts +0 -328
- package/test/plugin-integration-lines.test.ts +0 -979
- package/test/plugin-surface.test.ts +0 -152
- package/test/presence.test.ts +0 -459
- package/test/rail-surfaces.test.ts +0 -457
- package/test/rail-width.test.ts +0 -423
- package/test/regression.test.ts +0 -483
- package/test/repaint-api.test.ts +0 -200
- package/test/reserved-categories.test.ts +0 -361
- package/test/row-labels.test.ts +0 -284
- package/test/row-names.test.ts +0 -473
- package/test/row-origin.test.ts +0 -395
- package/test/section-interaction.test.ts +0 -364
- package/test/section-panel.test.ts +0 -403
- package/test/sections-rows.test.ts +0 -291
- package/test/sidebar-backend.test.ts +0 -471
- package/test/sidebar-e2e.test.ts +0 -331
- package/test/sidebar-hover.test.ts +0 -276
- package/test/sidebar-independent.test.ts +0 -349
- package/test/sidebar-render.test.ts +0 -281
- package/test/sidebar-sections.test.ts +0 -245
- package/test/status-card-styles.test.ts +0 -201
- package/test/status-section-frame.test.ts +0 -181
- package/test/status-sections.test.ts +0 -243
- package/test/status.test.ts +0 -209
- package/test/store.test.ts +0 -192
- package/test/surface-repaint-cost.test.ts +0 -520
- package/test/tool-design-chain.test.ts +0 -483
- package/test/tool-design-cost.test.ts +0 -473
- package/test/tool-design-rows.test.ts +0 -359
- package/test/tool-designs.test.ts +0 -745
- package/test/transform-pipeline.test.ts +0 -465
- package/test/transforms-bus.test.ts +0 -233
- package/test/verifier-width-regressions.test.ts +0 -164
- package/tsconfig.json +0 -18
package/test/fake-sidebar.ts
DELETED
|
@@ -1,516 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A fake sidebar host (S9), modelling `lib/shell-sidebar.ts` and `lib/shell-sidebar-layout.ts`.
|
|
3
|
-
*
|
|
4
|
-
* The real host cannot be imported: Pi loads every extension through its own jiti loader with
|
|
5
|
-
* `moduleCache: false`, so gentle-shell's modules are not reachable from this package, and the
|
|
6
|
-
* host is not a dependency we may patch. This file models only the seams the bridge touches:
|
|
7
|
-
*
|
|
8
|
-
* 1. **The state slot.** The host keeps its rail state on the *terminal* object behind
|
|
9
|
-
* `Symbol.for("gentle-pi.experimental-sidebar.state")` (`lib/shell-sidebar.ts:6,78-80`), not in
|
|
10
|
-
* module scope, so separately loaded copies see one rail map. The fake hangs the same slot on
|
|
11
|
-
* the same kind of object, which is why a test can assert the bridge reached the host's map
|
|
12
|
-
* rather than a module-level singleton of its own.
|
|
13
|
-
* 2. **The rail map.** `state.parts` is a real `Map<string, Rail>`, written through `set` exactly
|
|
14
|
-
* like `sidebarPart` does (`:88-89`). Registration is therefore observable at any time —
|
|
15
|
-
* including after the bridge armed, which is the ordering S4 has to survive. The only rail
|
|
16
|
-
* keys the layout paints are `["todo", "jobs", "agents", "footer"]`
|
|
17
|
-
* (`lib/shell-sidebar-layout.ts:344`), so an unknown key never renders and a new rail key is
|
|
18
|
-
* not a way to add a section.
|
|
19
|
-
* 3. **The frame.** A transparent rail's part is drawn as one card per `cardGroups` entry, its
|
|
20
|
-
* first row split into glyph/title/count and its remaining rows given the card frame
|
|
21
|
-
* (`sectionCardRows`, `lib/shell-sidebar-layout.ts:35-44,392-410`). `renderSidebar` reproduces
|
|
22
|
-
* that shape without ANSI styling, so tests assert framed output (`cardTitles`, the `│ rows`)
|
|
23
|
-
* instead of the raw lines a rail returned.
|
|
24
|
-
*
|
|
25
|
-
* This is a scaffold: it holds no tests. Every test that uses it lives next to the behaviour it
|
|
26
|
-
* proves.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/** The host's own state key: shared across copies, and the seam the bridge intercepts. */
|
|
30
|
-
export const SIDEBAR_STATE_KEY: symbol = Symbol.for("gentle-pi.experimental-sidebar.state");
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The host's terminal-side frame cache (`lib/shell-sidebar-layout.ts:97-107`). `invalidateSidebar(tui)`
|
|
34
|
-
* bumps `tui.terminal[CACHE].revision`; the layout's memo re-prepares the rail whenever the revision
|
|
35
|
-
* it stored no longer matches, which is the only way a part-owner's write reaches the screen.
|
|
36
|
-
*/
|
|
37
|
-
export const SIDEBAR_CACHE_KEY: symbol = Symbol.for("gentle-pi.experimental-sidebar.cache");
|
|
38
|
-
|
|
39
|
-
/** The layout protocol symbol, exactly as `pi-tui` defines it (`layout-node.js:1`). */
|
|
40
|
-
export const LAYOUT_NODE: symbol = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
41
|
-
|
|
42
|
-
/** The rail keys the layout actually paints, in paint order (`shell-sidebar-layout.ts:344`). */
|
|
43
|
-
const RAIL_KEYS = ["todo", "jobs", "agents", "footer"] as const;
|
|
44
|
-
|
|
45
|
-
const DEFAULT_WIDTH = 40;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* A rail slot (`SidebarRail`, `lib/shell-sidebar.ts:17-66`). A real rail is a TUI `Component`; the
|
|
49
|
-
* fields below are the ones the bridge reads, passthroughs and test readers depend on.
|
|
50
|
-
*/
|
|
51
|
-
export interface Rail {
|
|
52
|
-
render(width: number): string[];
|
|
53
|
-
/**
|
|
54
|
-
* The `Component`'s normalized mouse handler (`@earendil-works/pi-tui` `Component.handleMouse`).
|
|
55
|
-
* The host dispatches a hit on this part here, so the bridge must delegate to it, never replace it.
|
|
56
|
-
*/
|
|
57
|
-
handleMouse?(event: unknown): unknown;
|
|
58
|
-
/** Row ranges of the section cards this part draws; absent means "one card for the whole part". */
|
|
59
|
-
cardGroups?(
|
|
60
|
-
width: number,
|
|
61
|
-
): { groups: Array<{ start: number; end: number }>; loose?: number[] } | undefined;
|
|
62
|
-
/** Cheap digest of the live state the part paints; the layout memo skips re-renders while it holds. */
|
|
63
|
-
digest?(): string;
|
|
64
|
-
/** "panel" parts share one painted surface instead of drawing their own card chrome. */
|
|
65
|
-
surface?: "panel";
|
|
66
|
-
/** A one-row digest for the mobile work row. */
|
|
67
|
-
summary?(): unknown;
|
|
68
|
-
/** Opens the section's own view on a tap. */
|
|
69
|
-
open?(): void;
|
|
70
|
-
/** "Needs you" rows drawn above the panel editor's rule. */
|
|
71
|
-
alert?(width: number): string[];
|
|
72
|
-
/** What this section's work spent (started subagents), read by the Stats section. */
|
|
73
|
-
spend?(): { runs: number; tokens: number; cost: number } | undefined;
|
|
74
|
-
/** `Component` housekeeping the host calls on a disposed part. */
|
|
75
|
-
invalidate?(): void;
|
|
76
|
-
dispose?(): void;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The host's shared pointer claim (`SidebarState.hover`, `lib/shell-sidebar.ts:31`): the owner that
|
|
81
|
-
* currently holds the pointer and the callback the next owner runs to unpaint it.
|
|
82
|
-
*/
|
|
83
|
-
export interface FakeHover {
|
|
84
|
-
owner: object;
|
|
85
|
-
clear: () => void;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The host's state as it sits on the terminal (`SidebarState`, `lib/shell-sidebar.ts:8-36`). */
|
|
89
|
-
export interface FakeSidebarState {
|
|
90
|
-
active: boolean;
|
|
91
|
-
visibility?: { todo?: boolean };
|
|
92
|
-
/** One hovered surface at a time, shared across separately loaded extensions. */
|
|
93
|
-
hover?: FakeHover;
|
|
94
|
-
parts: Map<string, Rail>;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** A terminal object, the thing the host hangs its state on. */
|
|
98
|
-
export type FakeTerminal = Record<string | symbol, unknown>;
|
|
99
|
-
|
|
100
|
-
const terminal: FakeTerminal = { columns: 160, rows: 40, isTTY: true };
|
|
101
|
-
|
|
102
|
-
/** The fake terminal, so a test can prove where the state slot lives. */
|
|
103
|
-
export function fakeTerminal(): FakeTerminal {
|
|
104
|
-
return terminal;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The `tui` the host hands `sidebarState(tui)`: it only ever reads `tui.terminal`. */
|
|
108
|
-
export function fakeTui(): { terminal: FakeTerminal } {
|
|
109
|
-
return { terminal };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The host's rail state, created on first access exactly as `sidebarState` does (`??=`). Reading
|
|
114
|
-
* through this accessor is what makes a later `registerNativeFooter()` visible: it always returns
|
|
115
|
-
* the live slot, never a copy.
|
|
116
|
-
*/
|
|
117
|
-
export function fakeState(): FakeSidebarState {
|
|
118
|
-
const existing = terminal[SIDEBAR_STATE_KEY];
|
|
119
|
-
if (isFakeState(existing)) return existing;
|
|
120
|
-
const created: FakeSidebarState = { active: true, parts: new Map() };
|
|
121
|
-
terminal[SIDEBAR_STATE_KEY] = created;
|
|
122
|
-
return created;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function isFakeState(value: unknown): value is FakeSidebarState {
|
|
126
|
-
return (
|
|
127
|
-
typeof value === "object" &&
|
|
128
|
-
value !== null &&
|
|
129
|
-
(value as { parts?: unknown }).parts instanceof Map
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
interface NativeRailOptions {
|
|
134
|
-
/**
|
|
135
|
-
* Model the host's own part-level pointer dispatch. `true` for the real `footer`, which the host
|
|
136
|
-
* wraps in a `NativePointerRegion` (`extensions/gentle-shell.ts:2766`): its `handleMouse` answers
|
|
137
|
-
* EVERY `move` inside the part's frame, so it never declines one and the bridge's wrapper above it
|
|
138
|
-
* never sees the event (`lib/native-pointer-region.ts:80-88`,
|
|
139
|
-
* `extensions/gentle-shell.ts:2732-2741`).
|
|
140
|
-
*/
|
|
141
|
-
pointerRegion?: boolean;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* The native `STATS`-style part: two body rows under a `▾ TITLE` header, declared as one card
|
|
146
|
-
* group the way the host's own rails do.
|
|
147
|
-
*/
|
|
148
|
-
function nativeRail(
|
|
149
|
-
title: string,
|
|
150
|
-
body: readonly string[] = ["native row 1", "native row 2"],
|
|
151
|
-
options: NativeRailOptions = {},
|
|
152
|
-
): Rail {
|
|
153
|
-
const lines = [`▾ ${title}`, ...body];
|
|
154
|
-
const rail: Rail = {
|
|
155
|
-
render: () => [...lines],
|
|
156
|
-
cardGroups: () => ({ groups: [{ start: 0, end: lines.length }] }),
|
|
157
|
-
digest: () => `${title.toLowerCase()}:native`,
|
|
158
|
-
spend: () => ({ runs: 0, tokens: 0, cost: 0 }),
|
|
159
|
-
};
|
|
160
|
-
if (options.pointerRegion === true) rail.handleMouse = hostPartPointer(lines.length);
|
|
161
|
-
return rail;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* The host's shared pointer transfer (`setSidebarHover`, `lib/shell-sidebar.ts:72-78`): an unchanged
|
|
166
|
-
* owner keeps the claim without clearing itself, a new owner replaces it and the PREVIOUS owner's
|
|
167
|
-
* `clear()` runs, and no owner clears whatever is there.
|
|
168
|
-
*
|
|
169
|
-
* Exported so a test can perform the host's own "the pointer left the rail" step —
|
|
170
|
-
* `setSidebarHover(tui)` with no owner (`lib/shell-sidebar-layout.ts:280,447`) — against the same
|
|
171
|
-
* slot the bridge claims.
|
|
172
|
-
*/
|
|
173
|
-
export function setHostHover(owner?: object, clear?: () => void): void {
|
|
174
|
-
const state = fakeState();
|
|
175
|
-
if (state.hover?.owner === owner) return;
|
|
176
|
-
const previous = state.hover;
|
|
177
|
-
state.hover = owner !== undefined && clear !== undefined ? { owner, clear } : undefined;
|
|
178
|
-
previous?.clear();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** The pointer fields a host mouse event carries, when it has the shape the layout dispatches. */
|
|
182
|
-
function isPointerEvent(event: unknown): event is { type: string; button?: string; y: number } {
|
|
183
|
-
return (
|
|
184
|
-
typeof event === "object" &&
|
|
185
|
-
event !== null &&
|
|
186
|
-
typeof (event as { type?: unknown }).type === "string" &&
|
|
187
|
-
typeof (event as { y?: unknown }).y === "number"
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* The host's own pointer region around one part, as the real `footer` has one around its status card.
|
|
193
|
-
*
|
|
194
|
-
* This is what `NativePointerRegion` does (`lib/native-pointer-region.ts:39,80-88`): a `move` with no
|
|
195
|
-
* button is handed to the callbacks, and gentle-shell's `onHover` (`extensions/gentle-shell.ts:2732-2741`)
|
|
196
|
-
* answers EVERY one of them — it claims the shared pointer for a row it owns
|
|
197
|
-
* (`setSidebarHover(tui, statusRegion, …)`) and clears it otherwise (`setSidebarHover(tui)`), returning
|
|
198
|
-
* `{ handled: true }` either way. A move over a row the bridge APPENDED to this part is therefore
|
|
199
|
-
* consumed by the host's region before the bridge's wrapper is reached; that is the model T40 had to
|
|
200
|
-
* correct. A click is answered only on a row the region owns and declines everywhere else
|
|
201
|
-
* (`extensions/gentle-shell.ts:2747-2757`), which is why a click on a contributed row works.
|
|
202
|
-
*/
|
|
203
|
-
function hostPartPointer(ownRows: number): (event: unknown) => unknown {
|
|
204
|
-
const owner = { part: "footer-status-region" };
|
|
205
|
-
let hoveredRow: number | undefined;
|
|
206
|
-
return (event) => {
|
|
207
|
-
if (!isPointerEvent(event)) return undefined;
|
|
208
|
-
if (event.type === "move" && event.button === "none") {
|
|
209
|
-
const own = event.y >= 0 && event.y < ownRows;
|
|
210
|
-
if (own) {
|
|
211
|
-
setHostHover(owner, () => {
|
|
212
|
-
hoveredRow = undefined;
|
|
213
|
-
});
|
|
214
|
-
} else {
|
|
215
|
-
// Not one of ours — a contributed row included: the host clears its slot, as
|
|
216
|
-
// `setSidebarHover(tui)` does, and still reports the move handled.
|
|
217
|
-
setHostHover();
|
|
218
|
-
}
|
|
219
|
-
const next = own ? event.y : undefined;
|
|
220
|
-
if (next === hoveredRow) return { handled: true };
|
|
221
|
-
hoveredRow = next;
|
|
222
|
-
return { handled: true, render: true };
|
|
223
|
-
}
|
|
224
|
-
if (event.type === "click") {
|
|
225
|
-
// The real footer answers only a target row (the section header here) and declines the rest.
|
|
226
|
-
if (event.button !== "left" || event.y !== 0) return undefined;
|
|
227
|
-
return { handled: true, render: true };
|
|
228
|
-
}
|
|
229
|
-
return undefined;
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Register the host's own `footer` part; callable before or after the bridge armed. The footer is
|
|
235
|
-
* the part the bridge appends to, and the real one carries a `NativePointerRegion` (see
|
|
236
|
-
* `hostPartPointer`), so it is modelled by default.
|
|
237
|
-
*/
|
|
238
|
-
export function registerNativeFooter(
|
|
239
|
-
rail: Rail = nativeRail("FOOTER", undefined, { pointerRegion: true }),
|
|
240
|
-
): Rail {
|
|
241
|
-
return registerNativeRail("footer", rail);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Register any painted part (`todo`, `jobs`, `agents`, `footer`), replacing its slot in place. */
|
|
245
|
-
export function registerNativeRail(key: string, rail: Rail): Rail {
|
|
246
|
-
// Written through the live map, like `sidebarPart` (`lib/shell-sidebar.ts:88`): a bridge that
|
|
247
|
-
// intercepts the map's `set` (or replaces `parts`) sees this registration whenever it happens.
|
|
248
|
-
fakeState().parts.set(key, rail);
|
|
249
|
-
return rail;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Drop one part, as the host's `dispose()` does (`lib/shell-sidebar.ts:92-95`). */
|
|
253
|
-
export function unregisterNativeRail(key: string): void {
|
|
254
|
-
fakeState().parts.delete(key);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* The legacy switch the host honours for the Todos card (`shell-sidebar-layout.ts:344`): a rail
|
|
259
|
-
* whose key is hidden paints nothing, which is what a hidden section must also do.
|
|
260
|
-
*/
|
|
261
|
-
function hiddenByHost(key: string, state: FakeSidebarState): boolean {
|
|
262
|
-
return key === "todo" && state.visibility?.todo === false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Paint the rail the way the layout does: for every painted key, ask the part for its lines, then
|
|
267
|
-
* draw one card per `cardGroups` entry (`sectionCardRows`). A part without `cardGroups` is one
|
|
268
|
-
* card headed by its first row. "Loose" and otherwise unframed rows follow their cards.
|
|
269
|
-
*
|
|
270
|
-
* Deliberately unstyled: no ANSI, so a test can assert the frame itself. The box characters match
|
|
271
|
-
* the card chrome (`╭ ─ ╮ │ ╰ ╯`), which is what makes "framed like the native sections" checkable.
|
|
272
|
-
*
|
|
273
|
-
* One pass yields both the rows (what the terminal shows) and the cards (what a test reads), so
|
|
274
|
-
* no helper has to re-parse the frame it just drew.
|
|
275
|
-
*/
|
|
276
|
-
function paint(width: number): { rows: string[]; cards: PaintedCard[] } {
|
|
277
|
-
const state = fakeState();
|
|
278
|
-
const painted: string[] = [];
|
|
279
|
-
const cards: PaintedCard[] = [];
|
|
280
|
-
|
|
281
|
-
for (const key of RAIL_KEYS) {
|
|
282
|
-
if (hiddenByHost(key, state)) continue;
|
|
283
|
-
const rail = state.parts.get(key);
|
|
284
|
-
if (rail === undefined) continue;
|
|
285
|
-
|
|
286
|
-
const lines = [...(rail.render(width) ?? [])];
|
|
287
|
-
// The layout drops trailing blank rows before deciding whether a part has content.
|
|
288
|
-
while (lines.length > 0 && String(lines[lines.length - 1]).trim() === "") lines.pop();
|
|
289
|
-
if (lines.length === 0) continue;
|
|
290
|
-
|
|
291
|
-
if (painted.length > 0) painted.push(""); // one blank row between two sections
|
|
292
|
-
const layout = rail.cardGroups?.(width);
|
|
293
|
-
const groups = layout?.groups ?? [{ start: 0, end: lines.length }];
|
|
294
|
-
groups.forEach((group, index) => {
|
|
295
|
-
if (index > 0) painted.push("");
|
|
296
|
-
const header = splitHeader(lines[group.start] ?? "");
|
|
297
|
-
const body = lines.slice(group.start + 1, group.end);
|
|
298
|
-
cards.push({ title: header.title, body: [...body] });
|
|
299
|
-
painted.push(...cardFrame(header, body, width));
|
|
300
|
-
});
|
|
301
|
-
for (const row of layout?.loose ?? []) {
|
|
302
|
-
const line = lines[row] ?? "";
|
|
303
|
-
if (line.trim() !== "") painted.push(line);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return { rows: painted, cards };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** The rows the terminal would show: every registered rail, drawn as framed cards. */
|
|
311
|
-
export function renderSidebar(width: number = DEFAULT_WIDTH): string[] {
|
|
312
|
-
return paint(width).rows;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** One painted card: the title the user reads and the body rows inside its frame. */
|
|
316
|
-
export interface PaintedCard {
|
|
317
|
-
title: string;
|
|
318
|
-
body: string[];
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Titles of the cards currently painted, in paint order. This is the assertion surface for
|
|
323
|
-
* "our section renders inside the frame, after the native content": a bare string in a rail's
|
|
324
|
-
* output is not a rendered section.
|
|
325
|
-
*/
|
|
326
|
-
export function cardTitles(width: number = DEFAULT_WIDTH): string[] {
|
|
327
|
-
return paint(width).cards.map((card) => card.title);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** The body rows of the first card with that title, frame removed; empty when it never painted. */
|
|
331
|
-
export function cardBody(title: string, width: number = DEFAULT_WIDTH): string[] {
|
|
332
|
-
return paint(width).cards.find((card) => card.title === title)?.body ?? [];
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Empty the host's rail map and forget the state slot, so the next access starts from nothing. */
|
|
336
|
-
export function resetSidebar(): void {
|
|
337
|
-
const existing = terminal[SIDEBAR_STATE_KEY];
|
|
338
|
-
if (isFakeState(existing)) existing.parts.clear();
|
|
339
|
-
delete terminal[SIDEBAR_STATE_KEY];
|
|
340
|
-
delete terminal[SIDEBAR_CACHE_KEY];
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** What the host's memo compares against after a part-owner write (`invalidateSidebar`). */
|
|
344
|
-
export function cacheRevision(): number {
|
|
345
|
-
const cache = terminal[SIDEBAR_CACHE_KEY];
|
|
346
|
-
return isCache(cache) ? cache.revision : 0;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function isCache(value: unknown): value is { revision: number } {
|
|
350
|
-
return (
|
|
351
|
-
typeof value === "object" &&
|
|
352
|
-
value !== null &&
|
|
353
|
-
typeof (value as { revision?: unknown }).revision === "number"
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* The two section cards a transparent float rail paints, at width 40.
|
|
359
|
-
*
|
|
360
|
-
* Both were produced by running gentle-shell's own `lib/shell-card.ts` (read-only) exactly the way
|
|
361
|
-
* `sectionCardRows` does (`lib/shell-sidebar-layout.ts:36-47`): `floatRows` over `cardTop`, one
|
|
362
|
-
* `cardLine` per body row, and `cardBottom`, with the host's role→SGR mapping (`border`→90,
|
|
363
|
-
* `accent`→95, `hint`→dim, background `\u001b[48;5;236m`). They are the bytes the rail really
|
|
364
|
-
* frames, so a test that restyles them proves the repaint against host output, not against a shape
|
|
365
|
-
* this package invented.
|
|
366
|
-
*
|
|
367
|
-
* `RAIL_HOST_CARD` is a host section (`STATS`, two body rows); `RAIL_BRIDGE_CARD` is a
|
|
368
|
-
* bridge-contributed one (`Music`, two body rows). Both carry the header as a content row directly
|
|
369
|
-
* after the opener — the layout puts the title and the body in ONE card, so no separator row appears.
|
|
370
|
-
*/
|
|
371
|
-
export const RAIL_HOST_CARD: readonly string[] = [
|
|
372
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
373
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[95m▾ STATS\u001b[39m\u001b[90m \u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
374
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m native row 1 \u001b[90m \u001b[39m\u001b[49m ",
|
|
375
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m native row 2 \u001b[90m \u001b[39m\u001b[49m ",
|
|
376
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
377
|
-
];
|
|
378
|
-
|
|
379
|
-
export const RAIL_BRIDGE_CARD: readonly string[] = [
|
|
380
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
381
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[95m▾ Music\u001b[39m\u001b[90m \u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
382
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m track one \u001b[90m \u001b[39m\u001b[49m ",
|
|
383
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m track two \u001b[90m \u001b[39m\u001b[49m ",
|
|
384
|
-
" \u001b[48;5;236m\u001b[90m▎\u001b[39m\u001b[90m \u001b[39m\u001b[49m ",
|
|
385
|
-
];
|
|
386
|
-
|
|
387
|
-
/** What `LAYOUT_NODE()` returns for a stack / scroll view, narrowed to what a walk reads. */
|
|
388
|
-
export interface FakeLayoutNode {
|
|
389
|
-
type: string;
|
|
390
|
-
entries?: ReadonlyArray<{ component?: unknown }>;
|
|
391
|
-
component?: unknown;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export interface FakeRailTuiOptions {
|
|
395
|
-
/** What the host's rail component renders: every section card of the whole rail. */
|
|
396
|
-
rail: Rail;
|
|
397
|
-
/** The rail's content width, as `ScrollView.getContentWidth` reports it. */
|
|
398
|
-
contentWidth?: number;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* A `tui` whose layout tree is the host's fullscreen rail, reachable the way the host mounts it.
|
|
403
|
-
*
|
|
404
|
-
* The shape is copied from `lib/shell-sidebar-layout.ts:486-499`: the layout root is a `vstack`
|
|
405
|
-
* holding an `hstack` host whose entries are `[conversation, rail ScrollView]`, and the rail
|
|
406
|
-
* `ScrollView` reports its child (the rail component that renders the FRAMED rows) through the layout
|
|
407
|
-
* protocol. `renderRail` is therefore what the layout asks for one frame, and `requestRenderCount`
|
|
408
|
-
* is what a part-owner's repaint must produce (`extensions/gentle-agents.ts:2147-2148`).
|
|
409
|
-
*/
|
|
410
|
-
export interface FakeRailTui {
|
|
411
|
-
mode: "fullscreen";
|
|
412
|
-
terminal: FakeTerminal;
|
|
413
|
-
layoutRoot: { [LAYOUT_NODE](): FakeLayoutNode };
|
|
414
|
-
/** Render the rail the way the layout does, and return the component's own array. */
|
|
415
|
-
renderRail(): string[];
|
|
416
|
-
/** Every width the rail component was asked to render at. */
|
|
417
|
-
railWidths: number[];
|
|
418
|
-
requestRender(): void;
|
|
419
|
-
requestRenderCount(): number;
|
|
420
|
-
cacheRevision(): number;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
export function createRailTui(options: FakeRailTuiOptions): FakeRailTui {
|
|
424
|
-
const contentWidth = options.contentWidth ?? DEFAULT_WIDTH;
|
|
425
|
-
const railWidths: number[] = [];
|
|
426
|
-
let requestRenders = 0;
|
|
427
|
-
|
|
428
|
-
const rail: Rail = {
|
|
429
|
-
...options.rail,
|
|
430
|
-
render: (width: number) => {
|
|
431
|
-
railWidths.push(width);
|
|
432
|
-
return options.rail.render(width);
|
|
433
|
-
},
|
|
434
|
-
};
|
|
435
|
-
const left = {
|
|
436
|
-
render: () => [] as string[],
|
|
437
|
-
[LAYOUT_NODE]: (): FakeLayoutNode => ({ type: "vstack", entries: [] }),
|
|
438
|
-
};
|
|
439
|
-
const scroll = {
|
|
440
|
-
getContentWidth: () => contentWidth,
|
|
441
|
-
render: () => rail.render(contentWidth),
|
|
442
|
-
[LAYOUT_NODE]: (): FakeLayoutNode => ({ type: "scroll", component: rail }),
|
|
443
|
-
};
|
|
444
|
-
const hstackHost = {
|
|
445
|
-
render: () => [] as string[],
|
|
446
|
-
[LAYOUT_NODE]: (): FakeLayoutNode => ({
|
|
447
|
-
type: "hstack",
|
|
448
|
-
entries: [{ component: left }, { component: scroll }],
|
|
449
|
-
}),
|
|
450
|
-
};
|
|
451
|
-
const layoutRoot = {
|
|
452
|
-
[LAYOUT_NODE]: (): FakeLayoutNode => ({ type: "vstack", entries: [{ component: hstackHost }] }),
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
return {
|
|
456
|
-
mode: "fullscreen",
|
|
457
|
-
terminal,
|
|
458
|
-
layoutRoot,
|
|
459
|
-
renderRail: () => rail.render(contentWidth),
|
|
460
|
-
railWidths,
|
|
461
|
-
requestRender: () => {
|
|
462
|
-
requestRenders += 1;
|
|
463
|
-
// The host's own render loop asks the layout for a frame; nothing here schedules a timer.
|
|
464
|
-
},
|
|
465
|
-
requestRenderCount: () => requestRenders,
|
|
466
|
-
cacheRevision: () => cacheRevision(),
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Split a part's header row into the card's glyph, title and right-aligned count, the way
|
|
472
|
-
* `headerParts` does (`lib/shell-sidebar-layout.ts:57-66`): the count is whatever follows the gap
|
|
473
|
-
* before it, so a single-spaced title keeps its spaces.
|
|
474
|
-
*
|
|
475
|
-
* The host splits an ANSI-carrying header — it keeps each part's escape codes and hands them to the
|
|
476
|
-
* card — while this scaffold draws without styling. So the codes are stripped here: a test asserts
|
|
477
|
-
* the TITLE a user reads, and the coloured header's bytes are asserted directly against the host's
|
|
478
|
-
* own algorithm (`test/section-panel.test.ts`) instead of through this frame.
|
|
479
|
-
*/
|
|
480
|
-
function splitHeader(line: string): { glyph?: string; title: string; count?: string } {
|
|
481
|
-
const plain = line.replace(/\u001b\[[0-9;]*m/gu, "");
|
|
482
|
-
const glyphMatch = /^(\S+)\s+(.*)$/.exec(plain.trimEnd());
|
|
483
|
-
const glyph = glyphMatch?.[1];
|
|
484
|
-
const rest = glyphMatch?.[2] ?? plain.trimEnd();
|
|
485
|
-
const [title, ...tail] = rest.split(/\s{2,}/);
|
|
486
|
-
const count = tail.join(" ").trim();
|
|
487
|
-
return {
|
|
488
|
-
...(glyph === undefined ? {} : { glyph }),
|
|
489
|
-
title: title.trim(),
|
|
490
|
-
...(count === "" ? {} : { count }),
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* One card: a top rule carrying the title (and its count, when it fits), a framed row per body
|
|
496
|
-
* line, and a closing rule. Same arithmetic as `cardTop`/`cardLine`/`cardBottom` at a fixed
|
|
497
|
-
* width, minus styling; every row is exactly `width` wide.
|
|
498
|
-
*/
|
|
499
|
-
function cardFrame(
|
|
500
|
-
header: { glyph?: string; title: string; count?: string },
|
|
501
|
-
body: readonly string[],
|
|
502
|
-
width: number,
|
|
503
|
-
): string[] {
|
|
504
|
-
const head = `${header.glyph ?? "▾"} ${header.title}`;
|
|
505
|
-
const hint = header.count === undefined ? "" : ` ${header.count} `;
|
|
506
|
-
// The count rides the rule only while a minimum fill remains (cardTop's `shownHint` rule).
|
|
507
|
-
const showHint = hint !== "" && head.length + 5 + hint.length <= width;
|
|
508
|
-
const hintWidth = showHint ? hint.length : 0;
|
|
509
|
-
const fill = Math.max(0, width - head.length - hintWidth - 4);
|
|
510
|
-
const inner = Math.max(0, width - 4);
|
|
511
|
-
|
|
512
|
-
const top = `╭─${head} ${"─".repeat(fill)}${showHint ? hint : ""}╮`;
|
|
513
|
-
const rows = body.map((row) => `│ ${row.slice(0, inner).padEnd(inner, " ")} │`);
|
|
514
|
-
const bottom = `╰${"─".repeat(Math.max(0, width - 2))}╯`;
|
|
515
|
-
return [top, ...rows, bottom];
|
|
516
|
-
}
|