@sigrun/tui 0.4.1 → 0.5.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.
- package/CHANGELOG.md +10 -1
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +209 -0
- package/dist/api.js +282 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/chat-transport.d.ts +44 -0
- package/dist/blocks/chat-transport.js +116 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +117 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +66 -0
- package/dist/blocks/form/form.js +764 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +106 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/interaction-transport.d.ts +42 -0
- package/dist/blocks/interaction-transport.js +79 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +360 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +163 -0
- package/dist/blocks/surface-view.d.ts +41 -0
- package/dist/blocks/surface-view.js +137 -0
- package/dist/blocks/surface.d.ts +80 -0
- package/dist/blocks/surface.js +506 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +159 -0
- package/dist/launch.js +478 -0
- package/dist/sse.d.ts +112 -0
- package/dist/sse.js +418 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +78 -0
- package/dist/ui/ambient.js +264 -0
- package/dist/ui/app.d.ts +319 -0
- package/dist/ui/app.js +1539 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +133 -0
- package/dist/ui/conversation-pane.d.ts +143 -0
- package/dist/ui/conversation-pane.js +615 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +91 -0
- package/dist/ui/interactions.js +477 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/settings.d.ts +68 -0
- package/dist/ui/settings.js +254 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +4 -3
package/dist/theme.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI theme (Task C1, UI refresh): wraps the shared themes module's
|
|
3
|
+
* palettes in pi-tui's color pipeline (parseColor → foregroundAnsi /
|
|
4
|
+
* backgroundAnsi) so components style text through token names instead
|
|
5
|
+
* of raw SGR sequences. The palette DATA lives in one place
|
|
6
|
+
* (@sigrun/shared/themes, shared with the webui — zero duplication);
|
|
7
|
+
* this module owns only the terminal mapping.
|
|
8
|
+
*
|
|
9
|
+
* Degradation: `colorMode` threads through foregroundAnsi/backgroundAnsi,
|
|
10
|
+
* so a 256color terminal gets `38;5;N` (pi-tui's nearest-256 mapping)
|
|
11
|
+
* instead of truecolor `38;2;r;g;b`. The launcher resolves the mode at
|
|
12
|
+
* boot via `getTerminalColorMode()`; tests pass it explicitly.
|
|
13
|
+
*
|
|
14
|
+
* The `code` token is CSS `rgba(...)` (the shared data's overlay chip):
|
|
15
|
+
* pi-tui's parseColor takes hex/`rgb()` only, so it is alpha-composited
|
|
16
|
+
* over the mode's `bg` token here — the same visual the webui gets from
|
|
17
|
+
* layering the overlay on the background.
|
|
18
|
+
*/
|
|
19
|
+
import { backgroundAnsi, foregroundAnsi, parseColor, } from "@earendil-works/pi-tui";
|
|
20
|
+
import { ACCENTS, PALETTES, } from "@sigrun/shared/themes";
|
|
21
|
+
const RESET = "\u001b[0m";
|
|
22
|
+
const attr = (code, s) => `\u001b[${code}m${s}${RESET}`;
|
|
23
|
+
/** Alpha-composite a CSS `rgba(r,g,b,a)` over an opaque base. */
|
|
24
|
+
function compositeOver(rgba, base) {
|
|
25
|
+
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(rgba.replace(/\s/g, ""));
|
|
26
|
+
if (!m)
|
|
27
|
+
return base;
|
|
28
|
+
const [, rs, gs, bs, as = "1"] = m;
|
|
29
|
+
const [r, g, b, a] = [Number(rs), Number(gs), Number(bs), Number(as)];
|
|
30
|
+
const [br, bg, bb] = hexToRgb(base);
|
|
31
|
+
const mix = (fg, bgv) => Math.round(fg * a + bgv * (1 - a));
|
|
32
|
+
return rgbToHex(mix(r, br), mix(g, bg), mix(b, bb));
|
|
33
|
+
}
|
|
34
|
+
function hexToRgb(hex) {
|
|
35
|
+
const value = hex.replace("#", "");
|
|
36
|
+
return [
|
|
37
|
+
parseInt(value.slice(0, 2), 16),
|
|
38
|
+
parseInt(value.slice(2, 4), 16),
|
|
39
|
+
parseInt(value.slice(4, 6), 16),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
function rgbToHex(r, g, b) {
|
|
43
|
+
const two = (n) => n.toString(16).padStart(2, "0");
|
|
44
|
+
return `#${two(r)}${two(g)}${two(b)}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The state colours (warnings + errors), defined per mode like the
|
|
48
|
+
* webui's `--warn`/`--danger` (webui/src/app.css — they are NOT palette
|
|
49
|
+
* members in either channel). The TUI's tone table (blocks/tones.ts)
|
|
50
|
+
* paints `warn`/`bad` tones with them.
|
|
51
|
+
*/
|
|
52
|
+
const STATE_COLORS = {
|
|
53
|
+
warn: { dark: "#d9a35a", light: "#8a6116" },
|
|
54
|
+
bad: { dark: "#e0707a", light: "#b3363f" },
|
|
55
|
+
};
|
|
56
|
+
/** Resolve a token to a parseable colour for the palette+mode. */
|
|
57
|
+
function resolveToken(tokens, accentHex, mode, token) {
|
|
58
|
+
if (token === "accent")
|
|
59
|
+
return accentHex;
|
|
60
|
+
if (token === "warn" || token === "bad")
|
|
61
|
+
return STATE_COLORS[token][mode];
|
|
62
|
+
const value = tokens[token];
|
|
63
|
+
// `code` is the rgba overlay chip; composite over the mode's bg.
|
|
64
|
+
if (value.startsWith("rgba"))
|
|
65
|
+
return compositeOver(value, tokens.bg);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the theme. Precomputes each token's fg/bg SGR (pi-tui's cached
|
|
70
|
+
* sequence idiom — `styleTextWithAnsi` without the dependency) and wraps
|
|
71
|
+
* it in reset-terminated spans.
|
|
72
|
+
*/
|
|
73
|
+
export function createTuiTheme(options) {
|
|
74
|
+
const tokens = PALETTES[options.palette][options.mode];
|
|
75
|
+
const accentHex = ACCENTS[options.accent][options.mode];
|
|
76
|
+
const mode = options.colorMode;
|
|
77
|
+
const fgSequences = new Map();
|
|
78
|
+
const bgSequences = new Map();
|
|
79
|
+
for (const token of [
|
|
80
|
+
"bg",
|
|
81
|
+
"surface",
|
|
82
|
+
"surface2",
|
|
83
|
+
"border",
|
|
84
|
+
"text",
|
|
85
|
+
"muted",
|
|
86
|
+
"faint",
|
|
87
|
+
"ok",
|
|
88
|
+
"code",
|
|
89
|
+
"accent",
|
|
90
|
+
"warn",
|
|
91
|
+
"bad",
|
|
92
|
+
]) {
|
|
93
|
+
const color = parseColor(resolveToken(tokens, accentHex, options.mode, token));
|
|
94
|
+
fgSequences.set(token, foregroundAnsi(color, mode));
|
|
95
|
+
bgSequences.set(token, backgroundAnsi(color, mode));
|
|
96
|
+
}
|
|
97
|
+
const span = (seq, s) => `${seq}${s}${RESET}`;
|
|
98
|
+
return {
|
|
99
|
+
fg: (token) => (s) => span(fgSequences.get(token) ?? "", s),
|
|
100
|
+
bg: (token) => (s) => span(bgSequences.get(token) ?? "", s),
|
|
101
|
+
bold: (s) => attr("1", s),
|
|
102
|
+
dim: (s) => attr("2", s),
|
|
103
|
+
italic: (s) => attr("3", s),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The no-colour fallback (tests, non-TTY sinks): every method is the
|
|
108
|
+
* identity — the structure renders identically without SGR sequences.
|
|
109
|
+
*/
|
|
110
|
+
export function plainTuiTheme() {
|
|
111
|
+
const identity = (s) => s;
|
|
112
|
+
return {
|
|
113
|
+
fg: () => identity,
|
|
114
|
+
bg: () => identity,
|
|
115
|
+
bold: identity,
|
|
116
|
+
dim: identity,
|
|
117
|
+
italic: identity,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ambient seam's TUI renderer (UI-blocks U6 Task 3, the parity
|
|
3
|
+
* rule): ambient surfaces placed in the TUI's chrome per the seam's
|
|
4
|
+
* `place` field, rendered FROM BLOCK TREES (placement `ambient`, spec
|
|
5
|
+
* §9.2):
|
|
6
|
+
*
|
|
7
|
+
* - `status` → the statusline: one row, directly above the composer (the
|
|
8
|
+
* last row of the app body). Multiple status records join with " · ".
|
|
9
|
+
* - `footer` → the footer zone: the body's `columns` block renders the
|
|
10
|
+
* left/middle/right zones (three columns), above the statusline.
|
|
11
|
+
* - `panel` → the panel zone: the full tree rendering, above the footer.
|
|
12
|
+
*
|
|
13
|
+
* Genericity (the seam's whole point): the chrome renders blocks and
|
|
14
|
+
* knows NOTHING about what a surface means — no "todo", no "goal".
|
|
15
|
+
* `details` renders as one collapsed JSON line, never interpreted.
|
|
16
|
+
*
|
|
17
|
+
* Ambient bodies carry LITERAL content only (the 2026-10-24 ruling:
|
|
18
|
+
* the envelope has no data document, so validation rejects every
|
|
19
|
+
* binding outside a collection's item template), so the renderer's
|
|
20
|
+
* data document is EMPTY. Ambient surfaces are read-only
|
|
21
|
+
* (actions re-route through interaction records) — the render context's
|
|
22
|
+
* access is `read` and dispatch is a no-op.
|
|
23
|
+
*
|
|
24
|
+
* The envelope's `maxRows`/`overflow` budget applies to a panel's root
|
|
25
|
+
* literal-rows collection (Review Focus 3): `drop` folds the tail behind
|
|
26
|
+
* the collection's own quiet bucket ("N more"), `summarize` replaces
|
|
27
|
+
* the tail with a count line, `wrap` renders as authored.
|
|
28
|
+
*
|
|
29
|
+
* Fetching (W3 Task 4 — the timer cadence is RETIRED): on conversation
|
|
30
|
+
* connect, and on `ambient.changed` host events for the live
|
|
31
|
+
* conversation (the app wires `watchEvents` to {@link kick}). Fetch
|
|
32
|
+
* failures are QUIET (best-effort chrome).
|
|
33
|
+
*
|
|
34
|
+
* Expiry: `expires: "idle"` surfaces render only while the conversation
|
|
35
|
+
* has a live run ({@link setBusy}, driven by the stream's `steer.busy`).
|
|
36
|
+
*/
|
|
37
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
38
|
+
import type { AmbientWire, SigrunClient } from "../api.js";
|
|
39
|
+
import { type TuiTheme } from "../theme.js";
|
|
40
|
+
export declare class AmbientChrome implements Component {
|
|
41
|
+
#private;
|
|
42
|
+
/** Rendered-state changes (the app requests a repaint). */
|
|
43
|
+
onChanged?: () => void;
|
|
44
|
+
constructor(client: SigrunClient, options?: {
|
|
45
|
+
theme?: TuiTheme;
|
|
46
|
+
});
|
|
47
|
+
/** The latest served surfaces (before the idle filter). */
|
|
48
|
+
get records(): readonly AmbientWire[];
|
|
49
|
+
/**
|
|
50
|
+
* Retarget the chrome: reset state and fetch immediately (the brief's
|
|
51
|
+
* "fetch on connect"). `undefined` clears the chrome. (W3 Task 4: no
|
|
52
|
+
* timer cadence — `ambient.changed` host events own the refreshes.)
|
|
53
|
+
*/
|
|
54
|
+
setConversation(id: number | undefined): void;
|
|
55
|
+
/**
|
|
56
|
+
* Refetch when an `ambient.changed` host event names the LIVE
|
|
57
|
+
* conversation (the app's events wiring calls this; other
|
|
58
|
+
* conversations' events are ignored).
|
|
59
|
+
*/
|
|
60
|
+
kick(conversationId: number): void;
|
|
61
|
+
/**
|
|
62
|
+
* The conversation's run state (the app feeds it from stream frames).
|
|
63
|
+
* `expires: "idle"` surfaces render only while busy.
|
|
64
|
+
*/
|
|
65
|
+
setBusy(busy: boolean): void;
|
|
66
|
+
/** The statusline: the status records' one-line bodies joined " · ".
|
|
67
|
+
* `width` bounds each body's render (the app's frame width). */
|
|
68
|
+
statusline(width?: number): string;
|
|
69
|
+
/** The footer zone: one rendered line per footer record. */
|
|
70
|
+
footerLines(width?: number): readonly string[];
|
|
71
|
+
/** The panel zone: the full per-record tree rendering. */
|
|
72
|
+
panelLines(width?: number): readonly string[];
|
|
73
|
+
/** Detach (the app calls this on dispose): stale fetches never land. */
|
|
74
|
+
dispose(): void;
|
|
75
|
+
/** The chrome: panel blocks, then footer lines, then the statusline. */
|
|
76
|
+
render(width: number): string[];
|
|
77
|
+
invalidate(): void;
|
|
78
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ambient seam's TUI renderer (UI-blocks U6 Task 3, the parity
|
|
3
|
+
* rule): ambient surfaces placed in the TUI's chrome per the seam's
|
|
4
|
+
* `place` field, rendered FROM BLOCK TREES (placement `ambient`, spec
|
|
5
|
+
* §9.2):
|
|
6
|
+
*
|
|
7
|
+
* - `status` → the statusline: one row, directly above the composer (the
|
|
8
|
+
* last row of the app body). Multiple status records join with " · ".
|
|
9
|
+
* - `footer` → the footer zone: the body's `columns` block renders the
|
|
10
|
+
* left/middle/right zones (three columns), above the statusline.
|
|
11
|
+
* - `panel` → the panel zone: the full tree rendering, above the footer.
|
|
12
|
+
*
|
|
13
|
+
* Genericity (the seam's whole point): the chrome renders blocks and
|
|
14
|
+
* knows NOTHING about what a surface means — no "todo", no "goal".
|
|
15
|
+
* `details` renders as one collapsed JSON line, never interpreted.
|
|
16
|
+
*
|
|
17
|
+
* Ambient bodies carry LITERAL content only (the 2026-10-24 ruling:
|
|
18
|
+
* the envelope has no data document, so validation rejects every
|
|
19
|
+
* binding outside a collection's item template), so the renderer's
|
|
20
|
+
* data document is EMPTY. Ambient surfaces are read-only
|
|
21
|
+
* (actions re-route through interaction records) — the render context's
|
|
22
|
+
* access is `read` and dispatch is a no-op.
|
|
23
|
+
*
|
|
24
|
+
* The envelope's `maxRows`/`overflow` budget applies to a panel's root
|
|
25
|
+
* literal-rows collection (Review Focus 3): `drop` folds the tail behind
|
|
26
|
+
* the collection's own quiet bucket ("N more"), `summarize` replaces
|
|
27
|
+
* the tail with a count line, `wrap` renders as authored.
|
|
28
|
+
*
|
|
29
|
+
* Fetching (W3 Task 4 — the timer cadence is RETIRED): on conversation
|
|
30
|
+
* connect, and on `ambient.changed` host events for the live
|
|
31
|
+
* conversation (the app wires `watchEvents` to {@link kick}). Fetch
|
|
32
|
+
* failures are QUIET (best-effort chrome).
|
|
33
|
+
*
|
|
34
|
+
* Expiry: `expires: "idle"` surfaces render only while the conversation
|
|
35
|
+
* has a live run ({@link setBusy}, driven by the stream's `steer.busy`).
|
|
36
|
+
*/
|
|
37
|
+
import { renderBlock } from "../blocks/render.js";
|
|
38
|
+
import { truncate } from "../blocks/width.js";
|
|
39
|
+
import { plainTuiTheme } from "../theme.js";
|
|
40
|
+
/** Ambient bodies never bind (the ruling): the data document is empty. */
|
|
41
|
+
const EMPTY_DATA = {};
|
|
42
|
+
/** The read-only ambient render context (surfaces take no answers; the
|
|
43
|
+
* dispatch sink is a no-op). */
|
|
44
|
+
function ambientCtx(theme) {
|
|
45
|
+
return {
|
|
46
|
+
data: EMPTY_DATA,
|
|
47
|
+
theme,
|
|
48
|
+
locale: "en",
|
|
49
|
+
access: "read",
|
|
50
|
+
unicode: true,
|
|
51
|
+
dispatch: () => { },
|
|
52
|
+
view: { values: {} },
|
|
53
|
+
setView: () => { },
|
|
54
|
+
serverErrors: {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export class AmbientChrome {
|
|
58
|
+
#client;
|
|
59
|
+
#theme;
|
|
60
|
+
#conversationId;
|
|
61
|
+
/** Monotonic per-conversation generation: stale fetches never land. */
|
|
62
|
+
#gen = 0;
|
|
63
|
+
/** The latest served surfaces, keyed by slot key (fetch order). */
|
|
64
|
+
#records = [];
|
|
65
|
+
/** Whether the conversation has a live run (stream `steer.busy`). */
|
|
66
|
+
#busy = false;
|
|
67
|
+
/** Rendered-state changes (the app requests a repaint). */
|
|
68
|
+
onChanged;
|
|
69
|
+
constructor(client, options = {}) {
|
|
70
|
+
this.#client = client;
|
|
71
|
+
this.#theme = options.theme ?? plainTuiTheme();
|
|
72
|
+
}
|
|
73
|
+
/** The latest served surfaces (before the idle filter). */
|
|
74
|
+
get records() {
|
|
75
|
+
return this.#records;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Retarget the chrome: reset state and fetch immediately (the brief's
|
|
79
|
+
* "fetch on connect"). `undefined` clears the chrome. (W3 Task 4: no
|
|
80
|
+
* timer cadence — `ambient.changed` host events own the refreshes.)
|
|
81
|
+
*/
|
|
82
|
+
setConversation(id) {
|
|
83
|
+
this.#gen += 1;
|
|
84
|
+
this.#conversationId = id;
|
|
85
|
+
this.#records = [];
|
|
86
|
+
if (id === undefined) {
|
|
87
|
+
this.#changed();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
void this.#refresh();
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Refetch when an `ambient.changed` host event names the LIVE
|
|
94
|
+
* conversation (the app's events wiring calls this; other
|
|
95
|
+
* conversations' events are ignored).
|
|
96
|
+
*/
|
|
97
|
+
kick(conversationId) {
|
|
98
|
+
if (conversationId !== this.#conversationId)
|
|
99
|
+
return;
|
|
100
|
+
void this.#refresh();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The conversation's run state (the app feeds it from stream frames).
|
|
104
|
+
* `expires: "idle"` surfaces render only while busy.
|
|
105
|
+
*/
|
|
106
|
+
setBusy(busy) {
|
|
107
|
+
if (busy === this.#busy)
|
|
108
|
+
return;
|
|
109
|
+
this.#busy = busy;
|
|
110
|
+
this.#changed();
|
|
111
|
+
}
|
|
112
|
+
/** The statusline: the status records' one-line bodies joined " · ".
|
|
113
|
+
* `width` bounds each body's render (the app's frame width). */
|
|
114
|
+
statusline(width) {
|
|
115
|
+
return this.#visible("status")
|
|
116
|
+
.map((record) => this.#bodyText(record, width))
|
|
117
|
+
.filter((line) => line !== "")
|
|
118
|
+
.join(" · ");
|
|
119
|
+
}
|
|
120
|
+
/** The footer zone: one rendered line per footer record. */
|
|
121
|
+
footerLines(width) {
|
|
122
|
+
return this.#visible("footer").map((record) => this.#renderBody(record, width).join(" ").trim());
|
|
123
|
+
}
|
|
124
|
+
/** The panel zone: the full per-record tree rendering. */
|
|
125
|
+
panelLines(width) {
|
|
126
|
+
const lines = [];
|
|
127
|
+
for (const record of this.#visible("panel")) {
|
|
128
|
+
if (lines.length > 0)
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(`[${record.key}]${record.owner === "" ? "" : ` ${record.owner}`}`);
|
|
131
|
+
lines.push(...this.#renderBody(record, width));
|
|
132
|
+
if (record.details !== undefined) {
|
|
133
|
+
lines.push(`[details] ${JSON.stringify(record.details)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines;
|
|
137
|
+
}
|
|
138
|
+
/** Visible records in one place zone (fetch order preserved). */
|
|
139
|
+
#visible(place) {
|
|
140
|
+
return this.#records.filter((record) => record.place === place &&
|
|
141
|
+
(record.expires !== "idle" || this.#busy) &&
|
|
142
|
+
hasContent(record));
|
|
143
|
+
}
|
|
144
|
+
/** Render one record's body tree (budgeted per Review Focus 3). */
|
|
145
|
+
#renderBody(record, width) {
|
|
146
|
+
const body = record.body;
|
|
147
|
+
if (body === undefined)
|
|
148
|
+
return [];
|
|
149
|
+
const root = budgetTree(record, body.root);
|
|
150
|
+
// The chrome's own frame width bounds the render; a bare read
|
|
151
|
+
// (statusline/footerLines callers without a frame) falls back to
|
|
152
|
+
// the regular-class width.
|
|
153
|
+
return renderBlock(root, ambientCtx(this.#theme)).render(width ?? 120);
|
|
154
|
+
}
|
|
155
|
+
/** The one-line text of a status/footer body (a single markdown or a
|
|
156
|
+
* columns of one-line blocks — §9.2's one-line rule). */
|
|
157
|
+
#bodyText(record, width) {
|
|
158
|
+
const body = record.body;
|
|
159
|
+
if (body === undefined)
|
|
160
|
+
return "";
|
|
161
|
+
return this.#renderBody(record, width).join(" ").trim();
|
|
162
|
+
}
|
|
163
|
+
/** Generation-guarded fetch (quiet failures keep the last surfaces). */
|
|
164
|
+
async #refresh() {
|
|
165
|
+
const id = this.#conversationId;
|
|
166
|
+
const gen = this.#gen;
|
|
167
|
+
if (id === undefined)
|
|
168
|
+
return;
|
|
169
|
+
let list;
|
|
170
|
+
try {
|
|
171
|
+
list = await this.#client.getAmbient(id);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return; // quiet: keep the last surfaces
|
|
175
|
+
}
|
|
176
|
+
if (gen !== this.#gen)
|
|
177
|
+
return; // stale — a switch happened
|
|
178
|
+
this.#records = list.ambient;
|
|
179
|
+
this.#changed();
|
|
180
|
+
}
|
|
181
|
+
#changed() {
|
|
182
|
+
this.onChanged?.();
|
|
183
|
+
}
|
|
184
|
+
/** Detach (the app calls this on dispose): stale fetches never land. */
|
|
185
|
+
dispose() {
|
|
186
|
+
this.#gen += 1;
|
|
187
|
+
}
|
|
188
|
+
/** The chrome: panel blocks, then footer lines, then the statusline. */
|
|
189
|
+
render(width) {
|
|
190
|
+
const lines = [...this.panelLines(width), ...this.footerLines(width)];
|
|
191
|
+
const status = this.statusline(width);
|
|
192
|
+
if (status !== "")
|
|
193
|
+
lines.push(status);
|
|
194
|
+
// ANSI-aware clamp (Review fix): a raw UTF-16 slice cuts escape
|
|
195
|
+
// sequences and surrogate pairs mid-unit.
|
|
196
|
+
return lines.map((line) => truncate(line, width));
|
|
197
|
+
}
|
|
198
|
+
invalidate() {
|
|
199
|
+
// Stateless render: lines assemble from the current records.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** A surface with no body tree and no details renders nothing. */
|
|
203
|
+
function hasContent(record) {
|
|
204
|
+
return record.body !== undefined || record.details !== undefined;
|
|
205
|
+
}
|
|
206
|
+
/** The quiet fold for the drop overflow: mark the tail rows quiet so
|
|
207
|
+
* the collection's own "N more" toggle carries them (the budget never
|
|
208
|
+
* LOSES rows — the user can still unfold). A template WITHOUT a
|
|
209
|
+
* `quiet` binding has no fold to ride (its isQuiet is always false),
|
|
210
|
+
* so the budget falls back to truncating the tail behind a one-line
|
|
211
|
+
* "<n> more" markdown row (Review fix — a quiet-less template's drop
|
|
212
|
+
* overflow silently no-op'd). */
|
|
213
|
+
function dropTail(collection, items, maxRows) {
|
|
214
|
+
// No quiet binding: truncate + count line (the fold has nothing to
|
|
215
|
+
// key on).
|
|
216
|
+
if (collection.item.quiet === undefined) {
|
|
217
|
+
const visible = items.slice(0, maxRows);
|
|
218
|
+
const dropped = items.length - visible.length;
|
|
219
|
+
// SAFETY: same block with a literal array swapped in (the ambient
|
|
220
|
+
// literal-rows shape the schema now admits).
|
|
221
|
+
const trimmed = { ...collection, items: [...visible] };
|
|
222
|
+
return {
|
|
223
|
+
type: "stack",
|
|
224
|
+
children: [trimmed, { type: "markdown", text: `${dropped} more` }],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const visible = items.slice(0, maxRows);
|
|
228
|
+
const tail = items.slice(maxRows).map((row) => {
|
|
229
|
+
if (typeof row === "object" && row !== null && !Array.isArray(row)) {
|
|
230
|
+
return { ...row, quiet: true };
|
|
231
|
+
}
|
|
232
|
+
return row;
|
|
233
|
+
});
|
|
234
|
+
// SAFETY: `items` is the same block with a literal array swapped in
|
|
235
|
+
// (the ambient literal-rows shape the schema now admits); every other
|
|
236
|
+
// prop is verbatim.
|
|
237
|
+
return { ...collection, items: [...visible, ...tail] };
|
|
238
|
+
}
|
|
239
|
+
/** Apply the panel row budget (Review Focus 3): only a root
|
|
240
|
+
* literal-rows collection on a panel surface budgets; anything else
|
|
241
|
+
* renders as authored. */
|
|
242
|
+
function budgetTree(surface, root) {
|
|
243
|
+
if (surface.place !== "panel" || root.type !== "collection")
|
|
244
|
+
return root;
|
|
245
|
+
if (!Array.isArray(root.items))
|
|
246
|
+
return root; // bound elsewhere; ambient rejects these
|
|
247
|
+
const maxRows = surface.maxRows;
|
|
248
|
+
if (maxRows === undefined || root.items.length <= maxRows)
|
|
249
|
+
return root;
|
|
250
|
+
const dropped = root.items.length - maxRows;
|
|
251
|
+
if (surface.overflow === "summarize") {
|
|
252
|
+
// The count line: one markdown under the trimmed collection.
|
|
253
|
+
return {
|
|
254
|
+
type: "stack",
|
|
255
|
+
children: [
|
|
256
|
+
{ ...root, items: root.items.slice(0, maxRows) },
|
|
257
|
+
{ type: "markdown", text: `${dropped} rows` },
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (surface.overflow === "wrap")
|
|
262
|
+
return root; // panels wrap rows — no fold
|
|
263
|
+
return dropTail(root, root.items, maxRows);
|
|
264
|
+
}
|