pi-minimal 0.1.0 → 0.2.1
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/.github/dependabot.yml +11 -0
- package/.github/workflows/publish.yml +178 -14
- package/.github/workflows/release.yml +42 -0
- package/CHANGELOG.md +63 -0
- package/README.md +26 -1
- package/SECURITY.md +21 -0
- package/dist/activity.js +0 -3
- package/dist/cwd.js +92 -52
- package/dist/footer.js +26 -27
- package/dist/git-state.js +63 -0
- package/dist/index.js +48 -6
- package/dist/tool-rows.js +143 -12
- package/dist/tools.js +143 -77
- package/dist/transcript.js +50 -0
- package/dist/turn-rule.js +119 -0
- package/package.json +14 -3
- package/release-notes.md +30 -0
- package/src/activity.ts +0 -4
- package/src/cwd.ts +99 -51
- package/src/footer.ts +32 -32
- package/src/git-state.ts +71 -0
- package/src/index.ts +50 -6
- package/src/tool-rows.ts +170 -19
- package/src/tools.ts +156 -106
- package/src/transcript.ts +51 -0
- package/src/turn-rule.ts +135 -0
- package/test/activity.test.ts +4 -15
- package/test/footer.test.ts +38 -0
- package/test/git-state.test.ts +52 -0
- package/test/index.test.ts +40 -4
- package/test/package.test.ts +3 -2
- package/test/theme.test.ts +45 -0
- package/test/tool-rows.test.ts +109 -0
- package/test/tools.test.ts +49 -42
- package/test/transcript.test.ts +22 -0
- package/test/turn-rule.test.ts +33 -0
- package/themes/minimal-dark.json +84 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { CHROME_MARGIN } from "./chrome.js";
|
|
3
|
+
import { formatElapsed } from "./working-status.js";
|
|
4
|
+
export const TURN_ENTRY_TYPE = "pi-minimal.turn";
|
|
5
|
+
export function formatClock(epochMs) {
|
|
6
|
+
const d = new Date(epochMs);
|
|
7
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
8
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
9
|
+
return `${hh}:${mm}`;
|
|
10
|
+
}
|
|
11
|
+
export function turnLabel(summary) {
|
|
12
|
+
const parts = [formatClock(summary.endedAt), formatElapsed(summary.elapsedMs)];
|
|
13
|
+
if (summary.tools > 0)
|
|
14
|
+
parts.push(`${summary.tools} tool${summary.tools === 1 ? "" : "s"}`);
|
|
15
|
+
if (summary.outcome === "aborted")
|
|
16
|
+
parts.push("aborted");
|
|
17
|
+
if (summary.outcome === "error")
|
|
18
|
+
parts.push("error");
|
|
19
|
+
return parts.join(" · ");
|
|
20
|
+
}
|
|
21
|
+
/** `── label ─────────` filling `width`; label is dropped when the row is too narrow. */
|
|
22
|
+
export function ruleLine(width, label) {
|
|
23
|
+
const lead = "── ";
|
|
24
|
+
const tail = " ";
|
|
25
|
+
const minRule = 4;
|
|
26
|
+
if (width <= 0)
|
|
27
|
+
return "";
|
|
28
|
+
if (visibleWidth(lead) + visibleWidth(label) + visibleWidth(tail) + minRule > width) {
|
|
29
|
+
return "─".repeat(width);
|
|
30
|
+
}
|
|
31
|
+
const shown = truncateToWidth(label, width - lead.length - tail.length - minRule, "…");
|
|
32
|
+
const rest = Math.max(minRule, width - visibleWidth(lead) - visibleWidth(shown) - visibleWidth(tail));
|
|
33
|
+
return `${lead}${shown}${tail}${"─".repeat(rest)}`;
|
|
34
|
+
}
|
|
35
|
+
export function runOutcome(messages) {
|
|
36
|
+
if (!Array.isArray(messages))
|
|
37
|
+
return "completed";
|
|
38
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
39
|
+
const message = messages[i];
|
|
40
|
+
if (message?.role !== "assistant")
|
|
41
|
+
continue;
|
|
42
|
+
if (message.stopReason === "aborted")
|
|
43
|
+
return "aborted";
|
|
44
|
+
if (message.stopReason === "error")
|
|
45
|
+
return "error";
|
|
46
|
+
return "completed";
|
|
47
|
+
}
|
|
48
|
+
return "completed";
|
|
49
|
+
}
|
|
50
|
+
export function isTurnSummary(data) {
|
|
51
|
+
if (!data || typeof data !== "object")
|
|
52
|
+
return false;
|
|
53
|
+
const d = data;
|
|
54
|
+
return (typeof d.endedAt === "number" &&
|
|
55
|
+
typeof d.elapsedMs === "number" &&
|
|
56
|
+
typeof d.tools === "number" &&
|
|
57
|
+
(d.outcome === "completed" || d.outcome === "aborted" || d.outcome === "error"));
|
|
58
|
+
}
|
|
59
|
+
export function installTurnRule(pi) {
|
|
60
|
+
let ctx;
|
|
61
|
+
let startedAt = 0;
|
|
62
|
+
let tools = 0;
|
|
63
|
+
if (typeof pi.registerEntryRenderer !== "function" || typeof pi.appendEntry !== "function") {
|
|
64
|
+
return () => { };
|
|
65
|
+
}
|
|
66
|
+
pi.registerEntryRenderer(TURN_ENTRY_TYPE, (entry, _options, theme) => {
|
|
67
|
+
if (!isTurnSummary(entry.data))
|
|
68
|
+
return undefined;
|
|
69
|
+
const label = turnLabel(entry.data);
|
|
70
|
+
const color = entry.data.outcome === "completed" ? "borderMuted" : entry.data.outcome === "aborted" ? "warning" : "error";
|
|
71
|
+
return {
|
|
72
|
+
render(width) {
|
|
73
|
+
const inner = Math.max(0, width - CHROME_MARGIN * 2);
|
|
74
|
+
const rule = ruleLine(inner, label);
|
|
75
|
+
const colored = rule.replace(label, theme.fg("dim", label));
|
|
76
|
+
return ["", `${" ".repeat(CHROME_MARGIN)}${theme.fg(color, colored)}`];
|
|
77
|
+
},
|
|
78
|
+
invalidate() { },
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
pi.on("session_start", (_event, next) => {
|
|
82
|
+
if (next.mode !== "tui")
|
|
83
|
+
return;
|
|
84
|
+
ctx = next;
|
|
85
|
+
});
|
|
86
|
+
pi.on("agent_start", () => {
|
|
87
|
+
startedAt = Date.now();
|
|
88
|
+
tools = 0;
|
|
89
|
+
});
|
|
90
|
+
pi.on("tool_execution_end", () => {
|
|
91
|
+
tools += 1;
|
|
92
|
+
});
|
|
93
|
+
pi.on("agent_end", (event) => {
|
|
94
|
+
if (!ctx || ctx.mode !== "tui" || !startedAt)
|
|
95
|
+
return;
|
|
96
|
+
const ev = event;
|
|
97
|
+
const summary = {
|
|
98
|
+
endedAt: Date.now(),
|
|
99
|
+
elapsedMs: Date.now() - startedAt,
|
|
100
|
+
tools,
|
|
101
|
+
outcome: runOutcome(ev.messages),
|
|
102
|
+
};
|
|
103
|
+
startedAt = 0;
|
|
104
|
+
try {
|
|
105
|
+
pi.appendEntry(TURN_ENTRY_TYPE, summary);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// A session that cannot accept entries just goes without the separator.
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
pi.on("session_shutdown", () => {
|
|
112
|
+
ctx = undefined;
|
|
113
|
+
startedAt = 0;
|
|
114
|
+
});
|
|
115
|
+
return () => {
|
|
116
|
+
ctx = undefined;
|
|
117
|
+
startedAt = 0;
|
|
118
|
+
};
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-minimal",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -10,9 +10,20 @@
|
|
|
10
10
|
"keywords": [
|
|
11
11
|
"pi-package"
|
|
12
12
|
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/raviy06/pi-minimal.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/raviy06/pi-minimal#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/raviy06/pi-minimal/issues"
|
|
20
|
+
},
|
|
13
21
|
"pi": {
|
|
14
22
|
"extensions": [
|
|
15
23
|
"./dist/index.js"
|
|
24
|
+
],
|
|
25
|
+
"themes": [
|
|
26
|
+
"./themes/minimal-dark.json"
|
|
16
27
|
]
|
|
17
28
|
},
|
|
18
29
|
"peerDependencies": {
|
|
@@ -20,8 +31,8 @@
|
|
|
20
31
|
"@earendil-works/pi-tui": "*"
|
|
21
32
|
},
|
|
22
33
|
"devDependencies": {
|
|
23
|
-
"@earendil-works/pi-coding-agent": "
|
|
24
|
-
"@earendil-works/pi-tui": "
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
35
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
25
36
|
"@eslint/js": "^10.0.1",
|
|
26
37
|
"@types/node": "*",
|
|
27
38
|
"eslint": "^10.11.0",
|
package/release-notes.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
### Added
|
|
2
|
+
- Automated releases: when `version` in `package.json` changes on `main`, the
|
|
3
|
+
Publish workflow verifies the build, tags `vX.Y.Z`, creates a GitHub Release
|
|
4
|
+
(CHANGELOG section + generated notes + package tarball), records a deployment
|
|
5
|
+
to the `npm` environment, and publishes to npm via OIDC trusted publishing.
|
|
6
|
+
- `CHANGELOG.md` and a "Releasing" section in the README.
|
|
7
|
+
- The Publish workflow upgrades npm to >= 11.5.1 (required for OIDC trusted
|
|
8
|
+
publishing) and is re-runnable: an existing tag whose version is missing
|
|
9
|
+
from npm is published without re-tagging.
|
|
10
|
+
- `repository`, `homepage`, and `bugs` fields in `package.json`, required for
|
|
11
|
+
npm provenance verification.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- The release-triggered publish workflow is now a manual, token-based fallback
|
|
15
|
+
(`release.yml`) for re-publishing an existing tag.
|
|
16
|
+
|
|
17
|
+
## What's Changed
|
|
18
|
+
* Document trusted publishing setup by @raviy06 in https://github.com/raviy06/pi-minimal/pull/3
|
|
19
|
+
* Use npm for installation by @raviy06 in https://github.com/raviy06/pi-minimal/pull/4
|
|
20
|
+
* Design pass: tool rows, turn rule, status line, minimal-dark theme by @raviy06 in https://github.com/raviy06/pi-minimal/pull/5
|
|
21
|
+
* Automate releases on version change (0.2.1) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/6
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
**Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.1.0...v0.2.1
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
**Install / upgrade**
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:pi-minimal@0.2.1 # or: pi update --extensions
|
|
30
|
+
```
|
package/src/activity.ts
CHANGED
|
@@ -10,7 +10,3 @@ export function rowIsRunning(context: ActivityContext): boolean {
|
|
|
10
10
|
if (context.isPartial === false) return false
|
|
11
11
|
return true
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
export function activityBullet(running: boolean, frame: 0 | 1): "◆" | "◇" {
|
|
15
|
-
return running && frame === 1 ? "◇" : "◆"
|
|
16
|
-
}
|
package/src/cwd.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { CHROME_MARGIN } from "./chrome.js"
|
|
5
|
+
import { formatGitState, type GitState } from "./git-state.js"
|
|
6
|
+
import type { Sibling } from "./herdr.js"
|
|
5
7
|
|
|
6
|
-
function formatCwd(cwd: string): string {
|
|
8
|
+
export function formatCwd(cwd: string): string {
|
|
7
9
|
const home = process.env.HOME || process.env.USERPROFILE || ""
|
|
8
10
|
if (!home) return cwd
|
|
9
11
|
const resolved = path.resolve(cwd)
|
|
@@ -14,7 +16,7 @@ function formatCwd(cwd: string): string {
|
|
|
14
16
|
return cwd
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
function formatTokens(n: number): string {
|
|
19
|
+
export function formatTokens(n: number): string {
|
|
18
20
|
if (n < 1000) return String(n)
|
|
19
21
|
if (n < 10000) return `${(n / 1000).toFixed(1)}K`
|
|
20
22
|
if (n < 1e6) return `${Math.round(n / 1000)}K`
|
|
@@ -22,7 +24,7 @@ function formatTokens(n: number): string {
|
|
|
22
24
|
return `${Math.round(n / 1e6)}M`
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
function modelIcon(id: string): string {
|
|
27
|
+
export function modelIcon(id: string): string {
|
|
26
28
|
const s = id.toLowerCase()
|
|
27
29
|
if (s.includes("grok")) return "✦"
|
|
28
30
|
if (
|
|
@@ -38,7 +40,7 @@ function modelIcon(id: string): string {
|
|
|
38
40
|
return "◆"
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
function shortModel(id: string): string {
|
|
43
|
+
export function shortModel(id: string): string {
|
|
42
44
|
return (id.split("/").pop() || id).replace(/^claude-/, "")
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -48,61 +50,107 @@ function thinkingColor(level: string): "dim" | "accent" | "warning" {
|
|
|
48
50
|
return "dim"
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
/** Context pressure band used for the meter and percentage colour. */
|
|
54
|
+
export function contextBand(pct: number): "accent" | "warning" | "error" {
|
|
55
|
+
if (pct >= 90) return "error"
|
|
56
|
+
if (pct >= 70) return "warning"
|
|
57
|
+
return "accent"
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
export function meter(percent: number, fill: string, empty: string, cells = 8): string {
|
|
61
|
+
const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
|
|
62
|
+
return fill.repeat(filled) + empty.repeat(cells - filled)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fit `left` and `right` into `width`. Right-hand segments are dropped from the end first (they are
|
|
67
|
+
* the least important), then the left is truncated.
|
|
68
|
+
*/
|
|
69
|
+
export function splitRow(left: string, rightSegments: string[], width: number, sep = " "): string {
|
|
70
|
+
const segments = [...rightSegments.filter((s) => visibleWidth(s) > 0)]
|
|
71
|
+
let right = segments.join(sep)
|
|
72
|
+
while (segments.length > 0 && visibleWidth(left) + 2 + visibleWidth(right) > width) {
|
|
73
|
+
segments.pop()
|
|
74
|
+
right = segments.join(sep)
|
|
62
75
|
}
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
let L = left
|
|
77
|
+
if (visibleWidth(L) + (right ? 2 : 0) + visibleWidth(right) > width) {
|
|
78
|
+
L = truncateToWidth(L, Math.max(0, width - (right ? 2 : 0) - visibleWidth(right)), "")
|
|
65
79
|
}
|
|
66
|
-
const gap = Math.max(
|
|
67
|
-
return L + " ".repeat(gap) +
|
|
80
|
+
const gap = right ? Math.max(2, width - visibleWidth(L) - visibleWidth(right)) : 0
|
|
81
|
+
return L + " ".repeat(gap) + right
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function siblingColor(status: string): "success" | "warning" | "error" | "accent" | "muted" {
|
|
85
|
+
if (status === "idle" || status === "done") return "success"
|
|
86
|
+
if (status === "working") return "accent"
|
|
87
|
+
if (status === "blocked") return "error"
|
|
88
|
+
return "muted"
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
export
|
|
91
|
+
export type StatusLineSources = {
|
|
92
|
+
gitState: () => GitState | undefined
|
|
93
|
+
siblings: () => Sibling[] | undefined
|
|
94
|
+
onGitChange: (listener: () => void) => () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): void {
|
|
71
98
|
ctx.ui.setWidget(
|
|
72
99
|
"tw-cwd",
|
|
73
|
-
(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
(tui, theme) => {
|
|
101
|
+
const unsub = sources.onGitChange(() => tui.requestRender())
|
|
102
|
+
return {
|
|
103
|
+
dispose: unsub,
|
|
104
|
+
invalidate() {},
|
|
105
|
+
render(width: number): string[] {
|
|
106
|
+
const inner = Math.max(8, width - CHROME_MARGIN * 2)
|
|
107
|
+
const id = ctx.model?.id || "no-model"
|
|
108
|
+
const icon = theme.fg("accent", modelIcon(id))
|
|
109
|
+
const name = theme.fg("text", shortModel(id))
|
|
110
|
+
const thinking = ctx.thinkingLevel || ""
|
|
111
|
+
const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : ""
|
|
112
|
+
|
|
113
|
+
const usage = ctx.getContextUsage?.()
|
|
114
|
+
const window = usage?.contextWindow || ctx.model?.contextWindow
|
|
115
|
+
const used = usage?.tokens
|
|
116
|
+
let bar = theme.fg("dim", "░░░░░░░░")
|
|
117
|
+
let pctStr = theme.fg("dim", "?%")
|
|
118
|
+
let tokenStr = theme.fg("dim", "?/?")
|
|
119
|
+
if (typeof used === "number" && window) {
|
|
120
|
+
const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)))
|
|
121
|
+
const band = contextBand(pct)
|
|
122
|
+
bar = meter(pct, theme.fg(band, "▓"), theme.fg("dim", "░"))
|
|
123
|
+
pctStr = theme.fg(band === "accent" ? "muted" : band, `${pct}%`)
|
|
124
|
+
tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`)
|
|
125
|
+
} else if (used === null && window) {
|
|
126
|
+
tokenStr = theme.fg("dim", `?/${formatTokens(window)}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const midot = theme.fg("dim", " · ")
|
|
130
|
+
const model = [`${icon} ${name}`, think].filter(Boolean).join(midot)
|
|
131
|
+
const context = `${bar} ${pctStr} ${tokenStr}`
|
|
132
|
+
const left = `${model} ${context}`
|
|
133
|
+
|
|
134
|
+
const right: string[] = []
|
|
135
|
+
if (ctx.cwd) right.push(theme.fg("muted", formatCwd(ctx.cwd)))
|
|
136
|
+
const git = formatGitState(sources.gitState())
|
|
137
|
+
if (git.branch) {
|
|
138
|
+
const dirty = git.dirty ? ` ${theme.fg("warning", git.dirty)}` : ""
|
|
139
|
+
right.push(`${theme.fg("dim", "⎇")} ${theme.fg("muted", git.branch)}${dirty}`)
|
|
140
|
+
}
|
|
141
|
+
const siblings = sources.siblings()
|
|
142
|
+
if (siblings && siblings.length > 0) {
|
|
143
|
+
right.push(
|
|
144
|
+
siblings
|
|
145
|
+
.map((s) => `${theme.fg("text", s.name)} ${theme.fg(siblingColor(s.status), s.status)}`)
|
|
146
|
+
.join(theme.fg("dim", " · ")),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
const row = splitRow(left, right, inner)
|
|
150
|
+
return [`${" ".repeat(CHROME_MARGIN)}${row}`]
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
},
|
|
106
154
|
{ placement: "aboveEditor" },
|
|
107
155
|
)
|
|
108
156
|
}
|
package/src/footer.ts
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent"
|
|
3
|
-
import { truncateToWidth
|
|
3
|
+
import { truncateToWidth } from "@earendil-works/pi-tui"
|
|
4
4
|
import { CHROME_MARGIN } from "./chrome.js"
|
|
5
|
-
import type { Sibling } from "./herdr.js"
|
|
6
5
|
import { formatTrackerLines } from "./tracker.js"
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
export type FooterSources = {
|
|
8
|
+
/** True while an agent run is in progress. */
|
|
9
|
+
busy: () => boolean
|
|
10
|
+
/** Current editor text, when the host exposes it. */
|
|
11
|
+
editorText: () => string
|
|
12
|
+
/** Context usage percentage, when known. */
|
|
13
|
+
contextPct: () => number | undefined
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Key hints are shown only when nothing else needs the line: idle agent, empty editor. Otherwise
|
|
18
|
+
* the footer collapses to zero lines (tracker lines excepted).
|
|
19
|
+
*/
|
|
20
|
+
export function footerHints(sources: FooterSources, hints: string[]): string[] {
|
|
21
|
+
if (sources.busy()) return []
|
|
22
|
+
if (sources.editorText().trim().length > 0) return []
|
|
23
|
+
const pct = sources.contextPct()
|
|
24
|
+
const out = [...hints]
|
|
25
|
+
if (pct !== undefined && pct >= 70) out.push("/compact")
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function installFooter(ctx: ExtensionContext, sources: FooterSources): void {
|
|
21
30
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
22
31
|
const unsub = footerData.onBranchChange(() => tui.requestRender())
|
|
23
32
|
return {
|
|
24
33
|
dispose: unsub,
|
|
25
34
|
invalidate() {},
|
|
26
35
|
render(width: number): string[] {
|
|
27
|
-
const send = keyHint("tui.input.submit", "send")
|
|
28
|
-
const think = keyHint("app.thinking.cycle", "thinking")
|
|
29
|
-
const left = [send, think].filter(Boolean).join(" · ")
|
|
30
|
-
|
|
31
|
-
const siblings = getSiblings()
|
|
32
|
-
let right = ""
|
|
33
|
-
if (siblings && siblings.length > 0) {
|
|
34
|
-
right = siblings
|
|
35
|
-
.map((s) => {
|
|
36
|
-
const name = theme.fg("text", s.name)
|
|
37
|
-
const st = theme.fg(statusColor(s.status), s.status)
|
|
38
|
-
return `${name} ${st}`
|
|
39
|
-
})
|
|
40
|
-
.join(theme.fg("dim", " · "))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
36
|
const inner = Math.max(8, width - CHROME_MARGIN * 2)
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
37
|
+
const out: string[] = []
|
|
38
|
+
const hints = footerHints(sources, [
|
|
39
|
+
keyHint("tui.input.submit", "send"),
|
|
40
|
+
keyHint("app.thinking.cycle", "thinking"),
|
|
41
|
+
keyHint("app.tools.expand", "expand tools"),
|
|
42
|
+
].filter(Boolean))
|
|
43
|
+
if (hints.length > 0) {
|
|
44
|
+
const line = theme.fg("dim", hints.join(" · "))
|
|
45
|
+
out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(line, inner, "")}`)
|
|
46
|
+
}
|
|
47
47
|
for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
|
|
48
48
|
out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`)
|
|
49
49
|
}
|
package/src/git-state.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
|
|
3
|
+
export type GitState = {
|
|
4
|
+
branch: string | undefined
|
|
5
|
+
/** Count of changed paths from `git status --porcelain`; undefined when unknown. */
|
|
6
|
+
dirty: number | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseBranch(stdout: string): string | undefined {
|
|
10
|
+
const line = stdout.trim().split("\n")[0]?.trim()
|
|
11
|
+
if (!line || line === "HEAD") return undefined
|
|
12
|
+
return line
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parsePorcelain(stdout: string): number {
|
|
16
|
+
return stdout.split("\n").filter((line) => line.trim().length > 0).length
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatGitState(state: GitState | undefined): { branch: string; dirty: string } {
|
|
20
|
+
if (!state?.branch) return { branch: "", dirty: "" }
|
|
21
|
+
const dirty = state.dirty && state.dirty > 0 ? `±${state.dirty}` : ""
|
|
22
|
+
return { branch: state.branch, dirty }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Polls git for the current branch and dirty count. Call `refresh()` after events that may have
|
|
27
|
+
* changed the tree (tool results, turn end); `get()` is always synchronous.
|
|
28
|
+
*/
|
|
29
|
+
export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalMs = 5000) {
|
|
30
|
+
let state: GitState | undefined
|
|
31
|
+
let lastRun = 0
|
|
32
|
+
let inFlight: Promise<void> | undefined
|
|
33
|
+
const listeners = new Set<() => void>()
|
|
34
|
+
|
|
35
|
+
const refresh = (force = false): Promise<void> => {
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
if (inFlight) return inFlight
|
|
38
|
+
if (!force && now - lastRun < minIntervalMs) return Promise.resolve()
|
|
39
|
+
lastRun = now
|
|
40
|
+
inFlight = (async () => {
|
|
41
|
+
try {
|
|
42
|
+
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 })
|
|
43
|
+
if (branchRun.code !== 0) {
|
|
44
|
+
state = undefined
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const branch = parseBranch(branchRun.stdout)
|
|
48
|
+
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 })
|
|
49
|
+
const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined
|
|
50
|
+
state = { branch, dirty }
|
|
51
|
+
} catch {
|
|
52
|
+
state = undefined
|
|
53
|
+
} finally {
|
|
54
|
+
inFlight = undefined
|
|
55
|
+
for (const listener of listeners) listener()
|
|
56
|
+
}
|
|
57
|
+
})()
|
|
58
|
+
return inFlight
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
get: () => state,
|
|
63
|
+
refresh,
|
|
64
|
+
onChange(listener: () => void) {
|
|
65
|
+
listeners.add(listener)
|
|
66
|
+
return () => listeners.delete(listener)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type GitStateHandle = ReturnType<typeof createGitState>
|
package/src/index.ts
CHANGED
|
@@ -5,12 +5,18 @@ import { installFooter } from "./footer.js"
|
|
|
5
5
|
import { installCwd } from "./cwd.js"
|
|
6
6
|
import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
|
|
7
7
|
import { hideGauntletWidgets } from "./tracker.js"
|
|
8
|
-
import {
|
|
8
|
+
import { installToolRenderers, stopLiveWatch } from "./tools.js"
|
|
9
9
|
import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
|
|
10
10
|
import { installWorkingStatus } from "./working-status.js"
|
|
11
|
+
import { installTurnRule } from "./turn-rule.js"
|
|
12
|
+
import { installTranscript } from "./transcript.js"
|
|
13
|
+
import { createGitState } from "./git-state.js"
|
|
11
14
|
|
|
12
15
|
export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
13
16
|
const stopWorkingStatus = installWorkingStatus(pi)
|
|
17
|
+
const stopTurnRule = installTurnRule(pi)
|
|
18
|
+
installToolRenderers(pi)
|
|
19
|
+
installTranscript(pi)
|
|
14
20
|
let fastTimer: ReturnType<typeof setInterval> | undefined
|
|
15
21
|
let slowTimer: ReturnType<typeof setInterval> | undefined
|
|
16
22
|
let stealTimer: ReturnType<typeof setInterval> | undefined
|
|
@@ -18,6 +24,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
18
24
|
let siblings: Sibling[] | undefined
|
|
19
25
|
let generation = 0
|
|
20
26
|
let hideWidgets = true
|
|
27
|
+
let busy = false
|
|
28
|
+
let currentCwd = process.cwd()
|
|
29
|
+
const gitState = createGitState(pi, () => currentCwd)
|
|
21
30
|
const stopTimers = () => {
|
|
22
31
|
if (fastTimer) clearInterval(fastTimer)
|
|
23
32
|
if (slowTimer) clearInterval(slowTimer)
|
|
@@ -97,17 +106,40 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
97
106
|
},
|
|
98
107
|
chrome: () => {
|
|
99
108
|
installHeader(ctx)
|
|
100
|
-
|
|
109
|
+
installChromeFooter(ctx)
|
|
101
110
|
},
|
|
102
111
|
},
|
|
103
112
|
)
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
const contextPct = (ctx: ExtensionContext): number | undefined => {
|
|
116
|
+
const usage = ctx.getContextUsage?.()
|
|
117
|
+
const window = usage?.contextWindow || ctx.model?.contextWindow
|
|
118
|
+
if (typeof usage?.tokens !== "number" || !window) return undefined
|
|
119
|
+
return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const installChromeFooter = (ctx: ExtensionContext) => {
|
|
123
|
+
installFooter(ctx, {
|
|
124
|
+
busy: () => busy,
|
|
125
|
+
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
126
|
+
contextPct: () => contextPct(ctx),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const installStatusLine = (ctx: ExtensionContext) => {
|
|
131
|
+
installCwd(ctx, {
|
|
132
|
+
gitState: gitState.get,
|
|
133
|
+
siblings: () => siblings,
|
|
134
|
+
onGitChange: gitState.onChange,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
106
138
|
const pinChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
|
|
107
139
|
hideForeignChrome(ctx, reason)
|
|
108
140
|
if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome) return
|
|
109
141
|
ctx.ui.setWidget("tw-cwd", undefined)
|
|
110
|
-
|
|
142
|
+
installStatusLine(ctx)
|
|
111
143
|
}
|
|
112
144
|
|
|
113
145
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -118,10 +150,11 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
118
150
|
projectTrusted: ctx.isProjectTrusted(),
|
|
119
151
|
})
|
|
120
152
|
hideWidgets = config.hideGauntletWidgets
|
|
153
|
+
currentCwd = ctx.cwd
|
|
121
154
|
installHeader(ctx)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
installChromeFooter(ctx)
|
|
156
|
+
installStatusLine(ctx)
|
|
157
|
+
void gitState.refresh(true)
|
|
125
158
|
pinChrome(ctx, "session_start")
|
|
126
159
|
ctx.ui.setHiddenThinkingLabel("◆ Thought")
|
|
127
160
|
ctx.ui.setWorkingIndicator()
|
|
@@ -135,16 +168,27 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
|
|
|
135
168
|
}
|
|
136
169
|
})
|
|
137
170
|
|
|
171
|
+
pi.on("agent_start", () => {
|
|
172
|
+
busy = true
|
|
173
|
+
})
|
|
174
|
+
pi.on("agent_end", () => {
|
|
175
|
+
busy = false
|
|
176
|
+
void gitState.refresh()
|
|
177
|
+
})
|
|
138
178
|
pi.on("turn_end", (_event, ctx) => {
|
|
139
179
|
pinChrome(ctx, "turn_end")
|
|
180
|
+
void gitState.refresh()
|
|
140
181
|
})
|
|
141
182
|
pi.on("tool_execution_end", (_event, ctx) => {
|
|
142
183
|
pinChrome(ctx, "tool_execution_end")
|
|
184
|
+
void gitState.refresh()
|
|
143
185
|
})
|
|
144
186
|
|
|
145
187
|
pi.on("session_shutdown", async () => {
|
|
188
|
+
busy = false
|
|
146
189
|
stopTimers()
|
|
147
190
|
stopWorkingStatus()
|
|
191
|
+
stopTurnRule()
|
|
148
192
|
stopLiveWatch()
|
|
149
193
|
generation += 1
|
|
150
194
|
siblings = undefined
|