@xiliumz/pi-minimal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Azaria Beryl
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,79 @@
1
+ # pi-minimal
2
+
3
+ Bash-style prompt layout for [pi](https://pi.dev).
4
+
5
+ ```
6
+ ~/project (main*):
7
+ $ |
8
+ ```
9
+
10
+ No header. No footer. Session stats live behind `/info` or `F8`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # from git
16
+ pi install git:github.com/xiliumz/pi-minimal
17
+
18
+ # from a local checkout
19
+ pi install /path/to/pi-minimal
20
+
21
+ # from npm
22
+ pi install npm:@xiliumz/pi-minimal
23
+ ```
24
+
25
+ Restart pi or run `/reload`.
26
+
27
+ ## What you get
28
+
29
+ | Piece | Behavior |
30
+ | --- | --- |
31
+ | Prompt | `~/path (branch*):` above `$ ` input — dirty tree shows `*` |
32
+ | Spinner | Shown in the prompt while the agent runs |
33
+ | Header / footer | Removed |
34
+ | `/info` / `F8` | Model, thinking level, context %, tokens, cost, session file/id |
35
+
36
+ Multiline input and slash-command autocomplete start at column 0 (under `$ `).
37
+ Soft-wrapped continuations use the full terminal width — only the first visual line shares space with `$ `.
38
+
39
+ ## Notes
40
+
41
+ `/new`, `/resume`, and `/fork` briefly flash the stock pi header/footer/editor.
42
+ Pi resets extension UI before the old session context goes stale, then re-runs
43
+ `session_start` so this layout comes back. Extension-side fix is not available.
44
+
45
+ ## Develop
46
+
47
+ ```bash
48
+ git clone https://github.com/xiliumz/pi-minimal.git
49
+ cd pi-minimal
50
+
51
+ # point pi at the working tree (no copy)
52
+ pi install "$PWD"
53
+
54
+ # edit → /reload inside pi
55
+ ```
56
+
57
+ Layout:
58
+
59
+ ```
60
+ extensions/index.ts # pi entry (registers hooks + commands)
61
+ src/editor.ts # bash prompt CustomEditor
62
+ src/info.ts # /info UI + token stats
63
+ src/git.ts # branch + dirty
64
+ src/format.ts # cwd/tokens helpers
65
+ src/ui.ts # empty header/footer component
66
+ ```
67
+
68
+ ## Uninstall
69
+
70
+ ```bash
71
+ pi remove git:github.com/xiliumz/pi-minimal
72
+ # or
73
+ pi remove npm:@xiliumz/pi-minimal
74
+ pi remove /path/to/pi-minimal
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,104 @@
1
+ /**
2
+ * pi-minimal
3
+ *
4
+ * Bash-style prompt for pi:
5
+ * ~/path (branch*):
6
+ * $ |
7
+ *
8
+ * Header/footer chrome removed. Session stats via /info.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { TUI } from "@earendil-works/pi-tui";
13
+ import { createBashPromptEditor } from "../src/editor.js";
14
+ import { fetchGitState, type GitState } from "../src/git.js";
15
+ import { showInfoUi } from "../src/info.js";
16
+ import { EmptyComponent } from "../src/ui.js";
17
+
18
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
+
20
+ export default function bashLayoutExtension(pi: ExtensionAPI): void {
21
+ let activeTui: TUI | undefined;
22
+ let git: GitState = { dirty: false };
23
+ let isWorking = false;
24
+ let spinnerIndex = 0;
25
+ let spinnerTimer: ReturnType<typeof setInterval> | undefined;
26
+
27
+ const stopSpinner = () => {
28
+ if (spinnerTimer) {
29
+ clearInterval(spinnerTimer);
30
+ spinnerTimer = undefined;
31
+ }
32
+ };
33
+
34
+ const refreshGit = async (cwd: string) => {
35
+ git = await fetchGitState(pi, cwd);
36
+ activeTui?.requestRender();
37
+ };
38
+
39
+ const promptState = {
40
+ get git() {
41
+ return git;
42
+ },
43
+ get isWorking() {
44
+ return isWorking;
45
+ },
46
+ get spinnerFrame() {
47
+ return SPINNER_FRAMES[spinnerIndex]!;
48
+ },
49
+ onMount(tui: TUI) {
50
+ activeTui = tui;
51
+ },
52
+ };
53
+
54
+ pi.on("agent_start", () => {
55
+ isWorking = true;
56
+ stopSpinner();
57
+ spinnerTimer = setInterval(() => {
58
+ spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
59
+ activeTui?.requestRender();
60
+ }, 80);
61
+ activeTui?.requestRender();
62
+ });
63
+
64
+ pi.on("agent_end", (_event, ctx) => {
65
+ isWorking = false;
66
+ stopSpinner();
67
+ if (ctx.mode === "tui") {
68
+ void refreshGit(ctx.cwd);
69
+ }
70
+ activeTui?.requestRender();
71
+ });
72
+
73
+ pi.on("session_shutdown", () => {
74
+ stopSpinner();
75
+ activeTui = undefined;
76
+ });
77
+
78
+ pi.on("session_start", (_event, ctx) => {
79
+ if (ctx.mode !== "tui") return;
80
+
81
+ ctx.ui.setHeader(() => new EmptyComponent());
82
+ ctx.ui.setFooter(() => new EmptyComponent());
83
+ ctx.ui.setWorkingVisible(false);
84
+
85
+ void refreshGit(ctx.cwd);
86
+
87
+ const Editor = createBashPromptEditor(ctx, promptState);
88
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => new Editor(tui, theme, keybindings));
89
+ });
90
+
91
+ const showInfo = async (ctx: ExtensionContext) => {
92
+ await showInfoUi(ctx, pi.getThinkingLevel());
93
+ };
94
+
95
+ pi.registerCommand("info", {
96
+ description: "Show model, tokens, cost, and context usage for this session",
97
+ handler: (_args, ctx) => showInfo(ctx),
98
+ });
99
+
100
+ pi.registerShortcut("f8", {
101
+ description: "Show session info",
102
+ handler: showInfo,
103
+ });
104
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@xiliumz/pi-minimal",
3
+ "version": "1.0.0",
4
+ "description": "Bash-style prompt layout for pi — ~/path (branch*): with /info",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Azaria Beryl",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/xiliumz/pi-minimal.git"
11
+ },
12
+ "homepage": "https://github.com/xiliumz/pi-minimal#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/xiliumz/pi-minimal/issues"
15
+ },
16
+ "keywords": [
17
+ "pi",
18
+ "extension",
19
+ "layout",
20
+ "minimal"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "files": [
26
+ "extensions",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/index.ts"
34
+ ]
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": "*",
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*"
40
+ },
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "^0.83.0",
46
+ "@earendil-works/pi-coding-agent": "^0.83.0",
47
+ "@earendil-works/pi-tui": "^0.83.0",
48
+ "@types/node": "^26.1.2",
49
+ "typescript": "^7.0.2"
50
+ }
51
+ }
package/src/cost.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Cost section support for /info: cache re-billing (waste) and per-model
3
+ * cost breakdown.
4
+ *
5
+ * Port of pi-coding-agent internals — `core/cache-stats.ts` (computeCacheWaste)
6
+ * and `core/usage-totals.ts` (getUsageCostBreakdown) — neither is exported from
7
+ * the package root. Kept in sync manually with those files.
8
+ *
9
+ * Scope: the current branch's assistant-message usage, matching the
10
+ * "Tokens (branch)" stats in /info so the Cost section reconciles.
11
+ */
12
+
13
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
14
+
15
+ /** Per-turn misses at or below this are cache breakpoint granularity noise. */
16
+ const NOISE_FLOOR_TOKENS = 1024;
17
+
18
+ export interface CacheWasteTotals {
19
+ missedTokens: number;
20
+ missedCost: number;
21
+ /** Number of counted misses (turns above the noise floor). */
22
+ missCount: number;
23
+ }
24
+
25
+ export interface UsageCostBreakdownEntry {
26
+ key: string;
27
+ cost: number;
28
+ tokens: number;
29
+ }
30
+
31
+ /** Lite view of a session branch entry (structural subset of SessionEntry). */
32
+ export interface BranchEntry {
33
+ type: string;
34
+ message?: {
35
+ role?: string;
36
+ provider?: string;
37
+ model?: string;
38
+ responseModel?: string;
39
+ content?: unknown;
40
+ usage?: AssistantMessage["usage"];
41
+ };
42
+ }
43
+
44
+ /** Minimal pricing lookup, satisfied by ModelRegistry. Cost is $/million tokens. */
45
+ export interface ModelPriceSource {
46
+ find(provider: string, modelId: string): { cost: { cacheRead: number } } | undefined;
47
+ }
48
+
49
+ interface PrevRequest {
50
+ promptTokens: number;
51
+ reportedCache: boolean;
52
+ }
53
+
54
+ interface MissRecord {
55
+ missedTokens: number;
56
+ missedCost: number;
57
+ }
58
+
59
+ function detectMiss(
60
+ prev: PrevRequest | undefined,
61
+ msg: NonNullable<BranchEntry["message"]>,
62
+ models: ModelPriceSource,
63
+ ): MissRecord | undefined {
64
+ const usage = msg.usage;
65
+ if (!usage) return undefined;
66
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
67
+ // A zero-cache turn only counts when cache activity was reported before:
68
+ // on cache-read-only providers that is a total miss, while on providers
69
+ // that never report caching it means nothing.
70
+ if (!prev || promptTokens <= 0 || (usage.cacheRead + usage.cacheWrite === 0 && !prev.reportedCache)) {
71
+ return undefined;
72
+ }
73
+ const missedTokens = Math.min(prev.promptTokens, promptTokens) - usage.cacheRead;
74
+ if (missedTokens <= NOISE_FLOOR_TOKENS) return undefined;
75
+
76
+ // Extra cost = missed tokens billed at the actual paid rate (input/cacheWrite,
77
+ // incl. write premium) instead of the cache-read rate. Missed tokens can only
78
+ // land in the input or cacheWrite buckets, so the paid rate comes straight
79
+ // from this message's own cost breakdown.
80
+ const paidTokens = usage.input + usage.cacheWrite;
81
+ const paidPerToken = paidTokens > 0 ? (usage.cost.input + usage.cost.cacheWrite) / paidTokens : 0;
82
+ const readPerToken =
83
+ usage.cacheRead > 0
84
+ ? usage.cost.cacheRead / usage.cacheRead
85
+ : (models.find(msg.provider ?? "", msg.model ?? "")?.cost.cacheRead ?? 0) / 1_000_000;
86
+
87
+ return {
88
+ missedTokens,
89
+ missedCost: missedTokens * Math.max(0, paidPerToken - readPerToken),
90
+ };
91
+ }
92
+
93
+ function asPreviousRequest(
94
+ msg: NonNullable<BranchEntry["message"]>,
95
+ reportedCache: boolean,
96
+ ): PrevRequest | undefined {
97
+ const usage = msg.usage;
98
+ if (!usage) return undefined;
99
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
100
+ if (promptTokens <= 0) return undefined;
101
+ return {
102
+ promptTokens,
103
+ reportedCache: reportedCache || usage.cacheRead + usage.cacheWrite > 0,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Cumulative cache waste across the branch: prompt tokens that should have been
109
+ * cache reads (they were in the previous turn's prompt) but were re-billed.
110
+ */
111
+ export function computeCacheWaste(
112
+ entries: readonly BranchEntry[],
113
+ models: ModelPriceSource,
114
+ ): CacheWasteTotals {
115
+ let prev: PrevRequest | undefined;
116
+ const totals: CacheWasteTotals = { missedTokens: 0, missedCost: 0, missCount: 0 };
117
+ for (const entry of entries) {
118
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
119
+ // The context legitimately changed; the next turn's prompt is new
120
+ // content, not re-billed content. Model switches are NOT exempt:
121
+ // they re-bill the full prompt and should be counted.
122
+ prev = undefined;
123
+ continue;
124
+ }
125
+ if (entry.type === "message" && entry.message?.role === "assistant") {
126
+ const miss = detectMiss(prev, entry.message, models);
127
+ if (miss) {
128
+ totals.missedTokens += miss.missedTokens;
129
+ totals.missedCost += miss.missedCost;
130
+ totals.missCount += 1;
131
+ }
132
+ prev = asPreviousRequest(entry.message, prev?.reportedCache ?? false) ?? prev;
133
+ }
134
+ }
135
+ return totals;
136
+ }
137
+
138
+ /**
139
+ * Group assistant usage by model (`provider/responseModel ?? model`), sorted by
140
+ * cost descending. Scoped to the branch so the lines reconcile with the Total.
141
+ */
142
+ export function getUsageCostBreakdown(entries: readonly BranchEntry[]): UsageCostBreakdownEntry[] {
143
+ const costByKey = new Map<string, { cost: number; tokens: number }>();
144
+ for (const entry of entries) {
145
+ if (entry.type !== "message") continue;
146
+ const msg = entry.message;
147
+ if (msg?.role !== "assistant" || !msg.usage) continue;
148
+ const key = `${msg.provider ?? ""}/${msg.responseModel ?? msg.model ?? ""}`;
149
+ const totals = costByKey.get(key) ?? { cost: 0, tokens: 0 };
150
+ totals.cost += msg.usage.cost.total;
151
+ totals.tokens += msg.usage.input + msg.usage.output + msg.usage.cacheRead + msg.usage.cacheWrite;
152
+ costByKey.set(key, totals);
153
+ }
154
+ return Array.from(costByKey, ([key, totals]) => ({ key, ...totals }))
155
+ .filter((entry) => entry.cost > 0 || entry.tokens > 0)
156
+ .sort((a, b) => b.cost - a.cost);
157
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,230 @@
1
+ import {
2
+ CustomEditor,
3
+ type ExtensionContext,
4
+ type KeybindingsManager,
5
+ type Theme,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import {
10
+ formatCwd,
11
+ isBorderOrScrollLine,
12
+ wordWrapLineVariable,
13
+ type GraphemeSeg,
14
+ type WrapChunk,
15
+ } from "./format.js";
16
+ import type { GitState } from "./git.js";
17
+
18
+ export interface BashPromptState {
19
+ git: GitState;
20
+ isWorking: boolean;
21
+ spinnerFrame: string;
22
+ /** Called when the editor mounts so the extension can request re-renders. */
23
+ onMount: (tui: TUI) => void;
24
+ }
25
+
26
+ /** Subset of Editor internals used for dual-width soft-wrap. */
27
+ interface EditorInternals {
28
+ state: { lines: string[]; cursorLine: number; cursorCol: number };
29
+ scrollOffset: number;
30
+ segment(text: string, mode: "word" | "grapheme"): Iterable<{ segment: string; index: number }>;
31
+ }
32
+
33
+ interface LayoutLine {
34
+ text: string;
35
+ hasCursor: boolean;
36
+ cursorPos?: number;
37
+ }
38
+
39
+ /**
40
+ * Borderless editor with path above the input:
41
+ * ~/path (branch*):
42
+ * $ |
43
+ *
44
+ * Soft-wrap: first visual line reserves space for `$ `; every continuation
45
+ * line uses the full terminal width at column 0.
46
+ * Slash/path autocomplete also renders at column 0, full width.
47
+ */
48
+ export function createBashPromptEditor(
49
+ ctx: ExtensionContext,
50
+ state: BashPromptState,
51
+ ): new (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor {
52
+ return class BashPromptEditor extends CustomEditor {
53
+ /** Columns reserved on the first visual line for `$ `. */
54
+ private promptInset = 0;
55
+
56
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
57
+ super(tui, theme, keybindings, { paddingX: 0 });
58
+ // pi-tui's Editor declares `private layoutText`; TS forbids a subclass
59
+ // member with that name (TS2415/TS2322), but JS ignores `private` at
60
+ // runtime — so install the override as an instance property instead.
61
+ (this as unknown as { layoutText: (contentWidth: number) => LayoutLine[] })
62
+ .layoutText = (contentWidth) => this.layoutWithInset(contentWidth);
63
+ state.onMount(tui);
64
+ }
65
+
66
+ /** Host may push settings padding — keep 0 so prompt math stays correct. */
67
+ override setPaddingX(_paddingX: number): void {
68
+ super.setPaddingX(0);
69
+ }
70
+
71
+ private promptString(): string {
72
+ const thm = ctx.ui.theme as Theme;
73
+ const cwd = formatCwd(ctx.cwd);
74
+ const branch = state.git.branch
75
+ ? thm.fg("muted", ` (${state.git.branch}${state.git.dirty ? "*" : ""})`)
76
+ : "";
77
+ const path = thm.fg("success", cwd);
78
+ const working = state.isWorking
79
+ ? thm.fg("accent", `${state.spinnerFrame} `)
80
+ : "";
81
+ return `${working}${path}${branch}${thm.fg("dim", ":")}`;
82
+ }
83
+
84
+ private internals(): EditorInternals {
85
+ return this as unknown as EditorInternals;
86
+ }
87
+
88
+ /**
89
+ * Same shape as Editor.layoutText, but the first logical line's first chunk
90
+ * is narrowed by `promptInset` so `$ ` fits on that row. Soft-wrap
91
+ * continuations (and later hard lines) use the full `contentWidth`.
92
+ * Installed as the runtime `layoutText` override in the constructor.
93
+ */
94
+ layoutWithInset(contentWidth: number): LayoutLine[] {
95
+ // Keep method calls on `ed` so `segment` retains Editor as `this`
96
+ // (it calls `this.validPasteIds()` internally).
97
+ const ed = this.internals();
98
+ const layoutLines: LayoutLine[] = [];
99
+ const inset = Math.max(0, this.promptInset);
100
+ const firstWidth = Math.max(1, contentWidth - inset);
101
+
102
+ if (ed.state.lines.length === 0 || (ed.state.lines.length === 1 && ed.state.lines[0] === "")) {
103
+ layoutLines.push({ text: "", hasCursor: true, cursorPos: 0 });
104
+ return layoutLines;
105
+ }
106
+
107
+ for (let i = 0; i < ed.state.lines.length; i++) {
108
+ const line = ed.state.lines[i] || "";
109
+ const isCurrentLine = i === ed.state.cursorLine;
110
+ const lineVisibleWidth = visibleWidth(line);
111
+ // Only the very first logical line pays the `$ ` tax, and only on
112
+ // its first visual chunk. Hard-newline lines are full width.
113
+ const useInset = i === 0 && inset > 0;
114
+ const fitsSingle =
115
+ lineVisibleWidth <= (useInset ? firstWidth : contentWidth);
116
+
117
+ if (fitsSingle) {
118
+ if (isCurrentLine) {
119
+ layoutLines.push({
120
+ text: line,
121
+ hasCursor: true,
122
+ cursorPos: ed.state.cursorCol,
123
+ });
124
+ } else {
125
+ layoutLines.push({ text: line, hasCursor: false });
126
+ }
127
+ continue;
128
+ }
129
+
130
+ const graphemes: GraphemeSeg[] = [...ed.segment(line, "grapheme")].map((s) => ({
131
+ segment: s.segment,
132
+ index: s.index,
133
+ }));
134
+ const chunks: WrapChunk[] = useInset
135
+ ? wordWrapLineVariable(line, firstWidth, contentWidth, graphemes)
136
+ : wordWrapLineVariable(line, contentWidth, contentWidth, graphemes);
137
+
138
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
139
+ const chunk = chunks[chunkIndex]!;
140
+ const isLastChunk = chunkIndex === chunks.length - 1;
141
+ let hasCursorInChunk = false;
142
+ let adjustedCursorPos = 0;
143
+
144
+ if (isCurrentLine) {
145
+ const cursorPos = ed.state.cursorCol;
146
+ if (isLastChunk) {
147
+ hasCursorInChunk = cursorPos >= chunk.startIndex;
148
+ adjustedCursorPos = cursorPos - chunk.startIndex;
149
+ } else {
150
+ hasCursorInChunk =
151
+ cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
152
+ if (hasCursorInChunk) {
153
+ adjustedCursorPos = cursorPos - chunk.startIndex;
154
+ if (adjustedCursorPos > chunk.text.length) {
155
+ adjustedCursorPos = chunk.text.length;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ if (hasCursorInChunk) {
162
+ layoutLines.push({
163
+ text: chunk.text,
164
+ hasCursor: true,
165
+ cursorPos: adjustedCursorPos,
166
+ });
167
+ } else {
168
+ layoutLines.push({ text: chunk.text, hasCursor: false });
169
+ }
170
+ }
171
+ }
172
+
173
+ return layoutLines;
174
+ }
175
+
176
+ render(width: number): string[] {
177
+ const prompt = truncateToWidth(this.promptString(), width, "");
178
+ const inputPrefix = width >= 3 ? "$ " : "";
179
+ this.promptInset = inputPrefix.length;
180
+
181
+ // Full terminal width — layoutText narrows only the first visual chunk.
182
+ const raw = super.render(width);
183
+ this.promptInset = 0;
184
+
185
+ if (raw.length === 0) return [prompt];
186
+
187
+ // raw: [topBorder, ...content, bottomBorder, ...autocomplete?]
188
+ const withoutTop = raw.slice(1);
189
+ if (withoutTop.length === 0) return [prompt];
190
+
191
+ let borderIdx = -1;
192
+ for (let i = withoutTop.length - 1; i >= 0; i--) {
193
+ if (isBorderOrScrollLine(withoutTop[i]!)) {
194
+ borderIdx = i;
195
+ break;
196
+ }
197
+ }
198
+
199
+ const content = borderIdx >= 0 ? withoutTop.slice(0, borderIdx) : withoutTop;
200
+ const out: string[] = [prompt];
201
+ const scrolled = this.internals().scrollOffset > 0;
202
+ // First content row is padded to full width by Editor; keep only the
203
+ // budget to the right of `$ `, then splice the prefix in.
204
+ const firstBudget = Math.max(1, width - inputPrefix.length);
205
+
206
+ for (let i = 0; i < content.length; i++) {
207
+ const line = content[i]!;
208
+ if (i === 0 && !scrolled) {
209
+ const body = truncateToWidth(line, firstBudget, "");
210
+ out.push(truncateToWidth(inputPrefix + body, width, ""));
211
+ } else {
212
+ // Column 0 — full width for wrapped and hard-newline continuations.
213
+ out.push(truncateToWidth(line, width, ""));
214
+ }
215
+ }
216
+
217
+ // Autocomplete at column 0, full terminal width.
218
+ const acList = (
219
+ this as unknown as { autocompleteList?: { render: (w: number) => string[] } }
220
+ ).autocompleteList;
221
+ if (this.isShowingAutocomplete() && acList) {
222
+ for (const line of acList.render(width)) {
223
+ out.push(truncateToWidth(line, width, ""));
224
+ }
225
+ }
226
+
227
+ return out;
228
+ }
229
+ };
230
+ }
package/src/format.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
4
+
5
+ export function stripAnsi(s: string): string {
6
+ return s.replace(ANSI_RE, "");
7
+ }
8
+
9
+ /** Editor top/bottom chrome or scroll indicators from CustomEditor.render(). */
10
+ export function isBorderOrScrollLine(line: string): boolean {
11
+ const plain = stripAnsi(line).trim();
12
+ if (!plain) return true;
13
+ if (/^─+$/.test(plain)) return true;
14
+ if (/^─+\s*[↑↓]/.test(plain)) return true;
15
+ return false;
16
+ }
17
+
18
+ export interface WrapChunk {
19
+ text: string;
20
+ startIndex: number;
21
+ endIndex: number;
22
+ }
23
+
24
+ export interface GraphemeSeg {
25
+ segment: string;
26
+ index: number;
27
+ }
28
+
29
+ /**
30
+ * Soft-wrap one logical line.
31
+ * `firstWidth` applies only to the first visual chunk (room for a same-line prompt);
32
+ * later chunks use `restWidth` (full editor width).
33
+ */
34
+ export function wordWrapLineVariable(
35
+ line: string,
36
+ firstWidth: number,
37
+ restWidth: number,
38
+ preSegmented?: GraphemeSeg[],
39
+ ): WrapChunk[] {
40
+ if (!line) {
41
+ return [{ text: "", startIndex: 0, endIndex: 0 }];
42
+ }
43
+ const first = Math.max(1, firstWidth);
44
+ const rest = Math.max(1, restWidth);
45
+ const segments =
46
+ preSegmented ??
47
+ [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(line)].map((s) => ({
48
+ segment: s.segment,
49
+ index: s.index,
50
+ }));
51
+
52
+ const chunks: WrapChunk[] = [];
53
+ let maxWidth = first;
54
+ let currentWidth = 0;
55
+ let chunkStart = 0;
56
+ let wrapOppIndex = -1;
57
+ let wrapOppWidth = 0;
58
+
59
+ const pushChunk = (end: number) => {
60
+ chunks.push({ text: line.slice(chunkStart, end), startIndex: chunkStart, endIndex: end });
61
+ // After the first visual line, continuations get the full width.
62
+ maxWidth = rest;
63
+ };
64
+
65
+ for (let i = 0; i < segments.length; i++) {
66
+ const seg = segments[i]!;
67
+ const grapheme = seg.segment;
68
+ const gWidth = visibleWidth(grapheme);
69
+ const charIndex = seg.index;
70
+ const isWs = /\s/.test(grapheme);
71
+
72
+ if (currentWidth + gWidth > maxWidth) {
73
+ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
74
+ pushChunk(wrapOppIndex);
75
+ chunkStart = wrapOppIndex;
76
+ currentWidth -= wrapOppWidth;
77
+ } else if (chunkStart < charIndex) {
78
+ pushChunk(charIndex);
79
+ chunkStart = charIndex;
80
+ currentWidth = 0;
81
+ }
82
+ wrapOppIndex = -1;
83
+ wrapOppWidth = 0;
84
+ }
85
+
86
+ // Single grapheme wider than the line — hard-split by force.
87
+ if (gWidth > maxWidth) {
88
+ if (chunkStart < charIndex) {
89
+ pushChunk(charIndex);
90
+ chunkStart = charIndex;
91
+ }
92
+ // Put the oversized grapheme on its own chunk and continue.
93
+ pushChunk(charIndex + grapheme.length);
94
+ chunkStart = charIndex + grapheme.length;
95
+ currentWidth = 0;
96
+ wrapOppIndex = -1;
97
+ wrapOppWidth = 0;
98
+ continue;
99
+ }
100
+
101
+ currentWidth += gWidth;
102
+ const next = segments[i + 1];
103
+ if (isWs && next && !/\s/.test(next.segment)) {
104
+ // Prefer breaking after a whitespace run (at the start of the next word).
105
+ wrapOppIndex = next.index;
106
+ wrapOppWidth = currentWidth;
107
+ }
108
+ }
109
+
110
+ chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
111
+ return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
112
+ }
113
+
114
+ export function formatCwd(cwd: string): string {
115
+ const home = process.env.HOME;
116
+ if (home && cwd.startsWith(home)) {
117
+ return `~${cwd.slice(home.length)}`;
118
+ }
119
+ return cwd;
120
+ }
121
+
122
+ export function formatTokens(n: number): string {
123
+ if (n < 1000) return `${n}`;
124
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
125
+ return `${(n / 1_000_000).toFixed(2)}M`;
126
+ }
package/src/git.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface GitState {
4
+ branch?: string;
5
+ dirty: boolean;
6
+ }
7
+
8
+ export async function fetchGitState(pi: ExtensionAPI, cwd: string): Promise<GitState> {
9
+ try {
10
+ const branchResult = await pi.exec("git", ["branch", "--show-current"], { cwd });
11
+ const branch = branchResult?.stdout?.trim() || undefined;
12
+
13
+ const dirtyResult = await pi.exec("git", ["status", "--porcelain"], { cwd });
14
+ const dirty = Boolean(dirtyResult?.stdout?.trim());
15
+
16
+ return { branch: branch || undefined, dirty };
17
+ } catch {
18
+ // Stale ctx after reload/session replace, or non-git cwd.
19
+ return { dirty: false };
20
+ }
21
+ }
package/src/info.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type {
2
+ ExtensionContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { computeCacheWaste, getUsageCostBreakdown, type BranchEntry } from "./cost.js";
7
+ import { formatCwd, formatTokens } from "./format.js";
8
+
9
+ interface TokenStats {
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ cost: number;
15
+ assistantMessages: number;
16
+ userMessages: number;
17
+ toolCalls: number;
18
+ }
19
+
20
+ type SessionManagerLite = {
21
+ getBranch: () => ReadonlyArray<BranchEntry>;
22
+ getSessionName?: () => string | undefined;
23
+ getSessionFile?: () => string | undefined;
24
+ getSessionId?: () => string | undefined;
25
+ };
26
+
27
+ function collectTokenStats(branch: ReadonlyArray<BranchEntry>): TokenStats {
28
+ let input = 0;
29
+ let output = 0;
30
+ let cacheRead = 0;
31
+ let cacheWrite = 0;
32
+ let cost = 0;
33
+ let assistantMessages = 0;
34
+ let userMessages = 0;
35
+ let toolCalls = 0;
36
+
37
+ for (const entry of branch) {
38
+ if (entry.type !== "message" || !entry.message) continue;
39
+ const msg = entry.message;
40
+ if (msg.role === "user") {
41
+ userMessages++;
42
+ continue;
43
+ }
44
+ if (msg.role !== "assistant") continue;
45
+
46
+ assistantMessages++;
47
+ input += msg.usage?.input ?? 0;
48
+ output += msg.usage?.output ?? 0;
49
+ cacheRead += msg.usage?.cacheRead ?? 0;
50
+ cacheWrite += msg.usage?.cacheWrite ?? 0;
51
+ cost += msg.usage?.cost?.total ?? 0;
52
+ if (Array.isArray(msg.content)) {
53
+ toolCalls += msg.content.filter(
54
+ (c) => typeof c === "object" && c !== null && (c as { type?: string }).type === "toolCall",
55
+ ).length;
56
+ }
57
+ }
58
+
59
+ return { input, output, cacheRead, cacheWrite, cost, assistantMessages, userMessages, toolCalls };
60
+ }
61
+
62
+ export function buildInfoLines(
63
+ ctx: ExtensionContext,
64
+ theme: Theme,
65
+ thinkingLevel: string,
66
+ ): string[] {
67
+ const sm = ctx.sessionManager as SessionManagerLite;
68
+ const branch = sm.getBranch();
69
+ const stats = collectTokenStats(branch);
70
+ const waste = computeCacheWaste(branch, ctx.modelRegistry);
71
+ const costBreakdown = getUsageCostBreakdown(branch);
72
+ const usage = ctx.getContextUsage();
73
+ const model = ctx.model;
74
+ const dim = (s: string) => theme.fg("dim", s);
75
+ const label = (k: string, v: string) => `${dim(k.padEnd(14))} ${v}`;
76
+ const lines: string[] = [];
77
+
78
+ lines.push(theme.bold("Session"));
79
+ const sessionName = sm.getSessionName?.();
80
+ if (sessionName) lines.push(label("Name", sessionName));
81
+ lines.push(label("File", sm.getSessionFile?.() ?? "in-memory"));
82
+ const sessionId = sm.getSessionId?.();
83
+ if (sessionId) lines.push(label("ID", sessionId));
84
+ lines.push(label("Cwd", formatCwd(ctx.cwd)));
85
+ lines.push("");
86
+
87
+ lines.push(theme.bold("Model"));
88
+ lines.push(label("Model", model ? `${model.provider}/${model.id}` : "none"));
89
+ lines.push(label("Thinking", thinkingLevel));
90
+ if (model?.contextWindow) {
91
+ lines.push(label("Window", formatTokens(model.contextWindow)));
92
+ }
93
+ lines.push("");
94
+
95
+ lines.push(theme.bold("Context"));
96
+ if (usage?.percent != null && usage.tokens != null) {
97
+ lines.push(
98
+ label(
99
+ "Used",
100
+ `${formatTokens(usage.tokens)} / ${formatTokens(usage.contextWindow)} (${Math.round(usage.percent)}%)`,
101
+ ),
102
+ );
103
+ } else if (usage?.contextWindow) {
104
+ lines.push(label("Window", formatTokens(usage.contextWindow)));
105
+ lines.push(label("Used", "n/a (no estimate yet)"));
106
+ } else {
107
+ lines.push(label("Used", "n/a"));
108
+ }
109
+ lines.push("");
110
+
111
+ lines.push(theme.bold("Tokens (branch)"));
112
+ const promptTokens = stats.input + stats.cacheRead + stats.cacheWrite;
113
+ lines.push(label("Input", formatTokens(promptTokens)));
114
+ if (promptTokens > 0 && (stats.cacheRead > 0 || stats.cacheWrite > 0)) {
115
+ const hit = ((stats.cacheRead / promptTokens) * 100).toFixed(1);
116
+ lines.push(label(" cached", `${formatTokens(stats.cacheRead)} (${hit}%)`));
117
+ lines.push(label(" uncached", formatTokens(stats.input + stats.cacheWrite)));
118
+ }
119
+ lines.push(label("Output", formatTokens(stats.output)));
120
+ lines.push(
121
+ label("Total", formatTokens(stats.input + stats.output + stats.cacheRead + stats.cacheWrite)),
122
+ );
123
+ lines.push("");
124
+
125
+ if (stats.cost > 0 || waste.missedTokens > 0) {
126
+ lines.push(theme.bold("Cost"));
127
+ lines.push(`${dim("Total:")} $${stats.cost.toFixed(3)}`);
128
+ if (costBreakdown.length > 1) {
129
+ for (const entry of costBreakdown) {
130
+ lines.push(
131
+ ` ${dim(`${entry.key}:`)} $${entry.cost.toFixed(3)} ${dim(`(${formatTokens(entry.tokens)} tokens)`)}`,
132
+ );
133
+ }
134
+ }
135
+ if (waste.missedTokens > 0) {
136
+ const missLabel = waste.missCount === 1 ? "1 miss" : `${waste.missCount} misses`;
137
+ lines.push(
138
+ `${dim("Cache Re-billed:")} $${waste.missedCost.toFixed(3)} ${dim(`(${waste.missedTokens.toLocaleString()} tokens, ${missLabel})`)}`,
139
+ );
140
+ }
141
+ lines.push("");
142
+ }
143
+
144
+ lines.push(theme.bold("Messages (branch)"));
145
+ lines.push(label("User", `${stats.userMessages}`));
146
+ lines.push(label("Assistant", `${stats.assistantMessages}`));
147
+ lines.push(label("Tool calls", `${stats.toolCalls}`));
148
+
149
+ return lines;
150
+ }
151
+
152
+ export async function showInfoUi(
153
+ ctx: ExtensionContext,
154
+ thinkingLevel: string,
155
+ ): Promise<void> {
156
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
157
+ const plainTheme = {
158
+ bold: (s: string) => s,
159
+ fg: (_c: string, s: string) => s,
160
+ } as Theme;
161
+ ctx.ui.notify(buildInfoLines(ctx, plainTheme, thinkingLevel).join("\n"), "info");
162
+ return;
163
+ }
164
+
165
+ await ctx.ui.custom((tui, theme, _kb, done) => {
166
+ const body = buildInfoLines(ctx, theme, thinkingLevel);
167
+ return {
168
+ render(width: number): string[] {
169
+ const out: string[] = [""];
170
+ out.push(truncateToWidth(theme.fg("accent", theme.bold(" /info ")), width));
171
+ out.push(truncateToWidth(theme.fg("dim", "─".repeat(Math.min(width, 40))), width));
172
+ for (const line of body) {
173
+ out.push(truncateToWidth(line, width));
174
+ }
175
+ out.push("");
176
+ out.push(truncateToWidth(theme.fg("dim", "Enter/Esc to close"), width));
177
+ out.push("");
178
+ return out;
179
+ },
180
+ invalidate() {},
181
+ handleInput(data: string) {
182
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) {
183
+ done(undefined);
184
+ tui.requestRender();
185
+ }
186
+ },
187
+ };
188
+ });
189
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+
3
+ /** Zero-height component used to blank header/footer chrome. */
4
+ export class EmptyComponent implements Component {
5
+ render(): string[] {
6
+ return [];
7
+ }
8
+ invalidate(): void {}
9
+ }