@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 +21 -0
- package/README.md +79 -0
- package/extensions/index.ts +104 -0
- package/package.json +51 -0
- package/src/cost.ts +157 -0
- package/src/editor.ts +230 -0
- package/src/format.ts +126 -0
- package/src/git.ts +21 -0
- package/src/info.ts +189 -0
- package/src/ui.ts +9 -0
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