@bugabinga/pi-ext-ghost 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/CHANGELOG.md +12 -0
- package/README.md +11 -0
- package/__tests__/config.test.ts +110 -0
- package/__tests__/overlay-harness.ts +148 -0
- package/__tests__/overlay-render.test.ts +159 -0
- package/assets/advisor_suite.gif +0 -0
- package/config.ts +163 -0
- package/index.ts +227 -0
- package/overlay.ts +602 -0
- package/package.json +17 -0
package/index.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentSession,
|
|
3
|
+
type AgentSessionEvent,
|
|
4
|
+
createAgentSession,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
SessionManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import { GhostOverlayComponent } from "./overlay.js";
|
|
11
|
+
import { loadGhostConfig, type GhostConfig, type GhostConfigError } from "./config.js";
|
|
12
|
+
|
|
13
|
+
type GhostUi = Pick<ExtensionContext["ui"], "notify" | "setWidget">;
|
|
14
|
+
|
|
15
|
+
type GhostContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry" | "ui">;
|
|
16
|
+
|
|
17
|
+
interface GhostRuntimeState {
|
|
18
|
+
session: AgentSession | null;
|
|
19
|
+
sessionCwd: string | null;
|
|
20
|
+
modelLabel: string | null;
|
|
21
|
+
overlayHandle: OverlayHandle | null;
|
|
22
|
+
overlay: GhostOverlayComponent | null;
|
|
23
|
+
unsubscribeSession: (() => void) | null;
|
|
24
|
+
eventLog: AgentSessionEvent[];
|
|
25
|
+
config: GhostConfig | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type GhostOverlayResult = "hide" | "close";
|
|
29
|
+
|
|
30
|
+
const WIDGET_KEY = "pi-ghost";
|
|
31
|
+
const COMMAND = "ghost";
|
|
32
|
+
const UNKNOWN_MODEL = "unknown-model";
|
|
33
|
+
const HIDDEN_WIDGET_TEXT = "is running • run /ghost to bring it back";
|
|
34
|
+
|
|
35
|
+
export default function (pi: ExtensionAPI) {
|
|
36
|
+
const state: GhostRuntimeState = {
|
|
37
|
+
session: null,
|
|
38
|
+
sessionCwd: null,
|
|
39
|
+
modelLabel: null,
|
|
40
|
+
overlayHandle: null,
|
|
41
|
+
overlay: null,
|
|
42
|
+
unsubscribeSession: null,
|
|
43
|
+
eventLog: [],
|
|
44
|
+
config: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const loadConfig = (ctx: GhostContext): GhostConfig => {
|
|
48
|
+
const config = loadGhostConfig(ctx.cwd, (error) => notifyConfigError(ctx.ui, error));
|
|
49
|
+
state.config = config;
|
|
50
|
+
return config;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const cleanupGhost = (ui?: Pick<GhostUi, "setWidget">): void => {
|
|
54
|
+
state.overlayHandle?.hide();
|
|
55
|
+
state.overlayHandle = null;
|
|
56
|
+
state.overlay = null;
|
|
57
|
+
state.unsubscribeSession?.();
|
|
58
|
+
state.unsubscribeSession = null;
|
|
59
|
+
state.session?.dispose();
|
|
60
|
+
state.session = null;
|
|
61
|
+
state.sessionCwd = null;
|
|
62
|
+
state.modelLabel = null;
|
|
63
|
+
state.eventLog = [];
|
|
64
|
+
ui?.setWidget(WIDGET_KEY, undefined);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const ensureGhostSession = async (ctx: ExtensionContext): Promise<AgentSession> => {
|
|
68
|
+
if (state.session) return state.session;
|
|
69
|
+
if (!ctx.model) throw new Error("No model selected");
|
|
70
|
+
|
|
71
|
+
const config = state.config ?? loadConfig(ctx);
|
|
72
|
+
const model = config.model?.provider && config.model?.id
|
|
73
|
+
? ctx.modelRegistry.find(config.model.provider, config.model.id) ?? ctx.model
|
|
74
|
+
: ctx.model;
|
|
75
|
+
|
|
76
|
+
const result = await createAgentSession({
|
|
77
|
+
cwd: ctx.cwd,
|
|
78
|
+
model,
|
|
79
|
+
modelRegistry: ctx.modelRegistry,
|
|
80
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
state.session = result.session;
|
|
84
|
+
state.sessionCwd = ctx.cwd;
|
|
85
|
+
state.modelLabel = model.id;
|
|
86
|
+
state.eventLog = [];
|
|
87
|
+
state.unsubscribeSession = result.session.subscribe((event) => {
|
|
88
|
+
state.eventLog.push(event);
|
|
89
|
+
state.overlay?.handleSessionEvent(event);
|
|
90
|
+
});
|
|
91
|
+
return result.session;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const openGhostOverlay = async (
|
|
95
|
+
ctx: ExtensionContext,
|
|
96
|
+
initialPrompt?: string,
|
|
97
|
+
): Promise<void> => {
|
|
98
|
+
const config = state.config ?? loadConfig(ctx);
|
|
99
|
+
const session = await ensureGhostSession(ctx);
|
|
100
|
+
const prompt = initialPrompt?.trim();
|
|
101
|
+
|
|
102
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
103
|
+
|
|
104
|
+
void ctx.ui.custom<GhostOverlayResult>(
|
|
105
|
+
(tui, theme, _keybindings, done) => {
|
|
106
|
+
const overlay = new GhostOverlayComponent({
|
|
107
|
+
tui,
|
|
108
|
+
theme,
|
|
109
|
+
sessionCwd: state.sessionCwd ?? ctx.cwd,
|
|
110
|
+
modelLabel: state.modelLabel ?? ctx.model?.id ?? UNKNOWN_MODEL,
|
|
111
|
+
maxHeight: config.overlay.maxHeight,
|
|
112
|
+
margin: config.overlay.margin,
|
|
113
|
+
onSubmitMessage: (text) => void session.prompt(text, { images: [] }),
|
|
114
|
+
onHideOverlay: () => {
|
|
115
|
+
state.overlayHandle = null;
|
|
116
|
+
done("hide");
|
|
117
|
+
},
|
|
118
|
+
onCloseOverlay: () => {
|
|
119
|
+
state.overlayHandle = null;
|
|
120
|
+
done("close");
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
state.overlay = overlay;
|
|
124
|
+
if (state.eventLog.length > 0) overlay.loadEvents(state.eventLog);
|
|
125
|
+
else overlay.loadMessages(session.messages, session.isStreaming);
|
|
126
|
+
|
|
127
|
+
if (prompt) void session.prompt(prompt, { images: [] });
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
render: (width: number) => overlay.render(width),
|
|
131
|
+
invalidate: () => overlay.invalidate(),
|
|
132
|
+
handleInput: (data: string) => overlay.handleInput(data),
|
|
133
|
+
get focused() {
|
|
134
|
+
return overlay.focused;
|
|
135
|
+
},
|
|
136
|
+
set focused(value: boolean) {
|
|
137
|
+
overlay.focused = value;
|
|
138
|
+
},
|
|
139
|
+
dispose: () => {
|
|
140
|
+
if (state.overlay === overlay) state.overlay = null;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
overlay: true,
|
|
146
|
+
overlayOptions: {
|
|
147
|
+
anchor: config.overlay.anchor,
|
|
148
|
+
width: config.overlay.width,
|
|
149
|
+
maxHeight: config.overlay.maxHeight,
|
|
150
|
+
margin: config.overlay.margin,
|
|
151
|
+
},
|
|
152
|
+
onHandle: (handle) => {
|
|
153
|
+
state.overlayHandle = handle;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
.then((result) => {
|
|
158
|
+
state.overlayHandle = null;
|
|
159
|
+
if (result === "hide" && state.session) {
|
|
160
|
+
ctx.ui.setWidget(WIDGET_KEY, hiddenWidget, { placement: "aboveEditor" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
cleanupGhost(ctx.ui);
|
|
164
|
+
})
|
|
165
|
+
.catch(() => cleanupGhost(ctx.ui));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const showOrPromptGhost = async (
|
|
169
|
+
ctx: ExtensionContext,
|
|
170
|
+
prompt?: string,
|
|
171
|
+
): Promise<void> => {
|
|
172
|
+
const handle = state.overlayHandle;
|
|
173
|
+
if (!handle) return openGhostOverlay(ctx, prompt);
|
|
174
|
+
|
|
175
|
+
if (!prompt) {
|
|
176
|
+
handle.focus();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const session = await ensureGhostSession(ctx);
|
|
181
|
+
void session.prompt(prompt, { images: [] });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
pi.registerCommand(COMMAND, {
|
|
185
|
+
description: "Open ghost pi overlay",
|
|
186
|
+
handler: async (args, ctx) => {
|
|
187
|
+
if (!ctx.hasUI) {
|
|
188
|
+
ctx.ui.notify("/ghost requires interactive mode", "error");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await showOrPromptGhost(ctx, args.trim() || undefined);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.registerShortcut("alt+g", {
|
|
197
|
+
description: "Open ghost pi overlay",
|
|
198
|
+
handler: async (ctx) => {
|
|
199
|
+
if (!ctx.hasUI) return;
|
|
200
|
+
await showOrPromptGhost(ctx);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
205
|
+
loadConfig(ctx);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
209
|
+
cleanupGhost(ctx.ui);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hiddenWidget(_tui: TUI, theme: ExtensionContext["ui"]["theme"]) {
|
|
214
|
+
return {
|
|
215
|
+
render: (width: number) => [
|
|
216
|
+
truncateToWidth(
|
|
217
|
+
theme.fg("accent", "/ghost ") + theme.fg("dim", HIDDEN_WIDGET_TEXT),
|
|
218
|
+
width,
|
|
219
|
+
),
|
|
220
|
+
],
|
|
221
|
+
invalidate: () => {},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function notifyConfigError(ui: Pick<GhostUi, "notify">, error: GhostConfigError): void {
|
|
226
|
+
ui.notify(`Ignored ${error.path}: ${error.message}`, "warning");
|
|
227
|
+
}
|