pi-editor-footer 0.1.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.
Files changed (46) hide show
  1. package/AGENTS.md +19 -0
  2. package/CHANGELOG.md +26 -0
  3. package/CONTEXT.md +29 -0
  4. package/README.md +84 -0
  5. package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
  6. package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
  7. package/docs/agents/domain.md +51 -0
  8. package/docs/agents/issue-tracker.md +45 -0
  9. package/docs/agents/triage-labels.md +15 -0
  10. package/docs/reference/pi-tui-internals.md +144 -0
  11. package/docs/specs/01-config.md +57 -0
  12. package/docs/specs/02-identity.md +20 -0
  13. package/docs/specs/03-border-telemetry.md +48 -0
  14. package/docs/specs/04-header.md +30 -0
  15. package/docs/specs/05-footer.md +30 -0
  16. package/docs/specs/06-git.md +36 -0
  17. package/docs/specs/07-runtime.md +28 -0
  18. package/docs/specs/theme-overview.md +116 -0
  19. package/package.json +16 -0
  20. package/src/config.ts +184 -0
  21. package/src/detail-render.ts +119 -0
  22. package/src/footer.ts +479 -0
  23. package/src/git.ts +170 -0
  24. package/src/header.ts +185 -0
  25. package/src/icons.ts +197 -0
  26. package/src/index.ts +607 -0
  27. package/src/model-info.ts +341 -0
  28. package/src/runtime.ts +318 -0
  29. package/src/state.ts +144 -0
  30. package/src/telemetry.ts +437 -0
  31. package/src/theme-settings.ts +461 -0
  32. package/src/tracking-editor.ts +352 -0
  33. package/src/utils-workspace.ts +48 -0
  34. package/src/utils.ts +388 -0
  35. package/src/window-presentation.ts +56 -0
  36. package/test/config.test.ts +146 -0
  37. package/test/detail-render.test.ts +202 -0
  38. package/test/footer.test.ts +86 -0
  39. package/test/git.test.ts +45 -0
  40. package/test/header.test.ts +169 -0
  41. package/test/icons.test.ts +24 -0
  42. package/test/runtime.test.ts +71 -0
  43. package/test/telemetry.test.ts +199 -0
  44. package/test/utils.test.ts +71 -0
  45. package/test/window-presentation.test.ts +73 -0
  46. package/tsconfig.json +13 -0
@@ -0,0 +1,30 @@
1
+ # Spec 05 — Footer Wireframe & Segments
2
+
3
+ Ticket: #10 · Type: grilling · Branch: `feat/footer`
4
+
5
+ ## Question
6
+
7
+ What is the footer's wireframe: which segments, how they respond to width, and where cost lives?
8
+
9
+ ## Decision
10
+
11
+ - Segments (priority low→high, high survives): `cwd` (if not in header), `gitBranch`, `gitStatus`, `runtime`, `context`, `tokens`, `cost`, `extensionStatuses`. `sessionName` optional.
12
+ - Each segment: `{text, compactText?, truncate?, priority}` consumed by `fitSegmentsByPriority`.
13
+ - Placement: `setWidget("theme-footer", ..., {placement:"belowEditor"})`, always on.
14
+ - Responsive: `fitSegmentsByPriority(maxWidth)` — compact forms first, then drop lowest priority.
15
+ - Cost duplication: cost shown in **bottom-border telemetry** (rate) and optionally footer (total) — footer shows `fmtTokens` + extension status; border shows telemetry cost rate. Footer may omit cost if telemetry enabled (decide in impl).
16
+ - Refresh: git + runtime polled on `session_start` and interval; footer re-renders on `requestRender`.
17
+ - Theme: live `Theme`, glyphs via `icons.mode`.
18
+
19
+ ## Reference
20
+
21
+ - `tmp/pi-open-tui/extensions/open-tui/footer.ts` (300+ lines)
22
+ - `tmp/pi-open-tui/extensions/open-tui/utils.ts` (`fitSegmentsByPriority`, `truncatePath`, `fmtTokens`, `formatDuration`)
23
+ - `tmp/pi-open-tui/extensions/open-tui/state.ts`
24
+
25
+ ## Acceptance
26
+
27
+ - [ ] `src/footer.ts` with segment priorities and `fitSegmentsByPriority` integration
28
+ - [ ] Reads `config.footerSegments`, respects theme + icon mode
29
+ - [ ] Narrow width sheds gracefully (snapshot test)
30
+ - [ ] `npm run typecheck` + `npm test` pass
@@ -0,0 +1,36 @@
1
+ # Spec 06 — Git State Engine
2
+
3
+ Ticket: #13 · Type: task (AFK) · Branch: `feat/git` (or part of `feat/footer`) · Blocked by #10
4
+
5
+ ## Question
6
+
7
+ Which rich git states does the footer's git segment surface, and how are they computed?
8
+
9
+ ## Decision
10
+
11
+ Rebuild `tmp/pi-open-tui/extensions/open-tui/git.ts` bespoke. Expose:
12
+
13
+ ```ts
14
+ interface GitStatus {
15
+ branch: string | null;
16
+ commit: { oid: string; tag: string | null; detached: boolean } | null;
17
+ ahead: number; behind: number;
18
+ staged: number; modified: number; untracked: number;
19
+ conflicted: number; stashed: number;
20
+ }
21
+ function readGitStatus(cwd: string): Promise<GitStatus>
22
+ function emptyGitStatus(): GitStatus
23
+ ```
24
+
25
+ - Detection via `git` CLI (`git rev-parse`, `git status --porcelain`, `git rev-list --left-right`, stash list). No `isomorphic-git` dep.
26
+ - Handles detached HEAD (show short oid + tag), ahead/behind, staged/modified/untracked, stashed.
27
+ - Cached + debounced (don't spawn git on every render; refresh on interval + on demand from footer).
28
+ - Theme glyphs for each status via `icons.ts` (`resolveGlyphs`).
29
+
30
+ Blocked by footer wireframe (git segment shape). AFK.
31
+
32
+ ## Acceptance
33
+
34
+ - [ ] `src/git.ts` with `readGitStatus` + tests (mock git)
35
+ - [ ] Footer consumes it via `FooterState.git`
36
+ - [ ] `npm run typecheck` + `npm test` pass
@@ -0,0 +1,28 @@
1
+ # Spec 07 — Runtime Detection
2
+
3
+ Ticket: #14 · Type: task (AFK) · Branch: `feat/runtime` (or part of `feat/footer`) · Blocked by #10
4
+
5
+ ## Question
6
+
7
+ What is the runtime-signature catalog the footer's runtime segment recognises?
8
+
9
+ ## Decision
10
+
11
+ Rebuild `tmp/pi-open-tui/extensions/open-tui/runtime.ts` bespoke.
12
+
13
+ ```ts
14
+ interface RuntimeInfo { name: string; version?: string; icon: string }
15
+ function readRuntimeInfo(cwd: string): Promise<RuntimeInfo | null>
16
+ ```
17
+
18
+ - Catalog: at least 10 common runtimes (node, python, rust, go, ruby, java, deno, bun, etc.) detected via lockfiles/config presence (`package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `Gemfile`, etc.) — subset of 50+ is acceptable for v1, expand later.
19
+ - Priority order: explicit lockfile > version file (`.nvmrc`, `.python-version`) > generic.
20
+ - Glyph via `icons.ts` / `runtimeSymbol` helper, respects `icons.mode`.
21
+
22
+ Blocked by footer wireframe. AFK.
23
+
24
+ ## Acceptance
25
+
26
+ - [ ] `src/runtime.ts` + unit tests (fixture dirs)
27
+ - [ ] Footer consumes via `FooterState.runtime`
28
+ - [ ] `npm run typecheck` + `npm test` pass
@@ -0,0 +1,116 @@
1
+ # Spec: pi-skill-desc → Full pi TUI Theme
2
+
3
+ **Status:** draft
4
+ **Branch:** `dev/theme-refactor`
5
+ **Reference:** `tmp/pi-open-tui` (mirror, rebuild bespoke — never vendored)
6
+ **Map:** #5
7
+
8
+ ## 1. Destination
9
+
10
+ Turn `pi-skill-desc` (detail window above input, TrackingEditor, model-info border glow) into a **full TUI theme** — rebuilt bespoke on `TrackingEditor`'s architecture. Reaching the end: a renamed theme whose
11
+
12
+ - header (cwd + slash hints, no model/thinking),
13
+ - responsive footer (git, 50+ runtime, context, tokens, cost, extension status),
14
+ - editor cursor styles (block/bar/underline),
15
+ - live telemetry right-aligned on the input's **bottom** border (all six toggleable)
16
+
17
+ all render against **pi's live theme**, while
18
+
19
+ - skill-description detail window (cap 5 lines, scrollable)
20
+ - model-info border glow on the **top** border
21
+ - single editor slot ownership
22
+
23
+ are preserved, and `workspaceDisplay: "path" | "name"` is honoured.
24
+
25
+ All visuals must respect pi's live `Theme` (read via `ctx.ui.theme` / `getFgAnsi`, `getThinkingBorderColor`, `fg`, etc.). No hardcoded colors.
26
+
27
+ ## 2. Standing Decisions (R1/R2)
28
+
29
+ | Decision | Choice |
30
+ | --- | --- |
31
+ | Theme scope | **Full theme** (header+footer+project awareness) |
32
+ | Build method | **Rebuild bespoke**, `tmp/pi-open-tui` as reference only |
33
+ | Header model display | **Omit model/thinking** — border owns it |
34
+ | Footer richness | **Wholesale** — git + 50+ runtime + all segments |
35
+ | Border layout | **Model glow top, telemetry right bottom** |
36
+ | Settings surface | **Lightweight English dialog** (`/theme` or `/open-tui` equivalent) |
37
+ | Workspace toggle | `workspaceDisplay: "path" | "name"` config flag |
38
+ | Identity | **Rename** package + display name to read as theme |
39
+ | Execution | **Carry into map** — each ticket delivers working subsystem |
40
+
41
+ ## 3. Non-Goals / Out of Scope
42
+
43
+ - Full bilingual (EN/ZH) settings — English only.
44
+ - Vendoring `pi-open-tui` code.
45
+ - Replacing pi's native header/footer vs augmenting (theme adds its own).
46
+
47
+ ## 4. Architecture
48
+
49
+ ### 4.1 Editor Slot
50
+
51
+ `TrackingEditor extends Editor` owns `setEditorComponent`. It replicates `CustomEditor` inline (handleInput, actionHandlers, duck-typing). All editor features fold into it:
52
+
53
+ - highlight observation (`autocompleteList` + `applyAutocompleteSuggestions` patch)
54
+ - model-info glow (`applyModelInfo` on top border)
55
+ - telemetry segment on bottom border (right-aligned)
56
+ - cursor styles (bar `\x1b[6 q`, underline `\x1b[4 q`, hardware cursor)
57
+
58
+ See `docs/reference/pi-tui-internals.md`, ADRs 0001/0002.
59
+
60
+ ### 4.2 Widgets
61
+
62
+ - **Detail window** — `setWidget("pi-skill-desc", ..., {placement:"aboveEditor"})` — preserved.
63
+ - **Header** — `setWidget("theme-header", ..., {placement:"aboveEditor"})` or TUI header slot if available; always on.
64
+ - **Footer** — `setWidget("theme-footer", ..., {placement:"belowEditor"})`; responsive, priority-based shedding.
65
+
66
+ Header and detail window both use `aboveEditor` — they must not overlap. Header is always visible; detail window appears only with popup. Header sits above detail window (or detail window pushes header).
67
+
68
+ ### 4.3 Config
69
+
70
+ Single JSON file `~/.pi/agent/pi-skill-desc.json` or `~/.pi/agent/theme.json` (decided in config ticket). Shape:
71
+
72
+ ```ts
73
+ interface ThemeConfig {
74
+ enabled: boolean
75
+ workspaceDisplay: "path" | "name"
76
+ cursorStyle: "block" | "bar" | "underline"
77
+ icons: { mode: "auto" | "nerd" | "ascii" }
78
+ telemetry: { enabled: boolean; tps: boolean; ttft: boolean; duration: boolean; tokens: boolean; stalls: boolean; cost: boolean }
79
+ footerSegments: { cwd: boolean; sessionName: boolean; gitBranch: boolean; gitStatus: boolean; gitCommit: boolean; runtime: boolean; context: boolean; tokens: boolean; cost: boolean; extensionStatuses: boolean }
80
+ // + fullscreen wheel etc. deferred to fog
81
+ }
82
+ ```
83
+
84
+ Defaults match `tmp/pi-open-tui`'s DEFAULT_CONFIG where applicable. Live reload on settings dialog save, re-render requested.
85
+
86
+ ### 4.4 Theme Respect
87
+
88
+ Every new surface reads live theme:
89
+
90
+ - `theme.fg("accent" | "dim" | "border" | "text" | ...)`
91
+ - `theme.getThinkingBorderColor(level)` for glow
92
+ - Icons via glyphs resolved by `icons.mode`
93
+
94
+ No hardcoded ANSI except cursor sequences (not themed).
95
+
96
+ ## 5. Subsystems & Tickets
97
+
98
+ | # | Ticket | Spec File |
99
+ | --- | --- | --- |
100
+ | 6 | Theme identity & rename | `02-identity.md` |
101
+ | 7 | Config schema & persistence | `01-config.md` |
102
+ | 8 | Editor border: model-label + telemetry layout | `03-border.md` |
103
+ | 9 | Header: cwd + hints (no model) | `04-header.md` |
104
+ | 10 | Footer wireframe & segments | `05-footer.md` |
105
+ | 11 | Cursor styles in TrackingEditor | `02-editor-cursor.md` |
106
+ | 12 | Telemetry engine | `03-telemetry.md` |
107
+ | 13 | Git state engine | `06-git.md` |
108
+ | 14 | Runtime detection | `07-runtime.md` |
109
+
110
+ Each spec file defines acceptance criteria sized to one PR.
111
+
112
+ ## 6. Verification
113
+
114
+ - `npm run typecheck` passes
115
+ - `npm test` passes (new pure modules unit-tested via `node:test` + `tsx`)
116
+ - Manual live pi session: theme loads via `pi -e ./src/index.ts`, header/footer render, detail window tracks, border shows model top + telemetry bottom right, cursor style toggles, workspaceDisplay toggles.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "pi-editor-footer",
3
+ "type": "module",
4
+ "description": "Pi TUI theme — project-aware footer, model border, and skill detail window (TrackingEditor, live theme)",
5
+ "scripts": {
6
+ "test": "node --import tsx --test test/*.test.ts",
7
+ "typecheck": "tsc --noEmit"
8
+ },
9
+ "devDependencies": {
10
+ "@earendil-works/pi-tui": "0.84.2",
11
+ "@types/node": "^22.0.0",
12
+ "tsx": "^4.19.0",
13
+ "typescript": "^5.6.0"
14
+ },
15
+ "version": "0.1.0"
16
+ }
package/src/config.ts ADDED
@@ -0,0 +1,184 @@
1
+ /** Typed config for pi-skill-desc theme (admission boundary: validate once). */
2
+
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ export type WorkspaceDisplay = "path" | "name";
8
+ export type CursorStyle = "block" | "bar" | "underline";
9
+ export type IconMode = "auto" | "nerd" | "ascii";
10
+
11
+ export interface TelemetryConfig {
12
+ enabled: boolean;
13
+ tps: boolean;
14
+ ttft: boolean;
15
+ duration: boolean;
16
+ tokens: boolean;
17
+ stalls: boolean;
18
+ cost: boolean;
19
+ }
20
+
21
+ export interface FooterSegments {
22
+ cwd: boolean;
23
+ sessionName: boolean;
24
+ gitBranch: boolean;
25
+ gitStatus: boolean;
26
+ gitCommit: boolean;
27
+ runtime: boolean;
28
+ context: boolean;
29
+ tokens: boolean;
30
+ cost: boolean;
31
+ extensionStatuses: boolean;
32
+ }
33
+
34
+ export interface ThemeConfig {
35
+ enabled: boolean;
36
+ workspaceDisplay: WorkspaceDisplay;
37
+ cursorStyle: CursorStyle;
38
+ icons: {
39
+ mode: IconMode;
40
+ };
41
+ telemetry: TelemetryConfig;
42
+ footerSegments: FooterSegments;
43
+ }
44
+
45
+ export const DEFAULT_CONFIG: ThemeConfig = {
46
+ enabled: true,
47
+ workspaceDisplay: "path",
48
+ cursorStyle: "block",
49
+ icons: {
50
+ mode: "auto",
51
+ },
52
+ footerSegments: {
53
+ cwd: true,
54
+ sessionName: false,
55
+ gitBranch: true,
56
+ gitStatus: true,
57
+ gitCommit: false,
58
+ runtime: true,
59
+ context: true,
60
+ tokens: true,
61
+ cost: true,
62
+ extensionStatuses: true,
63
+ },
64
+ telemetry: {
65
+ enabled: true,
66
+ tps: true,
67
+ ttft: true,
68
+ duration: true,
69
+ tokens: true,
70
+ stalls: true,
71
+ cost: true,
72
+ },
73
+ };
74
+
75
+ export function getConfigPath(): string {
76
+ const home = homedir();
77
+ return join(home, ".pi", "agent", "pi-skill-desc.json");
78
+ }
79
+
80
+ function deepMerge<T>(base: T, override: unknown): T {
81
+ if (typeof base !== "object" || base === null || Array.isArray(base)) {
82
+ return (override as T) ?? base;
83
+ }
84
+ if (
85
+ typeof override !== "object" ||
86
+ override === null ||
87
+ Array.isArray(override)
88
+ ) {
89
+ return base;
90
+ }
91
+ const result = { ...(base as Record<string, unknown>) };
92
+ const rec = override as Record<string, unknown>;
93
+ for (const key of Object.keys(rec)) {
94
+ const bv = (base as Record<string, unknown>)[key];
95
+ const ov = rec[key];
96
+ if (
97
+ typeof bv === "object" &&
98
+ bv !== null &&
99
+ !Array.isArray(bv) &&
100
+ typeof ov === "object" &&
101
+ ov !== null &&
102
+ !Array.isArray(ov)
103
+ ) {
104
+ result[key] = deepMerge(bv, ov);
105
+ } else if (ov !== undefined) {
106
+ result[key] = ov;
107
+ }
108
+ }
109
+ return result as T;
110
+ }
111
+
112
+ function validate(config: ThemeConfig): ThemeConfig {
113
+ // workspaceDisplay
114
+ if (
115
+ config.workspaceDisplay !== "path" &&
116
+ config.workspaceDisplay !== "name"
117
+ ) {
118
+ config.workspaceDisplay = DEFAULT_CONFIG.workspaceDisplay;
119
+ }
120
+ // cursorStyle
121
+ if (
122
+ config.cursorStyle !== "block" &&
123
+ config.cursorStyle !== "bar" &&
124
+ config.cursorStyle !== "underline"
125
+ ) {
126
+ config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
127
+ }
128
+ // icons.mode
129
+ if (
130
+ config.icons.mode !== "auto" &&
131
+ config.icons.mode !== "nerd" &&
132
+ config.icons.mode !== "ascii"
133
+ ) {
134
+ config.icons.mode = DEFAULT_CONFIG.icons.mode;
135
+ }
136
+ return config;
137
+ }
138
+
139
+ export function ensureConfigExists(): void {
140
+ const path = getConfigPath();
141
+ if (existsSync(path)) return;
142
+ try {
143
+ const dir = join(path, "..");
144
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
145
+ writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
146
+ } catch {
147
+ // best-effort
148
+ }
149
+ }
150
+
151
+ export function loadConfig(): ThemeConfig {
152
+ const path = getConfigPath();
153
+ if (!existsSync(path)) {
154
+ ensureConfigExists();
155
+ return structuredClone(DEFAULT_CONFIG);
156
+ }
157
+ try {
158
+ const raw = readFileSync(path, "utf8");
159
+ const parsed: unknown = JSON.parse(raw);
160
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
161
+ return validate(merged);
162
+ } catch (err) {
163
+ console.warn(
164
+ `[pi-skill-desc] config parse error (${path}): ${err instanceof Error ? err.message : String(err)} — using defaults`,
165
+ );
166
+ return structuredClone(DEFAULT_CONFIG);
167
+ }
168
+ }
169
+
170
+ export function saveConfig(
171
+ patch: Partial<ThemeConfig> & Record<string, unknown>,
172
+ ): ThemeConfig {
173
+ const current = loadConfig();
174
+ const merged = validate(deepMerge(current, patch) as ThemeConfig);
175
+ const path = getConfigPath();
176
+ try {
177
+ const dir = join(path, "..");
178
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
179
+ writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
180
+ } catch {
181
+ // best-effort
182
+ }
183
+ return merged;
184
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pure, TUI-free rendering of the Detail window (ADR-0001).
3
+ *
4
+ * Given a highlighted completion candidate and window state, produces the
5
+ * lines to render in the 5-line detail window above the input box.
6
+ * No pi imports — this is the single testable seam of the extension.
7
+ */
8
+
9
+ export interface DetailItem {
10
+ label: string;
11
+ kind: string;
12
+ description: string;
13
+ }
14
+
15
+ /**
16
+ * Rendered lines for the detail window: `[header, ...contentLines]`.
17
+ *
18
+ * - Returns `[]` when `item` is null or its description is empty/whitespace.
19
+ * - Header is `<label> · <kind>`, suffixed with a ` offset/total` scroll
20
+ * marker (e.g. ` 3/8`) when the description overflows the window.
21
+ * - The description is wrapped to `width` characters per line (simple
22
+ * character-based wrap; embedded newlines become paragraph breaks).
23
+ * - Shrink-to-fit: exactly `min(maxLines, 1 + contentLines)` lines are
24
+ * returned (1 header + up to `maxLines - 1` content lines).
25
+ * - `scrollOffset` is clamped into `[0, max(0, contentLines - (maxLines - 1))]`.
26
+ */
27
+ export function renderDetail(
28
+ item: DetailItem | null,
29
+ width: number,
30
+ maxLines: number,
31
+ scrollOffset: number,
32
+ ): string[] {
33
+ if (!item || item.description.trim() === "") {
34
+ return [];
35
+ }
36
+
37
+ const wrapWidth = Math.max(1, Math.floor(width));
38
+ const safeMax = Math.max(1, Math.floor(maxLines));
39
+
40
+ const contentLines = wrapDescription(item.description, wrapWidth);
41
+ const capacity = Math.max(0, safeMax - 1);
42
+ const maxOffset =
43
+ capacity === 0 ? 0 : Math.max(0, contentLines.length - capacity);
44
+ const offset = clamp(Math.floor(scrollOffset) || 0, 0, maxOffset);
45
+
46
+ const visibleLines = contentLines.slice(offset, offset + capacity);
47
+
48
+ // "More content" marker: `...` replaces the last visible content line when
49
+ // there is content remaining BELOW the window (not yet scrolled to the
50
+ // bottom). The header's ` offset/total` marker still carries the totals.
51
+ const hasMoreBelow = offset + visibleLines.length < contentLines.length;
52
+ if (hasMoreBelow && visibleLines.length > 0) {
53
+ visibleLines[visibleLines.length - 1] = "...";
54
+ }
55
+ const overflows = contentLines.length > capacity;
56
+ const namePart = `${item.label} · ${item.kind}`;
57
+ let header: string;
58
+ if (overflows) {
59
+ // Reserve room for the scroll marker so it always survives truncation.
60
+ const marker = ` ${offset + 1}/${contentLines.length}`;
61
+ const nameWidth = Math.max(0, wrapWidth - marker.length);
62
+ header = truncateToWidth(namePart, nameWidth) + marker;
63
+ } else {
64
+ header = truncateToWidth(namePart, wrapWidth);
65
+ }
66
+
67
+ return [header, ...visibleLines];
68
+ }
69
+
70
+ /**
71
+ * Next scroll offset after moving by `delta` (-1 = back/up, +1 = forward/down).
72
+ *
73
+ * - Returns 0 when there is nothing to scroll (description fits the window).
74
+ * - Otherwise returns `offset + delta`, clamped to
75
+ * `[0, contentLines - (maxLines - 1)]`.
76
+ */
77
+ export function scroll(
78
+ offset: number,
79
+ delta: -1 | 1,
80
+ contentLines: number,
81
+ maxLines: number,
82
+ ): number {
83
+ const capacity = Math.max(0, maxLines - 1);
84
+ if (capacity === 0) {
85
+ return 0;
86
+ }
87
+ const maxOffset = Math.max(0, contentLines - capacity);
88
+ if (maxOffset === 0) {
89
+ return 0;
90
+ }
91
+ return clamp(Math.floor(offset) + delta, 0, maxOffset);
92
+ }
93
+
94
+ function wrapDescription(description: string, width: number): string[] {
95
+ const lines: string[] = [];
96
+ for (const rawLine of description.split("\n")) {
97
+ const trimmed = rawLine.replace(/\s+$/g, "");
98
+ if (trimmed === "") {
99
+ // Preserve explicit paragraph breaks (empty lines in the description).
100
+ lines.push("");
101
+ continue;
102
+ }
103
+ for (let i = 0; i < trimmed.length; i += width) {
104
+ const segment = trimmed.slice(i, i + width).replace(/\s+$/g, "");
105
+ if (segment !== "") {
106
+ lines.push(segment);
107
+ }
108
+ }
109
+ }
110
+ return lines;
111
+ }
112
+
113
+ function truncateToWidth(text: string, width: number): string {
114
+ return text.length <= width ? text : text.slice(0, width);
115
+ }
116
+
117
+ function clamp(value: number, min: number, max: number): number {
118
+ return Math.max(min, Math.min(max, value));
119
+ }