pi-minimal 0.1.0 → 0.2.2

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/src/cwd.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  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
- import { CHROME_MARGIN } from "./chrome.js"
4
+ import * as chrome 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
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
9
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
10
+
11
+
12
+ export function formatCwd(cwd: string): string {
7
13
  const home = process.env.HOME || process.env.USERPROFILE || ""
8
14
  if (!home) return cwd
9
15
  const resolved = path.resolve(cwd)
@@ -14,7 +20,7 @@ function formatCwd(cwd: string): string {
14
20
  return cwd
15
21
  }
16
22
 
17
- function formatTokens(n: number): string {
23
+ export function formatTokens(n: number): string {
18
24
  if (n < 1000) return String(n)
19
25
  if (n < 10000) return `${(n / 1000).toFixed(1)}K`
20
26
  if (n < 1e6) return `${Math.round(n / 1000)}K`
@@ -22,7 +28,7 @@ function formatTokens(n: number): string {
22
28
  return `${Math.round(n / 1e6)}M`
23
29
  }
24
30
 
25
- function modelIcon(id: string): string {
31
+ export function modelIcon(id: string): string {
26
32
  const s = id.toLowerCase()
27
33
  if (s.includes("grok")) return "✦"
28
34
  if (
@@ -38,7 +44,7 @@ function modelIcon(id: string): string {
38
44
  return "◆"
39
45
  }
40
46
 
41
- function shortModel(id: string): string {
47
+ export function shortModel(id: string): string {
42
48
  return (id.split("/").pop() || id).replace(/^claude-/, "")
43
49
  }
44
50
 
@@ -48,61 +54,107 @@ function thinkingColor(level: string): "dim" | "accent" | "warning" {
48
54
  return "dim"
49
55
  }
50
56
 
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)
57
+ /** Context pressure band used for the meter and percentage colour. */
58
+ export function contextBand(pct: number): "accent" | "warning" | "error" {
59
+ if (pct >= 90) return "error"
60
+ if (pct >= 70) return "warning"
61
+ return "accent"
55
62
  }
56
63
 
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), "")
64
+ export function meter(percent: number, fill: string, empty: string, cells = 8): string {
65
+ const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
66
+ return fill.repeat(filled) + empty.repeat(cells - filled)
67
+ }
68
+
69
+ /**
70
+ * Fit `left` and `right` into `width`. Right-hand segments are dropped from the end first (they are
71
+ * the least important), then the left is truncated.
72
+ */
73
+ export function splitRow(left: string, rightSegments: string[], width: number, sep = " "): string {
74
+ const segments = [...rightSegments.filter((s) => visibleWidth(s) > 0)]
75
+ let right = segments.join(sep)
76
+ while (segments.length > 0 && visibleWidth(left) + 2 + visibleWidth(right) > width) {
77
+ segments.pop()
78
+ right = segments.join(sep)
62
79
  }
63
- while (visibleWidth(L) + 1 + visibleWidth(R) > width && visibleWidth(L) > 0) {
64
- L = truncateToWidth(L, Math.max(0, visibleWidth(L) - 1), "")
80
+ let L = left
81
+ if (visibleWidth(L) + (right ? 2 : 0) + visibleWidth(right) > width) {
82
+ L = truncateToWidth(L, Math.max(0, width - (right ? 2 : 0) - visibleWidth(right)), "")
65
83
  }
66
- const gap = Math.max(1, width - visibleWidth(L) - visibleWidth(R))
67
- return L + " ".repeat(gap) + R
84
+ const gap = right ? Math.max(2, width - visibleWidth(L) - visibleWidth(right)) : 0
85
+ return L + " ".repeat(gap) + right
68
86
  }
69
87
 
70
- export function installCwd(ctx: ExtensionContext): void {
88
+ function siblingColor(status: string): "success" | "warning" | "error" | "accent" | "muted" {
89
+ if (status === "idle" || status === "done") return "success"
90
+ if (status === "working") return "accent"
91
+ if (status === "blocked") return "error"
92
+ return "muted"
93
+ }
94
+
95
+ export type StatusLineSources = {
96
+ gitState: () => GitState | undefined
97
+ siblings: () => Sibling[] | undefined
98
+ onGitChange: (listener: () => void) => () => void
99
+ }
100
+
101
+ export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): void {
71
102
  ctx.ui.setWidget(
72
103
  "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
- }),
104
+ (tui, theme) => {
105
+ const unsub = sources.onGitChange(() => tui.requestRender())
106
+ return {
107
+ dispose: unsub,
108
+ invalidate() {},
109
+ render(width: number): string[] {
110
+ const inner = Math.max(8, width - margin() * 2)
111
+ const id = ctx.model?.id || "no-model"
112
+ const icon = theme.fg("accent", modelIcon(id))
113
+ const name = theme.fg("text", shortModel(id))
114
+ const thinking = ctx.thinkingLevel || ""
115
+ const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : ""
116
+
117
+ const usage = ctx.getContextUsage?.()
118
+ const window = usage?.contextWindow || ctx.model?.contextWindow
119
+ const used = usage?.tokens
120
+ let bar = theme.fg("dim", "░░░░░░░░")
121
+ let pctStr = theme.fg("dim", "?%")
122
+ let tokenStr = theme.fg("dim", "?/?")
123
+ if (typeof used === "number" && window) {
124
+ const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)))
125
+ const band = contextBand(pct)
126
+ bar = meter(pct, theme.fg(band, "▓"), theme.fg("dim", "░"))
127
+ pctStr = theme.fg(band === "accent" ? "muted" : band, `${pct}%`)
128
+ tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`)
129
+ } else if (used === null && window) {
130
+ tokenStr = theme.fg("dim", `?/${formatTokens(window)}`)
131
+ }
132
+
133
+ const midot = theme.fg("dim", " · ")
134
+ const model = [`${icon} ${name}`, think].filter(Boolean).join(midot)
135
+ const context = `${bar} ${pctStr} ${tokenStr}`
136
+ const left = `${model} ${context}`
137
+
138
+ const right: string[] = []
139
+ if (ctx.cwd) right.push(theme.fg("muted", formatCwd(ctx.cwd)))
140
+ const git = formatGitState(sources.gitState())
141
+ if (git.branch) {
142
+ const dirty = git.dirty ? ` ${theme.fg("warning", git.dirty)}` : ""
143
+ right.push(`${theme.fg("dim", "⎇")} ${theme.fg("muted", git.branch)}${dirty}`)
144
+ }
145
+ const siblings = sources.siblings()
146
+ if (siblings && siblings.length > 0) {
147
+ right.push(
148
+ siblings
149
+ .map((s) => `${theme.fg("text", s.name)} ${theme.fg(siblingColor(s.status), s.status)}`)
150
+ .join(theme.fg("dim", " · ")),
151
+ )
152
+ }
153
+ const row = splitRow(left, right, inner)
154
+ return [`${" ".repeat(margin())}${row}`]
155
+ },
156
+ }
157
+ },
106
158
  { placement: "aboveEditor" },
107
159
  )
108
160
  }
package/src/footer.ts CHANGED
@@ -1,51 +1,55 @@
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"
4
- import { CHROME_MARGIN } from "./chrome.js"
5
- import type { Sibling } from "./herdr.js"
3
+ import { truncateToWidth } from "@earendil-works/pi-tui"
4
+ import * as chrome from "./chrome.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
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
8
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
9
+
10
+
11
+ export type FooterSources = {
12
+ /** True while an agent run is in progress. */
13
+ busy: () => boolean
14
+ /** Current editor text, when the host exposes it. */
15
+ editorText: () => string
16
+ /** Context usage percentage, when known. */
17
+ contextPct: () => number | undefined
18
+ }
19
+
20
+ /**
21
+ * Key hints are shown only when nothing else needs the line: idle agent, empty editor. Otherwise
22
+ * the footer collapses to zero lines (tracker lines excepted).
23
+ */
24
+ export function footerHints(sources: FooterSources, hints: string[]): string[] {
25
+ if (sources.busy()) return []
26
+ if (sources.editorText().trim().length > 0) return []
27
+ const pct = sources.contextPct()
28
+ const out = [...hints]
29
+ if (pct !== undefined && pct >= 70) out.push("/compact")
30
+ return out
15
31
  }
16
32
 
17
- export function installFooter(
18
- ctx: ExtensionContext,
19
- getSiblings: () => Sibling[] | undefined,
20
- ): void {
33
+ export function installFooter(ctx: ExtensionContext, sources: FooterSources): void {
21
34
  ctx.ui.setFooter((tui, theme, footerData) => {
22
35
  const unsub = footerData.onBranchChange(() => tui.requestRender())
23
36
  return {
24
37
  dispose: unsub,
25
38
  invalidate() {},
26
39
  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", " · "))
40
+ const inner = Math.max(8, width - margin() * 2)
41
+ const out: string[] = []
42
+ const hints = footerHints(sources, [
43
+ keyHint("tui.input.submit", "send"),
44
+ keyHint("app.thinking.cycle", "thinking"),
45
+ keyHint("app.tools.expand", "expand tools"),
46
+ ].filter(Boolean))
47
+ if (hints.length > 0) {
48
+ const line = theme.fg("dim", hints.join(" · "))
49
+ out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
41
50
  }
42
-
43
- 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}`]
47
51
  for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
48
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`)
52
+ out.push(`${" ".repeat(margin())}${truncateToWidth(extra, inner, "")}`)
49
53
  }
50
54
  return out
51
55
  },
@@ -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,22 @@ 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"
14
+ import { installCodeBox } from "./code-box.js"
15
+ import { setChromeMargin } from "./chrome.js"
16
+ import { SettingsManager } from "@earendil-works/pi-coding-agent"
11
17
 
12
18
  export default function piMinimalExtension(pi: ExtensionAPI): void {
13
19
  const stopWorkingStatus = installWorkingStatus(pi)
20
+ const stopTurnRule = installTurnRule(pi)
21
+ installToolRenderers(pi)
22
+ installTranscript(pi)
23
+ installCodeBox()
14
24
  let fastTimer: ReturnType<typeof setInterval> | undefined
15
25
  let slowTimer: ReturnType<typeof setInterval> | undefined
16
26
  let stealTimer: ReturnType<typeof setInterval> | undefined
@@ -18,6 +28,9 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
18
28
  let siblings: Sibling[] | undefined
19
29
  let generation = 0
20
30
  let hideWidgets = true
31
+ let busy = false
32
+ let currentCwd = process.cwd()
33
+ const gitState = createGitState(pi, () => currentCwd)
21
34
  const stopTimers = () => {
22
35
  if (fastTimer) clearInterval(fastTimer)
23
36
  if (slowTimer) clearInterval(slowTimer)
@@ -97,17 +110,40 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
97
110
  },
98
111
  chrome: () => {
99
112
  installHeader(ctx)
100
- installFooter(ctx, () => siblings)
113
+ installChromeFooter(ctx)
101
114
  },
102
115
  },
103
116
  )
104
117
  }
105
118
 
119
+ const contextPct = (ctx: ExtensionContext): number | undefined => {
120
+ const usage = ctx.getContextUsage?.()
121
+ const window = usage?.contextWindow || ctx.model?.contextWindow
122
+ if (typeof usage?.tokens !== "number" || !window) return undefined
123
+ return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)))
124
+ }
125
+
126
+ const installChromeFooter = (ctx: ExtensionContext) => {
127
+ installFooter(ctx, {
128
+ busy: () => busy,
129
+ editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
130
+ contextPct: () => contextPct(ctx),
131
+ })
132
+ }
133
+
134
+ const installStatusLine = (ctx: ExtensionContext) => {
135
+ installCwd(ctx, {
136
+ gitState: gitState.get,
137
+ siblings: () => siblings,
138
+ onGitChange: gitState.onChange,
139
+ })
140
+ }
141
+
106
142
  const pinChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
107
143
  hideForeignChrome(ctx, reason)
108
144
  if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome) return
109
145
  ctx.ui.setWidget("tw-cwd", undefined)
110
- installCwd(ctx)
146
+ installStatusLine(ctx)
111
147
  }
112
148
 
113
149
  pi.on("session_start", async (_event, ctx) => {
@@ -118,10 +154,17 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
118
154
  projectTrusted: ctx.isProjectTrusted(),
119
155
  })
120
156
  hideWidgets = config.hideGauntletWidgets
157
+ currentCwd = ctx.cwd
158
+ try {
159
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
160
+ setChromeMargin(settings.getOutputPad())
161
+ } catch {
162
+ // Keep the default margin when settings are unavailable.
163
+ }
121
164
  installHeader(ctx)
122
- installFooter(ctx, () => siblings)
123
- installCwd(ctx)
124
- wrapBuiltinTools(pi, ctx)
165
+ installChromeFooter(ctx)
166
+ installStatusLine(ctx)
167
+ void gitState.refresh(true)
125
168
  pinChrome(ctx, "session_start")
126
169
  ctx.ui.setHiddenThinkingLabel("◆ Thought")
127
170
  ctx.ui.setWorkingIndicator()
@@ -135,16 +178,27 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
135
178
  }
136
179
  })
137
180
 
181
+ pi.on("agent_start", () => {
182
+ busy = true
183
+ })
184
+ pi.on("agent_end", () => {
185
+ busy = false
186
+ void gitState.refresh()
187
+ })
138
188
  pi.on("turn_end", (_event, ctx) => {
139
189
  pinChrome(ctx, "turn_end")
190
+ void gitState.refresh()
140
191
  })
141
192
  pi.on("tool_execution_end", (_event, ctx) => {
142
193
  pinChrome(ctx, "tool_execution_end")
194
+ void gitState.refresh()
143
195
  })
144
196
 
145
197
  pi.on("session_shutdown", async () => {
198
+ busy = false
146
199
  stopTimers()
147
200
  stopWorkingStatus()
201
+ stopTurnRule()
148
202
  stopLiveWatch()
149
203
  generation += 1
150
204
  siblings = undefined