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/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +29 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/activity.js +12 -0
- package/dist/chrome.js +1 -0
- package/dist/config.js +45 -0
- package/dist/cwd.js +103 -0
- package/dist/footer.js +46 -0
- package/dist/header.js +14 -0
- package/dist/herdr.js +44 -0
- package/dist/index.js +155 -0
- package/dist/live-watch.js +52 -0
- package/dist/reclaim.js +19 -0
- package/dist/tool-rows.js +34 -0
- package/dist/tools.js +103 -0
- package/dist/tracker.js +63 -0
- package/dist/working-status.js +191 -0
- package/doc/plans/2026-09-22-pi-minimal-package.md +354 -0
- package/doc/specs/2026-09-22-pi-minimal-package.md +94 -0
- package/eslint.config.js +8 -0
- package/package.json +37 -0
- package/src/activity.ts +16 -0
- package/src/chrome.ts +1 -0
- package/src/config.ts +59 -0
- package/src/cwd.ts +108 -0
- package/src/footer.ts +54 -0
- package/src/header.ts +16 -0
- package/src/herdr.ts +66 -0
- package/src/index.ts +152 -0
- package/src/live-watch.ts +59 -0
- package/src/reclaim.ts +34 -0
- package/src/tool-rows.ts +58 -0
- package/src/tools.ts +161 -0
- package/src/tracker.ts +91 -0
- package/src/working-status.ts +214 -0
- package/test/activity.test.ts +27 -0
- package/test/config.test.ts +114 -0
- package/test/index.test.ts +262 -0
- package/test/package.test.ts +18 -0
- package/test/tools.test.ts +48 -0
- package/test/tracker.test.ts +104 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tests.json +8 -0
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
|
+
}
|
package/src/tool-rows.ts
ADDED
|
@@ -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
|
+
}
|