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 +21 -0
- package/README.md +160 -0
- package/extensions/zentui/config.ts +168 -0
- package/extensions/zentui/git.ts +103 -0
- package/extensions/zentui/index.ts +359 -0
- package/extensions/zentui/runtime.ts +193 -0
- package/extensions/zentui/ui.ts +164 -0
- package/package.json +48 -0
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
|
+

|
|
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
|
+
}
|