@leo-alvarenga/pi-zen-frame 0.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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/docs/preview.png +0 -0
- package/extensions/components/agent-mode.ts +25 -0
- package/extensions/components/cwd.ts +31 -0
- package/extensions/components/frame.ts +140 -0
- package/extensions/components/header.ts +192 -0
- package/extensions/components/model.ts +16 -0
- package/extensions/components/reasoning.ts +22 -0
- package/extensions/components/registry.ts +42 -0
- package/extensions/components/spinner.ts +24 -0
- package/extensions/components/token-count.ts +55 -0
- package/extensions/components/types.ts +106 -0
- package/extensions/config/constants.ts +279 -0
- package/extensions/config/settings.ts +150 -0
- package/extensions/config/types.ts +115 -0
- package/extensions/editor/frame-editor.ts +197 -0
- package/extensions/index.ts +261 -0
- package/extensions/utils/agent.ts +43 -0
- package/extensions/utils/git.ts +32 -0
- package/extensions/utils/index.ts +16 -0
- package/extensions/utils/path.ts +18 -0
- package/extensions/utils/string.ts +6 -0
- package/package.json +42 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FrameEditor — the editor component. Subclasses CustomEditor so all of
|
|
3
|
+
* pi's editing/undo/app-keybinding machinery keeps working; `render` layers
|
|
4
|
+
* the box frame. Vim behavior was removed — all input passes through to the
|
|
5
|
+
* base editor. Appearance lives in `components/` (segments + frame).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
CustomEditor,
|
|
9
|
+
ThemeColor,
|
|
10
|
+
type ExtensionAPI,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_ICONS, SPINNER_FRAMES } from "../config/constants";
|
|
14
|
+
import type { FrameSettings, SpinnerPhase } from "../config/types";
|
|
15
|
+
import { renderFrame, fitInfoRow } from "../components/frame";
|
|
16
|
+
import { segmentsFor } from "../components/registry";
|
|
17
|
+
import type {
|
|
18
|
+
ExternalData,
|
|
19
|
+
FrameData,
|
|
20
|
+
SegmentContext,
|
|
21
|
+
} from "../components/types";
|
|
22
|
+
import { isThemeColor } from "../utils";
|
|
23
|
+
|
|
24
|
+
export interface FrameEditorOptions {
|
|
25
|
+
frame: FrameSettings;
|
|
26
|
+
accentColor: ThemeColor;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FrameEditor extends CustomEditor {
|
|
30
|
+
private pi: ExtensionAPI;
|
|
31
|
+
private opts: FrameEditorOptions;
|
|
32
|
+
private provider: (pi: ExtensionAPI) => ExternalData;
|
|
33
|
+
|
|
34
|
+
private spinnerIdx = 0;
|
|
35
|
+
private spinnerPhase: SpinnerPhase | null = null;
|
|
36
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
pi: ExtensionAPI,
|
|
40
|
+
provider: (pi: ExtensionAPI) => ExternalData,
|
|
41
|
+
opts: FrameEditorOptions,
|
|
42
|
+
...args: ConstructorParameters<typeof CustomEditor>
|
|
43
|
+
) {
|
|
44
|
+
super(...args);
|
|
45
|
+
|
|
46
|
+
this.pi = pi;
|
|
47
|
+
this.opts = opts;
|
|
48
|
+
this.provider = provider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSpinner(phase: SpinnerPhase | null): void {
|
|
52
|
+
if (this.spinnerPhase === phase) return;
|
|
53
|
+
|
|
54
|
+
this.spinnerIdx = 0;
|
|
55
|
+
this.spinnerPhase = phase;
|
|
56
|
+
this.clearSpinnerTimer();
|
|
57
|
+
|
|
58
|
+
if (phase && SPINNER_FRAMES[phase]) {
|
|
59
|
+
const frames = SPINNER_FRAMES[phase];
|
|
60
|
+
|
|
61
|
+
this.spinnerTimer = setInterval(() => {
|
|
62
|
+
this.spinnerIdx = (this.spinnerIdx + 1) % frames.length;
|
|
63
|
+
this.tui.requestRender();
|
|
64
|
+
}, 80);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.tui.requestRender();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stopSpinner(): void {
|
|
71
|
+
this.clearSpinnerTimer();
|
|
72
|
+
this.spinnerPhase = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
refresh(): void {
|
|
76
|
+
this.tui.requestRender();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── rendering ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
render(width: number): string[] {
|
|
82
|
+
const frame = this.opts.frame;
|
|
83
|
+
|
|
84
|
+
const padX = Math.min(
|
|
85
|
+
frame.paddingX ?? 1,
|
|
86
|
+
Math.max(0, Math.floor((width - 2) / 2)),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const innerWidth = width - 2 - padX * 2;
|
|
90
|
+
if (innerWidth < 8) return super.render(width);
|
|
91
|
+
|
|
92
|
+
const ext = this.provider(this.pi);
|
|
93
|
+
const d: FrameData = {
|
|
94
|
+
cwd: ext.cwd,
|
|
95
|
+
context: ext.context,
|
|
96
|
+
gitDirty: ext.gitDirty,
|
|
97
|
+
agentMode: ext.agentMode,
|
|
98
|
+
gitBranch: ext.gitBranch,
|
|
99
|
+
modelName: ext.modelName,
|
|
100
|
+
spinnerPhase: ext.spinnerPhase,
|
|
101
|
+
thinkingLevel: ext.thinkingLevel,
|
|
102
|
+
spinnerFrame: this.spinnerFrame(),
|
|
103
|
+
accentColor: this.opts.accentColor,
|
|
104
|
+
zenMode: ext.zenMode,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let prefix = frame.prefix ?? "❯";
|
|
108
|
+
|
|
109
|
+
if (ext.theme?.fg) {
|
|
110
|
+
let borderColor: ThemeColor = "accent";
|
|
111
|
+
|
|
112
|
+
if (frame.borderColor === "agentMode") {
|
|
113
|
+
borderColor = ext.agentMode?.color ?? "accent";
|
|
114
|
+
} else {
|
|
115
|
+
borderColor = frame.borderColor ?? "accent";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let prefixColor: ThemeColor = "text";
|
|
119
|
+
if (frame.prefixColor === "agentMode") {
|
|
120
|
+
prefixColor = ext.agentMode?.color ?? "accent";
|
|
121
|
+
} else if (frame.prefixColor === "frameBorder") {
|
|
122
|
+
prefixColor = borderColor;
|
|
123
|
+
} else if (frame.prefixColor && isThemeColor(frame.prefixColor)) {
|
|
124
|
+
prefixColor = frame.prefixColor;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
prefix = ext.theme.fg(prefixColor, prefix);
|
|
128
|
+
|
|
129
|
+
this.borderColor = (s: string) => ext.theme!.fg(borderColor, s);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// No theme (non-TUI) or frame disabled or too narrow → plain editor.
|
|
133
|
+
if (!ext.theme || !frame.enable || width < (frame.minWidth ?? 20)) {
|
|
134
|
+
return super.render(width);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const inner = super.render(innerWidth);
|
|
138
|
+
const ctx: SegmentContext = {
|
|
139
|
+
cfg: frame,
|
|
140
|
+
theme: ext.theme,
|
|
141
|
+
border: this.borderColor,
|
|
142
|
+
icons: {
|
|
143
|
+
...DEFAULT_ICONS,
|
|
144
|
+
...frame.icons,
|
|
145
|
+
},
|
|
146
|
+
segColor: (key, fallback) =>
|
|
147
|
+
d.zenMode && key !== "agentMode"
|
|
148
|
+
? "muted"
|
|
149
|
+
: (frame.colors?.[key] ?? fallback),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const topLine = fitInfoRow(
|
|
153
|
+
segmentsFor("topLeft", d, ctx),
|
|
154
|
+
segmentsFor("topRight", d, ctx),
|
|
155
|
+
width,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const bottomLine = fitInfoRow(
|
|
159
|
+
segmentsFor("bottomLeft", d, ctx),
|
|
160
|
+
segmentsFor("bottomRight", d, ctx),
|
|
161
|
+
width,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const box = renderFrame(inner, {
|
|
165
|
+
width,
|
|
166
|
+
prefix,
|
|
167
|
+
paddingX: padX,
|
|
168
|
+
border: this.borderColor,
|
|
169
|
+
paddingTop: frame.paddingTop ?? 1,
|
|
170
|
+
paddingBottom: frame.paddingBottom ?? 1,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Blank rows OUTSIDE the box, above/below its borders.
|
|
174
|
+
const marginRow = " ".repeat(width);
|
|
175
|
+
const marginTop = Array(Math.max(0, frame.marginTop ?? 0)).fill(marginRow);
|
|
176
|
+
const marginBottom = Array(Math.max(0, frame.marginBottom ?? 0)).fill(
|
|
177
|
+
marginRow,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return [...marginTop, topLine, ...box, bottomLine, ...marginBottom];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private spinnerFrame(): string {
|
|
184
|
+
if (!this.spinnerPhase) return "";
|
|
185
|
+
|
|
186
|
+
const frames = SPINNER_FRAMES[this.spinnerPhase]!;
|
|
187
|
+
|
|
188
|
+
return frames[this.spinnerIdx % frames.length]!;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private clearSpinnerTimer(): void {
|
|
192
|
+
if (this.spinnerTimer) {
|
|
193
|
+
clearInterval(this.spinnerTimer);
|
|
194
|
+
this.spinnerTimer = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-zen-frame entry. Thin wiring layer:
|
|
3
|
+
* - loads config, subscribes to pi lifecycle + mode/agent-mode events
|
|
4
|
+
* - installs FrameEditor via setEditorComponent (factory is re-run on
|
|
5
|
+
* model switch — the old instance's spinner timer is stopped first)
|
|
6
|
+
* - assembles ExternalData per paint (model, thinking, context, git, ...)
|
|
7
|
+
*
|
|
8
|
+
* All appearance logic lives in components/ (segments + frame); the editor
|
|
9
|
+
* component lives in editor/. Nothing here renders anything.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionContext,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_SETTINGS,
|
|
22
|
+
PI_AGENT_MANAGER_AGENT_EVENT,
|
|
23
|
+
SPINNER_FRAMES,
|
|
24
|
+
ZEN_MODE_DEFAULT_KEY,
|
|
25
|
+
ZEN_MODE_SHORTCUT_ID,
|
|
26
|
+
} from "./config/constants";
|
|
27
|
+
import { loadSettings } from "./config/settings";
|
|
28
|
+
import type { Settings, SpinnerPhase } from "./config/types";
|
|
29
|
+
import type { AgentMode, AgentState, ExternalData } from "./components/types";
|
|
30
|
+
import { FrameEditor } from "./editor/frame-editor";
|
|
31
|
+
import {
|
|
32
|
+
capitalize,
|
|
33
|
+
readAgentModeFromSession,
|
|
34
|
+
readGit,
|
|
35
|
+
type GitInfo,
|
|
36
|
+
} from "./utils";
|
|
37
|
+
import { createHeader, type HeaderEnv } from "./components/header";
|
|
38
|
+
|
|
39
|
+
let editor: FrameEditor | null = null;
|
|
40
|
+
let currentCtx: ExtensionContext | null = null;
|
|
41
|
+
let settings: Settings = DEFAULT_SETTINGS;
|
|
42
|
+
let spinnerPhase: SpinnerPhase | null = null;
|
|
43
|
+
let agentMode: AgentMode = null;
|
|
44
|
+
let zenMode = true;
|
|
45
|
+
let git: GitInfo = {
|
|
46
|
+
branch: undefined,
|
|
47
|
+
dirty: 0,
|
|
48
|
+
};
|
|
49
|
+
let workingMessageTimer: ReturnType<typeof setInterval> | null = null;
|
|
50
|
+
let workingMessageBag: string[] = [];
|
|
51
|
+
|
|
52
|
+
/** Pop a random message; reshuffles when the pool is exhausted (no back-to-back repeats). */
|
|
53
|
+
function nextWorkingMessage(): string {
|
|
54
|
+
if (workingMessageBag.length === 0) {
|
|
55
|
+
workingMessageBag = [...(settings.workingMessage?.messages ?? [])];
|
|
56
|
+
|
|
57
|
+
for (let i = workingMessageBag.length - 1; i > 0; i--) {
|
|
58
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
59
|
+
[workingMessageBag[i], workingMessageBag[j]] = [
|
|
60
|
+
workingMessageBag[j],
|
|
61
|
+
workingMessageBag[i],
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (workingMessageBag.pop() ?? "").concat("...");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── data sources ─────────────────────────────────────────────────────────
|
|
70
|
+
//
|
|
71
|
+
|
|
72
|
+
/** Live env snapshot for the header's right column. Reads current git/ctx so
|
|
73
|
+
* model switches are reflected on the next render. */
|
|
74
|
+
function getHeaderEnv(pi: ExtensionAPI): HeaderEnv {
|
|
75
|
+
return {
|
|
76
|
+
gitDirty: git.dirty,
|
|
77
|
+
gitBranch: git.branch,
|
|
78
|
+
cwd: currentCtx?.cwd ?? "",
|
|
79
|
+
modelName: provideExternal(pi).modelName,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function provideExternal(pi: ExtensionAPI): ExternalData {
|
|
84
|
+
const ctx = currentCtx;
|
|
85
|
+
const usage = ctx?.getContextUsage?.();
|
|
86
|
+
const model = ctx?.model;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
zenMode,
|
|
90
|
+
agentMode,
|
|
91
|
+
spinnerPhase,
|
|
92
|
+
cwd: ctx?.cwd ?? "",
|
|
93
|
+
gitDirty: git.dirty,
|
|
94
|
+
gitBranch: git.branch,
|
|
95
|
+
theme: ctx?.ui.theme,
|
|
96
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
97
|
+
modelName: `${model?.name ?? model?.id ?? "Unknown"} (${capitalize(model?.provider ?? "unknown")})`,
|
|
98
|
+
context: usage
|
|
99
|
+
? {
|
|
100
|
+
tokens: usage.tokens,
|
|
101
|
+
window: usage.contextWindow,
|
|
102
|
+
percent: usage.percent,
|
|
103
|
+
}
|
|
104
|
+
: null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setSpinner(phase: SpinnerPhase | null): void {
|
|
109
|
+
if (spinnerPhase === phase) return;
|
|
110
|
+
|
|
111
|
+
spinnerPhase = phase;
|
|
112
|
+
editor?.setSpinner(phase);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Keys bound to the zen toggle in keybindings.json; default key if unset.
|
|
116
|
+
* `[]` disables the shortcut (the /zen_mode command still works). */
|
|
117
|
+
function zenModeKeys(): string[] {
|
|
118
|
+
try {
|
|
119
|
+
const raw = JSON.parse(
|
|
120
|
+
readFileSync(join(getAgentDir(), "keybindings.json"), "utf8"),
|
|
121
|
+
) as Record<string, unknown>;
|
|
122
|
+
const v = raw[ZEN_MODE_SHORTCUT_ID];
|
|
123
|
+
if (typeof v === "string") return [v];
|
|
124
|
+
if (Array.isArray(v) && v.every((k) => typeof k === "string")) return v;
|
|
125
|
+
} catch {
|
|
126
|
+
/* no keybindings file → default key */
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [ZEN_MODE_DEFAULT_KEY];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toggleZenMode(): boolean {
|
|
133
|
+
zenMode = !zenMode;
|
|
134
|
+
editor?.refresh();
|
|
135
|
+
return zenMode;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function notifyZen(ctx: {
|
|
139
|
+
ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
|
|
140
|
+
}): void {
|
|
141
|
+
ctx.ui.notify(toggleZenMode() ? "Zen mode: on" : "Zen mode: off", "info");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── extension entry ──────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export default async function (pi: ExtensionAPI) {
|
|
147
|
+
settings = await loadSettings();
|
|
148
|
+
zenMode = settings.zenMode !== false;
|
|
149
|
+
|
|
150
|
+
pi.registerCommand("zen_mode", {
|
|
151
|
+
description:
|
|
152
|
+
"Toggle Editor Zen mode (all segments muted except agent mode)",
|
|
153
|
+
handler: async (_, ctx) => notifyZen(ctx),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
for (const key of zenModeKeys()) {
|
|
157
|
+
pi.registerShortcut(key as KeyId, {
|
|
158
|
+
description: "Toggle Zen mode",
|
|
159
|
+
handler: (ctx) => notifyZen(ctx),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Optional pi-agent-manager integration (no hard dependency).
|
|
164
|
+
pi.events.on(PI_AGENT_MANAGER_AGENT_EVENT, (event) => {
|
|
165
|
+
const state = event as AgentState | undefined;
|
|
166
|
+
|
|
167
|
+
if (state?.currentAgentConfig?.name) {
|
|
168
|
+
agentMode = {
|
|
169
|
+
icon: state.currentAgentConfig.icon,
|
|
170
|
+
color: state.currentAgentConfig.color,
|
|
171
|
+
name: state.currentAgentLabel || state.currentAgentConfig.name,
|
|
172
|
+
};
|
|
173
|
+
} else if (state) {
|
|
174
|
+
agentMode = { name: state.currentAgentLabel || state.currentAgent };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
editor?.refresh();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
181
|
+
currentCtx = ctx;
|
|
182
|
+
agentMode = readAgentModeFromSession(ctx) || agentMode;
|
|
183
|
+
|
|
184
|
+
git = readGit(ctx.cwd);
|
|
185
|
+
|
|
186
|
+
ctx.ui.setWorkingIndicator({
|
|
187
|
+
intervalMs: 80,
|
|
188
|
+
frames: SPINNER_FRAMES.outputting,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Rotating messages in the built-in working loader while streaming.
|
|
192
|
+
const wm = settings.workingMessage;
|
|
193
|
+
if (wm && wm.enable !== false && (wm.messages?.length ?? 0) > 0) {
|
|
194
|
+
const intervalMs = Math.max(100, wm.intervalMs ?? 3000);
|
|
195
|
+
|
|
196
|
+
ctx.ui.setWorkingMessage(nextWorkingMessage());
|
|
197
|
+
|
|
198
|
+
workingMessageTimer = setInterval(() => {
|
|
199
|
+
ctx.ui.setWorkingMessage(nextWorkingMessage());
|
|
200
|
+
}, intervalMs);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (settings.header?.enable) {
|
|
204
|
+
ctx.ui.setHeader((_tui, theme) =>
|
|
205
|
+
createHeader(_tui, theme, pi, settings, getHeaderEnv),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ctx.ui.setFooter(() => ({
|
|
210
|
+
render() {
|
|
211
|
+
return [];
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
invalidate() {},
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
ctx.ui.setEditorComponent((...args) => {
|
|
218
|
+
editor?.stopSpinner();
|
|
219
|
+
|
|
220
|
+
editor = new FrameEditor(
|
|
221
|
+
pi,
|
|
222
|
+
provideExternal,
|
|
223
|
+
{
|
|
224
|
+
frame: settings.frame ?? DEFAULT_SETTINGS.frame!,
|
|
225
|
+
accentColor:
|
|
226
|
+
settings.accentColor ?? DEFAULT_SETTINGS.accentColor ?? "accent",
|
|
227
|
+
},
|
|
228
|
+
...args,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return editor;
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.on("session_shutdown", () => {
|
|
236
|
+
if (workingMessageTimer) clearInterval(workingMessageTimer);
|
|
237
|
+
workingMessageTimer = null;
|
|
238
|
+
workingMessageBag = [];
|
|
239
|
+
|
|
240
|
+
editor?.stopSpinner();
|
|
241
|
+
editor = null;
|
|
242
|
+
currentCtx = null;
|
|
243
|
+
spinnerPhase = null;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Spinner phases
|
|
247
|
+
pi.on("agent_end", () => setSpinner(null));
|
|
248
|
+
pi.on("turn_start", () => setSpinner("thinking"));
|
|
249
|
+
pi.on("tool_execution_start", () => setSpinner("exec"));
|
|
250
|
+
|
|
251
|
+
pi.on("message_update", (event) => {
|
|
252
|
+
const type = event?.assistantMessageEvent?.type;
|
|
253
|
+
|
|
254
|
+
if (typeof type !== "string") return;
|
|
255
|
+
|
|
256
|
+
if (type.startsWith("text_")) setSpinner("outputting");
|
|
257
|
+
else if (type.startsWith("thinking_")) setSpinner("thinking");
|
|
258
|
+
else if (type.startsWith("toolcall_")) setSpinner("toolcall");
|
|
259
|
+
else setSpinner("idle");
|
|
260
|
+
});
|
|
261
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain: agent-mode (pi-agent-manager) integration.
|
|
3
|
+
* Optional — no hard dependency on the manager.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { PI_AGENT_MANAGER_AGENT_DATA_KEY } from "../config/constants";
|
|
8
|
+
import { AgentMode, AgentState } from "../components/types";
|
|
9
|
+
|
|
10
|
+
/** Walk the session branch (newest first) for the latest pi-agent-manager agent
|
|
11
|
+
* entry. Returns null when the manager is absent or nothing is found. */
|
|
12
|
+
export function readAgentModeFromSession(ctx: ExtensionContext): AgentMode {
|
|
13
|
+
try {
|
|
14
|
+
const entries = [...ctx.sessionManager.getBranch()].reverse();
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
17
|
+
|
|
18
|
+
if (
|
|
19
|
+
e.type !== "custom" ||
|
|
20
|
+
e.customType !== PI_AGENT_MANAGER_AGENT_DATA_KEY
|
|
21
|
+
) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const state = e.data as AgentState | undefined;
|
|
26
|
+
|
|
27
|
+
if (state) {
|
|
28
|
+
return {
|
|
29
|
+
icon: state.currentAgentConfig?.icon,
|
|
30
|
+
color: state.currentAgentConfig?.color,
|
|
31
|
+
name:
|
|
32
|
+
state.currentAgentLabel ||
|
|
33
|
+
state.currentAgent ||
|
|
34
|
+
state.currentAgentConfig?.name,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// pi-agent-manager absent or session unreadable — silently stay unknown.
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Domain: git interactions. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
/** Snapshot of the repository state at read time. */
|
|
5
|
+
export interface GitInfo {
|
|
6
|
+
branch: string | undefined;
|
|
7
|
+
/** Number of lines in `git status --porcelain` (0 = clean). */
|
|
8
|
+
dirty: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Best-effort git snapshot for a working directory. Falls back to
|
|
12
|
+
* `{ branch: undefined, dirty: 0 }` when git is unavailable or fails. */
|
|
13
|
+
export function readGit(cwd: string): GitInfo {
|
|
14
|
+
try {
|
|
15
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
16
|
+
cwd,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
timeout: 2000,
|
|
20
|
+
}).trim();
|
|
21
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
22
|
+
cwd,
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
timeout: 2000,
|
|
26
|
+
});
|
|
27
|
+
const dirty = status.split("\n").filter((l) => l.trim() !== "").length;
|
|
28
|
+
return { branch: branch || undefined, dirty };
|
|
29
|
+
} catch {
|
|
30
|
+
return { branch: undefined, dirty: 0 };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { THEME_COLORS } from "../config/constants";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helpers, grouped by domain and re-exported from a single barrel.
|
|
6
|
+
* Import everything with `import { ... } from "../utils"`.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./agent";
|
|
9
|
+
export * from "./git";
|
|
10
|
+
export * from "./path";
|
|
11
|
+
export * from "./string";
|
|
12
|
+
|
|
13
|
+
export function isThemeColor(value: string): value is ThemeColor {
|
|
14
|
+
return value in THEME_COLORS;
|
|
15
|
+
}
|
|
16
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Domain: path helpers. */
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Shorten an absolute path for display: `~`-ify the home dir, and collapse
|
|
6
|
+
* to the basename when the path is deeply nested. */
|
|
7
|
+
export function getShortCwd(cwd: string): string {
|
|
8
|
+
const home = homedir();
|
|
9
|
+
|
|
10
|
+
let path = cwd.replace(home, "~");
|
|
11
|
+
const subpathCount = path.match(/\//gm)?.length ?? 0;
|
|
12
|
+
|
|
13
|
+
if (subpathCount > 3) {
|
|
14
|
+
path = basename(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return path;
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leo-alvarenga/pi-zen-frame",
|
|
3
|
+
"description": "A pi-coding-agent extension that gives the TUI editor a simple yet polished look and feel",
|
|
4
|
+
"author": "Leonardo A. Alvarenga",
|
|
5
|
+
"version": "0.11.0",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github.com/leo-alvarenga/pi-mono",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"extensions",
|
|
17
|
+
"docs",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
23
|
+
"@earendil-works/pi-tui": "*"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./extensions/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"allowScripts": {
|
|
31
|
+
"@google/genai@1.52.0": true,
|
|
32
|
+
"protobufjs@7.6.5": true
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^26.1.2",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"typecheck": "tsc"
|
|
41
|
+
}
|
|
42
|
+
}
|