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
package/src/header.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
// Keep helpers local to avoid conflict with footer track which owns shared src/utils.ts.
|
|
5
|
+
// Copied from tmp/pi-open-tui/extensions/open-tui/utils.ts (MIT).
|
|
6
|
+
|
|
7
|
+
export type WorkspaceDisplay = "path" | "name";
|
|
8
|
+
|
|
9
|
+
export interface HeaderThemeLike {
|
|
10
|
+
fg(style: string, s: string): string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HeaderDeps {
|
|
14
|
+
cwd: string;
|
|
15
|
+
workspaceDisplay: WorkspaceDisplay;
|
|
16
|
+
tipCommands: string[];
|
|
17
|
+
/** Cwd glyph; defaults to "@" (ascii) if not provided. Footer owns full icons.ts. */
|
|
18
|
+
iconCwd?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatCwd(cwd: string): string {
|
|
22
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
23
|
+
if (!home) return cwd;
|
|
24
|
+
const resolvedCwd = resolve(cwd);
|
|
25
|
+
const resolvedHome = resolve(home);
|
|
26
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
27
|
+
const insideHome =
|
|
28
|
+
rel === "" ||
|
|
29
|
+
(rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
30
|
+
if (!insideHome) return cwd;
|
|
31
|
+
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function basenamePath(path: string): string {
|
|
35
|
+
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function truncatePath(path: string, maxLen: number): string {
|
|
39
|
+
if (path.length <= maxLen) return path;
|
|
40
|
+
if (maxLen <= 3) return "...".slice(0, maxLen);
|
|
41
|
+
const sepChar = path.includes("/") ? "/" : "\\";
|
|
42
|
+
const parts = path.split(/[\\/]/);
|
|
43
|
+
if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
|
|
44
|
+
const tail: string[] = [];
|
|
45
|
+
let tailLen = 0;
|
|
46
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
47
|
+
const seg = parts[i]!;
|
|
48
|
+
if (tailLen + seg.length + 4 > maxLen) break;
|
|
49
|
+
tail.unshift(seg);
|
|
50
|
+
tailLen += seg.length + 1;
|
|
51
|
+
}
|
|
52
|
+
const head = parts[0]!;
|
|
53
|
+
const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
|
|
54
|
+
return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function displayCwd(rawCwd: string, _mode: WorkspaceDisplay): string {
|
|
58
|
+
return formatCwd(rawCwd);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Keep layout logic local; footer owns the full headerColumnWidths.
|
|
62
|
+
// Simple: at narrow widths, tips are dropped.
|
|
63
|
+
const GAP = 3;
|
|
64
|
+
|
|
65
|
+
export function renderHeader(
|
|
66
|
+
width: number,
|
|
67
|
+
deps: HeaderDeps,
|
|
68
|
+
theme: HeaderThemeLike,
|
|
69
|
+
): string[] {
|
|
70
|
+
if (width <= 0) return [];
|
|
71
|
+
if (!deps.cwd) return [];
|
|
72
|
+
|
|
73
|
+
const icon = deps.iconCwd ?? "@";
|
|
74
|
+
const cwdText = displayCwd(deps.cwd, deps.workspaceDisplay);
|
|
75
|
+
// Single-line content: "icon cwd | tip0 tip1 tip2"
|
|
76
|
+
const tips = (deps.tipCommands ?? [])
|
|
77
|
+
.slice(0, 3)
|
|
78
|
+
.map((t) => (t.startsWith("/") ? t : `/${t}`));
|
|
79
|
+
const tipsText =
|
|
80
|
+
tips.length > 0 ? tips.map((t) => theme.fg("dim", t)).join(" ") : "";
|
|
81
|
+
|
|
82
|
+
// At very narrow widths, just show cwd with icon, truncated.
|
|
83
|
+
if (width < 24 || tipsText === "") {
|
|
84
|
+
const raw = `${icon} ${cwdText}`;
|
|
85
|
+
const truncated = truncateToWidth(raw, width, theme.fg("dim", "..."));
|
|
86
|
+
return [truncated];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tipsW = visibleWidth(tipsText);
|
|
90
|
+
const gapW = GAP;
|
|
91
|
+
// Reserve for tips; if not enough room, drop tips.
|
|
92
|
+
if (tipsW + gapW + 10 > width) {
|
|
93
|
+
const raw = `${icon} ${cwdText}`;
|
|
94
|
+
return [truncateToWidth(raw, width, theme.fg("dim", "..."))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Allocate widths
|
|
98
|
+
const maxTipsW = Math.min(28, tipsW);
|
|
99
|
+
const leftW = width - gapW - maxTipsW;
|
|
100
|
+
const cwdIconText = `${icon} ${cwdText}`;
|
|
101
|
+
const leftText = truncatePath(cwdIconText, leftW);
|
|
102
|
+
// Pad left to leftW, then gap, then tips (right-aligned within maxTipsW)
|
|
103
|
+
// For simplicity, left + gap + tipsText (tips already themed dim)
|
|
104
|
+
// Use visibleWidth for padding.
|
|
105
|
+
const leftVisible = visibleWidth(leftText);
|
|
106
|
+
const pad = Math.max(gapW, width - leftVisible - tipsW);
|
|
107
|
+
const line = leftText + " ".repeat(pad) + tipsText;
|
|
108
|
+
return [truncateToWidth(line, width, theme.fg("dim", "..."))];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Minimal widget installer — keeps header always on when enabled.
|
|
112
|
+
// Does not depend on full config; caller provides getters so tests stay pure.
|
|
113
|
+
|
|
114
|
+
export type GetHeaderConfig = () => {
|
|
115
|
+
enabled: boolean;
|
|
116
|
+
workspaceDisplay: WorkspaceDisplay;
|
|
117
|
+
};
|
|
118
|
+
export type GetCwd = () => string;
|
|
119
|
+
export type GetTips = () => string[];
|
|
120
|
+
|
|
121
|
+
export function installHeader(
|
|
122
|
+
ctx: {
|
|
123
|
+
ui: {
|
|
124
|
+
setWidget(
|
|
125
|
+
key: string,
|
|
126
|
+
content: unknown,
|
|
127
|
+
options?: { placement?: string },
|
|
128
|
+
): void;
|
|
129
|
+
};
|
|
130
|
+
mode?: string;
|
|
131
|
+
},
|
|
132
|
+
getConfig: GetHeaderConfig,
|
|
133
|
+
getCwd: GetCwd,
|
|
134
|
+
getTips: GetTips,
|
|
135
|
+
): () => void {
|
|
136
|
+
const key = "theme-header";
|
|
137
|
+
|
|
138
|
+
// Avoid touching UI when not in TUI.
|
|
139
|
+
const mode = (ctx as { mode?: string }).mode;
|
|
140
|
+
if (mode && mode !== "tui") {
|
|
141
|
+
return () => {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Create a TUI component that renders the header at current width.
|
|
145
|
+
const factory = (_tui: unknown, theme: unknown) => {
|
|
146
|
+
const th = theme as HeaderThemeLike;
|
|
147
|
+
return {
|
|
148
|
+
invalidate(): void {},
|
|
149
|
+
render(width: number): string[] {
|
|
150
|
+
const cfg = getConfig();
|
|
151
|
+
if (!cfg.enabled) return [];
|
|
152
|
+
return renderHeader(
|
|
153
|
+
width,
|
|
154
|
+
{
|
|
155
|
+
cwd: getCwd(),
|
|
156
|
+
workspaceDisplay: cfg.workspaceDisplay,
|
|
157
|
+
tipCommands: getTips(),
|
|
158
|
+
},
|
|
159
|
+
th,
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
dispose(): void {},
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Adapter: ExtensionUIContextLike.setWidget expects (key, content, options)
|
|
167
|
+
// where content can be string[] or factory (tui, theme) => Component.
|
|
168
|
+
const ui = ctx.ui as unknown as {
|
|
169
|
+
setWidget: (
|
|
170
|
+
key: string,
|
|
171
|
+
content:
|
|
172
|
+
| ((
|
|
173
|
+
tui: unknown,
|
|
174
|
+
theme: unknown,
|
|
175
|
+
) => { render(width: number): string[] })
|
|
176
|
+
| undefined,
|
|
177
|
+
options?: { placement?: string },
|
|
178
|
+
) => void;
|
|
179
|
+
};
|
|
180
|
+
ui.setWidget(key, factory as unknown as never, { placement: "aboveEditor" });
|
|
181
|
+
|
|
182
|
+
return () => {
|
|
183
|
+
ui.setWidget(key, undefined);
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
export type IconMode = "auto" | "nerd" | "ascii";
|
|
2
|
+
|
|
3
|
+
export interface IconGlyphs {
|
|
4
|
+
cwd: string;
|
|
5
|
+
session: string;
|
|
6
|
+
git: string;
|
|
7
|
+
working: string;
|
|
8
|
+
done: string;
|
|
9
|
+
context: string;
|
|
10
|
+
model: string;
|
|
11
|
+
thinking: string;
|
|
12
|
+
input: string;
|
|
13
|
+
output: string;
|
|
14
|
+
cacheHit: string;
|
|
15
|
+
cost: string;
|
|
16
|
+
speed: string;
|
|
17
|
+
latency: string;
|
|
18
|
+
stall: string;
|
|
19
|
+
extensions: string;
|
|
20
|
+
ahead: string;
|
|
21
|
+
behind: string;
|
|
22
|
+
diverged: string;
|
|
23
|
+
conflicted: string;
|
|
24
|
+
stashed: string;
|
|
25
|
+
modified: string;
|
|
26
|
+
staged: string;
|
|
27
|
+
untracked: string;
|
|
28
|
+
renamed: string;
|
|
29
|
+
deleted: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NERD_GLYPHS: IconGlyphs = {
|
|
33
|
+
cwd: "",
|
|
34
|
+
session: "",
|
|
35
|
+
git: "",
|
|
36
|
+
working: "",
|
|
37
|
+
done: "",
|
|
38
|
+
context: "",
|
|
39
|
+
model: "",
|
|
40
|
+
thinking: "",
|
|
41
|
+
input: "",
|
|
42
|
+
output: "",
|
|
43
|
+
cacheHit: "",
|
|
44
|
+
cost: "",
|
|
45
|
+
speed: "",
|
|
46
|
+
latency: "",
|
|
47
|
+
stall: "",
|
|
48
|
+
extensions: "",
|
|
49
|
+
ahead: "↑",
|
|
50
|
+
behind: "↓",
|
|
51
|
+
diverged: "⇕",
|
|
52
|
+
conflicted: "=",
|
|
53
|
+
stashed: "$",
|
|
54
|
+
modified: "!",
|
|
55
|
+
staged: "+",
|
|
56
|
+
untracked: "?",
|
|
57
|
+
renamed: "»",
|
|
58
|
+
deleted: "✘",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ASCII_GLYPHS: IconGlyphs = {
|
|
62
|
+
cwd: "@",
|
|
63
|
+
session: "s",
|
|
64
|
+
git: "*",
|
|
65
|
+
working: "o",
|
|
66
|
+
done: "+",
|
|
67
|
+
context: "#",
|
|
68
|
+
model: "M",
|
|
69
|
+
thinking: "~",
|
|
70
|
+
input: "↑",
|
|
71
|
+
output: "↓",
|
|
72
|
+
cacheHit: "c",
|
|
73
|
+
cost: "$",
|
|
74
|
+
speed: ">",
|
|
75
|
+
latency: "~",
|
|
76
|
+
stall: "!",
|
|
77
|
+
extensions: "&",
|
|
78
|
+
ahead: "^",
|
|
79
|
+
behind: "v",
|
|
80
|
+
diverged: "^v",
|
|
81
|
+
conflicted: "=",
|
|
82
|
+
stashed: "S",
|
|
83
|
+
modified: "!",
|
|
84
|
+
staged: "A",
|
|
85
|
+
untracked: "?",
|
|
86
|
+
renamed: "r",
|
|
87
|
+
deleted: "x",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const NERD_FONT_TERMINALS = new Set([
|
|
91
|
+
"iTerm.app",
|
|
92
|
+
"Ghostty",
|
|
93
|
+
"WezTerm",
|
|
94
|
+
"kitty",
|
|
95
|
+
"rio",
|
|
96
|
+
"tabby",
|
|
97
|
+
"WindowsTerminal",
|
|
98
|
+
"vscode",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
export function detectNerdFont(): boolean {
|
|
102
|
+
const termProgram = process.env.TERM_PROGRAM;
|
|
103
|
+
if (termProgram && NERD_FONT_TERMINALS.has(termProgram)) return true;
|
|
104
|
+
const lcTerminal = process.env.LC_TERMINAL;
|
|
105
|
+
if (lcTerminal && NERD_FONT_TERMINALS.has(lcTerminal)) return true;
|
|
106
|
+
if (process.env.TERM === "xterm-kitty") return true;
|
|
107
|
+
if (process.env.WT_SESSION) return true;
|
|
108
|
+
if (process.env.TERM_PROGRAM === "vscode") return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
|
|
113
|
+
if (mode === "nerd") return "nerd";
|
|
114
|
+
if (mode === "ascii") return "ascii";
|
|
115
|
+
return detectNerdFont() ? "nerd" : "ascii";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function resolveGlyphs(mode: IconMode): IconGlyphs {
|
|
119
|
+
const resolved = resolveIconMode(mode);
|
|
120
|
+
return resolved === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const RUNTIME_SYMBOLS: Record<string, string> = {
|
|
124
|
+
nodejs: "\uE718",
|
|
125
|
+
rust: "\uE7A8",
|
|
126
|
+
go: "\uE626",
|
|
127
|
+
python: "\uE73C",
|
|
128
|
+
ruby: "\uE739",
|
|
129
|
+
java: "\uE256",
|
|
130
|
+
cpp: "\uE61D",
|
|
131
|
+
c: "\uE61E",
|
|
132
|
+
swift: "\uE755",
|
|
133
|
+
kotlin: "\uE634",
|
|
134
|
+
deno: "\uE7FB",
|
|
135
|
+
bun: "\uE6FB",
|
|
136
|
+
php: "\uE73D",
|
|
137
|
+
haskell: "\uE777",
|
|
138
|
+
julia: "\uE624",
|
|
139
|
+
lua: "\uE620",
|
|
140
|
+
elixir: "\uE62B",
|
|
141
|
+
erlang: "\uE7B1",
|
|
142
|
+
gleam: "\uE6B4",
|
|
143
|
+
crystal: "\uE62F",
|
|
144
|
+
dart: "\uE7C0",
|
|
145
|
+
nim: "\uE677",
|
|
146
|
+
zig: "\uE6A9",
|
|
147
|
+
ocaml: "\uE67A",
|
|
148
|
+
clojure: "\uE76A",
|
|
149
|
+
scala: "\uE747",
|
|
150
|
+
perl: "\uE769",
|
|
151
|
+
r: "\uE68A",
|
|
152
|
+
elm: "\uE62C",
|
|
153
|
+
haxe: "\uE7B7",
|
|
154
|
+
vagrant: "\uE21A",
|
|
155
|
+
terraform: "\uE1A5",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
|
|
159
|
+
nodejs: "node",
|
|
160
|
+
rust: "rs",
|
|
161
|
+
go: "go",
|
|
162
|
+
python: "py",
|
|
163
|
+
ruby: "rb",
|
|
164
|
+
java: "java",
|
|
165
|
+
swift: "swift",
|
|
166
|
+
kotlin: "kt",
|
|
167
|
+
cpp: "c++",
|
|
168
|
+
c: "c",
|
|
169
|
+
deno: "deno",
|
|
170
|
+
bun: "bun",
|
|
171
|
+
php: "php",
|
|
172
|
+
haskell: "hs",
|
|
173
|
+
julia: "jl",
|
|
174
|
+
lua: "lua",
|
|
175
|
+
elixir: "ex",
|
|
176
|
+
erlang: "erl",
|
|
177
|
+
gleam: "gleam",
|
|
178
|
+
crystal: "cr",
|
|
179
|
+
dart: "dart",
|
|
180
|
+
nim: "nim",
|
|
181
|
+
zig: "zig",
|
|
182
|
+
ocaml: "ml",
|
|
183
|
+
clojure: "clj",
|
|
184
|
+
scala: "scala",
|
|
185
|
+
perl: "pl",
|
|
186
|
+
r: "R",
|
|
187
|
+
elm: "elm",
|
|
188
|
+
haxe: "hx",
|
|
189
|
+
vagrant: "vag",
|
|
190
|
+
terraform: "tf",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export function runtimeSymbol(name: string, mode: IconMode): string {
|
|
194
|
+
if (resolveIconMode(mode) === "ascii")
|
|
195
|
+
return RUNTIME_ASCII_SYMBOLS[name] ?? name;
|
|
196
|
+
return RUNTIME_SYMBOLS[name] ?? "";
|
|
197
|
+
}
|