pi-minimal 0.1.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/src/footer.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
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"
6
+ import { formatTrackerLines } from "./tracker.js"
7
+
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"
15
+ }
16
+
17
+ export function installFooter(
18
+ ctx: ExtensionContext,
19
+ getSiblings: () => Sibling[] | undefined,
20
+ ): void {
21
+ ctx.ui.setFooter((tui, theme, footerData) => {
22
+ const unsub = footerData.onBranchChange(() => tui.requestRender())
23
+ return {
24
+ dispose: unsub,
25
+ invalidate() {},
26
+ 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
+ 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
+ for (const extra of formatTrackerLines(ctx, theme as { fg: (name: string, s: string) => string })) {
48
+ out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`)
49
+ }
50
+ return out
51
+ },
52
+ }
53
+ })
54
+ }
package/src/header.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { Text } from "@earendil-works/pi-tui"
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
3
+
4
+ export function installHeader(ctx: ExtensionContext): void {
5
+ ctx.ui.setHeader(() => {
6
+ const text = new Text("", 0, 0)
7
+ return {
8
+ render(): string[] {
9
+ return text.render(1)
10
+ },
11
+ invalidate() {
12
+ text.invalidate()
13
+ },
14
+ }
15
+ })
16
+ }
package/src/herdr.ts ADDED
@@ -0,0 +1,66 @@
1
+ export type HerdrAgent = {
2
+ agent: string
3
+ agent_status: string
4
+ pane_id: string
5
+ tab_id: string
6
+ }
7
+
8
+ export type HerdrTab = {
9
+ label: string
10
+ tab_id: string
11
+ }
12
+
13
+ function parseJson(stdout: string): unknown {
14
+ return JSON.parse(stdout)
15
+ }
16
+
17
+ export function parseAgentList(stdout: string): HerdrAgent[] {
18
+ const data = parseJson(stdout) as {
19
+ result?: { agents?: Array<Record<string, unknown>> }
20
+ }
21
+ const rows = data.result?.agents
22
+ if (!Array.isArray(rows)) throw new Error("invalid herdr agent list")
23
+ return rows.map((row) => ({
24
+ agent: String(row.agent ?? ""),
25
+ agent_status: String(row.agent_status ?? ""),
26
+ pane_id: String(row.pane_id ?? ""),
27
+ tab_id: String(row.tab_id ?? ""),
28
+ }))
29
+ }
30
+
31
+ export function parseTabList(stdout: string): HerdrTab[] {
32
+ const data = parseJson(stdout) as {
33
+ result?: { tabs?: Array<Record<string, unknown>> }
34
+ }
35
+ const rows = data.result?.tabs
36
+ if (!Array.isArray(rows)) throw new Error("invalid herdr tab list")
37
+ return rows.map((row) => ({
38
+ label: String(row.label ?? ""),
39
+ tab_id: String(row.tab_id ?? ""),
40
+ }))
41
+ }
42
+
43
+ export type Sibling = {
44
+ name: string
45
+ status: string
46
+ }
47
+
48
+ export function listSiblings(
49
+ agents: HerdrAgent[],
50
+ tabs: HerdrTab[],
51
+ selfPane?: string,
52
+ selfTab?: string,
53
+ ): Sibling[] | undefined {
54
+ if (!selfPane && !selfTab) return undefined
55
+ const labels = new Map(tabs.map((t) => [t.tab_id, t.label]))
56
+ const parts: Sibling[] = []
57
+ for (const agent of agents) {
58
+ if (selfPane && agent.pane_id === selfPane) continue
59
+ if (!selfPane && selfTab && agent.tab_id === selfTab) continue
60
+ const name = labels.get(agent.tab_id) || agent.agent
61
+ if (!name) continue
62
+ parts.push({ name, status: agent.agent_status })
63
+ }
64
+ if (parts.length === 0) return undefined
65
+ return parts
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { loadPiMinimalConfig } from "./config.js"
3
+ import { installHeader } from "./header.js"
4
+ import { installFooter } from "./footer.js"
5
+ import { installCwd } from "./cwd.js"
6
+ import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
7
+ import { hideGauntletWidgets } from "./tracker.js"
8
+ import { stopLiveWatch, wrapBuiltinTools } from "./tools.js"
9
+ import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
10
+ import { installWorkingStatus } from "./working-status.js"
11
+
12
+ export default function piMinimalExtension(pi: ExtensionAPI): void {
13
+ const stopWorkingStatus = installWorkingStatus(pi)
14
+ let fastTimer: ReturnType<typeof setInterval> | undefined
15
+ let slowTimer: ReturnType<typeof setInterval> | undefined
16
+ let stealTimer: ReturnType<typeof setInterval> | undefined
17
+ let inFlight: AbortController | undefined
18
+ let siblings: Sibling[] | undefined
19
+ let generation = 0
20
+ let hideWidgets = true
21
+ const stopTimers = () => {
22
+ if (fastTimer) clearInterval(fastTimer)
23
+ if (slowTimer) clearInterval(slowTimer)
24
+ if (stealTimer) clearInterval(stealTimer)
25
+ fastTimer = undefined
26
+ slowTimer = undefined
27
+ stealTimer = undefined
28
+ inFlight?.abort()
29
+ inFlight = undefined
30
+ }
31
+
32
+ const scheduleFast = (ctx: ExtensionContext) => {
33
+ if (fastTimer) return
34
+ if (slowTimer) clearInterval(slowTimer)
35
+ slowTimer = undefined
36
+ fastTimer = setInterval(() => {
37
+ void probe(ctx)
38
+ }, 2000)
39
+ }
40
+
41
+ const scheduleSlow = (ctx: ExtensionContext) => {
42
+ if (slowTimer) return
43
+ if (fastTimer) clearInterval(fastTimer)
44
+ fastTimer = undefined
45
+ slowTimer = setInterval(() => {
46
+ void probe(ctx)
47
+ }, 10000)
48
+ }
49
+
50
+ const probe = async (ctx: ExtensionContext) => {
51
+ if (process.env.HERDR_ENV !== "1") return
52
+ if (inFlight) return
53
+ const ac = new AbortController()
54
+ const gen = generation
55
+ inFlight = ac
56
+ try {
57
+ const agentRun = await pi.exec("herdr", ["agent", "list"], {
58
+ timeout: 3000,
59
+ signal: ac.signal,
60
+ })
61
+ if (generation !== gen) return
62
+ const tabRun = await pi.exec("herdr", ["tab", "list"], {
63
+ timeout: 3000,
64
+ signal: ac.signal,
65
+ })
66
+ if (generation !== gen) return
67
+ if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
68
+ siblings = undefined
69
+ scheduleSlow(ctx)
70
+ return
71
+ }
72
+ siblings = listSiblings(
73
+ parseAgentList(agentRun.stdout),
74
+ parseTabList(tabRun.stdout),
75
+ process.env.HERDR_PANE_ID,
76
+ process.env.HERDR_TAB_ID,
77
+ )
78
+ if (siblings) scheduleFast(ctx)
79
+ else scheduleSlow(ctx)
80
+ } catch {
81
+ if (generation !== gen) return
82
+ siblings = undefined
83
+ scheduleSlow(ctx)
84
+ } finally {
85
+ if (inFlight === ac) inFlight = undefined
86
+ }
87
+ }
88
+
89
+ const hideForeignChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
90
+ if (ctx.mode !== "tui") return
91
+ applyReclaim(
92
+ reason,
93
+ ctx.ui,
94
+ {
95
+ widgets: () => {
96
+ if (hideWidgets) hideGauntletWidgets(ctx)
97
+ },
98
+ chrome: () => {
99
+ installHeader(ctx)
100
+ installFooter(ctx, () => siblings)
101
+ },
102
+ },
103
+ )
104
+ }
105
+
106
+ const pinChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
107
+ hideForeignChrome(ctx, reason)
108
+ if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome) return
109
+ ctx.ui.setWidget("tw-cwd", undefined)
110
+ installCwd(ctx)
111
+ }
112
+
113
+ pi.on("session_start", async (_event, ctx) => {
114
+ if (ctx.mode !== "tui") return
115
+ const config = loadPiMinimalConfig({
116
+ agentDir: getAgentDir(),
117
+ cwd: ctx.cwd,
118
+ projectTrusted: ctx.isProjectTrusted(),
119
+ })
120
+ hideWidgets = config.hideGauntletWidgets
121
+ installHeader(ctx)
122
+ installFooter(ctx, () => siblings)
123
+ installCwd(ctx)
124
+ wrapBuiltinTools(pi, ctx)
125
+ pinChrome(ctx, "session_start")
126
+ ctx.ui.setHiddenThinkingLabel("◆ Thought")
127
+ ctx.ui.setWorkingIndicator()
128
+ setTimeout(() => pinChrome(ctx, "session_settle"), 0)
129
+ setTimeout(() => pinChrome(ctx, "session_settle"), 50)
130
+ if (!stealTimer) {
131
+ stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200)
132
+ }
133
+ if (process.env.HERDR_ENV === "1") {
134
+ await probe(ctx)
135
+ }
136
+ })
137
+
138
+ pi.on("turn_end", (_event, ctx) => {
139
+ pinChrome(ctx, "turn_end")
140
+ })
141
+ pi.on("tool_execution_end", (_event, ctx) => {
142
+ pinChrome(ctx, "tool_execution_end")
143
+ })
144
+
145
+ pi.on("session_shutdown", async () => {
146
+ stopTimers()
147
+ stopWorkingStatus()
148
+ stopLiveWatch()
149
+ generation += 1
150
+ siblings = undefined
151
+ })
152
+ }
@@ -0,0 +1,59 @@
1
+ export type LiveWatch = {
2
+ watch: (id: string, invalidate: () => void) => void
3
+ unwatch: (id: string) => void
4
+ has: (id: string) => boolean
5
+ stop: () => void
6
+ tick: () => void
7
+ readonly frame: 0 | 1
8
+ readonly size: number
9
+ }
10
+
11
+ export function createLiveWatch(intervalMs = 400): LiveWatch {
12
+ let frame: 0 | 1 = 0
13
+ let timer: ReturnType<typeof setInterval> | undefined
14
+ const invalidators = new Map<string, () => void>()
15
+
16
+ const tick = () => {
17
+ frame = frame === 0 ? 1 : 0
18
+ const snapshot = [...invalidators.values()]
19
+ for (const invalidate of snapshot) {
20
+ try {
21
+ invalidate()
22
+ } catch {
23
+ // A single renderer failure must not stop the shared activity frame.
24
+ }
25
+ }
26
+ }
27
+
28
+ return {
29
+ watch(id, invalidate) {
30
+ if (!id) return
31
+ invalidators.set(id, invalidate)
32
+ if (!timer) timer = setInterval(tick, intervalMs)
33
+ },
34
+ unwatch(id) {
35
+ if (!id) return
36
+ invalidators.delete(id)
37
+ if (invalidators.size === 0 && timer) {
38
+ clearInterval(timer)
39
+ timer = undefined
40
+ }
41
+ },
42
+ has(id) {
43
+ return Boolean(id) && invalidators.has(id)
44
+ },
45
+ stop() {
46
+ if (timer) clearInterval(timer)
47
+ timer = undefined
48
+ invalidators.clear()
49
+ frame = 0
50
+ },
51
+ tick,
52
+ get frame() {
53
+ return frame
54
+ },
55
+ get size() {
56
+ return invalidators.size
57
+ },
58
+ }
59
+ }
package/src/reclaim.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type ReclaimReason =
2
+ | "session_start"
3
+ | "session_settle"
4
+ | "interval"
5
+ | "turn_end"
6
+ | "tool_execution_end"
7
+
8
+ export type ReclaimPolicy = {
9
+ resetEditor: boolean
10
+ reinstallChrome: boolean
11
+ }
12
+
13
+ export function reclaimPolicy(reason: ReclaimReason): ReclaimPolicy {
14
+ if (reason === "session_start") {
15
+ return { resetEditor: true, reinstallChrome: true }
16
+ }
17
+ if (reason === "interval") {
18
+ return { resetEditor: false, reinstallChrome: false }
19
+ }
20
+ // session_settle / turn_end / tool_execution_end: restore chrome, keep editor
21
+ return { resetEditor: false, reinstallChrome: true }
22
+ }
23
+
24
+ export function applyReclaim(
25
+ reason: ReclaimReason,
26
+ ui: { setEditorComponent: (factory: undefined) => void },
27
+ install: { widgets: () => void; chrome: () => void },
28
+ ): ReclaimPolicy {
29
+ const policy = reclaimPolicy(reason)
30
+ install.widgets()
31
+ if (policy.resetEditor) ui.setEditorComponent(undefined)
32
+ if (policy.reinstallChrome) install.chrome()
33
+ return policy
34
+ }
@@ -0,0 +1,58 @@
1
+ export type ToolKind = "read" | "edit" | "write" | "bash" | "grep" | "find" | "ls"
2
+
3
+ export type ToolArgs = {
4
+ command?: unknown
5
+ path?: unknown
6
+ file_path?: unknown
7
+ pattern?: unknown
8
+ target_directory?: unknown
9
+ }
10
+
11
+ export type GroupedRow = {
12
+ header?: string
13
+ item: string
14
+ }
15
+
16
+ type ActivityGroup = {
17
+ verb: string
18
+ leadId: string
19
+ seen: Set<string>
20
+ }
21
+
22
+ let activityGroup: ActivityGroup | undefined
23
+
24
+ export function resetActivityGroup(): void {
25
+ activityGroup = undefined
26
+ }
27
+
28
+ export function shortName(path: string): string {
29
+ const parts = path.split("/").filter(Boolean)
30
+ if (parts.length <= 1) return path || ""
31
+ return parts.slice(-2).join("/")
32
+ }
33
+
34
+ export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
35
+ if (kind === "bash") {
36
+ const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : ""
37
+ return command || "bash"
38
+ }
39
+ if (kind === "grep" || kind === "find") {
40
+ return typeof args?.pattern === "string" && args.pattern ? args.pattern : kind
41
+ }
42
+ const path =
43
+ (typeof args?.path === "string" && args.path) ||
44
+ (typeof args?.file_path === "string" && args.file_path) ||
45
+ (typeof args?.target_directory === "string" && args.target_directory) ||
46
+ ""
47
+ return path ? shortName(path) : kind
48
+ }
49
+
50
+ export function nextGroupedRow(verb: string, item: string, id: string): GroupedRow {
51
+ if (activityGroup && activityGroup.verb === verb) {
52
+ activityGroup.seen.add(id)
53
+ if (id === activityGroup.leadId) return { header: verb, item }
54
+ return { header: undefined, item }
55
+ }
56
+ activityGroup = { verb, leadId: id, seen: new Set([id]) }
57
+ return { header: verb, item }
58
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"
2
+ import {
3
+ createBashToolDefinition,
4
+ createEditToolDefinition,
5
+ createFindToolDefinition,
6
+ createGrepToolDefinition,
7
+ createLsToolDefinition,
8
+ createReadToolDefinition,
9
+ createWriteToolDefinition,
10
+ SettingsManager,
11
+ } from "@earendil-works/pi-coding-agent"
12
+ import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"
13
+ import { activityBullet, rowIsRunning } from "./activity.js"
14
+ import { createLiveWatch } from "./live-watch.js"
15
+ import { nextGroupedRow, toolItem, type ToolKind } from "./tool-rows.js"
16
+
17
+ type ToolArgs = {
18
+ command?: unknown
19
+ path?: unknown
20
+ file_path?: unknown
21
+ pattern?: unknown
22
+ target_directory?: unknown
23
+ }
24
+
25
+ type ToolResult = {
26
+ content?: Array<{ type?: string; text?: string }>
27
+ }
28
+
29
+ type RenderOptions = {
30
+ expanded?: boolean
31
+ }
32
+
33
+ type RowState = {
34
+ done?: boolean
35
+ running?: boolean
36
+ }
37
+
38
+ type RenderContext = {
39
+ isPartial?: boolean
40
+ isError?: boolean
41
+ invalidate: () => void
42
+ state: RowState
43
+ args?: ToolArgs
44
+ toolCallId?: string
45
+ expanded?: boolean
46
+ }
47
+
48
+ const live = createLiveWatch(400)
49
+
50
+ export function stopLiveWatch(): void {
51
+ live.stop()
52
+ }
53
+
54
+ function liveId(context: RenderContext, item: string): string {
55
+ return context.toolCallId || item
56
+ }
57
+
58
+ function contentText(result: ToolResult | undefined): string {
59
+ if (!result?.content) return ""
60
+ return result.content
61
+ .filter((part) => part.type === "text" && typeof part.text === "string")
62
+ .map((part) => part.text as string)
63
+ .join("\n")
64
+ }
65
+
66
+ function collapsedResult() {
67
+ return new Container()
68
+ }
69
+
70
+ function expandedContent(result: ToolResult | undefined, options: RenderOptions) {
71
+ if (!options.expanded) return collapsedResult()
72
+ const text = contentText(result)
73
+ return text ? new Text(text, 0, 0) : collapsedResult()
74
+ }
75
+
76
+ function activityBlock(
77
+ kind: ToolKind,
78
+ args: ToolArgs | undefined,
79
+ theme: Theme,
80
+ context: RenderContext,
81
+ ) {
82
+ const item = toolItem(kind, args)
83
+ const id = liveId(context, item)
84
+ const row = nextGroupedRow(kind, item, id)
85
+ const running = rowIsRunning(context)
86
+ context.state.running = running
87
+ if (running) live.watch(id, context.invalidate)
88
+ else live.unwatch(id)
89
+ const color = context.isError ? "error" : running ? "warning" : "dim"
90
+ return {
91
+ render(width: number): string[] {
92
+ const pad = 1
93
+ const inner = Math.max(0, width - pad)
94
+ const lines: string[] = []
95
+ if (row.header) {
96
+ const blink = Boolean(context.state.running) && live.has(id)
97
+ const bullet = activityBullet(blink, live.frame)
98
+ lines.push(" ".repeat(pad) + theme.fg(color, truncateToWidth(`${bullet} ${row.header}`, inner)))
99
+ }
100
+ lines.push(" ".repeat(pad) + " " + theme.fg(color, truncateToWidth(row.item, Math.max(0, inner - 2))))
101
+ return lines
102
+ },
103
+ invalidate() {},
104
+ }
105
+ }
106
+
107
+ type WrappableTool = {
108
+ name: string
109
+ label: string
110
+ description: string
111
+ parameters: Parameters<ExtensionAPI["registerTool"]>[0]["parameters"]
112
+ execute: Parameters<ExtensionAPI["registerTool"]>[0]["execute"]
113
+ }
114
+
115
+ function wrapOne(pi: ExtensionAPI, kind: ToolKind, factory: () => WrappableTool) {
116
+ try {
117
+ const tool = factory()
118
+ pi.registerTool({
119
+ ...tool,
120
+ renderShell: "self",
121
+ renderCall(args, theme, context) {
122
+ return activityBlock(kind, args as ToolArgs, theme, context as RenderContext)
123
+ },
124
+ renderResult(result, options, _theme, context) {
125
+ const renderContext = context as RenderContext
126
+ if (!renderContext.isPartial) {
127
+ renderContext.state.done = true
128
+ renderContext.state.running = false
129
+ live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
130
+ }
131
+ return expandedContent(result as ToolResult, options)
132
+ },
133
+ })
134
+ } catch {
135
+ // A missing built-in tool leaves the rest of the compact renderers available.
136
+ }
137
+ }
138
+
139
+ export function wrapBuiltinTools(pi: ExtensionAPI, ctx: ExtensionContext): void {
140
+ let commandPrefix: string | undefined
141
+ let shellPath: string | undefined
142
+ let autoResizeImages: boolean | undefined
143
+ try {
144
+ const settings = SettingsManager.create(ctx.cwd, undefined, {
145
+ projectTrusted: ctx.isProjectTrusted(),
146
+ })
147
+ commandPrefix = settings.getShellCommandPrefix()
148
+ shellPath = settings.getShellPath()
149
+ autoResizeImages = settings.getImageAutoResize()
150
+ } catch {
151
+ // Tool wrapping still works when optional shell or image settings are unavailable.
152
+ }
153
+
154
+ wrapOne(pi, "bash", () => createBashToolDefinition(ctx.cwd, { commandPrefix, shellPath }) as WrappableTool)
155
+ wrapOne(pi, "read", () => createReadToolDefinition(ctx.cwd, { autoResizeImages }) as WrappableTool)
156
+ wrapOne(pi, "edit", () => createEditToolDefinition(ctx.cwd) as WrappableTool)
157
+ wrapOne(pi, "write", () => createWriteToolDefinition(ctx.cwd) as WrappableTool)
158
+ wrapOne(pi, "grep", () => createGrepToolDefinition(ctx.cwd) as WrappableTool)
159
+ wrapOne(pi, "find", () => createFindToolDefinition(ctx.cwd) as WrappableTool)
160
+ wrapOne(pi, "ls", () => createLsToolDefinition(ctx.cwd) as WrappableTool)
161
+ }
package/src/tracker.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+
3
+ const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const
4
+ type PhaseName = (typeof PHASES)[number]
5
+ type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped"
6
+ type TaskStatus = "pending" | "in_progress" | "complete" | "failed"
7
+
8
+ type PhaseMap = Record<PhaseName, { status: PhaseStatus; substep?: string | null }>
9
+
10
+ type Task = { name: string; status: TaskStatus }
11
+
12
+ export type TrackerSnapshot = {
13
+ phases?: PhaseMap
14
+ tasks?: Task[]
15
+ }
16
+
17
+ function readSnapshot(ctx: ExtensionContext): TrackerSnapshot {
18
+ let phases: PhaseMap | undefined
19
+ let tasks: Task[] | undefined
20
+ for (const entry of ctx.sessionManager.getBranch()) {
21
+ if (entry.type !== "message") continue
22
+ const msg = entry.message as {
23
+ role?: string
24
+ toolName?: string
25
+ details?: {
26
+ error?: string
27
+ phases?: PhaseMap
28
+ tasks?: Task[]
29
+ }
30
+ }
31
+ if (msg.role !== "toolResult" || !msg.details || msg.details.error) continue
32
+ if (msg.toolName === "phase_tracker" && msg.details.phases) {
33
+ phases = msg.details.phases
34
+ }
35
+ if (msg.toolName === "plan_tracker" && msg.details.tasks) {
36
+ tasks = msg.details.tasks
37
+ }
38
+ }
39
+ return { phases, tasks }
40
+ }
41
+
42
+ function phaseActive(phases: PhaseMap): boolean {
43
+ return PHASES.some((p) => phases[p]?.status && phases[p].status !== "pending")
44
+ }
45
+
46
+ export function formatTrackerLines(
47
+ ctx: ExtensionContext,
48
+ theme: { fg: (name: string, s: string) => string },
49
+ ): string[] {
50
+ const { phases, tasks } = readSnapshot(ctx)
51
+ const lines: string[] = []
52
+ if (phases && phaseActive(phases)) {
53
+ const parts = PHASES.map((p) => {
54
+ const st = phases[p]?.status || "pending"
55
+ const icon =
56
+ st === "complete"
57
+ ? theme.fg("success", "✓")
58
+ : st === "in_progress"
59
+ ? theme.fg("warning", "→")
60
+ : st === "skipped"
61
+ ? theme.fg("dim", "⊘")
62
+ : theme.fg("dim", "○")
63
+ const label = st === "skipped" ? theme.fg("dim", p) : p
64
+ return `${icon} ${label}`
65
+ })
66
+ lines.push(`${theme.fg("muted", "Phases")} ${parts.join(theme.fg("dim", " → "))}`)
67
+ }
68
+ if (tasks && tasks.length > 0) {
69
+ const done = tasks.filter((t) => t.status === "complete").length
70
+ const icons = tasks
71
+ .map((t) => {
72
+ if (t.status === "complete") return theme.fg("success", "✓")
73
+ if (t.status === "in_progress") return theme.fg("warning", "→")
74
+ if (t.status === "failed") return theme.fg("error", "✗")
75
+ return theme.fg("dim", "○")
76
+ })
77
+ .join("")
78
+ const current =
79
+ tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending")
80
+ const name = current ? theme.fg("text", ` ${current.name}`) : ""
81
+ lines.push(
82
+ `${theme.fg("muted", "Tasks")} ${icons} ${theme.fg("dim", `(${done}/${tasks.length})`)}${name}`,
83
+ )
84
+ }
85
+ return lines
86
+ }
87
+
88
+ export function hideGauntletWidgets(ctx: ExtensionContext): void {
89
+ ctx.ui.setWidget("phase_tracker", undefined)
90
+ ctx.ui.setWidget("plan_tracker", undefined)
91
+ }