pi-zentui 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # Zentui
2
+
3
+ A Starship-inspired statusline and Opencode-style TUI for [Pi](https://github.com/badlogic/pi-mono).
4
+
5
+ ## Screenshots
6
+
7
+ ![Zentui](https://raw.githubusercontent.com/lmilojevicc/pi-zentui/main/assets/zentui.png)
8
+
9
+ ## What is this?
10
+
11
+ Zentui brings two popular aesthetics to Pi:
12
+
13
+ - **[Starship](https://starship.rs/) footer** — shows your current directory, git branch, git status indicators, and runtime/version detection in a compact, icon-rich format
14
+ - **[Opencode](https://github.com/opencode-ai/opencode) editor and messages** — clean bordered input box with accent rail, transparent previous messages, and model/provider display inside the editor frame
15
+
16
+ ## Features
17
+
18
+ ### Footer (Starship-inspired)
19
+
20
+ - `󰝰 dirname` — current directory with icon
21
+ - `on branch` — git branch with icon
22
+ - `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
23
+ - `via v5.5.0` — runtime detection with version (Bun, Deno, Node, Python, Go, Rust, Lua, Java, Ruby, PHP)
24
+ - Right side shows context usage, token counts, and cost
25
+
26
+ ### Editor (Opencode-inspired)
27
+
28
+ - Bordered input box with accent-colored left rail
29
+ - Model name and provider displayed inside the editor frame
30
+ - Thinking level indicator when enabled
31
+ - Matching style for previous user messages
32
+
33
+ ### Git Status Icons
34
+
35
+ | Icon | Meaning |
36
+ | ---- | ---------- |
37
+ | `!` | Modified |
38
+ | `?` | Untracked |
39
+ | `+` | Staged |
40
+ | `✘` | Deleted |
41
+ | `»` | Renamed |
42
+ | `=` | Conflicted |
43
+ | `$` | Stashed |
44
+ | `↑` | Ahead |
45
+ | `↓` | Behind |
46
+ | `⇕` | Diverged |
47
+
48
+ ### Runtime Detection
49
+
50
+ Detects project type and shows runtime version:
51
+
52
+ | Runtime | Detection |
53
+ | ------- | ----------------------------------------------------------- |
54
+ | Bun | `bun.lock`, `bun.lockb` |
55
+ | Deno | `deno.json`, `deno.jsonc`, `deno.lock` |
56
+ | Node.js | `package.json`, `.nvmrc`, `.node-version` |
57
+ | Python | `pyproject.toml`, `requirements.txt`, `setup.py`, `Pipfile` |
58
+ | Go | `go.mod` |
59
+ | Rust | `Cargo.toml` |
60
+ | Lua | `stylua.toml`, `.luarc.json`, `init.lua`, `lua/` dir |
61
+ | Java | `pom.xml`, `build.gradle` |
62
+ | Ruby | `Gemfile`, `.ruby-version` |
63
+ | PHP | `composer.json` |
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ # From npm
69
+ pi install npm:pi-zentui
70
+
71
+ # From git
72
+ pi install git:github.com/lmilojevicc/pi-zentui
73
+ ```
74
+
75
+ ## Config
76
+
77
+ On first run, Zentui creates a config file at:
78
+
79
+ ```
80
+ ~/.pi/agent/zentui.json
81
+ ```
82
+
83
+ ### Default config
84
+
85
+ ```json
86
+ {
87
+ "icons": {
88
+ "cwd": "󰝰",
89
+ "git": "",
90
+ "ahead": "↑",
91
+ "behind": "↓",
92
+ "diverged": "⇕",
93
+ "conflicted": "=",
94
+ "untracked": "?",
95
+ "stashed": "$",
96
+ "modified": "!",
97
+ "staged": "+",
98
+ "renamed": "»",
99
+ "deleted": "✘",
100
+ "typechanged": "T"
101
+ },
102
+ "colors": {
103
+ "cwdText": "syntaxOperator",
104
+ "git": "syntaxKeyword",
105
+ "gitStatus": "error",
106
+ "contextNormal": "muted",
107
+ "contextWarning": "warning",
108
+ "contextError": "error",
109
+ "tokens": "muted",
110
+ "cost": "success",
111
+ "separator": "borderMuted"
112
+ }
113
+ }
114
+ ```
115
+
116
+ ### Color values
117
+
118
+ Colors can be:
119
+
120
+ - Pi theme token names (e.g., `accent`, `error`, `syntaxKeyword`)
121
+ - Hex colors (e.g., `#89b4fa`)
122
+
123
+ This means Zentui works with any Pi theme — it uses your theme's colors by default.
124
+
125
+ ## Requirements
126
+
127
+ - [Pi](https://github.com/badlogic/pi-mono) coding agent
128
+ - A [Nerd Font](https://www.nerdfonts.com/) for icons
129
+
130
+ ## Development
131
+
132
+ If you use [mise](https://mise.jdx.dev/):
133
+
134
+ ```bash
135
+ mise install
136
+ mise run setup
137
+ mise run verify
138
+ mise run fmt
139
+ mise run ci
140
+ ```
141
+
142
+ Without mise:
143
+
144
+ ```bash
145
+ npm install
146
+ npm run verify
147
+ npm run fmt
148
+ npm run pack:check
149
+ ```
150
+
151
+ ## Credits
152
+
153
+ Inspired by:
154
+
155
+ - [Starship](https://starship.rs/) — the minimal, blazing-fast, and infinitely customizable prompt
156
+ - [Opencode](https://github.com/opencode-ai/opencode) — terminal-based AI coding assistant
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,168 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ export type ColorSpec = string;
6
+
7
+ export type PolishedTuiConfig = {
8
+ icons: {
9
+ cwd: string;
10
+ git: string;
11
+ ahead: string;
12
+ behind: string;
13
+ diverged: string;
14
+ conflicted: string;
15
+ untracked: string;
16
+ stashed: string;
17
+ modified: string;
18
+ staged: string;
19
+ renamed: string;
20
+ deleted: string;
21
+ typechanged: string;
22
+ };
23
+ colors: {
24
+ cwdText: ColorSpec;
25
+ git: ColorSpec;
26
+ gitStatus: ColorSpec;
27
+ contextNormal: ColorSpec;
28
+ contextWarning: ColorSpec;
29
+ contextError: ColorSpec;
30
+ tokens: ColorSpec;
31
+ cost: ColorSpec;
32
+ separator: ColorSpec;
33
+ };
34
+ };
35
+
36
+ export const configPath = join(getAgentDir(), "zentui.json");
37
+
38
+ const themeColorTokens = new Set([
39
+ "accent",
40
+ "border",
41
+ "borderAccent",
42
+ "borderMuted",
43
+ "success",
44
+ "error",
45
+ "warning",
46
+ "muted",
47
+ "dim",
48
+ "text",
49
+ "thinkingText",
50
+ "userMessageText",
51
+ "customMessageText",
52
+ "customMessageLabel",
53
+ "toolTitle",
54
+ "toolOutput",
55
+ "mdHeading",
56
+ "mdLink",
57
+ "mdLinkUrl",
58
+ "mdCode",
59
+ "mdCodeBlock",
60
+ "mdCodeBlockBorder",
61
+ "mdQuote",
62
+ "mdQuoteBorder",
63
+ "mdHr",
64
+ "mdListBullet",
65
+ "toolDiffAdded",
66
+ "toolDiffRemoved",
67
+ "toolDiffContext",
68
+ "syntaxComment",
69
+ "syntaxKeyword",
70
+ "syntaxFunction",
71
+ "syntaxVariable",
72
+ "syntaxString",
73
+ "syntaxNumber",
74
+ "syntaxType",
75
+ "syntaxOperator",
76
+ "syntaxPunctuation",
77
+ "thinkingOff",
78
+ "thinkingMinimal",
79
+ "thinkingLow",
80
+ "thinkingMedium",
81
+ "thinkingHigh",
82
+ "thinkingXhigh",
83
+ "bashMode",
84
+ ]);
85
+
86
+ export const defaultConfig: PolishedTuiConfig = {
87
+ icons: {
88
+ cwd: "󰝰",
89
+ git: "",
90
+ ahead: "↑",
91
+ behind: "↓",
92
+ diverged: "⇕",
93
+ conflicted: "=",
94
+ untracked: "?",
95
+ stashed: "$",
96
+ modified: "!",
97
+ staged: "+",
98
+ renamed: "»",
99
+ deleted: "✘",
100
+ typechanged: "T",
101
+ },
102
+ colors: {
103
+ cwdText: "syntaxOperator",
104
+ git: "syntaxKeyword",
105
+ gitStatus: "error",
106
+ contextNormal: "muted",
107
+ contextWarning: "warning",
108
+ contextError: "error",
109
+ tokens: "muted",
110
+ cost: "success",
111
+ separator: "borderMuted",
112
+ },
113
+ };
114
+
115
+ function isHexColor(value: string): boolean {
116
+ return /^#(?:[0-9a-fA-F]{6})$/.test(value);
117
+ }
118
+
119
+ function hexToAnsi(hex: string, isBackground = false): string {
120
+ const normalized = hex.slice(1);
121
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
122
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
123
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
124
+ return `\x1b[${isBackground ? 48 : 38};2;${r};${g};${b}m`;
125
+ }
126
+
127
+ type ThemeLike = {
128
+ fg(color: string, text: string): string;
129
+ };
130
+
131
+ export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
132
+ if (themeColorTokens.has(color)) {
133
+ return theme.fg(color, text);
134
+ }
135
+ if (isHexColor(color)) {
136
+ return `${hexToAnsi(color)}${text}\x1b[39m`;
137
+ }
138
+ return theme.fg("text", text);
139
+ }
140
+
141
+ export function ensureConfigExists(): void {
142
+ try {
143
+ if (!existsSync(configPath)) {
144
+ writeFileSync(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
145
+ }
146
+ } catch {
147
+ // Ignore config bootstrap failures; extension will fall back to defaults.
148
+ }
149
+ }
150
+
151
+ export function loadConfig(): PolishedTuiConfig {
152
+ try {
153
+ if (!existsSync(configPath)) return defaultConfig;
154
+ const parsed = JSON.parse(readFileSync(configPath, "utf8")) as Partial<PolishedTuiConfig>;
155
+ return {
156
+ icons: {
157
+ ...defaultConfig.icons,
158
+ ...(parsed.icons ?? {}),
159
+ },
160
+ colors: {
161
+ ...defaultConfig.colors,
162
+ ...(parsed.colors ?? {}),
163
+ },
164
+ };
165
+ } catch {
166
+ return defaultConfig;
167
+ }
168
+ }
@@ -0,0 +1,103 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export type GitStatusSummary = {
7
+ branch?: string;
8
+ dirty: boolean;
9
+ ahead: number;
10
+ behind: number;
11
+ conflicted: number;
12
+ untracked: number;
13
+ stashed: boolean;
14
+ modified: number;
15
+ staged: number;
16
+ renamed: number;
17
+ deleted: number;
18
+ typechanged: number;
19
+ };
20
+
21
+ export function emptyGitStatus(): GitStatusSummary {
22
+ return {
23
+ branch: undefined,
24
+ dirty: false,
25
+ ahead: 0,
26
+ behind: 0,
27
+ conflicted: 0,
28
+ untracked: 0,
29
+ stashed: false,
30
+ modified: 0,
31
+ staged: 0,
32
+ renamed: 0,
33
+ deleted: 0,
34
+ typechanged: 0,
35
+ };
36
+ }
37
+
38
+ export function parseGitStatusPorcelain(stdoutText: string, hasStash: boolean): GitStatusSummary {
39
+ const status = emptyGitStatus();
40
+ status.stashed = hasStash;
41
+
42
+ for (const line of stdoutText.split(/\r?\n/)) {
43
+ if (!line) continue;
44
+ if (line.startsWith("# branch.head ")) {
45
+ const branch = line.slice("# branch.head ".length).trim();
46
+ status.branch = branch && branch !== "(detached)" ? branch : undefined;
47
+ continue;
48
+ }
49
+ if (line.startsWith("# branch.ab ")) {
50
+ const match = line.match(/\+(\d+)\s+-(\d+)/);
51
+ if (match) {
52
+ status.ahead = Number(match[1] ?? 0);
53
+ status.behind = Number(match[2] ?? 0);
54
+ }
55
+ continue;
56
+ }
57
+ if (line.startsWith("#")) continue;
58
+
59
+ status.dirty = true;
60
+
61
+ if (line.startsWith("? ")) {
62
+ status.untracked += 1;
63
+ continue;
64
+ }
65
+ if (line.startsWith("u ")) {
66
+ status.conflicted += 1;
67
+ continue;
68
+ }
69
+ if (!(line.startsWith("1 ") || line.startsWith("2 "))) continue;
70
+
71
+ const xy = line.split(" ")[1] ?? "..";
72
+ const x = xy[0] ?? ".";
73
+ const y = xy[1] ?? ".";
74
+
75
+ if (x === "R") status.renamed += 1;
76
+ else if (x === "D") status.deleted += 1;
77
+ else if (x === "T") status.typechanged += 1;
78
+ else if (x !== "." && x !== " ") status.staged += 1;
79
+
80
+ if (y === "M") status.modified += 1;
81
+ else if (y === "D") status.deleted += 1;
82
+ else if (y === "T") status.typechanged += 1;
83
+ }
84
+
85
+ return status;
86
+ }
87
+
88
+ export async function readGitStatus(cwd: string): Promise<GitStatusSummary> {
89
+ try {
90
+ const [{ stdout: statusStdout }, stashResult] = await Promise.all([
91
+ execFileAsync("git", ["status", "--porcelain=2", "--branch"], { cwd }),
92
+ execFileAsync("git", ["rev-parse", "--verify", "--quiet", "refs/stash"], { cwd }).catch(
93
+ () => ({ stdout: "" }),
94
+ ),
95
+ ]);
96
+ const stdoutText = typeof statusStdout === "string" ? statusStdout : String(statusStdout);
97
+ const stashStdout =
98
+ typeof stashResult.stdout === "string" ? stashResult.stdout : String(stashResult.stdout);
99
+ return parseGitStatusPorcelain(stdoutText, stashStdout.trim().length > 0);
100
+ } catch {
101
+ return emptyGitStatus();
102
+ }
103
+ }
@@ -0,0 +1,359 @@
1
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ KeybindingsManager,
6
+ Theme,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import { type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
+ import { type PolishedTuiConfig, colorize, ensureConfigExists, loadConfig } from "./config";
10
+ import { type GitStatusSummary, emptyGitStatus, readGitStatus } from "./git";
11
+ import { type RuntimeInfo, readRuntimeInfo } from "./runtime";
12
+ import { PolishedEditor, patchUserMessageComponent } from "./ui";
13
+
14
+ type FooterState = GitStatusSummary & {
15
+ busy: boolean;
16
+ modelLabel: string;
17
+ providerLabel: string;
18
+ contextLabel: string;
19
+ tokenLabel: string;
20
+ costLabel: string;
21
+ runtime?: RuntimeInfo;
22
+ };
23
+
24
+ type UsageTotals = {
25
+ input: number;
26
+ output: number;
27
+ cost: number;
28
+ };
29
+
30
+ function formatCount(value: number): string {
31
+ if (value < 1000) return `${value}`;
32
+ if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
33
+ return `${Math.round(value / 1000)}k`;
34
+ }
35
+
36
+ function formatProviderLabel(provider: string | undefined): string {
37
+ if (!provider) return "Unknown";
38
+
39
+ const known: Record<string, string> = {
40
+ anthropic: "Anthropic",
41
+ gemini: "Google",
42
+ google: "Google",
43
+ ollama: "Ollama",
44
+ openai: "OpenAI",
45
+ "openai-codex": "OpenAI",
46
+ };
47
+
48
+ return (
49
+ known[provider] ?? provider.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
50
+ );
51
+ }
52
+
53
+ function getUsageTotals(ctx: ExtensionContext): UsageTotals {
54
+ let input = 0;
55
+ let output = 0;
56
+ let cost = 0;
57
+
58
+ for (const entry of ctx.sessionManager.getBranch()) {
59
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
60
+ const message = entry.message as AssistantMessage;
61
+ input += message.usage?.input ?? 0;
62
+ output += message.usage?.output ?? 0;
63
+ cost += message.usage?.cost?.total ?? 0;
64
+ }
65
+
66
+ return { input, output, cost };
67
+ }
68
+
69
+ function buildTokenLabel(totals: UsageTotals): string {
70
+ return `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
71
+ }
72
+
73
+ function buildCostLabel(totals: UsageTotals): string {
74
+ return `$${totals.cost.toFixed(3)}`;
75
+ }
76
+
77
+ function buildContextLabel(ctx: ExtensionContext): string {
78
+ const usage = ctx.getContextUsage();
79
+ const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
80
+
81
+ if (!usage || !contextWindow || contextWindow <= 0) return "--";
82
+
83
+ const percent =
84
+ usage.percent === null ? "?" : `${Math.max(0, Math.min(999, Math.round(usage.percent)))}%`;
85
+ return `${percent}/${formatCount(contextWindow)}`;
86
+ }
87
+
88
+ function getRuntimeColorToken(runtime: RuntimeInfo | undefined): string {
89
+ switch (runtime?.name) {
90
+ case "nodejs":
91
+ return "success";
92
+ case "deno":
93
+ return "syntaxType";
94
+ case "bun":
95
+ return "warning";
96
+ case "python":
97
+ case "java":
98
+ return "warning";
99
+ case "rust":
100
+ case "ruby":
101
+ return "error";
102
+ case "golang":
103
+ return "syntaxType";
104
+ case "lua":
105
+ case "php":
106
+ return "accent";
107
+ default:
108
+ return "text";
109
+ }
110
+ }
111
+
112
+ function formatRuntimeSegment(
113
+ theme: Pick<Theme, "fg">,
114
+ runtime: RuntimeInfo | undefined,
115
+ mutedColor: string,
116
+ ): string {
117
+ if (!runtime) return "";
118
+ const label = runtime.version ? `${runtime.symbol} ${runtime.version}` : runtime.symbol;
119
+ return `${colorize(theme, mutedColor, "via")} ${colorize(theme, getRuntimeColorToken(runtime), label)}`;
120
+ }
121
+
122
+ function formatCwdLabel(cwd: string, cwdIcon: string): string {
123
+ const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
124
+ const parts = normalized.split("/").filter(Boolean);
125
+ const last = parts[parts.length - 1] ?? cwd;
126
+ return cwdIcon ? `${cwdIcon} ${last}` : last;
127
+ }
128
+
129
+ export default function (pi: ExtensionAPI) {
130
+ const state: FooterState = {
131
+ busy: false,
132
+ modelLabel: "no-model",
133
+ providerLabel: "Unknown",
134
+ contextLabel: "--",
135
+ tokenLabel: "↑0 ↓0",
136
+ costLabel: "$0.000",
137
+ runtime: undefined,
138
+ ...emptyGitStatus(),
139
+ };
140
+
141
+ let currentConfig: PolishedTuiConfig = loadConfig();
142
+ let requestFooterRender: (() => void) | undefined;
143
+ let projectRefreshInFlight = false;
144
+ let projectRefreshPending = false;
145
+
146
+ const refresh = () => requestFooterRender?.();
147
+
148
+ const syncState = (ctx: ExtensionContext) => {
149
+ const totals = getUsageTotals(ctx);
150
+ state.modelLabel = ctx.model?.id ?? "no-model";
151
+ state.providerLabel = formatProviderLabel(ctx.model?.provider);
152
+ state.contextLabel = buildContextLabel(ctx);
153
+ state.tokenLabel = buildTokenLabel(totals);
154
+ state.costLabel = buildCostLabel(totals);
155
+ };
156
+
157
+ const refreshProjectState = async (ctx: ExtensionContext) => {
158
+ const [gitStatus, runtime] = await Promise.all([
159
+ readGitStatus(ctx.cwd),
160
+ readRuntimeInfo(ctx.cwd),
161
+ ]);
162
+ Object.assign(state, gitStatus);
163
+ state.runtime = runtime;
164
+ };
165
+
166
+ const scheduleProjectRefresh = (ctx: ExtensionContext) => {
167
+ if (projectRefreshInFlight) {
168
+ projectRefreshPending = true;
169
+ return;
170
+ }
171
+
172
+ projectRefreshInFlight = true;
173
+ void refreshProjectState(ctx).finally(() => {
174
+ projectRefreshInFlight = false;
175
+ refresh();
176
+ if (projectRefreshPending) {
177
+ projectRefreshPending = false;
178
+ scheduleProjectRefresh(ctx);
179
+ }
180
+ });
181
+ };
182
+
183
+ const installFooter = (ctx: ExtensionContext) => {
184
+ syncState(ctx);
185
+
186
+ ctx.ui.setFooter((tui, theme, footerData) => {
187
+ requestFooterRender = () => tui.requestRender();
188
+ const unsubscribeBranch = footerData.onBranchChange(() => {
189
+ scheduleProjectRefresh(ctx);
190
+ tui.requestRender();
191
+ });
192
+ const separator = colorize(theme, currentConfig.colors.separator, " | ");
193
+
194
+ return {
195
+ dispose: () => {
196
+ unsubscribeBranch();
197
+ requestFooterRender = undefined;
198
+ },
199
+ invalidate() {},
200
+ render(width: number): string[] {
201
+ const innerWidth = Math.max(1, width - 2);
202
+ const cwdLabel = colorize(
203
+ theme,
204
+ currentConfig.colors.cwdText,
205
+ formatCwdLabel(ctx.cwd, currentConfig.icons.cwd),
206
+ );
207
+ const branch = state.branch;
208
+ const contextUsage = ctx.getContextUsage();
209
+ const contextColor =
210
+ contextUsage?.percent !== null && contextUsage?.percent !== undefined
211
+ ? contextUsage.percent >= 90
212
+ ? currentConfig.colors.contextError
213
+ : contextUsage.percent >= 70
214
+ ? currentConfig.colors.contextWarning
215
+ : currentConfig.colors.contextNormal
216
+ : currentConfig.colors.contextNormal;
217
+ const gitColor = (text: string) => colorize(theme, currentConfig.colors.git, text);
218
+ const gitStatusColor = (text: string) =>
219
+ colorize(theme, currentConfig.colors.gitStatus, text);
220
+ const gitIcon = gitColor(currentConfig.icons.git);
221
+ const allStatus = [
222
+ state.conflicted > 0 ? currentConfig.icons.conflicted : "",
223
+ state.stashed ? currentConfig.icons.stashed : "",
224
+ state.deleted > 0 ? currentConfig.icons.deleted : "",
225
+ state.renamed > 0 ? currentConfig.icons.renamed : "",
226
+ state.modified > 0 ? currentConfig.icons.modified : "",
227
+ state.typechanged > 0 ? currentConfig.icons.typechanged : "",
228
+ state.staged > 0 ? currentConfig.icons.staged : "",
229
+ state.untracked > 0 ? currentConfig.icons.untracked : "",
230
+ ].join("");
231
+ const aheadBehind =
232
+ state.ahead > 0 && state.behind > 0
233
+ ? currentConfig.icons.diverged
234
+ : state.ahead > 0
235
+ ? currentConfig.icons.ahead
236
+ : state.behind > 0
237
+ ? currentConfig.icons.behind
238
+ : "";
239
+ const statusBlock =
240
+ allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
241
+ const branchLabel = branch
242
+ ? `${colorize(theme, "text", "on")} ${gitIcon} ${gitColor(branch)}${statusBlock ? ` ${statusBlock}` : ""}`
243
+ : "";
244
+ const runtimeLabel = formatRuntimeSegment(theme, state.runtime, "text");
245
+
246
+ const left = [cwdLabel, branchLabel, runtimeLabel].filter(Boolean).join(" ");
247
+ const right = [
248
+ colorize(theme, contextColor, state.contextLabel),
249
+ colorize(theme, currentConfig.colors.tokens, state.tokenLabel),
250
+ colorize(theme, currentConfig.colors.cost, state.costLabel),
251
+ ].join(separator);
252
+
253
+ const leftWidth = visibleWidth(left);
254
+ const rightWidth = visibleWidth(right);
255
+ const content =
256
+ leftWidth >= innerWidth
257
+ ? truncateToWidth(left, innerWidth)
258
+ : leftWidth + 1 + rightWidth <= innerWidth
259
+ ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
260
+ : left;
261
+ return [` ${content} `];
262
+ },
263
+ };
264
+ });
265
+ };
266
+
267
+ const installEditor = (ctx: ExtensionContext) => {
268
+ syncState(ctx);
269
+
270
+ let currentEditor: PolishedEditor | undefined;
271
+ let autocompleteFixed = false;
272
+
273
+ type AutocompleteEditorInternals = {
274
+ autocompleteProvider?: unknown;
275
+ };
276
+
277
+ const editorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
278
+ const editor = new PolishedEditor(
279
+ tui,
280
+ theme,
281
+ keybindings,
282
+ ctx.ui.theme,
283
+ () =>
284
+ [
285
+ ctx.ui.theme.fg("accent", state.modelLabel),
286
+ ctx.ui.theme.fg("text", state.providerLabel),
287
+ ].join(ctx.ui.theme.fg("borderMuted", " ")),
288
+ () => pi.getThinkingLevel(),
289
+ );
290
+ currentEditor = editor;
291
+
292
+ const originalHandleInput = editor.handleInput.bind(editor);
293
+ editor.handleInput = (data: string) => {
294
+ const editorInternals = editor as unknown as AutocompleteEditorInternals;
295
+ if (!autocompleteFixed && !editorInternals.autocompleteProvider) {
296
+ autocompleteFixed = true;
297
+ ctx.ui.setEditorComponent(editorFactory);
298
+ currentEditor?.handleInput(data);
299
+ return;
300
+ }
301
+ originalHandleInput(data);
302
+ };
303
+
304
+ return editor;
305
+ };
306
+
307
+ ctx.ui.setEditorComponent(editorFactory);
308
+ };
309
+
310
+ const installUi = (ctx: ExtensionContext) => {
311
+ ensureConfigExists();
312
+ currentConfig = loadConfig();
313
+ patchUserMessageComponent(ctx.ui.theme);
314
+ installFooter(ctx);
315
+ installEditor(ctx);
316
+ scheduleProjectRefresh(ctx);
317
+ refresh();
318
+ };
319
+
320
+ pi.on("session_start", async (_event, ctx) => {
321
+ installUi(ctx);
322
+ });
323
+
324
+ pi.on("agent_start", async (_event, ctx) => {
325
+ state.busy = true;
326
+ syncState(ctx);
327
+ refresh();
328
+ });
329
+
330
+ pi.on("agent_end", async (_event, ctx) => {
331
+ state.busy = false;
332
+ syncState(ctx);
333
+ scheduleProjectRefresh(ctx);
334
+ refresh();
335
+ });
336
+
337
+ pi.on("model_select", async (_event, ctx) => {
338
+ syncState(ctx);
339
+ refresh();
340
+ });
341
+
342
+ pi.on("message_end", async (_event, ctx) => {
343
+ syncState(ctx);
344
+ scheduleProjectRefresh(ctx);
345
+ refresh();
346
+ });
347
+
348
+ pi.on("tool_execution_end", async (_event, ctx) => {
349
+ syncState(ctx);
350
+ scheduleProjectRefresh(ctx);
351
+ refresh();
352
+ });
353
+
354
+ pi.on("session_compact", async (_event, ctx) => {
355
+ syncState(ctx);
356
+ scheduleProjectRefresh(ctx);
357
+ refresh();
358
+ });
359
+ }
@@ -0,0 +1,193 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const VERSION_TIMEOUT_MS = 2500;
8
+
9
+ export type RuntimeInfo = {
10
+ name: string;
11
+ symbol: string;
12
+ version?: string;
13
+ };
14
+
15
+ type RuntimeCandidate = {
16
+ name: string;
17
+ symbol: string;
18
+ detect: (cwd: string, entries: string[]) => boolean;
19
+ version: (cwd: string) => Promise<string | undefined>;
20
+ };
21
+
22
+ function hasAnyFile(cwd: string, names: string[]): boolean {
23
+ return names.some((name) => existsSync(join(cwd, name)));
24
+ }
25
+
26
+ function hasLuaFile(entries: string[]): boolean {
27
+ return entries.some((entry) => entry.endsWith(".lua"));
28
+ }
29
+
30
+ function hasAnyEntry(entries: string[], names: string[]): boolean {
31
+ return names.some((name) => entries.includes(name));
32
+ }
33
+
34
+ async function runVersion(
35
+ command: string,
36
+ args: string[] = [],
37
+ cwd?: string,
38
+ ): Promise<string | undefined> {
39
+ try {
40
+ const { stdout, stderr } = await execFileAsync(command, args, {
41
+ cwd,
42
+ timeout: VERSION_TIMEOUT_MS,
43
+ });
44
+ const text =
45
+ `${typeof stdout === "string" ? stdout : String(stdout)}\n${typeof stderr === "string" ? stderr : String(stderr)}`.trim();
46
+ return text || undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function prefixVersion(version: string | undefined): string | undefined {
53
+ if (!version) return undefined;
54
+ return version.startsWith("v") ? version : `v${version}`;
55
+ }
56
+
57
+ const runtimes: RuntimeCandidate[] = [
58
+ {
59
+ name: "bun",
60
+ symbol: "",
61
+ detect: (cwd) => hasAnyFile(cwd, ["bun.lock", "bun.lockb"]),
62
+ version: async () => prefixVersion(await runVersion("bun", ["--version"])),
63
+ },
64
+ {
65
+ name: "deno",
66
+ symbol: "",
67
+ detect: (cwd) => hasAnyFile(cwd, ["deno.json", "deno.jsonc", "deno.lock"]),
68
+ version: async () => {
69
+ const output = await runVersion("deno", ["--version"]);
70
+ const match = output?.match(/deno\s+([0-9][^\s]*)/i);
71
+ return prefixVersion(match?.[1]);
72
+ },
73
+ },
74
+ {
75
+ name: "lua",
76
+ symbol: "",
77
+ detect: (cwd, entries) =>
78
+ hasAnyFile(cwd, ["stylua.toml", ".stylua.toml", ".luarc.json", ".luarc.jsonc", "init.lua"]) ||
79
+ hasAnyEntry(entries, ["lua"]) ||
80
+ hasLuaFile(entries),
81
+ version: async () => {
82
+ const lua = await runVersion("lua", ["-v"]);
83
+ const luaMatch = lua?.match(/Lua\s+([0-9][^\s]*)/i);
84
+ if (luaMatch?.[1]) return prefixVersion(luaMatch[1]);
85
+ const luajit = await runVersion("luajit", ["-v"]);
86
+ const luajitMatch = luajit?.match(/LuaJIT\s+([0-9][^\s]*)/i);
87
+ return prefixVersion(luajitMatch?.[1]);
88
+ },
89
+ },
90
+ {
91
+ name: "nodejs",
92
+ symbol: "",
93
+ detect: (cwd) => hasAnyFile(cwd, ["package.json", ".nvmrc", ".node-version"]),
94
+ version: async () => prefixVersion(await runVersion("node", ["--version"])),
95
+ },
96
+ {
97
+ name: "python",
98
+ symbol: "",
99
+ detect: (cwd) =>
100
+ hasAnyFile(cwd, [
101
+ "pyproject.toml",
102
+ "requirements.txt",
103
+ "setup.py",
104
+ "setup.cfg",
105
+ "Pipfile",
106
+ ".python-version",
107
+ ]),
108
+ version: async () => {
109
+ const python3 = await runVersion("python3", ["--version"]);
110
+ const python3Match = python3?.match(/Python\s+([0-9][^\s]*)/i);
111
+ if (python3Match?.[1]) return prefixVersion(python3Match[1]);
112
+ const python = await runVersion("python", ["--version"]);
113
+ const pythonMatch = python?.match(/Python\s+([0-9][^\s]*)/i);
114
+ return prefixVersion(pythonMatch?.[1]);
115
+ },
116
+ },
117
+ {
118
+ name: "golang",
119
+ symbol: "",
120
+ detect: (cwd) => hasAnyFile(cwd, ["go.mod"]),
121
+ version: async () => {
122
+ const output = await runVersion("go", ["version"]);
123
+ const match = output?.match(/go version go([0-9][^\s]*)/i);
124
+ return prefixVersion(match?.[1]);
125
+ },
126
+ },
127
+ {
128
+ name: "rust",
129
+ symbol: "󱘗",
130
+ detect: (cwd) => hasAnyFile(cwd, ["Cargo.toml"]),
131
+ version: async () => {
132
+ const output = await runVersion("rustc", ["--version"]);
133
+ const match = output?.match(/rustc\s+([0-9][^\s]*)/i);
134
+ return prefixVersion(match?.[1]);
135
+ },
136
+ },
137
+ {
138
+ name: "java",
139
+ symbol: "",
140
+ detect: (cwd) => hasAnyFile(cwd, ["pom.xml", "build.gradle", "build.gradle.kts"]),
141
+ version: async () => {
142
+ const output = await runVersion("java", ["-version"]);
143
+ const quoted = output?.match(/"([0-9][^"]*)"/);
144
+ if (quoted?.[1]) return prefixVersion(quoted[1]);
145
+ const plain = output?.match(/version\s+([0-9][^\s]*)/i);
146
+ return prefixVersion(plain?.[1]);
147
+ },
148
+ },
149
+ {
150
+ name: "ruby",
151
+ symbol: "",
152
+ detect: (cwd) => hasAnyFile(cwd, ["Gemfile", ".ruby-version"]),
153
+ version: async () => {
154
+ const output = await runVersion("ruby", ["--version"]);
155
+ const match = output?.match(/ruby\s+([0-9][^\s]*)/i);
156
+ return prefixVersion(match?.[1]);
157
+ },
158
+ },
159
+ {
160
+ name: "php",
161
+ symbol: "",
162
+ detect: (cwd) => hasAnyFile(cwd, ["composer.json"]),
163
+ version: async () => {
164
+ const output = await runVersion("php", ["--version"]);
165
+ const match = output?.match(/PHP\s+([0-9][^\s]*)/i);
166
+ return prefixVersion(match?.[1]);
167
+ },
168
+ },
169
+ ];
170
+
171
+ export function detectRuntime(cwd: string, entries: string[]): RuntimeCandidate | undefined {
172
+ for (const runtime of runtimes) {
173
+ if (runtime.detect(cwd, entries)) return runtime;
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ export async function readRuntimeInfo(cwd: string): Promise<RuntimeInfo | undefined> {
179
+ let entries: string[] = [];
180
+ try {
181
+ entries = readdirSync(cwd);
182
+ } catch {
183
+ entries = [];
184
+ }
185
+
186
+ const runtime = detectRuntime(cwd, entries);
187
+ if (!runtime) return undefined;
188
+ return {
189
+ name: runtime.name,
190
+ symbol: runtime.symbol,
191
+ version: await runtime.version(cwd),
192
+ };
193
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ CustomEditor,
3
+ type KeybindingsManager,
4
+ type Theme,
5
+ UserMessageComponent,
6
+ } from "@mariozechner/pi-coding-agent";
7
+ import {
8
+ type Component,
9
+ Container,
10
+ type EditorTheme,
11
+ type TUI,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ } from "@mariozechner/pi-tui";
15
+
16
+ const OSC133_ZONE_START = "\x1b]133;A\x07";
17
+ const OSC133_ZONE_END = "\x1b]133;B\x07";
18
+ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
19
+ const originalUserMessageRender = UserMessageComponent.prototype.render;
20
+
21
+ type AutocompleteEditorInternals = {
22
+ autocompleteList?: Pick<Component, "render">;
23
+ isShowingAutocomplete?: () => boolean;
24
+ };
25
+
26
+ let currentUiTheme: Theme | undefined;
27
+
28
+ const TRUECOLOR_BACKGROUND_ANSI = /\x1b\[48;2;\d+;\d+;\d+m/g;
29
+ const INDEXED_BACKGROUND_ANSI = /\x1b\[48;5;\d+m/g;
30
+ const SIMPLE_BACKGROUND_ANSI = /\x1b\[(?:4\d|10[0-7]|49)m/g;
31
+
32
+ function stripBackgroundAnsi(text: string): string {
33
+ return text
34
+ .replace(TRUECOLOR_BACKGROUND_ANSI, "")
35
+ .replace(INDEXED_BACKGROUND_ANSI, "")
36
+ .replace(SIMPLE_BACKGROUND_ANSI, "");
37
+ }
38
+
39
+ function fillStyledLine(
40
+ content: string,
41
+ width: number,
42
+ background?: (text: string) => string,
43
+ ): string {
44
+ const truncated = truncateToWidth(stripBackgroundAnsi(content), width, "");
45
+ const padWidth = Math.max(0, width - visibleWidth(truncated));
46
+ const pad =
47
+ padWidth > 0 ? (background ? background(" ".repeat(padWidth)) : " ".repeat(padWidth)) : "";
48
+ return `${truncated}${pad}`;
49
+ }
50
+
51
+ export function patchUserMessageComponent(uiTheme: Theme): void {
52
+ currentUiTheme = uiTheme;
53
+
54
+ const prototype = UserMessageComponent.prototype as {
55
+ render(width: number): string[];
56
+ };
57
+ prototype.render = function (this: UserMessageComponent, width: number): string[] {
58
+ if (!currentUiTheme) {
59
+ return originalUserMessageRender.call(this, width);
60
+ }
61
+
62
+ const railWidth = 2;
63
+ const innerWidth = Math.max(1, width - railWidth);
64
+ const baseLines = Container.prototype.render.call(this, innerWidth) as string[];
65
+ if (baseLines.length === 0) return baseLines;
66
+
67
+ const hasLeadingSpacer = baseLines.length > 1 && visibleWidth(baseLines[0] ?? "") === 0;
68
+ const leadingLines = hasLeadingSpacer ? [baseLines[0] ?? ""] : [];
69
+ const contentLines = hasLeadingSpacer ? baseLines.slice(1) : baseLines;
70
+ const rail = `${currentUiTheme.fg("accent", "│")}\x1b[0m `;
71
+ const border = currentUiTheme.fg("border", "─".repeat(width));
72
+ const styledLines = contentLines.map((line) => `${rail}${fillStyledLine(line, innerWidth)}`);
73
+
74
+ if (styledLines.length === 0) {
75
+ return leadingLines;
76
+ }
77
+
78
+ const framedLines = [border, ...styledLines, border];
79
+ framedLines[0] = OSC133_ZONE_START + framedLines[0];
80
+ framedLines[framedLines.length - 1] =
81
+ framedLines[framedLines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
82
+ return [...leadingLines, ...framedLines];
83
+ };
84
+ }
85
+
86
+ export class PolishedEditor extends CustomEditor {
87
+ private readonly getModelMeta: () => string;
88
+ private readonly getThinkingLevel: () => string | undefined;
89
+ private readonly uiTheme: Theme;
90
+ private readonly reset = "\x1b[0m";
91
+
92
+ constructor(
93
+ tui: TUI,
94
+ theme: EditorTheme,
95
+ keybindings: KeybindingsManager,
96
+ uiTheme: Theme,
97
+ getModelMeta: () => string,
98
+ getThinkingLevel: () => string | undefined,
99
+ ) {
100
+ super(tui, theme, keybindings, { paddingX: 0 });
101
+ this.borderColor = (text: string) => uiTheme.fg("border", text);
102
+ this.uiTheme = uiTheme;
103
+ this.getModelMeta = getModelMeta;
104
+ this.getThinkingLevel = getThinkingLevel;
105
+ }
106
+
107
+ private fillLine(content: string, width: number): string {
108
+ const truncated = truncateToWidth(content, width, "");
109
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
110
+ return `${truncated}${pad}`;
111
+ }
112
+
113
+ render(width: number): string[] {
114
+ const innerWidth = Math.max(1, width - 2);
115
+ const rendered = super.render(innerWidth);
116
+ const editorInternals = this as unknown as AutocompleteEditorInternals;
117
+ const isShowingAutocomplete =
118
+ typeof editorInternals.isShowingAutocomplete === "function"
119
+ ? Boolean(editorInternals.isShowingAutocomplete())
120
+ : false;
121
+
122
+ if (rendered.length < 2) {
123
+ return super.render(width);
124
+ }
125
+
126
+ const { autocompleteList } = editorInternals;
127
+ const autocompleteCount =
128
+ isShowingAutocomplete && typeof autocompleteList?.render === "function"
129
+ ? autocompleteList.render(innerWidth).length
130
+ : 0;
131
+ const editorFrame =
132
+ autocompleteCount > 0 && autocompleteCount < rendered.length
133
+ ? rendered.slice(0, -autocompleteCount)
134
+ : rendered;
135
+ const autocompleteLines =
136
+ autocompleteCount > 0 && autocompleteCount < rendered.length
137
+ ? rendered.slice(-autocompleteCount)
138
+ : [];
139
+
140
+ if (editorFrame.length < 2) {
141
+ return rendered;
142
+ }
143
+
144
+ const editorLines = editorFrame.slice(1, -1);
145
+ const metaParts = [this.getModelMeta()];
146
+ const thinkingLevel = this.getThinkingLevel();
147
+ if (thinkingLevel && thinkingLevel !== "off") {
148
+ metaParts.push(this.uiTheme.fg("muted", thinkingLevel));
149
+ }
150
+ const meta = metaParts.filter(Boolean).join(this.uiTheme.fg("border", " "));
151
+
152
+ const rail = `${this.uiTheme.fg("accent", "│")}${this.reset} `;
153
+ const top = this.uiTheme.fg("border", "─".repeat(width));
154
+ const bottom = this.uiTheme.fg("border", "─".repeat(width));
155
+ const lines = ["", ...editorLines, "", meta];
156
+
157
+ return [
158
+ top,
159
+ ...lines.map((line) => `${rail}${this.fillLine(line, innerWidth)}`),
160
+ bottom,
161
+ ...autocompleteLines,
162
+ ];
163
+ }
164
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "pi-zentui",
3
+ "version": "0.1.0",
4
+ "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lmilojevicc/pi-zentui.git"
10
+ },
11
+ "homepage": "https://github.com/lmilojevicc/pi-zentui#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/lmilojevicc/pi-zentui/issues"
14
+ },
15
+ "keywords": ["pi-package", "pi-extension", "starship", "tui"],
16
+ "files": ["extensions"],
17
+ "scripts": {
18
+ "lint": "biome check .",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "fmt": "biome format --write .",
22
+ "fmt:check": "biome format --check .",
23
+ "verify": "npm run lint && npm run typecheck && npm run test",
24
+ "pack:check": "npm pack --dry-run",
25
+ "prepublishOnly": "npm run verify && npm run pack:check"
26
+ },
27
+ "peerDependencies": {
28
+ "@mariozechner/pi-ai": "*",
29
+ "@mariozechner/pi-coding-agent": "*",
30
+ "@mariozechner/pi-tui": "*"
31
+ },
32
+ "pi": {
33
+ "extensions": ["./extensions"],
34
+ "image": "https://raw.githubusercontent.com/lmilojevicc/pi-zentui/main/assets/zentui.png"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "^1.9.4",
41
+ "@mariozechner/pi-ai": "^0.65.2",
42
+ "@mariozechner/pi-coding-agent": "^0.65.2",
43
+ "@mariozechner/pi-tui": "^0.65.2",
44
+ "@types/node": "^25.5.2",
45
+ "typescript": "^6.0.2",
46
+ "vitest": "^3.2.4"
47
+ }
48
+ }