omni-pi 0.3.0 → 0.4.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/README.md +12 -15
- package/bin/omni.js +94 -0
- package/extensions/omni-core/index.ts +9 -0
- package/package.json +11 -4
- package/src/brain.ts +3 -0
- package/src/header.ts +76 -0
- package/src/model-command.ts +47 -0
- package/src/theme-command.ts +141 -0
- package/src/theme.ts +242 -0
package/README.md
CHANGED
|
@@ -14,27 +14,31 @@ Requires Node.js 22 or newer.
|
|
|
14
14
|
|
|
15
15
|
- One friendly brain talks to the user.
|
|
16
16
|
- `.omni/` remains the durable memory layer for goals, specs, tasks, checks, progress, and decisions.
|
|
17
|
-
- Work is
|
|
18
|
-
- Verification
|
|
17
|
+
- Work is broken into small, verifiable slices before code changes happen.
|
|
18
|
+
- Verification is explicit and recorded alongside implementation progress.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
|
-
Install the
|
|
22
|
+
Install the standalone executable:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
|
|
25
|
+
npm install -g omni-pi
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Then run it in any project:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
|
|
31
|
+
cd your-project
|
|
32
|
+
omni
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
For local development from this checkout:
|
|
35
36
|
|
|
36
37
|
```bash
|
|
37
|
-
|
|
38
|
+
git clone https://github.com/EdGy2k/Omni-Pi.git
|
|
39
|
+
cd Omni-Pi
|
|
40
|
+
npm install
|
|
41
|
+
npm run chat
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
## Use
|
|
@@ -66,14 +70,7 @@ Omni-Pi keeps its working notes in `.omni/`:
|
|
|
66
70
|
|
|
67
71
|
## Development
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
git clone https://github.com/EdGy2k/Omni-Pi.git
|
|
71
|
-
cd Omni-Pi
|
|
72
|
-
npm install
|
|
73
|
-
npm run chat
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
`npm run chat` launches Pi with this package loaded from the local checkout.
|
|
73
|
+
`npm run chat` launches the local `omni` wrapper from this checkout, which in turn starts Pi with this package loaded.
|
|
77
74
|
|
|
78
75
|
For local verification:
|
|
79
76
|
|
package/bin/omni.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
export function getOmniPackageDir() {
|
|
9
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolvePiCliPath() {
|
|
13
|
+
return path.join(
|
|
14
|
+
getOmniPackageDir(),
|
|
15
|
+
"node_modules",
|
|
16
|
+
"@mariozechner",
|
|
17
|
+
"pi-coding-agent",
|
|
18
|
+
"dist",
|
|
19
|
+
"cli.js",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildOmniEnvironment(baseEnv = process.env) {
|
|
24
|
+
return {
|
|
25
|
+
...baseEnv,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildPiProcessSpec(
|
|
30
|
+
argv = process.argv.slice(2),
|
|
31
|
+
baseEnv = process.env,
|
|
32
|
+
) {
|
|
33
|
+
return {
|
|
34
|
+
command: process.execPath,
|
|
35
|
+
args: [resolvePiCliPath(), "-e", getOmniPackageDir(), ...argv],
|
|
36
|
+
env: buildOmniEnvironment(baseEnv),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runOmni(argv = process.argv.slice(2), options = {}) {
|
|
41
|
+
const spec = buildPiProcessSpec(argv, options.env);
|
|
42
|
+
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(spec.command, spec.args, {
|
|
45
|
+
cwd: options.cwd ?? process.cwd(),
|
|
46
|
+
env: spec.env,
|
|
47
|
+
stdio: "inherit",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const forwardSignal = (sig) => child.kill(sig);
|
|
51
|
+
process.on("SIGINT", forwardSignal);
|
|
52
|
+
process.on("SIGTERM", forwardSignal);
|
|
53
|
+
|
|
54
|
+
child.on("exit", (code, signal) => {
|
|
55
|
+
process.off("SIGINT", forwardSignal);
|
|
56
|
+
process.off("SIGTERM", forwardSignal);
|
|
57
|
+
|
|
58
|
+
if (signal) {
|
|
59
|
+
reject(new Error(`omni terminated with signal ${signal}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.exitCode = code ?? 0;
|
|
64
|
+
resolve(code ?? 0);
|
|
65
|
+
});
|
|
66
|
+
child.on("error", (err) => {
|
|
67
|
+
process.off("SIGINT", forwardSignal);
|
|
68
|
+
process.off("SIGTERM", forwardSignal);
|
|
69
|
+
reject(err);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isOmniEntrypointInvocation(
|
|
75
|
+
argvPath = process.argv[1],
|
|
76
|
+
moduleUrl = import.meta.url,
|
|
77
|
+
) {
|
|
78
|
+
if (!argvPath) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return realpathSync(argvPath) === realpathSync(fileURLToPath(moduleUrl));
|
|
84
|
+
} catch {
|
|
85
|
+
return path.resolve(argvPath) === fileURLToPath(moduleUrl);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isOmniEntrypointInvocation()) {
|
|
90
|
+
runOmni().catch((error) => {
|
|
91
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -4,13 +4,22 @@ import {
|
|
|
4
4
|
buildBrainSystemPromptSuffix,
|
|
5
5
|
ensureOmniInitialized,
|
|
6
6
|
} from "../../src/brain.js";
|
|
7
|
+
import { renderHeader } from "../../src/header.js";
|
|
8
|
+
import { registerModelCommand } from "../../src/model-command.js";
|
|
7
9
|
import { registerOmniMessageRenderer } from "../../src/pi.js";
|
|
10
|
+
import { createOmniTheme } from "../../src/theme.js";
|
|
11
|
+
import { registerThemeCommand } from "../../src/theme-command.js";
|
|
8
12
|
|
|
9
13
|
export default function omniCoreExtension(api: ExtensionAPI): void {
|
|
10
14
|
registerOmniMessageRenderer(api);
|
|
15
|
+
registerModelCommand(api);
|
|
16
|
+
registerThemeCommand(api);
|
|
11
17
|
|
|
12
18
|
api.on("session_start", async (_event, ctx) => {
|
|
13
19
|
await ensureOmniInitialized(ctx.cwd);
|
|
20
|
+
ctx.ui.setTitle("Omni-Pi");
|
|
21
|
+
ctx.ui.setTheme(createOmniTheme());
|
|
22
|
+
ctx.ui.setHeader((_tui, theme) => renderHeader(theme));
|
|
14
23
|
ctx.ui.setStatus("omni", undefined);
|
|
15
24
|
});
|
|
16
25
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Single-agent Pi package that interviews the user, documents the spec, and implements work in bounded slices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=22"
|
|
18
18
|
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"omni": "bin/omni.js"
|
|
21
|
+
},
|
|
19
22
|
"keywords": [
|
|
20
23
|
"pi-package",
|
|
21
24
|
"pi",
|
|
@@ -25,6 +28,7 @@
|
|
|
25
28
|
"interview"
|
|
26
29
|
],
|
|
27
30
|
"files": [
|
|
31
|
+
"bin",
|
|
28
32
|
"agents",
|
|
29
33
|
"extensions",
|
|
30
34
|
"prompts",
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
"CREDITS.md"
|
|
36
40
|
],
|
|
37
41
|
"scripts": {
|
|
38
|
-
"chat": "node ./
|
|
42
|
+
"chat": "node ./bin/omni.js",
|
|
39
43
|
"check": "tsc --noEmit",
|
|
40
44
|
"lint": "biome check .",
|
|
41
45
|
"format": "biome check --write .",
|
|
@@ -53,10 +57,12 @@
|
|
|
53
57
|
"extensions": [
|
|
54
58
|
"./extensions/omni-providers/index.ts",
|
|
55
59
|
"./extensions/omni-core/index.ts",
|
|
56
|
-
"./extensions/omni-memory/index.ts"
|
|
60
|
+
"./extensions/omni-memory/index.ts",
|
|
61
|
+
"./node_modules/pi-web-access/index.ts"
|
|
57
62
|
],
|
|
58
63
|
"skills": [
|
|
59
|
-
"./skills"
|
|
64
|
+
"./skills",
|
|
65
|
+
"./node_modules/pi-web-access/skills"
|
|
60
66
|
],
|
|
61
67
|
"prompts": [
|
|
62
68
|
"./prompts"
|
|
@@ -66,6 +72,7 @@
|
|
|
66
72
|
"@anthropic-ai/claude-agent-sdk": "0.2.84",
|
|
67
73
|
"@mariozechner/pi-coding-agent": "^0.62.0",
|
|
68
74
|
"pi-subagents": "^0.11.11",
|
|
75
|
+
"pi-web-access": "^0.10.3",
|
|
69
76
|
"zod": "^4.3.6"
|
|
70
77
|
}
|
|
71
78
|
}
|
package/src/brain.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { access, readFile } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import type { OmniState } from "./contracts.js";
|
|
5
|
+
import { ensurePiSettings, loadSavedTheme } from "./theme.js";
|
|
5
6
|
import { initializeOmniProject, readOmniStatus } from "./workflow.js";
|
|
6
7
|
|
|
7
8
|
const BRAIN_SYSTEM_APPEND = `## Omni-Pi Single-Brain Mode
|
|
@@ -69,6 +70,8 @@ function clipSection(value: string | null, maxChars: number): string {
|
|
|
69
70
|
export async function ensureOmniInitialized(
|
|
70
71
|
cwd: string,
|
|
71
72
|
): Promise<"initialized" | "existing"> {
|
|
73
|
+
await ensurePiSettings(cwd);
|
|
74
|
+
loadSavedTheme(cwd);
|
|
72
75
|
const statePath = path.join(cwd, ".omni", "STATE.md");
|
|
73
76
|
if (await fileExists(statePath)) {
|
|
74
77
|
return "existing";
|
package/src/header.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
import { brand, welcome as welcomeColor } from "./theme.js";
|
|
9
|
+
|
|
10
|
+
const WELCOME_MESSAGES: readonly string[] = [
|
|
11
|
+
"Ready to turn ideas into code.",
|
|
12
|
+
"What are we building today?",
|
|
13
|
+
"Your brain is warmed up and ready.",
|
|
14
|
+
"Let's ship something great.",
|
|
15
|
+
"All systems nominal. Awaiting orders.",
|
|
16
|
+
"Standing by for your next move.",
|
|
17
|
+
"The plan-build-verify loop awaits.",
|
|
18
|
+
"Focus loaded. Distractions discarded.",
|
|
19
|
+
"Initialized. Let's make it happen.",
|
|
20
|
+
"Another day, another deploy.",
|
|
21
|
+
"Spec it. Build it. Verify it.",
|
|
22
|
+
"Engineering mode: activated.",
|
|
23
|
+
"Coffee optional. Code inevitable.",
|
|
24
|
+
"Ctrl+C is always an option. But not today.",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const ASCII_LOGO = [
|
|
28
|
+
" ██████╗ ███╗ ███╗███╗ ██╗██╗",
|
|
29
|
+
"██╔═══██╗████╗ ████║████╗ ██║██║",
|
|
30
|
+
"██║ ██║██╔████╔██║██╔██╗ ██║██║",
|
|
31
|
+
"██║ ██║██║╚██╔╝██║██║╚██╗██║██║",
|
|
32
|
+
"╚██████╔╝██║ ╚═╝ ██║██║ ╚████║██║",
|
|
33
|
+
" ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const LOGO_WIDTH = Math.max(...ASCII_LOGO.map((l) => l.length));
|
|
37
|
+
|
|
38
|
+
export function centerIn(text: string, width: number): string {
|
|
39
|
+
const pad = Math.max(0, Math.floor((width - text.length) / 2));
|
|
40
|
+
return " ".repeat(pad) + text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readVersion(): string {
|
|
44
|
+
try {
|
|
45
|
+
const pkgPath = path.resolve(
|
|
46
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
47
|
+
"..",
|
|
48
|
+
"package.json",
|
|
49
|
+
);
|
|
50
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
|
51
|
+
version?: string;
|
|
52
|
+
};
|
|
53
|
+
return pkg.version ?? "0.0.0";
|
|
54
|
+
} catch {
|
|
55
|
+
return "0.0.0";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function pickWelcome(): string {
|
|
60
|
+
return WELCOME_MESSAGES[Math.floor(Math.random() * WELCOME_MESSAGES.length)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderHeader(theme: Theme): Text {
|
|
64
|
+
const version = readVersion();
|
|
65
|
+
const welcome = pickWelcome();
|
|
66
|
+
|
|
67
|
+
const logo = ASCII_LOGO.map((line) => brand(line)).join("\n");
|
|
68
|
+
const subtitleText = `— P I v${version} —`;
|
|
69
|
+
const subtitle = theme.fg("muted", centerIn(subtitleText, LOGO_WIDTH));
|
|
70
|
+
const taglineText = "plan · build · verify";
|
|
71
|
+
const tagline = theme.fg("muted", centerIn(taglineText, LOGO_WIDTH));
|
|
72
|
+
const greeting = welcomeColor(centerIn(welcome, LOGO_WIDTH));
|
|
73
|
+
|
|
74
|
+
const lines = [logo, subtitle, "", tagline, greeting, ""];
|
|
75
|
+
return new Text(lines.join("\n"), 1, 0);
|
|
76
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getAuthenticatedModelOptions,
|
|
8
|
+
setupCustomProviderModel,
|
|
9
|
+
} from "./model-setup.js";
|
|
10
|
+
|
|
11
|
+
async function handleAdd(ctx: ExtensionCommandContext): Promise<void> {
|
|
12
|
+
const result = await setupCustomProviderModel({ ctx });
|
|
13
|
+
ctx.ui.notify(result.summary, "info");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function handleList(ctx: ExtensionCommandContext): Promise<void> {
|
|
17
|
+
const models = getAuthenticatedModelOptions(ctx.modelRegistry);
|
|
18
|
+
|
|
19
|
+
if (models.length === 0) {
|
|
20
|
+
ctx.ui.notify("No models available.", "info");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await ctx.ui.select("Available models:", models);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerModelCommand(api: ExtensionAPI): void {
|
|
28
|
+
api.registerCommand("model-setup", {
|
|
29
|
+
description: "Add or list custom model providers",
|
|
30
|
+
async handler(args: string, ctx: ExtensionCommandContext) {
|
|
31
|
+
const sub = args.trim().toLowerCase();
|
|
32
|
+
|
|
33
|
+
if (sub === "add") return handleAdd(ctx);
|
|
34
|
+
if (sub === "list") return handleList(ctx);
|
|
35
|
+
|
|
36
|
+
const choice = await ctx.ui.select("Model setup:", [
|
|
37
|
+
"add — Add a custom provider/model",
|
|
38
|
+
"list — Show available models",
|
|
39
|
+
]);
|
|
40
|
+
if (!choice) return;
|
|
41
|
+
|
|
42
|
+
const picked = choice.split("—")[0].trim();
|
|
43
|
+
if (picked === "add") return handleAdd(ctx);
|
|
44
|
+
if (picked === "list") return handleList(ctx);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { Component, KeybindingsManager, TUI } from "@mariozechner/pi-tui";
|
|
6
|
+
import {
|
|
7
|
+
ASCII_LOGO,
|
|
8
|
+
centerIn,
|
|
9
|
+
LOGO_WIDTH,
|
|
10
|
+
pickWelcome,
|
|
11
|
+
renderHeader,
|
|
12
|
+
} from "./header.js";
|
|
13
|
+
import {
|
|
14
|
+
ansiColor,
|
|
15
|
+
applyPreset,
|
|
16
|
+
createOmniTheme,
|
|
17
|
+
getActivePresetName,
|
|
18
|
+
PRESETS,
|
|
19
|
+
saveThemeChoice,
|
|
20
|
+
} from "./theme.js";
|
|
21
|
+
|
|
22
|
+
const PRESET_KEYS = Object.keys(PRESETS);
|
|
23
|
+
|
|
24
|
+
function renderMiniPreview(brandHex: string, welcomeHex: string): string[] {
|
|
25
|
+
const logo = ASCII_LOGO.map((line) => ansiColor(brandHex, line));
|
|
26
|
+
const tagline = ansiColor(
|
|
27
|
+
"#808080",
|
|
28
|
+
centerIn("plan · build · verify", LOGO_WIDTH),
|
|
29
|
+
);
|
|
30
|
+
const greeting = ansiColor(welcomeHex, centerIn(pickWelcome(), LOGO_WIDTH));
|
|
31
|
+
return [...logo, "", tagline, greeting];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type PickerResult = { key: string } | "cancelled";
|
|
35
|
+
|
|
36
|
+
class ThemePickerComponent implements Component {
|
|
37
|
+
private selectedIndex: number;
|
|
38
|
+
private tui: TUI;
|
|
39
|
+
private kb: KeybindingsManager;
|
|
40
|
+
private done: (result: PickerResult) => void;
|
|
41
|
+
private originalKey: string;
|
|
42
|
+
|
|
43
|
+
invalidate(): void {
|
|
44
|
+
// No cached state to clear — render() is always fresh.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
tui: TUI,
|
|
49
|
+
keybindings: KeybindingsManager,
|
|
50
|
+
done: (result: PickerResult) => void,
|
|
51
|
+
currentKey: string,
|
|
52
|
+
) {
|
|
53
|
+
this.tui = tui;
|
|
54
|
+
this.kb = keybindings;
|
|
55
|
+
this.done = done;
|
|
56
|
+
this.originalKey = currentKey;
|
|
57
|
+
this.selectedIndex = Math.max(0, PRESET_KEYS.indexOf(currentKey));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
handleInput(data: string): void {
|
|
61
|
+
const prev = this.selectedIndex;
|
|
62
|
+
|
|
63
|
+
if (this.kb.matches(data, "tui.select.up")) {
|
|
64
|
+
this.selectedIndex =
|
|
65
|
+
this.selectedIndex === 0
|
|
66
|
+
? PRESET_KEYS.length - 1
|
|
67
|
+
: this.selectedIndex - 1;
|
|
68
|
+
} else if (this.kb.matches(data, "tui.select.down")) {
|
|
69
|
+
this.selectedIndex =
|
|
70
|
+
this.selectedIndex === PRESET_KEYS.length - 1
|
|
71
|
+
? 0
|
|
72
|
+
: this.selectedIndex + 1;
|
|
73
|
+
} else if (this.kb.matches(data, "tui.select.confirm")) {
|
|
74
|
+
this.done({ key: PRESET_KEYS[this.selectedIndex] });
|
|
75
|
+
return;
|
|
76
|
+
} else if (this.kb.matches(data, "tui.select.cancel")) {
|
|
77
|
+
this.done("cancelled");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (prev !== this.selectedIndex) {
|
|
82
|
+
this.tui.requestRender(true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
const key = PRESET_KEYS[this.selectedIndex];
|
|
88
|
+
const preset = PRESETS[key];
|
|
89
|
+
|
|
90
|
+
// ── Preview ──
|
|
91
|
+
const preview = renderMiniPreview(preset.brand, preset.welcome);
|
|
92
|
+
|
|
93
|
+
// ── List ──
|
|
94
|
+
const listLines = PRESET_KEYS.map((k, i) => {
|
|
95
|
+
const p = PRESETS[k];
|
|
96
|
+
const swatch = ansiColor(p.brand, "████");
|
|
97
|
+
const welcomeSwatch = ansiColor(p.welcome, "██");
|
|
98
|
+
const current = k === this.originalKey ? " *" : "";
|
|
99
|
+
const label = `${swatch} ${welcomeSwatch} ${p.label}${current}`;
|
|
100
|
+
return i === this.selectedIndex ? ` > ${label}` : ` ${label}`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const separator = "─".repeat(Math.min(width, LOGO_WIDTH + 4));
|
|
104
|
+
const hint = "\x1b[2m↑/↓ navigate · enter select · esc cancel\x1b[0m";
|
|
105
|
+
|
|
106
|
+
return ["", ...preview, "", separator, ...listLines, "", hint, ""];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function registerThemeCommand(api: ExtensionAPI): void {
|
|
111
|
+
api.registerCommand("theme", {
|
|
112
|
+
description: "Pick an Omni-Pi color theme with live preview",
|
|
113
|
+
async handler(_args: string, ctx: ExtensionCommandContext) {
|
|
114
|
+
const originalKey = getActivePresetName() ?? "lavender";
|
|
115
|
+
|
|
116
|
+
const result = await ctx.ui.custom<PickerResult>(
|
|
117
|
+
(tui, _theme, keybindings, done) =>
|
|
118
|
+
new ThemePickerComponent(tui, keybindings, done, originalKey),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (result === "cancelled") {
|
|
122
|
+
// Revert to original
|
|
123
|
+
applyPreset(originalKey);
|
|
124
|
+
ctx.ui.setTheme(createOmniTheme());
|
|
125
|
+
ctx.ui.setHeader((_tui, theme) => renderHeader(theme));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
applyPreset(result.key);
|
|
130
|
+
ctx.ui.setTheme(createOmniTheme());
|
|
131
|
+
ctx.ui.setHeader((_tui, theme) => renderHeader(theme));
|
|
132
|
+
saveThemeChoice(ctx.cwd, result.key);
|
|
133
|
+
|
|
134
|
+
const chosen = PRESETS[result.key];
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`Theme set to ${ansiColor(chosen.brand, chosen.label)}`,
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
// ── Curated presets ──────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface OmniPreset {
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly brand: string;
|
|
12
|
+
readonly welcome: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PRESETS: Record<string, OmniPreset> = {
|
|
16
|
+
lavender: {
|
|
17
|
+
label: "Lavender",
|
|
18
|
+
brand: "#c5bceb",
|
|
19
|
+
welcome: "#4969c9",
|
|
20
|
+
},
|
|
21
|
+
ember: {
|
|
22
|
+
label: "Ember",
|
|
23
|
+
brand: "#e8836b",
|
|
24
|
+
welcome: "#d4a054",
|
|
25
|
+
},
|
|
26
|
+
ocean: {
|
|
27
|
+
label: "Ocean",
|
|
28
|
+
brand: "#5fb3d4",
|
|
29
|
+
welcome: "#4a90b8",
|
|
30
|
+
},
|
|
31
|
+
mint: {
|
|
32
|
+
label: "Mint",
|
|
33
|
+
brand: "#7ecba1",
|
|
34
|
+
welcome: "#52a37a",
|
|
35
|
+
},
|
|
36
|
+
rose: {
|
|
37
|
+
label: "Rose",
|
|
38
|
+
brand: "#e88aaf",
|
|
39
|
+
welcome: "#c76b8f",
|
|
40
|
+
},
|
|
41
|
+
gold: {
|
|
42
|
+
label: "Gold",
|
|
43
|
+
brand: "#d4b96a",
|
|
44
|
+
welcome: "#b89b4a",
|
|
45
|
+
},
|
|
46
|
+
arctic: {
|
|
47
|
+
label: "Arctic",
|
|
48
|
+
brand: "#a0c4e8",
|
|
49
|
+
welcome: "#7ba3cc",
|
|
50
|
+
},
|
|
51
|
+
neon: {
|
|
52
|
+
label: "Neon",
|
|
53
|
+
brand: "#b97aff",
|
|
54
|
+
welcome: "#ff6bde",
|
|
55
|
+
},
|
|
56
|
+
copper: {
|
|
57
|
+
label: "Copper",
|
|
58
|
+
brand: "#d4956a",
|
|
59
|
+
welcome: "#b87a4f",
|
|
60
|
+
},
|
|
61
|
+
slate: {
|
|
62
|
+
label: "Slate",
|
|
63
|
+
brand: "#8fa3b8",
|
|
64
|
+
welcome: "#6b8299",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_PRESET = "lavender";
|
|
69
|
+
|
|
70
|
+
// ── Runtime state ────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
let activeBrand = PRESETS[DEFAULT_PRESET].brand;
|
|
73
|
+
let activeWelcome = PRESETS[DEFAULT_PRESET].welcome;
|
|
74
|
+
let activePresetName: string | null = DEFAULT_PRESET;
|
|
75
|
+
|
|
76
|
+
export function getBrandHex(): string {
|
|
77
|
+
return activeBrand;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getWelcomeHex(): string {
|
|
81
|
+
return activeWelcome;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getActivePresetName(): string | null {
|
|
85
|
+
return activePresetName;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function applyPreset(name: string): void {
|
|
89
|
+
const preset = PRESETS[name];
|
|
90
|
+
if (!preset) return;
|
|
91
|
+
activeBrand = preset.brand;
|
|
92
|
+
activeWelcome = preset.welcome;
|
|
93
|
+
activePresetName = name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Persistence (.pi/settings.json) ─────────────────────────────
|
|
97
|
+
|
|
98
|
+
interface PiSettings {
|
|
99
|
+
quietStartup?: boolean;
|
|
100
|
+
omniTheme?: string;
|
|
101
|
+
[key: string]: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function settingsPath(cwd: string): string {
|
|
105
|
+
return path.join(cwd, ".pi", "settings.json");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readSettings(cwd: string): PiSettings {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(settingsPath(cwd), "utf8")) as PiSettings;
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeSettings(cwd: string, settings: PiSettings): void {
|
|
117
|
+
writeFileSync(
|
|
118
|
+
settingsPath(cwd),
|
|
119
|
+
`${JSON.stringify(settings, null, 2)}\n`,
|
|
120
|
+
"utf8",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function ensurePiSettings(cwd: string): Promise<void> {
|
|
125
|
+
const p = settingsPath(cwd);
|
|
126
|
+
try {
|
|
127
|
+
readFileSync(p);
|
|
128
|
+
} catch {
|
|
129
|
+
await mkdir(path.join(cwd, ".pi"), { recursive: true });
|
|
130
|
+
writeSettings(cwd, { quietStartup: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Load the saved theme from .pi/settings.json, or fall back to default. */
|
|
135
|
+
export function loadSavedTheme(cwd: string): void {
|
|
136
|
+
const settings = readSettings(cwd);
|
|
137
|
+
const name = settings.omniTheme;
|
|
138
|
+
if (typeof name === "string" && name in PRESETS) {
|
|
139
|
+
applyPreset(name);
|
|
140
|
+
} else {
|
|
141
|
+
applyPreset(DEFAULT_PRESET);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Persist the chosen preset to .pi/settings.json. */
|
|
146
|
+
export function saveThemeChoice(cwd: string, presetName: string): void {
|
|
147
|
+
const settings = readSettings(cwd);
|
|
148
|
+
writeSettings(cwd, { ...settings, omniTheme: presetName });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── ANSI helpers ─────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const ANSI_RESET = "\x1b[0m";
|
|
154
|
+
|
|
155
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
|
156
|
+
const n = Number.parseInt(hex.replace("#", ""), 16);
|
|
157
|
+
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function ansiColor(hex: string, text: string): string {
|
|
161
|
+
const { r, g, b } = hexToRgb(hex);
|
|
162
|
+
return `\x1b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Wrap text in true-color ANSI foreground using the active brand color. */
|
|
166
|
+
export function brand(text: string): string {
|
|
167
|
+
return ansiColor(activeBrand, text);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Wrap text in true-color ANSI foreground using the active welcome color. */
|
|
171
|
+
export function welcome(text: string): string {
|
|
172
|
+
return ansiColor(activeWelcome, text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Theme constructor ────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Omni-Pi theme — dark base with brand accent.
|
|
179
|
+
* Reads the active brand color so `/theme` changes propagate.
|
|
180
|
+
*/
|
|
181
|
+
export function createOmniTheme(): Theme {
|
|
182
|
+
const accent = activeBrand;
|
|
183
|
+
return new Theme(
|
|
184
|
+
{
|
|
185
|
+
accent,
|
|
186
|
+
border: "#5f87ff",
|
|
187
|
+
borderAccent: accent,
|
|
188
|
+
borderMuted: "#505050",
|
|
189
|
+
success: "#b5bd68",
|
|
190
|
+
error: "#cc6666",
|
|
191
|
+
warning: "#ffff00",
|
|
192
|
+
muted: "#808080",
|
|
193
|
+
dim: "#666666",
|
|
194
|
+
text: "",
|
|
195
|
+
thinkingText: "#808080",
|
|
196
|
+
userMessageText: "",
|
|
197
|
+
customMessageText: "",
|
|
198
|
+
customMessageLabel: "#9575cd",
|
|
199
|
+
toolTitle: "",
|
|
200
|
+
toolOutput: "#808080",
|
|
201
|
+
mdHeading: "#f0c674",
|
|
202
|
+
mdLink: "#81a2be",
|
|
203
|
+
mdLinkUrl: "#666666",
|
|
204
|
+
mdCode: accent,
|
|
205
|
+
mdCodeBlock: "#b5bd68",
|
|
206
|
+
mdCodeBlockBorder: "#808080",
|
|
207
|
+
mdQuote: "#808080",
|
|
208
|
+
mdQuoteBorder: "#808080",
|
|
209
|
+
mdHr: "#808080",
|
|
210
|
+
mdListBullet: accent,
|
|
211
|
+
toolDiffAdded: "#b5bd68",
|
|
212
|
+
toolDiffRemoved: "#cc6666",
|
|
213
|
+
toolDiffContext: "#808080",
|
|
214
|
+
syntaxComment: "#6A9955",
|
|
215
|
+
syntaxKeyword: "#569CD6",
|
|
216
|
+
syntaxFunction: "#DCDCAA",
|
|
217
|
+
syntaxVariable: "#9CDCFE",
|
|
218
|
+
syntaxString: "#CE9178",
|
|
219
|
+
syntaxNumber: "#B5CEA8",
|
|
220
|
+
syntaxType: "#4EC9B0",
|
|
221
|
+
syntaxOperator: "#D4D4D4",
|
|
222
|
+
syntaxPunctuation: "#D4D4D4",
|
|
223
|
+
thinkingOff: "#505050",
|
|
224
|
+
thinkingMinimal: "#6e6e6e",
|
|
225
|
+
thinkingLow: "#5f87af",
|
|
226
|
+
thinkingMedium: "#81a2be",
|
|
227
|
+
thinkingHigh: "#b294bb",
|
|
228
|
+
thinkingXhigh: "#d183e8",
|
|
229
|
+
bashMode: "#b5bd68",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
selectedBg: "#3a3a4a",
|
|
233
|
+
userMessageBg: "#343541",
|
|
234
|
+
customMessageBg: "#2d2838",
|
|
235
|
+
toolPendingBg: "#282832",
|
|
236
|
+
toolSuccessBg: "#283228",
|
|
237
|
+
toolErrorBg: "#3c2828",
|
|
238
|
+
},
|
|
239
|
+
"truecolor",
|
|
240
|
+
{ name: "omni" },
|
|
241
|
+
);
|
|
242
|
+
}
|