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.
- package/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- 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
|
+
}
|