@ceo.paludetto/pi-starship 0.0.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/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@ceo.paludetto/pi-starship",
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "description": "Starship-style status bar for pi with mode, model, tokens and path segments",
6
+ "pi": {
7
+ "extensions": [
8
+ "./dist/index.mjs"
9
+ ]
10
+ },
11
+ "author": {
12
+ "name": "Carlos Paludetto",
13
+ "email": "ceo.paludetto@gmail.com"
14
+ },
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/ceopaludetto/pi",
19
+ "directory": "packages/pi-starship"
20
+ },
21
+ "sideEffects": false,
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "import": "./dist/index.mjs",
29
+ "require": "./dist/index.cjs"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.mjs",
35
+ "types": "./dist/index.d.cts",
36
+ "scripts": {
37
+ "build": "tsdown",
38
+ "test": "bun test"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": ">=0.79.0",
42
+ "@earendil-works/pi-coding-agent": ">=0.79.0",
43
+ "@earendil-works/pi-tui": ">=0.79.0",
44
+ "@gotgenes/pi-permission-system": ">=16.0.0",
45
+ "pi-mcp-adapter": ">=2.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@gotgenes/pi-permission-system": {
49
+ "optional": true
50
+ },
51
+ "pi-mcp-adapter": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@ceo.paludetto/pi-utilities": "0.0.0",
57
+ "@earendil-works/pi-ai": "^0.80.2",
58
+ "@earendil-works/pi-coding-agent": "^0.80.2",
59
+ "@earendil-works/pi-tui": "^0.80.2"
60
+ }
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { Module, ModuleContext } from "./utilities/types";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { dim } from "@ceo.paludetto/pi-utilities";
5
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ import { McpModule } from "./modules/mcp.module";
8
+ import { createPhaseTracker, ModeModule } from "./modules/mode.module";
9
+ import { ModelModule } from "./modules/model.module";
10
+ import { StarshipModule } from "./modules/starship.module";
11
+ import { TokensModule } from "./modules/tokens.module";
12
+ import { YoloModule } from "./modules/yolo.module";
13
+
14
+ const MODULE_SEPARATOR = dim(" ⋅ ");
15
+ const HORIZONTAL_PADDING = " ";
16
+
17
+ export default function StarshipExtension(pi: ExtensionAPI) {
18
+ let thinkingLevel = "off";
19
+ let lastRenderWidth = 120;
20
+ let requestRender: (() => void) | undefined;
21
+ let refreshAll: (() => Promise<void>) | undefined;
22
+ let extensionStatuses: ReadonlyMap<string, string> = new Map();
23
+
24
+ const phaseTracker = createPhaseTracker(pi, () => requestRender?.());
25
+
26
+ pi.on("session_start", async (_, context) => {
27
+ thinkingLevel = pi.getThinkingLevel();
28
+
29
+ const moduleContext: ModuleContext = {
30
+ ...context,
31
+ getStatuses: () => extensionStatuses,
32
+ getThinkingLevel: () => thinkingLevel,
33
+ getWidth: () => lastRenderWidth,
34
+ };
35
+
36
+ const leftModules: Module[] = [
37
+ new StarshipModule(moduleContext),
38
+ new McpModule(moduleContext),
39
+ ];
40
+
41
+ const rightModules: Module[] = [
42
+ new YoloModule(moduleContext),
43
+ new ModeModule(moduleContext, phaseTracker),
44
+ new ModelModule(moduleContext),
45
+ new TokensModule(moduleContext),
46
+ ];
47
+
48
+ const allModules = [...leftModules, ...rightModules];
49
+
50
+ refreshAll = async () => {
51
+ await Promise.all(allModules.map((item) => item.refresh()));
52
+ requestRender?.();
53
+ };
54
+
55
+ await refreshAll();
56
+
57
+ context.ui.setFooter((tui, _, data) => {
58
+ requestRender = () => tui.requestRender();
59
+ const unsubscribe = data.onBranchChange(() => refreshAll?.());
60
+
61
+ return {
62
+ dispose: unsubscribe,
63
+ invalidate() {},
64
+
65
+ render(width: number): string[] {
66
+ extensionStatuses = data.getExtensionStatuses();
67
+ phaseTracker.query();
68
+
69
+ if (width !== lastRenderWidth) {
70
+ lastRenderWidth = width;
71
+ for (const item of leftModules)
72
+ item.refresh();
73
+ }
74
+
75
+ const contentWidth = width - 2;
76
+
77
+ const left = leftModules
78
+ .map((item) => item.render())
79
+ .filter((value): value is string => value !== null)
80
+ .join(MODULE_SEPARATOR);
81
+
82
+ const right = rightModules
83
+ .map((item) => item.render())
84
+ .filter((value): value is string => value !== null)
85
+ .join(MODULE_SEPARATOR);
86
+
87
+ const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(left) - visibleWidth(right)));
88
+ return [HORIZONTAL_PADDING + truncateToWidth(left + gap + right, contentWidth) + HORIZONTAL_PADDING];
89
+ },
90
+ };
91
+ });
92
+ });
93
+
94
+ pi.on("agent_end", () => {
95
+ refreshAll?.();
96
+ });
97
+
98
+ pi.on("thinking_level_select", (event, _) => {
99
+ thinkingLevel = event.level;
100
+ requestRender?.();
101
+ });
102
+ }
@@ -0,0 +1,23 @@
1
+ import { cyan, dim } from "@ceo.paludetto/pi-utilities";
2
+
3
+ import { Module } from "~/utilities/types";
4
+
5
+ /** Status key published by pi-mcp-adapter (e.g. "MCP: 2/3 servers"). */
6
+ const MCP_STATUS_KEY = "mcp";
7
+ const SERVER_RATIO_PATTERN = /(\d+)\/(\d+)/;
8
+
9
+ export class McpModule extends Module {
10
+ public override render(): string | null {
11
+ const status = this.getStatuses().get(MCP_STATUS_KEY);
12
+
13
+ if (!status)
14
+ return null;
15
+
16
+ const match = SERVER_RATIO_PATTERN.exec(status);
17
+
18
+ if (!match)
19
+ return null;
20
+
21
+ return `${cyan("MCP")} ${dim("(")}${match[1]}/${match[2]}${dim(")")}`;
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ModuleContext } from "~/utilities/types";
3
+
4
+ import { green, yellow } from "@ceo.paludetto/pi-utilities";
5
+
6
+ import { Module } from "~/utilities/types";
7
+
8
+ const REQUEST_CHANNEL = "plannotator:request";
9
+
10
+ type PlannotatorPhase = "idle" | "planning" | "executing";
11
+
12
+ export type PhaseTracker = {
13
+ query: () => void;
14
+ getPhase: () => PlannotatorPhase | null;
15
+ };
16
+
17
+ export function createPhaseTracker(pi: ExtensionAPI, onPhaseChange: () => void): PhaseTracker {
18
+ let currentPhase: PlannotatorPhase | null = null;
19
+
20
+ function applyPhaseFromResponse(response: any) {
21
+ if (response?.status !== "handled" || !response.result?.phase)
22
+ return;
23
+ if (currentPhase === response.result.phase)
24
+ return;
25
+
26
+ currentPhase = response.result.phase;
27
+ onPhaseChange();
28
+ }
29
+
30
+ pi.events.on(REQUEST_CHANNEL, (data: any) => {
31
+ if (data?.action !== "plan-mode" || typeof data?.respond !== "function")
32
+ return;
33
+
34
+ const originalRespond = data.respond;
35
+
36
+ data.respond = (response: any) => {
37
+ originalRespond(response);
38
+ applyPhaseFromResponse(response);
39
+ };
40
+ });
41
+
42
+ function query() {
43
+ pi.events.emit(REQUEST_CHANNEL, {
44
+ requestId: Math.random().toString(36).slice(2),
45
+ action: "plan-mode",
46
+ payload: { mode: "status" },
47
+ respond: (response: any) => applyPhaseFromResponse(response),
48
+ });
49
+ }
50
+
51
+ return { query, getPhase: () => currentPhase };
52
+ }
53
+
54
+ export class ModeModule extends Module {
55
+ public constructor(context: ModuleContext, private readonly phaseTracker: PhaseTracker) {
56
+ super(context);
57
+ }
58
+
59
+ public override render(): string | null {
60
+ const phase = this.phaseTracker.getPhase();
61
+
62
+ if (phase === null)
63
+ return null;
64
+
65
+ return phase !== "idle" ? yellow("Plan") : green("Build");
66
+ }
67
+ }
@@ -0,0 +1,21 @@
1
+ import { bold, capitalizeFirst, cyan, dim, formatProviderName, magenta } from "@ceo.paludetto/pi-utilities";
2
+
3
+ import { Module } from "~/utilities/types";
4
+
5
+ export class ModelModule extends Module {
6
+ public override render(): string | null {
7
+ const model = this.context.model;
8
+
9
+ if (!model)
10
+ return null;
11
+
12
+ const provider = formatProviderName(model.provider);
13
+ const thinkingLevel = this.context.getThinkingLevel();
14
+
15
+ const parenthesisContent = thinkingLevel !== "off"
16
+ ? `${provider}${dim("/")}${cyan(capitalizeFirst(thinkingLevel))}`
17
+ : provider;
18
+
19
+ return `${bold(magenta(model.name))}${dim(" (")}${parenthesisContent}${dim(")")}`;
20
+ }
21
+ }
@@ -0,0 +1,63 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { Module } from "~/utilities/types";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ const STARSHIP_ARGUMENTS = [
9
+ "--status=0",
10
+ "--keymap=",
11
+ "--pipestatus=0",
12
+ "--cmd-duration=0",
13
+ "--jobs=0",
14
+ ];
15
+
16
+ const ESCAPE_CHARACTER = "\x1B";
17
+ const TRAILING_SGR_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[[0-9;]*m+$`, "g");
18
+
19
+ export class StarshipModule extends Module {
20
+ private prompt: string | null = null;
21
+
22
+ public override async refresh(): Promise<void> {
23
+ this.prompt = await this.fetchStarshipPrompt();
24
+ }
25
+
26
+ public override render(): string | null {
27
+ return this.prompt;
28
+ }
29
+
30
+ private async fetchStarshipPrompt(): Promise<string | null> {
31
+ try {
32
+ const { stdout } = await execFileAsync(
33
+ "starship",
34
+ ["prompt", `--terminal-width=${this.context.getWidth()}`, ...STARSHIP_ARGUMENTS],
35
+ {
36
+ cwd: this.context.cwd,
37
+ timeout: 3000,
38
+ env: { ...process.env, PWD: this.context.cwd, STARSHIP_SHELL: "bash" },
39
+ },
40
+ );
41
+
42
+ return this.cleanStarshipOutput(stdout);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ private cleanStarshipOutput(stdout: string): string | null {
49
+ const firstLine = stdout.split("\n")[0] ?? "";
50
+
51
+ const withAnsiEscapes = firstLine
52
+ .replace(/\\\[/g, "")
53
+ .replace(/\\\]/g, "")
54
+ .replace(/%\{/g, ESCAPE_CHARACTER)
55
+ .replace(/%\}/g, "");
56
+
57
+ const trimmed = withAnsiEscapes
58
+ .replace(TRAILING_SGR_PATTERN, "")
59
+ .trimEnd();
60
+
61
+ return trimmed || null;
62
+ }
63
+ }
@@ -0,0 +1,38 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+
3
+ import { cyan, dim, formatTokenCount, green, yellow } from "@ceo.paludetto/pi-utilities";
4
+
5
+ import { Module } from "~/utilities/types";
6
+
7
+ export class TokensModule extends Module {
8
+ public override render(): string | null {
9
+ const { inputTokens, outputTokens, totalCost } = this.aggregateUsage(this.context.sessionManager.getBranch());
10
+
11
+ if (inputTokens === 0 && outputTokens === 0)
12
+ return null;
13
+
14
+ return [
15
+ cyan(`↑${formatTokenCount(inputTokens)}`),
16
+ green(`↓${formatTokenCount(outputTokens)}`),
17
+ yellow(`$${totalCost.toFixed(3)}`),
18
+ ].join(dim("/"));
19
+ }
20
+
21
+ private aggregateUsage(branch: any[]): { inputTokens: number; outputTokens: number; totalCost: number } {
22
+ let inputTokens = 0;
23
+ let outputTokens = 0;
24
+ let totalCost = 0;
25
+
26
+ for (const entry of branch) {
27
+ if (entry.type !== "message" || entry.message.role !== "assistant")
28
+ continue;
29
+
30
+ const message = entry.message as AssistantMessage;
31
+ inputTokens += message.usage.input;
32
+ outputTokens += message.usage.output;
33
+ totalCost += message.usage.cost.total;
34
+ }
35
+
36
+ return { inputTokens, outputTokens, totalCost };
37
+ }
38
+ }
@@ -0,0 +1,16 @@
1
+ import { bold, rainbow } from "@ceo.paludetto/pi-utilities";
2
+
3
+ import { Module } from "~/utilities/types";
4
+
5
+ /** Published by @gotgenes/pi-permission-system via ctx.ui.setStatus(). */
6
+ const PERMISSION_SYSTEM_STATUS_KEY = "pi-permission-system";
7
+ const PERMISSION_SYSTEM_YOLO_STATUS_VALUE = "yolo";
8
+
9
+ export class YoloModule extends Module {
10
+ public override render(): string | null {
11
+ if (this.getStatuses().get(PERMISSION_SYSTEM_STATUS_KEY) !== PERMISSION_SYSTEM_YOLO_STATUS_VALUE)
12
+ return null;
13
+
14
+ return bold(rainbow("Yolo"));
15
+ }
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ModuleContext = {
4
+ getStatuses: () => ReadonlyMap<string, string>;
5
+ getThinkingLevel: () => string;
6
+ getWidth: () => number;
7
+ } & ExtensionContext;
8
+
9
+ export abstract class Module {
10
+ public constructor(
11
+ protected readonly context: ModuleContext,
12
+ ) {}
13
+
14
+ protected getStatuses(): ReadonlyMap<string, string> {
15
+ return this.context.getStatuses();
16
+ }
17
+
18
+ public abstract render(): string | null;
19
+
20
+ public async refresh(): Promise<void> {}
21
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "@total-typescript/tsconfig/bundler/no-dom",
3
+ "compilerOptions": {
4
+ "paths": {
5
+ "~/*": ["./src/*"]
6
+ },
7
+
8
+ "types": ["node"]
9
+ },
10
+ "include": ["**/*.ts"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ // @keep-sorted
4
+ export default defineConfig({
5
+ clean: true,
6
+ deps: { alwaysBundle: ["@ceo.paludetto/pi-utilities"] },
7
+ dts: { sourcemap: true },
8
+ entry: ["./src/index.ts"],
9
+ exports: { legacy: true },
10
+ format: ["cjs", "esm"],
11
+ publint: true,
12
+ sourcemap: true,
13
+ });