@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-05-21
|
|
4
|
+
|
|
5
|
+
- a40a427 prepare extensions for npm release
|
|
6
|
+
- 133cb7d chore(pi): migrate extensions to earendil packages
|
|
7
|
+
- db7b1c5 Update agent and terminal configs
|
|
8
|
+
- 5ca1296 Rework Pi agent extensions
|
|
9
|
+
- b045a6b fix(pi/mux): cooldown exhausted keys on provider usage-limit errors
|
|
10
|
+
- b87a61a feat(pi): monorepo workspace — all extensions are proper packages
|
|
11
|
+
- 5fbf178 pi(ext): add remaining extensions (angel, animations, code-actions, files-widget, ghost, git-safe, neko, prune, web, etc.)
|
|
12
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# ghost
|
|
2
|
+
|
|
3
|
+
Background ghost agent helper for Pi.
|
|
4
|
+
|
|
5
|
+
Runs a secondary Pi agent session for background analysis and coordination.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+
<!-- demo:advisor_suite:start -->
|
|
10
|
+

|
|
11
|
+
<!-- demo:advisor_suite:end -->
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { defaultConfig, loadGhostConfig, normalizeGhostConfig } from "../config.ts";
|
|
6
|
+
|
|
7
|
+
describe("normalizeGhostConfig", () => {
|
|
8
|
+
it("uses strict defaults for empty config", () => {
|
|
9
|
+
expect(normalizeGhostConfig()).toEqual(defaultConfig);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("lets project config override global config field by field", () => {
|
|
13
|
+
const config = normalizeGhostConfig(
|
|
14
|
+
{
|
|
15
|
+
model: { provider: "anthropic", id: "global-model" },
|
|
16
|
+
overlay: {
|
|
17
|
+
anchor: "top-left",
|
|
18
|
+
width: "50%",
|
|
19
|
+
maxHeight: "40%",
|
|
20
|
+
margin: { top: 1, bottom: 2, left: 3, right: 4 },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
model: { id: "project-model" },
|
|
25
|
+
overlay: { width: "90%", margin: { bottom: 9 } },
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(config).toEqual({
|
|
30
|
+
model: { provider: "anthropic", id: "project-model" },
|
|
31
|
+
overlay: {
|
|
32
|
+
anchor: "top-left",
|
|
33
|
+
width: "90%",
|
|
34
|
+
maxHeight: "40%",
|
|
35
|
+
margin: { top: 1, bottom: 9, left: 3, right: 4 },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects invalid anchors, sizes, margins, and model values", () => {
|
|
41
|
+
const config = normalizeGhostConfig(
|
|
42
|
+
{
|
|
43
|
+
model: { provider: "", id: 42 },
|
|
44
|
+
overlay: {
|
|
45
|
+
anchor: "bottom",
|
|
46
|
+
width: "wide",
|
|
47
|
+
maxHeight: "0%",
|
|
48
|
+
margin: { top: -1, bottom: Number.NaN, left: "2", right: Infinity },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(config).toEqual(defaultConfig);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("accepts every real pi overlay anchor", () => {
|
|
58
|
+
for (const anchor of [
|
|
59
|
+
"center",
|
|
60
|
+
"top-left",
|
|
61
|
+
"top-center",
|
|
62
|
+
"top-right",
|
|
63
|
+
"right-center",
|
|
64
|
+
"bottom-right",
|
|
65
|
+
"bottom-center",
|
|
66
|
+
"bottom-left",
|
|
67
|
+
"left-center",
|
|
68
|
+
]) {
|
|
69
|
+
expect(normalizeGhostConfig({}, { overlay: { anchor } }).overlay.anchor).toBe(anchor);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts numeric and percentage sizes only", () => {
|
|
74
|
+
expect(normalizeGhostConfig({}, { overlay: { width: 72, maxHeight: "33.5%" } }).overlay)
|
|
75
|
+
.toMatchObject({ width: 72, maxHeight: "33.5%" });
|
|
76
|
+
expect(normalizeGhostConfig({}, { overlay: { width: 0, maxHeight: -1 } }).overlay)
|
|
77
|
+
.toMatchObject({
|
|
78
|
+
width: defaultConfig.overlay.width,
|
|
79
|
+
maxHeight: defaultConfig.overlay.maxHeight,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ignores deleted legacy junk without shaping config", () => {
|
|
84
|
+
const config = normalizeGhostConfig(
|
|
85
|
+
{},
|
|
86
|
+
{ documents: ["x"], interval: 10, behavior: { hideOnClose: false } },
|
|
87
|
+
);
|
|
88
|
+
expect(config).toEqual(defaultConfig);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("loadGhostConfig", () => {
|
|
93
|
+
it("reports invalid project JSON instead of silently swallowing it", () => {
|
|
94
|
+
const cwd = mkdtempSync(join(tmpdir(), "ghost-config-"));
|
|
95
|
+
const piDir = join(cwd, ".pi");
|
|
96
|
+
const errors: string[] = [];
|
|
97
|
+
|
|
98
|
+
mkdirSync(piDir);
|
|
99
|
+
writeFileSync(join(piDir, "ghost.json"), "{ nope", "utf-8");
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const config = loadGhostConfig(cwd, (error) => errors.push(`${error.path}: ${error.message}`));
|
|
103
|
+
expect(config).toEqual(defaultConfig);
|
|
104
|
+
expect(errors).toHaveLength(1);
|
|
105
|
+
expect(errors[0]).toContain("ghost.json");
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { expect } from "bun:test";
|
|
2
|
+
import { initTheme, type AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
4
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { GhostOverlayComponent } from "../overlay.ts";
|
|
8
|
+
|
|
9
|
+
initTheme("dark", false);
|
|
10
|
+
|
|
11
|
+
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
12
|
+
type MessageStartEvent = Extract<AgentSessionEvent, { type: "message_start" }>;
|
|
13
|
+
type MessageUpdateEvent = Extract<AgentSessionEvent, { type: "message_update" }>;
|
|
14
|
+
type MessageEndEvent = Extract<AgentSessionEvent, { type: "message_end" }>;
|
|
15
|
+
type ToolStartEvent = Extract<AgentSessionEvent, { type: "tool_execution_start" }>;
|
|
16
|
+
type ToolUpdateEvent = Extract<AgentSessionEvent, { type: "tool_execution_update" }>;
|
|
17
|
+
type ToolEndEvent = Extract<AgentSessionEvent, { type: "tool_execution_end" }>;
|
|
18
|
+
|
|
19
|
+
export interface OverlayHarness {
|
|
20
|
+
overlay: GhostOverlayComponent;
|
|
21
|
+
submitted: string[];
|
|
22
|
+
hiddenCount(): number;
|
|
23
|
+
closedCount(): number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const plainTheme = {
|
|
27
|
+
fg: (_color: string, text: string) => text,
|
|
28
|
+
bg: (_color: string, text: string) => text,
|
|
29
|
+
bold: (text: string) => text,
|
|
30
|
+
italic: (text: string) => text,
|
|
31
|
+
underline: (text: string) => text,
|
|
32
|
+
inverse: (text: string) => text,
|
|
33
|
+
strikethrough: (text: string) => text,
|
|
34
|
+
getFgAnsi: () => "",
|
|
35
|
+
getBgAnsi: () => "",
|
|
36
|
+
getColorMode: () => "truecolor",
|
|
37
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
38
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
39
|
+
} as Theme;
|
|
40
|
+
|
|
41
|
+
export function createOverlay(rows = 30, columns = 100, maxHeight: `${number}%` | number = "55%"): OverlayHarness {
|
|
42
|
+
const submitted: string[] = [];
|
|
43
|
+
let hidden = 0;
|
|
44
|
+
let closed = 0;
|
|
45
|
+
|
|
46
|
+
const overlay = new GhostOverlayComponent({
|
|
47
|
+
tui: minimalTui(rows, columns),
|
|
48
|
+
theme: plainTheme,
|
|
49
|
+
sessionCwd: `${process.env.HOME ?? "/home/me"}/repo/with/a/very/very/very/long/path`,
|
|
50
|
+
modelLabel: "provider/some-ridiculously-long-model-name-that-must-never-break-render-width",
|
|
51
|
+
maxHeight,
|
|
52
|
+
margin: { top: 0, bottom: 1 },
|
|
53
|
+
onSubmitMessage: (text) => submitted.push(text),
|
|
54
|
+
onHideOverlay: () => { hidden++; },
|
|
55
|
+
onCloseOverlay: () => { closed++; },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
overlay,
|
|
60
|
+
submitted,
|
|
61
|
+
hiddenCount: () => hidden,
|
|
62
|
+
closedCount: () => closed,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function expectLinesFit(lines: string[], width: number): void {
|
|
67
|
+
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assistantMessage(text: string): AssistantMessage {
|
|
71
|
+
return {
|
|
72
|
+
role: "assistant",
|
|
73
|
+
content: [{ type: "text", text }],
|
|
74
|
+
api: "test",
|
|
75
|
+
provider: "test",
|
|
76
|
+
model: "test",
|
|
77
|
+
usage: emptyUsage(),
|
|
78
|
+
stopReason: "stop",
|
|
79
|
+
timestamp: 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function userStart(text: string): MessageStartEvent {
|
|
84
|
+
return {
|
|
85
|
+
type: "message_start",
|
|
86
|
+
message: { role: "user", content: [{ type: "text", text }], timestamp: 0 },
|
|
87
|
+
} as MessageStartEvent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function assistantStart(text: string): MessageStartEvent {
|
|
91
|
+
return { type: "message_start", message: assistantMessage(text) } as MessageStartEvent;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assistantUpdate(text: string): MessageUpdateEvent {
|
|
95
|
+
return { type: "message_update", message: assistantMessage(text) } as MessageUpdateEvent;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function assistantEnd(text: string): MessageEndEvent {
|
|
99
|
+
return { type: "message_end", message: assistantMessage(text) } as MessageEndEvent;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toolStart(args: unknown = {}): ToolStartEvent {
|
|
103
|
+
return {
|
|
104
|
+
type: "tool_execution_start",
|
|
105
|
+
toolCallId: "tool-1",
|
|
106
|
+
toolName: "ghost_tool",
|
|
107
|
+
args,
|
|
108
|
+
} as ToolStartEvent;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function toolUpdate(text: string): ToolUpdateEvent {
|
|
112
|
+
return {
|
|
113
|
+
type: "tool_execution_update",
|
|
114
|
+
toolCallId: "tool-1",
|
|
115
|
+
toolName: "ghost_tool",
|
|
116
|
+
args: {},
|
|
117
|
+
partialResult: { content: [{ type: "text", text }] },
|
|
118
|
+
} as ToolUpdateEvent;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function toolEnd(text: string): ToolEndEvent {
|
|
122
|
+
return {
|
|
123
|
+
type: "tool_execution_end",
|
|
124
|
+
toolCallId: "tool-1",
|
|
125
|
+
toolName: "ghost_tool",
|
|
126
|
+
args: {},
|
|
127
|
+
result: { content: [{ type: "text", text }] },
|
|
128
|
+
isError: false,
|
|
129
|
+
} as ToolEndEvent;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function minimalTui(rows: number, columns: number): TUI {
|
|
133
|
+
return {
|
|
134
|
+
terminal: { rows, columns },
|
|
135
|
+
requestRender: () => {},
|
|
136
|
+
} as TUI;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function emptyUsage(): AssistantMessage["usage"] {
|
|
140
|
+
return {
|
|
141
|
+
input: 0,
|
|
142
|
+
output: 0,
|
|
143
|
+
cacheRead: 0,
|
|
144
|
+
cacheWrite: 0,
|
|
145
|
+
totalTokens: 0,
|
|
146
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { extractMessageText, fitLine, formatGhostCwd } from "../overlay.ts";
|
|
4
|
+
import {
|
|
5
|
+
assistantEnd,
|
|
6
|
+
assistantMessage,
|
|
7
|
+
assistantStart,
|
|
8
|
+
assistantUpdate,
|
|
9
|
+
createOverlay,
|
|
10
|
+
expectLinesFit,
|
|
11
|
+
toolEnd,
|
|
12
|
+
toolStart,
|
|
13
|
+
toolUpdate,
|
|
14
|
+
userStart,
|
|
15
|
+
} from "./overlay-harness.ts";
|
|
16
|
+
|
|
17
|
+
describe("fitLine", () => {
|
|
18
|
+
it("truncates and pads to exact visible width", () => {
|
|
19
|
+
expect(visibleWidth(fitLine("abcdef", 3))).toBe(3);
|
|
20
|
+
expect(visibleWidth(fitLine("x", 3))).toBe(3);
|
|
21
|
+
expect(fitLine("x", 0)).toBe("");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("GhostOverlayComponent rendering", () => {
|
|
26
|
+
it("never emits a line wider than render(width), including brutal tiny widths", () => {
|
|
27
|
+
for (let rows = 1; rows <= 24; rows++) {
|
|
28
|
+
for (let width = 0; width <= 120; width++) {
|
|
29
|
+
const { overlay } = createOverlay(rows, width);
|
|
30
|
+
overlay.setStatus("status ".repeat(80));
|
|
31
|
+
const lines = overlay.render(width);
|
|
32
|
+
expectLinesFit(lines, width);
|
|
33
|
+
expect(lines.length).toBeLessThanOrEqual(Math.max(1, rows));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps all lines fitted after long transcript, tool, status, and input churn", () => {
|
|
39
|
+
const { overlay } = createOverlay(18, 60);
|
|
40
|
+
overlay.setStatus("S".repeat(500));
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < 30; i++) {
|
|
43
|
+
overlay.handleSessionEvent(userStart(`user ${i} ${"u".repeat(120)}`));
|
|
44
|
+
overlay.handleSessionEvent(assistantStart(`assistant ${i} ${"a".repeat(120)}`));
|
|
45
|
+
overlay.handleSessionEvent(assistantUpdate(`assistant update ${i} ${"b".repeat(120)}`));
|
|
46
|
+
overlay.handleSessionEvent(assistantEnd(`assistant end ${i} ${"c".repeat(120)}`));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
overlay.handleSessionEvent(toolStart({ huge: "x".repeat(200) }));
|
|
50
|
+
overlay.handleSessionEvent(toolUpdate("partial".repeat(80)));
|
|
51
|
+
overlay.handleSessionEvent(toolEnd("done".repeat(80)));
|
|
52
|
+
|
|
53
|
+
for (const width of [4, 5, 8, 13, 20, 40, 80]) {
|
|
54
|
+
expectLinesFit(overlay.render(width), width);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("invalidates transcript cache on assistant streaming updates", () => {
|
|
59
|
+
const { overlay } = createOverlay(24, 90);
|
|
60
|
+
|
|
61
|
+
overlay.handleSessionEvent(assistantStart("first token"));
|
|
62
|
+
expect(overlay.render(90).join("\n")).toContain("first token");
|
|
63
|
+
|
|
64
|
+
overlay.handleSessionEvent(assistantUpdate("second token"));
|
|
65
|
+
const rendered = overlay.render(90).join("\n");
|
|
66
|
+
|
|
67
|
+
expect(rendered).toContain("second token");
|
|
68
|
+
expect(rendered).not.toContain("first token");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("invalidates transcript cache on partial tool output", () => {
|
|
72
|
+
const { overlay } = createOverlay(24, 90);
|
|
73
|
+
|
|
74
|
+
overlay.handleSessionEvent(toolStart());
|
|
75
|
+
expect(overlay.render(90).join("\n")).toContain("ghost_tool");
|
|
76
|
+
|
|
77
|
+
overlay.handleSessionEvent(toolUpdate("partial-output-visible"));
|
|
78
|
+
expect(overlay.render(90).join("\n")).toContain("partial-output-visible");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not emit shell prompt OSC markers inside overlay transcript", () => {
|
|
82
|
+
const { overlay } = createOverlay(24, 90);
|
|
83
|
+
|
|
84
|
+
overlay.handleSessionEvent(userStart("hi"));
|
|
85
|
+
overlay.handleSessionEvent(assistantStart("hello"));
|
|
86
|
+
|
|
87
|
+
expect(overlay.render(90).join("\n")).not.toContain("\x1b]133;");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("replays events captured while overlay was hidden", () => {
|
|
91
|
+
const first = createOverlay(24, 90).overlay;
|
|
92
|
+
const events = [
|
|
93
|
+
userStart("hidden question"),
|
|
94
|
+
assistantStart(""),
|
|
95
|
+
assistantUpdate("hidden partial"),
|
|
96
|
+
assistantUpdate("hidden final"),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
first.loadEvents(events);
|
|
100
|
+
expect(first.render(90).join("\n")).toContain("hidden final");
|
|
101
|
+
|
|
102
|
+
const reopened = createOverlay(24, 90).overlay;
|
|
103
|
+
reopened.loadEvents(events);
|
|
104
|
+
const rendered = reopened.render(90).join("\n");
|
|
105
|
+
expect(rendered).toContain("hidden question");
|
|
106
|
+
expect(rendered).toContain("hidden final");
|
|
107
|
+
expect(rendered).not.toContain("hidden partial");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("hydrates prior session messages when reopened", () => {
|
|
111
|
+
const { overlay } = createOverlay(24, 90);
|
|
112
|
+
|
|
113
|
+
overlay.loadMessages([
|
|
114
|
+
{ role: "user", content: [{ type: "text", text: "previous question" }] },
|
|
115
|
+
assistantMessage("partial prior answer"),
|
|
116
|
+
], true);
|
|
117
|
+
|
|
118
|
+
expect(overlay.render(90).join("\n")).toContain("previous question");
|
|
119
|
+
expect(overlay.render(90).join("\n")).toContain("partial prior answer");
|
|
120
|
+
|
|
121
|
+
overlay.handleSessionEvent(assistantUpdate("continued prior answer"));
|
|
122
|
+
const rendered = overlay.render(90).join("\n");
|
|
123
|
+
expect(rendered).toContain("continued prior answer");
|
|
124
|
+
expect(rendered).not.toContain("partial prior answer");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("submits trimmed input, hides on ctrl+s, closes on escape", () => {
|
|
128
|
+
const harness = createOverlay(20, 80);
|
|
129
|
+
|
|
130
|
+
for (const char of " hello ghost ") harness.overlay.handleInput(char);
|
|
131
|
+
harness.overlay.handleInput("\r");
|
|
132
|
+
harness.overlay.handleInput("\x13");
|
|
133
|
+
harness.overlay.handleInput("\x1b");
|
|
134
|
+
|
|
135
|
+
expect(harness.submitted).toEqual(["hello ghost"]);
|
|
136
|
+
expect(harness.hiddenCount()).toBe(1);
|
|
137
|
+
expect(harness.closedCount()).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("message/cwd helpers", () => {
|
|
142
|
+
it("extracts only text content", () => {
|
|
143
|
+
expect(extractMessageText({
|
|
144
|
+
content: [
|
|
145
|
+
{ type: "image" },
|
|
146
|
+
{ type: "text", text: " a " },
|
|
147
|
+
{ type: "text", text: "b" },
|
|
148
|
+
],
|
|
149
|
+
})).toBe("a \nb");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("formats home cwd compactly", () => {
|
|
153
|
+
const home = process.env.HOME;
|
|
154
|
+
if (!home) return;
|
|
155
|
+
|
|
156
|
+
expect(formatGhostCwd(home)).toBe("~");
|
|
157
|
+
expect(formatGhostCwd(`${home}/repo`)).toBe("~/repo");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
Binary file
|
package/config.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { OverlayAnchor, OverlayMargin, SizeValue } from "@earendil-works/pi-tui";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const ANCHORS = new Set<OverlayAnchor>([
|
|
7
|
+
"center",
|
|
8
|
+
"top-left",
|
|
9
|
+
"top-center",
|
|
10
|
+
"top-right",
|
|
11
|
+
"right-center",
|
|
12
|
+
"bottom-right",
|
|
13
|
+
"bottom-center",
|
|
14
|
+
"bottom-left",
|
|
15
|
+
"left-center",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export interface GhostModelConfig {
|
|
19
|
+
provider?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GhostOverlayConfig {
|
|
24
|
+
anchor: OverlayAnchor;
|
|
25
|
+
width: SizeValue;
|
|
26
|
+
maxHeight: SizeValue;
|
|
27
|
+
margin: Required<OverlayMargin>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GhostConfig {
|
|
31
|
+
model?: GhostModelConfig;
|
|
32
|
+
overlay: GhostOverlayConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const defaultConfig: GhostConfig = {
|
|
36
|
+
overlay: {
|
|
37
|
+
anchor: "bottom-center",
|
|
38
|
+
width: "100%",
|
|
39
|
+
maxHeight: "55%",
|
|
40
|
+
margin: { top: 0, bottom: 1, left: 0, right: 0 },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type RawConfig = Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
export interface GhostConfigError {
|
|
47
|
+
path: string;
|
|
48
|
+
message: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ProjectConfigResult {
|
|
52
|
+
config: RawConfig;
|
|
53
|
+
error?: GhostConfigError;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readProjectConfig(path: string): ProjectConfigResult {
|
|
57
|
+
if (!existsSync(path)) return { config: {} };
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
61
|
+
return isObject(parsed)
|
|
62
|
+
? { config: parsed }
|
|
63
|
+
: { config: {}, error: { path, message: "root must be an object" } };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
+
return { config: {}, error: { path, message } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readGlobalGhostConfig(): RawConfig {
|
|
71
|
+
const path = join(getAgentDir(), "settings.json");
|
|
72
|
+
if (!existsSync(path)) return {};
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
75
|
+
return isObject(parsed?.ghost) ? parsed.ghost : {};
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadGhostConfig(
|
|
82
|
+
cwd: string,
|
|
83
|
+
onError?: (error: GhostConfigError) => void,
|
|
84
|
+
): GhostConfig {
|
|
85
|
+
const global = readGlobalGhostConfig();
|
|
86
|
+
const project = readProjectConfig(join(cwd, ".pi", "ghost.json"));
|
|
87
|
+
if (project.error) onError?.(project.error);
|
|
88
|
+
return normalizeGhostConfig(global, project.config);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeGhostConfig(global: RawConfig = {}, project: RawConfig = {}): GhostConfig {
|
|
92
|
+
const globalOverlay = objectValue(global.overlay);
|
|
93
|
+
const projectOverlay = objectValue(project.overlay);
|
|
94
|
+
const model = pickModel(objectValue(global.model), objectValue(project.model));
|
|
95
|
+
const overlay = {
|
|
96
|
+
anchor: pickAnchor(projectOverlay.anchor, globalOverlay.anchor, defaultConfig.overlay.anchor),
|
|
97
|
+
width: pickSize(projectOverlay.width, globalOverlay.width, defaultConfig.overlay.width),
|
|
98
|
+
maxHeight: pickSize(projectOverlay.maxHeight, globalOverlay.maxHeight, defaultConfig.overlay.maxHeight),
|
|
99
|
+
margin: pickMargin(objectValue(globalOverlay.margin), objectValue(projectOverlay.margin)),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return model ? { model, overlay } : { overlay };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pickModel(global: RawConfig, project: RawConfig): GhostModelConfig | undefined {
|
|
106
|
+
const provider = pickString(project.provider, global.provider);
|
|
107
|
+
const id = pickString(project.id, global.id);
|
|
108
|
+
return provider || id ? { provider, id } : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pickMargin(global: RawConfig, project: RawConfig): Required<OverlayMargin> {
|
|
112
|
+
const fallback = defaultConfig.overlay.margin;
|
|
113
|
+
return {
|
|
114
|
+
top: pickNonNegativeNumber(project.top, global.top, fallback.top),
|
|
115
|
+
bottom: pickNonNegativeNumber(project.bottom, global.bottom, fallback.bottom),
|
|
116
|
+
left: pickNonNegativeNumber(project.left, global.left, fallback.left),
|
|
117
|
+
right: pickNonNegativeNumber(project.right, global.right, fallback.right),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function pickAnchor(...values: unknown[]): OverlayAnchor {
|
|
122
|
+
for (const value of values) {
|
|
123
|
+
if (typeof value === "string" && ANCHORS.has(value as OverlayAnchor)) return value as OverlayAnchor;
|
|
124
|
+
}
|
|
125
|
+
return defaultConfig.overlay.anchor;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pickSize(...values: unknown[]): SizeValue {
|
|
129
|
+
for (const value of values) {
|
|
130
|
+
if (isSize(value)) return value;
|
|
131
|
+
}
|
|
132
|
+
return defaultConfig.overlay.width;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickString(...values: unknown[]): string | undefined {
|
|
136
|
+
for (const value of values) {
|
|
137
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pickNonNegativeNumber(...values: unknown[]): number {
|
|
143
|
+
for (const value of values) {
|
|
144
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function objectValue(value: unknown): RawConfig {
|
|
150
|
+
return isObject(value) ? value : {};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isObject(value: unknown): value is RawConfig {
|
|
154
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSize(value: unknown): value is SizeValue {
|
|
158
|
+
if (typeof value === "number") return Number.isFinite(value) && value > 0;
|
|
159
|
+
if (typeof value !== "string") return false;
|
|
160
|
+
|
|
161
|
+
const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
|
|
162
|
+
return match !== null && Number(match[1]) > 0;
|
|
163
|
+
}
|