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.
@@ -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.0",
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",
@@ -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
- function meter(percent: number, fill: string, empty: string): string {
52
- const n = 8
53
- const filled = Math.max(0, Math.min(n, Math.round((percent / 100) * n)))
54
- return fill.repeat(filled) + empty.repeat(n - filled)
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 splitRow(left: string, right: string, width: number): string {
58
- let L = left
59
- let R = right
60
- while (visibleWidth(L) + 1 + visibleWidth(R) > width && visibleWidth(R) > 0) {
61
- R = truncateToWidth(R, Math.max(0, visibleWidth(R) - 1), "")
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
- while (visibleWidth(L) + 1 + visibleWidth(R) > width && visibleWidth(L) > 0) {
64
- L = truncateToWidth(L, Math.max(0, visibleWidth(L) - 1), "")
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(1, width - visibleWidth(L) - visibleWidth(R))
67
- return L + " ".repeat(gap) + R
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 function installCwd(ctx: ExtensionContext): void {
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
- (_tui, theme) => ({
74
- invalidate() {},
75
- render(width: number): string[] {
76
- const inner = Math.max(8, width - CHROME_MARGIN * 2)
77
- const id = ctx.model?.id || "no-model"
78
- const icon = theme.fg("accent", modelIcon(id))
79
- const name = theme.fg("text", shortModel(id))
80
- const usage = ctx.getContextUsage?.()
81
- const window = usage?.contextWindow || ctx.model?.contextWindow
82
- const used = usage?.tokens
83
- let tokenStr = theme.fg("dim", "?/?")
84
- let bar = theme.fg("dim", "░░░░░░░░")
85
- let pctStr = theme.fg("dim", "?%")
86
- if (typeof used === "number" && window) {
87
- const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)))
88
- tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`)
89
- const hot = pct >= 85
90
- bar = meter(pct, theme.fg(hot ? "warning" : "accent", "█"), theme.fg("dim", "░"))
91
- pctStr = theme.fg(hot ? "warning" : "muted", `${pct}%`)
92
- } else if (used === null && window) {
93
- tokenStr = theme.fg("dim", `?/${formatTokens(window)}`)
94
- }
95
- const thinking = ctx.thinkingLevel || ""
96
- const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : ""
97
- const midot = theme.fg("dim", " · ")
98
- const left = [`${icon} ${name}`, tokenStr, `${bar} ${pctStr}`, think]
99
- .filter(Boolean)
100
- .join(midot)
101
- const right = ctx.cwd ? theme.fg("accent", formatCwd(ctx.cwd)) : ""
102
- const row = splitRow(left, right, inner)
103
- return [`${" ".repeat(CHROME_MARGIN)}${row}`]
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, visibleWidth } from "@earendil-works/pi-tui"
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
- function statusColor(
9
- status: string,
10
- ): "success" | "warning" | "error" | "accent" | "muted" {
11
- if (status === "idle" || status === "done") return "success"
12
- if (status === "working") return "accent"
13
- if (status === "blocked") return "error"
14
- return "muted"
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
- export function installFooter(
18
- ctx: ExtensionContext,
19
- getSiblings: () => Sibling[] | undefined,
20
- ): void {
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 pad = Math.max(1, inner - visibleWidth(left) - visibleWidth(right))
45
- const line = truncateToWidth(left + " ".repeat(pad) + right, inner, "")
46
- const out = [`${" ".repeat(CHROME_MARGIN)}${line}`]
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
  }
@@ -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 { stopLiveWatch, wrapBuiltinTools } from "./tools.js"
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
- installFooter(ctx, () => siblings)
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
- installCwd(ctx)
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
- installFooter(ctx, () => siblings)
123
- installCwd(ctx)
124
- wrapBuiltinTools(pi, ctx)
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