@pi-archimedes/core 2.10.3 → 2.11.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.
@@ -0,0 +1,190 @@
1
+ // ── Runtime pi-version support guard ─────────────────────────────────────────
2
+ //
3
+ // A published `peerDependencies` range protects nobody. `pi install` installs
4
+ // extensions with peers omitted (`--legacy-peer-deps` /
5
+ // `--config.auto-install-peers=false` / `--omit=peer`), and at runtime the
6
+ // extension loader aliases every host specifier to pi's own copy, so a range is
7
+ // never installed, never solved, and never parsed. The only mechanism that can
8
+ // reach a user running an older host is a comparison against the `VERSION` the
9
+ // running pi exports, at the moment the package registers itself (ADR 0026).
10
+ //
11
+ // Two rules the shape of this module follows from:
12
+ //
13
+ // 1. **Warn and still register.** A package that refuses to load makes `/plugins`
14
+ // report an enabled package that loaded nothing — strictly worse than a
15
+ // warning. Nothing here can prevent registration; `reportPiSupport` only
16
+ // records.
17
+ // 2. **Silent by construction.** No console write on either path. A stray write
18
+ // smears the fullscreen frame and is discarded by a subagent child, so the
19
+ // warning goes into a registry that `meta` prints once, through the
20
+ // notification surface, and to `diag()` for anyone reading the log file.
21
+ //
22
+ // Reload-stability: pi builds a fresh jiti/module registry on every extension
23
+ // load, so a module-level `let warned = false` re-arms on `/reload` and the
24
+ // aggregated notice would be printed again. Both pieces of state therefore live
25
+ // on `globalThis` under `Symbol.for(...)`, the same device `profiler.ts` uses.
26
+ //
27
+ // The `Symbol.for` keys and the plain-object entry shape are a contract, not an
28
+ // implementation detail: `packages/diff` is a standalone package with no
29
+ // dependency on core (AGENTS.md), so it keeps a sibling copy of this logic and
30
+ // reports into the same registry through the same keys. Keep the two in step —
31
+ // `pi-support.test.ts` here and `packages/diff/src/pi-support.test.ts` pin both
32
+ // ends of it.
33
+
34
+ import { VERSION } from "@earendil-works/pi-coding-agent";
35
+ import { diag } from "./log.js";
36
+
37
+ /** Result of measuring the running pi against one package's floor. */
38
+ export interface PiSupport {
39
+ ok: boolean;
40
+ /** The version the host reports (`VERSION` as exported by pi). */
41
+ actual: string;
42
+ /** The floor the package asked for. */
43
+ min: string;
44
+ }
45
+
46
+ /** One below-floor report. Plain data: `diff` writes this same shape itself. */
47
+ export interface PiSupportWarning {
48
+ pkg: string;
49
+ min: string;
50
+ actual: string;
51
+ }
52
+
53
+ const WARNINGS_KEY = Symbol.for("archimedes:piSupportWarnings");
54
+ const NOTICED_KEY = Symbol.for("archimedes:piSupportNoticed");
55
+
56
+ type Registry = Record<string, PiSupportWarning>;
57
+
58
+ // Type-safe globalThis access (the pattern `profiler.ts` also uses).
59
+ function getGlobal<T>(key: symbol): T | undefined {
60
+ return (globalThis as Record<symbol, unknown>)[key] as T | undefined;
61
+ }
62
+ function setGlobal<T>(key: symbol, value: T): void {
63
+ (globalThis as Record<symbol, unknown>)[key] = value;
64
+ }
65
+
66
+ function registry(): Registry {
67
+ return getGlobal<Registry>(WARNINGS_KEY) ?? {};
68
+ }
69
+
70
+ /**
71
+ * Numeric three-component comparison: -1 when `a` is older, 1 when newer, 0
72
+ * when equal. Leading `v` and absent components are tolerated (`"1.0"` is
73
+ * `"1.0.0"`), and a component that is not numeric counts as 0 rather than NaN —
74
+ * NaN compares false against everything, which would make a floor
75
+ * simultaneously satisfied and violated.
76
+ *
77
+ * This is the suite's only comparator on the core side of the dependency
78
+ * islands; `packages/diff` keeps a copy because it does not depend on core.
79
+ */
80
+ export function compareVersions(a: string, b: string): number {
81
+ const part = (v: string, i: number): number => {
82
+ const n = Number(v.replace(/^v/, "").split(".")[i] ?? "0");
83
+ return Number.isFinite(n) ? n : 0;
84
+ };
85
+ for (let i = 0; i < 3; i++) {
86
+ const left = part(a, i);
87
+ const right = part(b, i);
88
+ if (left > right) return 1;
89
+ if (left < right) return -1;
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ /** Measure the running pi against `min`. Inclusive: a host at the floor is ok. */
95
+ export function checkPiSupport(min: string): PiSupport {
96
+ const actual = typeof VERSION === "string" ? VERSION : "";
97
+ return { ok: compareVersions(actual, min) >= 0, actual, min };
98
+ }
99
+
100
+ /**
101
+ * Record a below-floor warning for `meta` to print, aggregated, once.
102
+ *
103
+ * Never throws and never writes to the console: this runs at the top of a
104
+ * package's `register()`, where the only acceptable failure mode is "nothing
105
+ * happened, carry on registering". Idempotent per package, so a `/reload` or a
106
+ * `/new` (both re-run registration) does not multiply the entry.
107
+ */
108
+ export function reportPiSupport(pkg: string, min: string): void {
109
+ try {
110
+ const support = checkPiSupport(min);
111
+ if (support.ok) return;
112
+ const current = registry();
113
+ if (pkg in current) return;
114
+ current[pkg] = { pkg, min, actual: support.actual };
115
+ setGlobal(WARNINGS_KEY, current);
116
+ // The log file is the only other place this can go: a standalone install
117
+ // has no aggregator, and a subagent child discards stdout.
118
+ diag(pkg, `pi ${support.actual} is below this package's floor (>=${min}); loaded anyway`);
119
+ } catch {
120
+ // A guard that can throw is a guard that can break registration.
121
+ }
122
+ }
123
+
124
+ /** Every warning recorded so far, in report order (mainly for `meta` and tests). */
125
+ export function piSupportWarnings(): PiSupportWarning[] {
126
+ try {
127
+ return Object.values(registry());
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Is this registry entry something this module can actually report?
135
+ *
136
+ * The registry is shared memory: `packages/diff` writes it without importing
137
+ * here, and a standalone `diff` and an installed suite are installed and
138
+ * versioned separately, so an entry may predate or postdate this shape. A
139
+ * sentence built from missing fields reads "pi undefined is older than
140
+ * undefined (>=undefined) need.", which is worse than saying nothing — so an
141
+ * unrecognisable entry is left out of the notice and named in the log instead.
142
+ */
143
+ function isReportable(entry: unknown): entry is PiSupportWarning {
144
+ const w = entry as Partial<PiSupportWarning> | null | undefined;
145
+ return (
146
+ typeof w === "object" &&
147
+ w !== null &&
148
+ typeof w.pkg === "string" &&
149
+ typeof w.min === "string" &&
150
+ typeof w.actual === "string"
151
+ );
152
+ }
153
+
154
+ /**
155
+ * The one aggregated message, or `undefined` when there is nothing to say or it
156
+ * has already been handed out.
157
+ *
158
+ * Reading it spends it: a second call returns `undefined`, which is how `meta`
159
+ * prints one line per session rather than one per package, and how a `/reload`
160
+ * (fresh modules, same `globalThis`) stays quiet — the pi version cannot change
161
+ * without restarting the process, so repeating the notice would be noise. An
162
+ * empty registry does not spend anything, so a floor raised later in the
163
+ * process is still heard.
164
+ */
165
+ export function takePiSupportNotice(): string | undefined {
166
+ try {
167
+ const entries = Object.entries(registry());
168
+ const warnings = entries.filter(([, entry]) => isReportable(entry)).map(([, entry]) => entry);
169
+ const unreadable = entries.length - warnings.length;
170
+ if (unreadable > 0) {
171
+ // Loud in the log, silent on screen: a dropped warning must never be a
172
+ // silent one, and a shape this module cannot read is a drift bug someone
173
+ // needs to be able to find.
174
+ diag(
175
+ "core",
176
+ `pi-support registry held ${unreadable} entr${unreadable === 1 ? "y" : "ies"} outside the agreed shape; left out of the host-version notice`,
177
+ );
178
+ }
179
+ if (warnings.length === 0) return undefined;
180
+ if (getGlobal<boolean>(NOTICED_KEY)) return undefined;
181
+ setGlobal(NOTICED_KEY, true);
182
+ const floors = warnings.map((w) => `${w.pkg} (>=${w.min})`).join(", ");
183
+ return [
184
+ `pi-archimedes: pi ${warnings[0]!.actual} is older than ${floors} need.`,
185
+ "Everything still loaded; update pi if something behaves oddly.",
186
+ ].join("\n");
187
+ } catch {
188
+ return undefined;
189
+ }
190
+ }
package/src/profiler.ts CHANGED
@@ -1,4 +1,6 @@
1
- /** Lightweight startup profiler — prints per-package timings when PI_TIMING=1. */
1
+ /** Lightweight startup profiler — records per-package timings when PI_TIMING=1. */
2
+
3
+ import { diag } from "./log.js";
2
4
 
3
5
  const ENABLED = process.env.PI_TIMING === "1";
4
6
 
@@ -32,18 +34,25 @@ export function reset(): void {
32
34
  globalThis[TIMINGS_KEY] = { baseline: Date.now(), entries: [] };
33
35
  }
34
36
 
35
- /** Print accumulated timings to stderr. Call once at session_shutdown or on demand. */
37
+ /**
38
+ * Append accumulated timings to the diagnostics log. Call once at
39
+ * session_shutdown or on demand.
40
+ *
41
+ * `PI_TIMING=1` decides *what* is measured; `PI_ARCHIMEDES_LOG` decides where
42
+ * it lands. Both are required to see anything, because timings are a developer
43
+ * diagnostic and the console is off-limits: on fullscreen screen a stray write
44
+ * smears the viewport, and in a subagent child it is discarded before anyone
45
+ * reads it.
46
+ */
36
47
  export function print(): void {
37
48
  if (!ENABLED) return;
38
49
  const state = getState();
39
50
  if (state.entries.length === 0) return;
40
51
 
41
52
  let prevMs = 0;
42
- console.error("");
43
53
  for (const entry of state.entries) {
44
54
  const delta = entry.ms - prevMs;
45
55
  prevMs = entry.ms;
46
- console.error(` archimedes: ${entry.label}: +${delta}ms (${entry.ms}ms cumulative)`);
56
+ diag("core", `archimedes: ${entry.label}: +${delta}ms (${entry.ms}ms cumulative)`);
47
57
  }
48
- console.error("".padEnd(60, "-"));
49
58
  }
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // ── The rule (ADR 0027) ─────────────────────────────────────────────────────
7
+ //
8
+ // Screen mode is mutable mid-session: `switchTuiMode()` stops the running TUI,
9
+ // builds a new one, re-mounts every child and rebinds extension input
10
+ // listeners. A stored `tui` therefore keeps a *stopped* renderer — every write
11
+ // is accepted, nothing throws, and the component silently renders into a
12
+ // renderer nobody draws from. So: never store a renderer; ask the one you were
13
+ // just handed. This test is the mechanical half of that rule.
14
+ //
15
+ // The `\w*tui\w*` widening (not `\btui\b`) is load-bearing: the shape this
16
+ // guard was written for is `let uiTui: TUI | undefined` in
17
+ // `packages/ui/src/index.ts`, where `ui` glues straight onto `Tui`, so a
18
+ // word-anchored pattern matches nothing of the kind. That declaration is gone
19
+ // (Task 8 deleted the startup un-patch that read it), but the pattern stays
20
+ // widened — the `catches every shape` test below is what proves the widening
21
+ // still has something to catch. The patterns are also case-insensitive for the
22
+ // same reason: a name like `uiTui` has a capital T.
23
+ //
24
+ // Module-scope declarations accept an `export` prefix and an optional type
25
+ // annotation, because those are how the binding is really written: `export let
26
+ // sharedTui: TUI` and `const appTui: TUI = tui` hoard a renderer just as much
27
+ // as `let sharedTui = tui` does, and a pattern that requires the declaration
28
+ // keyword at column 0 followed immediately by `=` sees neither. A `const` with
29
+ // no type annotation is only caught when its initializer is a renderer, which
30
+ // is what keeps the `tuiMode` *setting* — a screen-mode string whose name also
31
+ // contains "tui" — out of the net.
32
+
33
+ interface ViolationRule {
34
+ /** What shape of renderer hoarding this catches. */
35
+ readonly label: string;
36
+ readonly pattern: RegExp;
37
+ }
38
+
39
+ const RULES: readonly ViolationRule[] = [
40
+ { label: "class field holding a renderer", pattern: /(private|public|readonly)\s+\w*tui\w*\s*[:=]/i },
41
+ { label: "renderer assigned to an instance property", pattern: /\bthis\.\w*tui\w*\s*=/i },
42
+ { label: "module-scope mutable renderer binding", pattern: /^(export\s+)?(let|var)\s+\w*tui\w*\b/im },
43
+ { label: "module-scope const typed as a renderer", pattern: /^(export\s+)?const\s+\w*tui\w*\s*:\s*TUI\b/im },
44
+ { label: "module-scope binding initialised from a renderer", pattern: /^(export\s+)?(let|var|const)\s+\w+\s*=\s*\w*tui\w*\s*[;,]?\s*$/im },
45
+ ];
46
+
47
+ /**
48
+ * Known violations, each with the one-line reason that keeps it, and the
49
+ * removal point. Matched on the exact trimmed line so a *new* violation in the
50
+ * same file still fails the guard.
51
+ *
52
+ * Empty as of Task 8: the last entry was the `let uiTui` whose only reader was
53
+ * the startup `addChild` un-patch, and both went together. Keep the shape of
54
+ * this list even while it is empty — an exemption without a reason and a
55
+ * removal point is how the guard quietly widens.
56
+ */
57
+ interface AllowEntry {
58
+ readonly file: string;
59
+ readonly line: string;
60
+ readonly reason: string;
61
+ }
62
+
63
+ const ALLOW_LIST: readonly AllowEntry[] = [];
64
+
65
+ // ── Walking the tree ────────────────────────────────────────────────────────
66
+
67
+ /** Repo root, resolved from this file (`packages/core/src` → repo root). */
68
+ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
69
+
70
+ // Every `packages/<name>/src` directory, plus `meta/src`.
71
+ function sourceDirs(): string[] {
72
+ const dirs: string[] = [];
73
+ for (const entry of readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true })) {
74
+ if (!entry.isDirectory()) continue;
75
+ const src = join(REPO_ROOT, "packages", entry.name, "src");
76
+ if (existsSync(src)) dirs.push(src);
77
+ }
78
+ const metaSrc = join(REPO_ROOT, "meta", "src");
79
+ if (existsSync(metaSrc)) dirs.push(metaSrc);
80
+ return dirs;
81
+ }
82
+
83
+ /** Source files under those trees, excluding tests (which fake renderers). */
84
+ function collectSourceFiles(): string[] {
85
+ const out: string[] = [];
86
+ const walk = (dir: string): void => {
87
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
88
+ const full = join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ if (entry.name !== "node_modules") walk(full);
91
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
92
+ out.push(relative(REPO_ROOT, full).split(/[\\/]/).join("/"));
93
+ }
94
+ }
95
+ };
96
+ for (const dir of sourceDirs()) walk(dir);
97
+ return out.sort();
98
+ }
99
+
100
+ interface Hit {
101
+ file: string;
102
+ line: number;
103
+ rule: string;
104
+ text: string;
105
+ }
106
+
107
+ function findViolations(files: readonly string[]): Hit[] {
108
+ const hits: Hit[] = [];
109
+ for (const file of files) {
110
+ const lines = readFileSync(join(REPO_ROOT, file), "utf8").split("\n");
111
+ lines.forEach((text, i) => {
112
+ for (const rule of RULES) {
113
+ // Fresh lastIndex-free test: these patterns have no /g.
114
+ if (rule.pattern.test(text)) {
115
+ hits.push({ file, line: i + 1, rule: rule.label, text: text.trim() });
116
+ }
117
+ }
118
+ });
119
+ }
120
+ return hits;
121
+ }
122
+
123
+ function isAllowed(hit: Hit): boolean {
124
+ return ALLOW_LIST.some((e) => e.file === hit.file && e.line === hit.text);
125
+ }
126
+
127
+ const FILES = collectSourceFiles();
128
+ const VIOLATIONS = findViolations(FILES).filter((h) => !isAllowed(h));
129
+
130
+ // ── Tests ───────────────────────────────────────────────────────────────────
131
+
132
+ describe("screen-mode discipline: never store a renderer", () => {
133
+ it("walks the whole suite, not an empty file set", () => {
134
+ expect(existsSync(join(REPO_ROOT, "pnpm-workspace.yaml"))).toBe(true);
135
+ expect(FILES.length).toBeGreaterThan(50);
136
+ // The three trees the guard is supposed to cover.
137
+ for (const expected of [
138
+ "packages/core/src/screen-mode.ts",
139
+ "packages/ui/src/index.ts",
140
+ "meta/src/index.ts",
141
+ ]) {
142
+ expect(FILES).toContain(expected);
143
+ }
144
+ });
145
+
146
+ it("reports no renderer hoarding outside the allow-list", () => {
147
+ expect(
148
+ VIOLATIONS.map((v) => `${v.file}:${v.line} [${v.rule}] ${v.text}`),
149
+ ).toEqual([]);
150
+ });
151
+
152
+ it("catches every shape the rule forbids (patterns are not dead)", () => {
153
+ const offenders = [
154
+ "private tui: TUI | undefined;",
155
+ "readonly viewportTui = tui;",
156
+ "this.tui = tui;",
157
+ "this.altTui = next;",
158
+ "let sharedTui: TUI | undefined;",
159
+ "var cachedTui = tui;",
160
+ "const appTui = tui;",
161
+ // Re-exported bindings are the same hoarding with `export` in front —
162
+ // a pattern anchored on `^(let|var|const)` walks straight past them.
163
+ "export let sharedTui: TUI | undefined;",
164
+ "export var cachedTui = tui;",
165
+ "export const appTui = tui;",
166
+ "export let cachedTui;",
167
+ // A type annotation between the name and the initializer is how a
168
+ // renderer binding is actually written in TypeScript, so the shape the
169
+ // guard most needs to see must not be the shape it skips.
170
+ "const appTui: TUI = tui;",
171
+ "export const appTui: TUI = tui;",
172
+ "let currentTui: TUI = tui;",
173
+ ];
174
+ for (const line of offenders) {
175
+ expect(RULES.some((r) => r.pattern.test(line)), `expected a rule to reject: ${line}`).toBe(true);
176
+ }
177
+
178
+ // And leaves honest code alone: renderers received as parameters, locals
179
+ // inside a function, unrelated names that merely contain "tui", and the
180
+ // tuiMode *setting* (a screen-mode string, not a renderer) at module
181
+ // scope — widening the declaration patterns must not swallow it.
182
+ const fine = [
183
+ " const factory = (tui: TUI, theme: Theme) => {",
184
+ " const tuiMode = settings.tuiMode;",
185
+ " return screenMode(tui);",
186
+ " const tui = getRenderer();",
187
+ "export function screenMode(tui: object): ScreenMode {",
188
+ "const tuiMode = settings.tuiMode;",
189
+ "export const tuiModes = [\"regular\", \"fullscreen\"] as const;",
190
+ "const tuiMode: TuiMode = readMode();",
191
+ ];
192
+ for (const line of fine) {
193
+ expect(RULES.some((r) => r.pattern.test(line)), `expected no rule to reject: ${line}`).toBe(false);
194
+ }
195
+ });
196
+
197
+ it("keeps the allow-list as small as the work that removes it", () => {
198
+ // The list is empty: every renderer hoard the guard was written to excuse
199
+ // was deleted with the startup patch that needed it (Task 8). An entry
200
+ // reappearing is allowed only with a reason naming the task that removes
201
+ // it, so this assertion is the rule, not a snapshot.
202
+ expect(
203
+ ALLOW_LIST.map((e) => `${e.file}: ${e.line}`),
204
+ ).toEqual([]);
205
+ for (const entry of ALLOW_LIST) {
206
+ const actual = readFileSync(join(REPO_ROOT, entry.file), "utf8").split("\n");
207
+ expect(
208
+ actual.some((l) => l.trim() === entry.line),
209
+ `allow-list entry no longer matches ${entry.file}: ${entry.line}`,
210
+ ).toBe(true);
211
+ expect(entry.reason).toMatch(/Task \d+/);
212
+ }
213
+ });
214
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { screenMode, screenCapabilities } from "./screen-mode.js";
3
+
4
+ // The renderer identity check is pi-tui's (`isViewportTUI` reads a symbol
5
+ // marker on the instance). A viewport TUI cannot be fabricated in a test —
6
+ // `VIEWPORT_TUI` is not exported — which is why `screenCapabilities()` takes
7
+ // the mode rather than the renderer: the capability table stays testable, and
8
+ // `screenMode()` keeps a single, one-line delegation to pi.
9
+
10
+ describe("screenMode", () => {
11
+ it("reports regular screen for a renderer without the viewport marker", () => {
12
+ expect(screenMode({})).toBe("regular");
13
+ });
14
+
15
+ it("reports regular screen for a renderer-shaped object that is not a viewport TUI", () => {
16
+ expect(screenMode({ terminal: { rows: 24 }, children: [] })).toBe("regular");
17
+ });
18
+ });
19
+
20
+ describe("screenCapabilities", () => {
21
+ it("forbids scrollback writes and requires frame fitting on fullscreen screen", () => {
22
+ expect(screenCapabilities("fullscreen")).toEqual({
23
+ transcriptWrite: false,
24
+ frameBounded: true,
25
+ });
26
+ });
27
+
28
+ it("allows scrollback writes and no frame fitting on regular screen", () => {
29
+ expect(screenCapabilities("regular")).toEqual({
30
+ transcriptWrite: true,
31
+ frameBounded: false,
32
+ });
33
+ });
34
+
35
+ it("exposes exactly the two capabilities that differ between screens", () => {
36
+ expect(Object.keys(screenCapabilities("regular")).sort()).toEqual([
37
+ "frameBounded",
38
+ "transcriptWrite",
39
+ ]);
40
+ });
41
+
42
+ it("resolves the two capabilities inversely for every mode", () => {
43
+ for (const mode of ["regular", "fullscreen"] as const) {
44
+ const caps = screenCapabilities(mode);
45
+ expect(caps.transcriptWrite).not.toBe(caps.frameBounded);
46
+ }
47
+ });
48
+ });
@@ -0,0 +1,45 @@
1
+ import { isViewportTUI } from "@earendil-works/pi-tui";
2
+
3
+ // ── Screen mode (ADR 0027) ──────────────────────────────────────────────────
4
+ //
5
+ // Pi 1.0.0 made fullscreen screen the default `tuiMode`: components render
6
+ // into a clipped, height-allocated, wheel-routed viewport instead of terminal
7
+ // scrollback. Screen mode is also mutable mid-session — `switchTuiMode()`
8
+ // stops the running TUI, builds a new one and re-mounts every child — so the
9
+ // only trustworthy answer is the renderer instance currently in hand. Nothing
10
+ // here holds on to one.
11
+
12
+ export type ScreenMode = "regular" | "fullscreen";
13
+
14
+ /**
15
+ * Which screen the given renderer draws into.
16
+ *
17
+ * Ask the renderer we were just handed. Never store the result or the `tui`:
18
+ * a retained renderer is a stopped renderer after a `tuiMode` switch, and it
19
+ * accepts every write without throwing.
20
+ */
21
+ export function screenMode(tui: object): ScreenMode {
22
+ return isViewportTUI(tui as never) ? "fullscreen" : "regular";
23
+ }
24
+
25
+ export interface ScreenCapabilities {
26
+ /** May append into terminal scrollback. Off on fullscreen screen. */
27
+ transcriptWrite: boolean;
28
+ /** Must fit an allocated, wheel-routed viewport. On fullscreen screen. */
29
+ frameBounded: boolean;
30
+ }
31
+
32
+ /**
33
+ * The policy a screen mode implies. Takes the mode, not the renderer, so the
34
+ * table is unit-testable: pi-tui does not export the viewport marker, so a
35
+ * test cannot fabricate a viewport TUI to ask.
36
+ *
37
+ * These two are the only capabilities that differ between the screens today —
38
+ * add one here when a second real difference shows up, never ad hoc in a
39
+ * component.
40
+ */
41
+ export function screenCapabilities(mode: ScreenMode): ScreenCapabilities {
42
+ return mode === "fullscreen"
43
+ ? { transcriptWrite: false, frameBounded: true }
44
+ : { transcriptWrite: true, frameBounded: false };
45
+ }