pi-open-tui 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 +72 -0
- package/extensions/open-tui/config.ts +109 -0
- package/extensions/open-tui/editor.ts +101 -0
- package/extensions/open-tui/footer.ts +267 -0
- package/extensions/open-tui/git.ts +154 -0
- package/extensions/open-tui/header.ts +177 -0
- package/extensions/open-tui/icons.ts +194 -0
- package/extensions/open-tui/index.ts +241 -0
- package/extensions/open-tui/runtime.ts +174 -0
- package/extensions/open-tui/session-lifecycle.ts +43 -0
- package/extensions/open-tui/settings-command.ts +258 -0
- package/extensions/open-tui/state.ts +90 -0
- package/extensions/open-tui/utils.ts +157 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-open-tui contributors
|
|
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,72 @@
|
|
|
1
|
+
# pi-open-tui
|
|
2
|
+
|
|
3
|
+
A polished TUI for [Pi](https://pi.dev) coding agent. Combines the best of pi-haiku, pi-claude-code-tui, and pi-zentui into one cohesive package.
|
|
4
|
+
|
|
5
|
+
## What's in it
|
|
6
|
+
|
|
7
|
+
- **Animated Pi logo header** — 16-frame color-changing logo animation + "Let's build something great" tagline
|
|
8
|
+
- **Starship-style footer** — 2 lines showing cwd, git branch/status, runtime version, context bar, model, token counts, and cost
|
|
9
|
+
- **Rounded editor** — accent rail + borderMuted rounded corners, clean visual frame
|
|
10
|
+
- **60+ runtime detection** — Node, Rust, Go, Python, Ruby, Java, Swift, Kotlin, C/C++, Deno, Bun, and many more
|
|
11
|
+
- **Git status** — branch, ahead/behind, modified/untracked/staged/stashed, detached HEAD commit hash + tag
|
|
12
|
+
- **Working timer** — live elapsed time while the agent is working, done duration when finished
|
|
13
|
+
- **Zero prototype patches** — uses public Pi APIs (setHeader/setFooter/setEditorComponent), safe across Pi updates
|
|
14
|
+
- **Interactive settings UI** — `/open-tui` opens a tabbed settings dialog (Features / Icons / Segments)
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install npm:pi-open-tui
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or try it for one run:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi -e npm:pi-open-tui
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
Run `/open-tui` to open the interactive settings UI. Configuration is stored at `~/.pi/agent/open-tui.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"enabled": true,
|
|
35
|
+
"icons": {
|
|
36
|
+
"mode": "auto"
|
|
37
|
+
},
|
|
38
|
+
"footerSegments": {
|
|
39
|
+
"cwd": true,
|
|
40
|
+
"gitBranch": true,
|
|
41
|
+
"gitStatus": true,
|
|
42
|
+
"gitCommit": false,
|
|
43
|
+
"runtime": true,
|
|
44
|
+
"context": true,
|
|
45
|
+
"tokens": true,
|
|
46
|
+
"cost": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `icons.mode`: `auto` (detect Nerd Font), `nerd` (force Nerd Font glyphs), or `ascii` (plain fallbacks)
|
|
52
|
+
- `footerSegments.gitCommit`: shows short hash + tag on detached HEAD (off by default)
|
|
53
|
+
|
|
54
|
+
## Local development
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pi -e .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
63
|
+
|
|
64
|
+
## Acknowledgements
|
|
65
|
+
|
|
66
|
+
This project builds on the work of several Pi community packages:
|
|
67
|
+
|
|
68
|
+
- **[pi-haiku](https://github.com/nnocte/pi-haiku)** — the 2-line footer structure (location+model · timer+context) and working-timer pattern
|
|
69
|
+
- **[pi-claude-code-tui](https://github.com/Phoobobo/pi-claude-code-tui)** — the 16-frame animated Pi logo and rounded editor border technique
|
|
70
|
+
- **[pi-zentui](https://github.com/lmilojevicc/pi-zentui)** — the Starship-style footer segments (git status icons, runtime detection, context gauge), generation-based session lifecycle, and interactive settings UI pattern
|
|
71
|
+
|
|
72
|
+
The animated logo frames are derived from `pi-claude-code-tui`, which in turn derive from Pi's official install script (`pi.dev/install.sh`). The runtime detection list and git porcelain parsing borrow structure from `pi-zentui`.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { IconMode } from "./icons.ts";
|
|
5
|
+
|
|
6
|
+
export type { IconMode } from "./icons.ts";
|
|
7
|
+
|
|
8
|
+
export interface FooterSegments {
|
|
9
|
+
cwd: boolean;
|
|
10
|
+
gitBranch: boolean;
|
|
11
|
+
gitStatus: boolean;
|
|
12
|
+
gitCommit: boolean;
|
|
13
|
+
runtime: boolean;
|
|
14
|
+
context: boolean;
|
|
15
|
+
tokens: boolean;
|
|
16
|
+
cost: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OpenTuiConfig {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
icons: {
|
|
22
|
+
mode: IconMode;
|
|
23
|
+
};
|
|
24
|
+
footerSegments: FooterSegments;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
icons: {
|
|
30
|
+
mode: "auto",
|
|
31
|
+
},
|
|
32
|
+
footerSegments: {
|
|
33
|
+
cwd: true,
|
|
34
|
+
gitBranch: true,
|
|
35
|
+
gitStatus: true,
|
|
36
|
+
gitCommit: false,
|
|
37
|
+
runtime: true,
|
|
38
|
+
context: true,
|
|
39
|
+
tokens: true,
|
|
40
|
+
cost: true,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function getConfigPath(): string {
|
|
45
|
+
const agentDir = getAgentDir();
|
|
46
|
+
return join(agentDir, "open-tui.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deepMerge<T>(base: T, override: unknown): T {
|
|
50
|
+
if (typeof base !== "object" || base === null || Array.isArray(base)) {
|
|
51
|
+
return (override as T) ?? base;
|
|
52
|
+
}
|
|
53
|
+
if (typeof override !== "object" || override === null || Array.isArray(override)) {
|
|
54
|
+
return base;
|
|
55
|
+
}
|
|
56
|
+
const result = { ...(base as Record<string, unknown>) };
|
|
57
|
+
const overrideRec = override as Record<string, unknown>;
|
|
58
|
+
for (const key of Object.keys(overrideRec)) {
|
|
59
|
+
const baseVal = (base as Record<string, unknown>)[key];
|
|
60
|
+
const overVal = overrideRec[key];
|
|
61
|
+
if (typeof baseVal === "object" && baseVal !== null && !Array.isArray(baseVal)
|
|
62
|
+
&& typeof overVal === "object" && overVal !== null && !Array.isArray(overVal)) {
|
|
63
|
+
result[key] = deepMerge(baseVal, overVal);
|
|
64
|
+
} else if (overVal !== undefined) {
|
|
65
|
+
result[key] = overVal;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result as T;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ensureConfigExists(): void {
|
|
72
|
+
const path = getConfigPath();
|
|
73
|
+
if (existsSync(path)) return;
|
|
74
|
+
try {
|
|
75
|
+
const agentDir = getAgentDir();
|
|
76
|
+
if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
|
|
77
|
+
writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
// ponytail: silent fallback — config creation is best-effort
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function loadConfig(notify?: (msg: string, level: "warning" | "info") => void): OpenTuiConfig {
|
|
84
|
+
const path = getConfigPath();
|
|
85
|
+
if (!existsSync(path)) {
|
|
86
|
+
ensureConfigExists();
|
|
87
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const raw = readFileSync(path, "utf8");
|
|
92
|
+
const parsed: unknown = JSON.parse(raw);
|
|
93
|
+
return deepMerge(DEFAULT_CONFIG, parsed);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
notify?.(`open-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
96
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function saveConfig(config: OpenTuiConfig): void {
|
|
101
|
+
const path = getConfigPath();
|
|
102
|
+
try {
|
|
103
|
+
const agentDir = getAgentDir();
|
|
104
|
+
if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
|
|
105
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
106
|
+
} catch {
|
|
107
|
+
// ponytail: silent fallback — config save is best-effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type KeybindingsManager,
|
|
6
|
+
type Theme,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { findBottomBorderIndex, isEditorBorderLine, stripAnsi } from "./utils.ts";
|
|
11
|
+
|
|
12
|
+
function fillLine(content: string, width: number): string {
|
|
13
|
+
const truncated = truncateToWidth(content, Math.max(0, width), "");
|
|
14
|
+
const pad = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
15
|
+
return `${truncated}${pad}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function roundedBorder(
|
|
19
|
+
width: number,
|
|
20
|
+
kind: "top" | "bottom",
|
|
21
|
+
paint: (s: string) => string,
|
|
22
|
+
sourceLine?: string,
|
|
23
|
+
): string {
|
|
24
|
+
if (width < 2) return paint(truncateToWidth(kind === "top" ? "╭╮" : "╰╯", width, ""));
|
|
25
|
+
const corners = kind === "top" ? (["╭", "╮"] as const) : (["╰", "╯"] as const);
|
|
26
|
+
|
|
27
|
+
if (sourceLine) {
|
|
28
|
+
const plain = stripAnsi(sourceLine);
|
|
29
|
+
const scrollMatch = plain.match(/([↑↓]\s+\d+\s+more)/);
|
|
30
|
+
if (scrollMatch) {
|
|
31
|
+
const label = `─── ${scrollMatch[1]} `;
|
|
32
|
+
const fill = Math.max(0, width - 2 - visibleWidth(label));
|
|
33
|
+
return paint(`${corners[0]}${label}${"─".repeat(fill)}${corners[1]}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return paint(`${corners[0]}${"─".repeat(Math.max(0, width - 2))}${corners[1]}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class OpenTuiEditor extends CustomEditor {
|
|
41
|
+
private readonly getRail: () => string;
|
|
42
|
+
private readonly getBorder: (s: string) => string;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
tui: TUI,
|
|
46
|
+
editorTheme: EditorTheme,
|
|
47
|
+
keybindings: KeybindingsManager,
|
|
48
|
+
getUiTheme: () => Theme,
|
|
49
|
+
) {
|
|
50
|
+
super(tui, editorTheme, keybindings, { paddingX: 0 });
|
|
51
|
+
this.getRail = () => getUiTheme().fg("accent", "│");
|
|
52
|
+
this.getBorder = (s: string) => getUiTheme().fg("borderMuted", s);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override setPaddingX(_padding: number): void {
|
|
56
|
+
// The custom rail owns the horizontal inset and keeps one stable text gap.
|
|
57
|
+
super.setPaddingX(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
if (width < 4) return super.render(width);
|
|
62
|
+
|
|
63
|
+
const rail = this.getRail();
|
|
64
|
+
const borderPaint = this.getBorder;
|
|
65
|
+
const railWidth = 2;
|
|
66
|
+
|
|
67
|
+
const innerWidth = Math.max(0, width - railWidth);
|
|
68
|
+
const baseLines = super.render(innerWidth);
|
|
69
|
+
const bottomIdx = findBottomBorderIndex(baseLines);
|
|
70
|
+
|
|
71
|
+
const result: string[] = [];
|
|
72
|
+
result.push(roundedBorder(width, "top", borderPaint, baseLines[0]));
|
|
73
|
+
|
|
74
|
+
for (let i = 1; i < bottomIdx; i++) {
|
|
75
|
+
const line = baseLines[i] ?? "";
|
|
76
|
+
if (isEditorBorderLine(line)) {
|
|
77
|
+
result.push(`${rail} ${fillLine("", innerWidth)}`);
|
|
78
|
+
} else {
|
|
79
|
+
result.push(`${rail} ${fillLine(line, innerWidth)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
result.push(roundedBorder(width, "bottom", borderPaint, baseLines[bottomIdx]));
|
|
84
|
+
|
|
85
|
+
for (let i = bottomIdx + 1; i < baseLines.length; i++) {
|
|
86
|
+
result.push(baseLines[i]!);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result.map((line) => truncateToWidth(line, width, ""));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function installEditor(_pi: ExtensionAPI, ctx: ExtensionContext): () => void {
|
|
94
|
+
const getUiTheme = () => ctx.ui.theme;
|
|
95
|
+
ctx.ui.setEditorComponent((tui, editorTheme, keybindings) =>
|
|
96
|
+
new OpenTuiEditor(tui, editorTheme, keybindings, getUiTheme),
|
|
97
|
+
);
|
|
98
|
+
return () => {
|
|
99
|
+
ctx.ui.setEditorComponent(undefined);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { OpenTuiConfig } from "./config.ts";
|
|
4
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
5
|
+
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
|
|
6
|
+
import type { GitStatus } from "./git.ts";
|
|
7
|
+
import type { RuntimeInfo } from "./runtime.ts";
|
|
8
|
+
import {
|
|
9
|
+
alignRight,
|
|
10
|
+
cacheHitColor,
|
|
11
|
+
effortColor,
|
|
12
|
+
fmtTokens,
|
|
13
|
+
formatCwd,
|
|
14
|
+
formatDuration,
|
|
15
|
+
formatProviderLabel,
|
|
16
|
+
providerColor,
|
|
17
|
+
sanitizeStatus,
|
|
18
|
+
stressColor,
|
|
19
|
+
} from "./utils.ts";
|
|
20
|
+
import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
|
|
21
|
+
import { getUsageTotals } from "./state.ts";
|
|
22
|
+
|
|
23
|
+
function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
|
|
24
|
+
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
25
|
+
const empty = barWidth - filled;
|
|
26
|
+
const color = stressColor(pct);
|
|
27
|
+
const filledCell = ascii ? "#" : "█";
|
|
28
|
+
const emptyCell = ascii ? "-" : "░";
|
|
29
|
+
return (
|
|
30
|
+
theme.fg("dim", "[") +
|
|
31
|
+
theme.fg(color, filledCell.repeat(filled)) +
|
|
32
|
+
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
33
|
+
theme.fg("dim", "]")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderGitSegment(
|
|
38
|
+
theme: Theme,
|
|
39
|
+
git: GitStatus,
|
|
40
|
+
glyphs: IconGlyphs,
|
|
41
|
+
segments: OpenTuiConfig["footerSegments"],
|
|
42
|
+
): string {
|
|
43
|
+
const parts: string[] = [];
|
|
44
|
+
if (segments.gitBranch) {
|
|
45
|
+
if (git.branch) {
|
|
46
|
+
parts.push(theme.fg("mdLink", glyphs.git));
|
|
47
|
+
parts.push(theme.fg("success", git.branch));
|
|
48
|
+
} else if (git.commit?.detached) {
|
|
49
|
+
parts.push(theme.fg("mdLink", glyphs.git));
|
|
50
|
+
parts.push(theme.fg("success", "HEAD"));
|
|
51
|
+
if (segments.gitCommit && git.commit.oid) {
|
|
52
|
+
const shortHash = git.commit.oid.slice(0, 7);
|
|
53
|
+
const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
|
|
54
|
+
parts.push(theme.fg("success", `(${shortHash}${tag})`));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (segments.gitStatus) {
|
|
60
|
+
const statusIcons: string[] = [];
|
|
61
|
+
const addStatus = (count: number, glyph: string, color: ThemeColor) => {
|
|
62
|
+
if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count > 1 ? count : ""}`));
|
|
63
|
+
};
|
|
64
|
+
addStatus(git.conflicted, glyphs.conflicted, "error");
|
|
65
|
+
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
66
|
+
addStatus(git.deleted, glyphs.deleted, "error");
|
|
67
|
+
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
68
|
+
addStatus(git.modified, glyphs.modified, "warning");
|
|
69
|
+
addStatus(git.staged, glyphs.staged, "success");
|
|
70
|
+
addStatus(git.untracked, glyphs.untracked, "muted");
|
|
71
|
+
|
|
72
|
+
if (git.ahead > 0 && git.behind > 0) {
|
|
73
|
+
statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
|
|
74
|
+
} else if (git.ahead > 0) {
|
|
75
|
+
statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
|
|
76
|
+
} else if (git.behind > 0) {
|
|
77
|
+
statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const statusBlock = statusIcons.join(" ");
|
|
81
|
+
if (statusBlock) {
|
|
82
|
+
parts.push(`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return parts.join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderRuntimeSegment(
|
|
90
|
+
theme: Theme,
|
|
91
|
+
runtime: RuntimeInfo | null,
|
|
92
|
+
iconMode: OpenTuiConfig["icons"]["mode"],
|
|
93
|
+
): string {
|
|
94
|
+
if (!runtime) return "";
|
|
95
|
+
const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
|
|
96
|
+
const version = runtime.version ? theme.fg("muted", runtime.version) : "";
|
|
97
|
+
const label = [symbol, version].filter(Boolean).join(" ");
|
|
98
|
+
return label;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderTimerSegment(theme: Theme, state: FooterState, glyphs: IconGlyphs): string {
|
|
102
|
+
if (state.workingSince !== undefined) {
|
|
103
|
+
return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
|
|
104
|
+
}
|
|
105
|
+
if (state.lastDoneIn !== undefined) {
|
|
106
|
+
return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
|
|
107
|
+
}
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderContextBar(
|
|
112
|
+
theme: Theme,
|
|
113
|
+
ctx: ExtensionContext,
|
|
114
|
+
width: number,
|
|
115
|
+
glyphs: IconGlyphs,
|
|
116
|
+
iconMode: OpenTuiConfig["icons"]["mode"],
|
|
117
|
+
): string {
|
|
118
|
+
const contextUsage = ctx.getContextUsage();
|
|
119
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
120
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
121
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
122
|
+
|
|
123
|
+
// ponytail: render 0% bar once we know the window — keeps the right side
|
|
124
|
+
// populated instead of collapsing everything left in an empty session.
|
|
125
|
+
if (contextWindow <= 0) return "";
|
|
126
|
+
|
|
127
|
+
const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
|
|
128
|
+
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
129
|
+
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
130
|
+
const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
|
|
131
|
+
const barWidth = Math.max(6, Math.min(16, width - reserved));
|
|
132
|
+
return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderStatsBlock(
|
|
136
|
+
theme: Theme,
|
|
137
|
+
totals: UsageTotals,
|
|
138
|
+
glyphs: IconGlyphs,
|
|
139
|
+
segments: OpenTuiConfig["footerSegments"],
|
|
140
|
+
): string {
|
|
141
|
+
const stats: string[] = [];
|
|
142
|
+
if (segments.tokens) {
|
|
143
|
+
stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
|
|
144
|
+
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
145
|
+
if (totals.latestCacheHitRate !== undefined) {
|
|
146
|
+
stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(0)}%`));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (segments.cost) {
|
|
150
|
+
stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return stats.join(` ${theme.fg("dim", "|")} `);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderExtensionStatusLines(
|
|
157
|
+
theme: Theme,
|
|
158
|
+
extensionStatuses: ReadonlyMap<string, string>,
|
|
159
|
+
glyphs: IconGlyphs,
|
|
160
|
+
width: number,
|
|
161
|
+
): string[] {
|
|
162
|
+
const statuses = Array.from(extensionStatuses.entries())
|
|
163
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
164
|
+
.map(([, text]) => sanitizeStatus(text))
|
|
165
|
+
.filter((text) => text.length > 0);
|
|
166
|
+
if (statuses.length === 0) return [];
|
|
167
|
+
|
|
168
|
+
const separator = ` ${theme.fg("dim", "|")} `;
|
|
169
|
+
const statusText = statuses.map((status) => theme.fg("muted", status)).join(separator);
|
|
170
|
+
const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
|
|
171
|
+
return wrapTextWithAnsi(line, width);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface FooterHooks {
|
|
175
|
+
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
176
|
+
scheduleGitRefresh: () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function installFooter(
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
getState: () => FooterState,
|
|
182
|
+
getConfig: () => OpenTuiConfig,
|
|
183
|
+
getModelMeta: () => ModelMeta,
|
|
184
|
+
hooks: FooterHooks,
|
|
185
|
+
): () => void {
|
|
186
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
187
|
+
hooks.setRequestRender(() => tui.requestRender());
|
|
188
|
+
const unsubBranch = footerData.onBranchChange(() => {
|
|
189
|
+
hooks.scheduleGitRefresh();
|
|
190
|
+
tui.requestRender();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
dispose() {
|
|
195
|
+
unsubBranch();
|
|
196
|
+
hooks.setRequestRender(undefined);
|
|
197
|
+
},
|
|
198
|
+
invalidate() {},
|
|
199
|
+
render(width: number): string[] {
|
|
200
|
+
if (width <= 0) return [""];
|
|
201
|
+
const state = getState();
|
|
202
|
+
const config = getConfig();
|
|
203
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
204
|
+
const segments = config.footerSegments;
|
|
205
|
+
const meta = getModelMeta();
|
|
206
|
+
|
|
207
|
+
const totals = getUsageTotals(ctx);
|
|
208
|
+
|
|
209
|
+
const leftParts: string[] = [];
|
|
210
|
+
if (segments.cwd) {
|
|
211
|
+
leftParts.push(`${theme.fg("mdLink", glyphs.cwd)} ${theme.fg("accent", formatCwd(ctx.sessionManager.getCwd()))}`);
|
|
212
|
+
}
|
|
213
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
214
|
+
if (gitSeg) leftParts.push(gitSeg);
|
|
215
|
+
if (segments.runtime) {
|
|
216
|
+
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
217
|
+
if (runtimeSeg) leftParts.push(runtimeSeg);
|
|
218
|
+
}
|
|
219
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
220
|
+
if (timerSeg) leftParts.push(timerSeg);
|
|
221
|
+
|
|
222
|
+
let rightBlock = "";
|
|
223
|
+
if (segments.context) {
|
|
224
|
+
rightBlock = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const line1 = alignRight(leftParts.join(" "), rightBlock, width, theme);
|
|
228
|
+
|
|
229
|
+
const modelParts: string[] = [];
|
|
230
|
+
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
231
|
+
if (meta.provider && meta.provider !== "Unknown") {
|
|
232
|
+
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
233
|
+
}
|
|
234
|
+
modelParts.push(theme.fg("text", meta.model));
|
|
235
|
+
if (meta.effort && meta.effort !== "off") {
|
|
236
|
+
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
237
|
+
}
|
|
238
|
+
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
239
|
+
|
|
240
|
+
const statsBlock = renderStatsBlock(
|
|
241
|
+
theme,
|
|
242
|
+
totals,
|
|
243
|
+
glyphs,
|
|
244
|
+
segments,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const line2 = alignRight(modelBlock, statsBlock, width, theme);
|
|
248
|
+
|
|
249
|
+
const mainLines = [line1, line2]
|
|
250
|
+
.map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
|
|
251
|
+
return [
|
|
252
|
+
...mainLines,
|
|
253
|
+
...renderExtensionStatusLines(
|
|
254
|
+
theme,
|
|
255
|
+
footerData.getExtensionStatuses(),
|
|
256
|
+
glyphs,
|
|
257
|
+
width,
|
|
258
|
+
),
|
|
259
|
+
];
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
ctx.ui.setFooter(undefined);
|
|
266
|
+
};
|
|
267
|
+
}
|