@maheidem/pi-loop 0.7.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/LICENSE ADDED
@@ -0,0 +1,32 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maheidem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This project builds against and vendors patterns from works under the MIT
26
+ License, copyright their respective authors:
27
+
28
+ - @earendil-works/pi-coding-agent (pi coding agent), including the official
29
+ `subagent` and `plan-mode` extension examples and RPC documentation.
30
+ - @earendil-works/pi-tui.
31
+ - The local `/loop` extension (same workspace), from which
32
+ `ui/settings-panel.ts` is vendored.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # loop — `/loop` for pi
2
+
3
+ Run any prompt on a schedule inside the current session. Modeled on
4
+ Claude Code's `/loop` (CronCreate): the interval and the prompt are yours;
5
+ the loop just keeps injecting the prompt into **this conversation** every X.
6
+
7
+ The loop knows nothing about what the prompt says. It is a **generic
8
+ mechanism**. Specializations — like the bundled `shepherd` skill
9
+ (`skills/shepherd/`) — are *consumers* of `/loop`, not part of it.
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ /loop # interactive dashboard
15
+ /loop 5m check the deploy and tell me what happened
16
+ /loop 30s run the test suite and fix failures # fast loop (min 15s)
17
+ /loop 2h <prompt> # slow loop
18
+ /loop status # show active loop
19
+ /loop stop # cancel
20
+ ```
21
+
22
+ The bare command opens a responsive dashboard in TUI mode. It shows the
23
+ current schedule, live countdown, delivery mode, prompt preview, and safe
24
+ start/edit/stop actions. In print/RPC/JSON modes, the bare command reports
25
+ status instead. The nested forms remain the stable scripting interface and
26
+ are taught by argument autocomplete.
27
+
28
+ Interval formats: `30s`, `5m`, `2h`, `1d` (minimum 15s). Omit the interval
29
+ for the default 10m. A trailing `every 5m` clause also works:
30
+ `/loop check CI every 5m`.
31
+
32
+ Each tick arrives as a `loop-tick` custom message — the prompt verbatim,
33
+ with a one-line provenance header so the model knows it is a scheduled
34
+ message, not a fresh user request — rendered in the TUI as a collapsible
35
+ card (`↻ loop tick #7 · check the deploy…`):
36
+
37
+ ```
38
+ [loop tick #7 · scheduled by /loop, not typed by the user]
39
+ check the deploy and tell me what happened
40
+ ```
41
+
42
+ ## How it works
43
+
44
+ One mechanism: an **in-session timer** (`index.ts`). Each tick is a single
45
+ `pi.sendMessage({ customType: "loop-tick", content, display: true,
46
+ details }, { deliverAs: "steer", triggerTurn: true })` call — steer
47
+ injects into a busy agent and wakes an idle one in the same call, so there
48
+ is no idle check to race. Catch-up: on `agent_settled`, if a tick's fire
49
+ time passed while the agent was busy, it fires immediately (at most one
50
+ catch-up tick, no backlog). The timer dies with the pi process, the same
51
+ lifetime a Claude Code in-session cron has. (An earlier version also
52
+ spawned an external RPC runner; it was removed — old state entries
53
+ carrying a `runnerPid` are still read, the field is ignored.)
54
+
55
+ ### State & resume
56
+
57
+ Loop state persists as a `loop` custom entry in the session JSONL
58
+ (`pi.appendEntry`). On `/resume`, `session_start` re-arms the timer — CC's
59
+ "restored on `--resume`" semantics. `/loop stop` appends a `stopped: true`
60
+ entry. (Entries written by earlier versions under the old entry type, or
61
+ carrying a legacy `runnerPid`, are still read.)
62
+
63
+ ### Safety & limits
64
+
65
+ - One active loop per session (`/loop` while armed → stop first).
66
+ - Session-scoped by lifetime (delivery is a single in-process call — no
67
+ child processes, nothing to orphan). No wall-clock expiry.
68
+
69
+ ## The shepherd skill (a consumer of /loop)
70
+
71
+ The bundled `skills/shepherd/` skill (installed at
72
+ `~/.pi/agent/skills/shepherd`) is the pi port of the Claude Code shepherd
73
+ plugin: it pins `/loop` to a fixed 10-minute cadence with the
74
+ shepherd-role prompt (watch, act, unblock, understand problems). It's a
75
+ separate thing from the loop — the loop is generic; the skill just builds
76
+ a specific prompt and hands it to `/loop`.
77
+
78
+ ```
79
+ /skill:shepherd <goal to shepherd>
80
+ ```
81
+
82
+ ## Differences vs Claude Code `/loop`
83
+
84
+ | | Claude Code | pi /loop |
85
+ |---|---|---|
86
+ | Scheduler | built-in `CronCreate` (in-process, session-scoped) | in-session timer, single steer delivery |
87
+ | Survives model stall | yes (cron fires between turns) | yes (steer injects into a busy turn) |
88
+ | Survives restart | no (restored on `--resume`) | no (re-armed on `/resume` via session entry) |
89
+ | Interval | cron granularity, 1m–7d | any ≥15s, no expiry |
90
+ | Dynamic interval | yes (model picks delay) | no — fixed interval only |
91
+ | Jitter | yes (deterministic offset) | no |
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ cd custom-extensions/loop
97
+ npm install
98
+ npm run typecheck
99
+ npm test # domain + dashboard contracts + real pi E2E (~30s)
100
+ ```
101
+
102
+ Install as a pi package (`~/.pi/agent/settings.json` → `packages`):
103
+
104
+ ```json
105
+ "/Users/maheidem/Documents/dev/pi-coder-management/custom-extensions/loop"
106
+ ```
107
+
108
+ then `/reload` in pi. The skill is in `skills/shepherd/SKILL.md` (source of
109
+ truth) and installed to `~/.pi/agent/skills/shepherd/` (copy after edits).
110
+
111
+ ## Files
112
+
113
+ - `index.ts` — the extension: `/loop` commands, timer, steer delivery,
114
+ catch-up, tick renderer
115
+ - `state.ts` — pure helpers: interval parsing, tick provenance line, state
116
+ - `ui/loop-panel.ts` — Pi-free dashboard view model
117
+ - `ui/settings-panel.ts` — vendored workbench panel interaction primitive
118
+ - `skills/shepherd/SKILL.md` — the shepherd skill (a consumer of /loop)
package/index.ts ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * loop — generic /loop for pi.
3
+ *
4
+ * Recurring scheduled prompts, modeled on Claude Code's /loop (CronCreate).
5
+ * The extension knows nothing about what the prompt says: whatever you give
6
+ * it fires verbatim every interval. Role/specialization (skills that use
7
+ * /loop) are consumers of the loop, not part of it.
8
+ *
9
+ * One mechanism: an in-session timer that fires each tick as a single
10
+ * `pi.sendMessage` call with `deliverAs: "steer", triggerTurn: true` — steer
11
+ * injects-when-busy and wakes-when-idle in one call, so there is no idle
12
+ * check to race. Dies with the session, same lifetime as a CC in-session
13
+ * cron. (An earlier external runner was removed; state entries carrying a
14
+ * `runnerPid` are still read, the field is ignored.)
15
+ *
16
+ * State persists as a `loop` custom entry in the session, so /resume
17
+ * re-arms the timer. (Legacy entry types from earlier versions are still
18
+ * read for compatibility.)
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { Text } from "@earendil-works/pi-tui";
23
+ import { Type } from "typebox";
24
+ import {
25
+ CUSTOM_TYPE,
26
+ DEFAULT_INTERVAL_MS,
27
+ formatInterval,
28
+ loopTickMessage,
29
+ parseInterval,
30
+ readLoopState,
31
+ type LoopState,
32
+ } from "./state.ts";
33
+ import { buildLoopPanelSnapshot, formatCountdown } from "./ui/loop-panel.ts";
34
+ import { SettingsPanel, type PanelResult } from "./ui/settings-panel.ts";
35
+
36
+ const TAG = "[loop]";
37
+ const TICK_CUSTOM_TYPE = "loop-tick";
38
+
39
+ interface Runtime {
40
+ timer: NodeJS.Timeout | null;
41
+ nextFireAt: number;
42
+ state: LoopState | null;
43
+ }
44
+
45
+ function say(ctx: ExtensionContext, msg: string, level: "info" | "warning" | "error" = "info"): void {
46
+ if (ctx.hasUI) ctx.ui.notify(msg, level);
47
+ else console.log(msg);
48
+ }
49
+
50
+ export default function (pi: ExtensionAPI) {
51
+ const rt: Runtime = { timer: null, nextFireAt: 0, state: null };
52
+
53
+ /** Parse `/loop` args. Returns { intervalMs, prompt } or an error string. */
54
+ function parseArgs(args: string): { intervalMs: number; prompt: string } | string {
55
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
56
+ if (tokens.length === 0) {
57
+ return "usage: /loop <interval> <prompt...> (e.g. /loop 5m check the build)";
58
+ }
59
+ let intervalMs = NaN;
60
+ let rest = tokens;
61
+ const first = tokens[0];
62
+ if (/^\d/.test(first)) {
63
+ intervalMs = parseInterval(first);
64
+ if (Number.isNaN(intervalMs)) {
65
+ return `invalid interval "${first}" — use e.g. 30s, 5m, 2h, 1d`;
66
+ }
67
+ rest = tokens.slice(1);
68
+ } else {
69
+ // Trailing-clause form: "check the build every 5m"
70
+ const m = /(?:^|\s)every\s+(\d+(?:\.\d+)?[smhd])(?=\s|$)/i.exec(args);
71
+ if (m) intervalMs = parseInterval(m[1]);
72
+ }
73
+ if (Number.isNaN(intervalMs)) intervalMs = DEFAULT_INTERVAL_MS;
74
+ const prompt = rest.join(" ").trim();
75
+ if (!prompt) {
76
+ return "no prompt given — /loop <interval> <prompt...>";
77
+ }
78
+ return { intervalMs, prompt };
79
+ }
80
+
81
+ function loopStatusText(): string {
82
+ if (!rt.state) return `${TAG} no active loop. Start one from /loop or use /loop 5m <prompt>.`;
83
+ const state = rt.state;
84
+ return [
85
+ `${TAG} active`,
86
+ `interval: ${formatInterval(state.intervalMs)}`,
87
+ `next tick: ${formatCountdown(rt.nextFireAt - Date.now())}`,
88
+ `ticks sent: ${state.tickCount}`,
89
+ `delivery: in-session steer`,
90
+ `prompt: ${state.prompt}`,
91
+ ].join("\n");
92
+ }
93
+
94
+ function panelView() {
95
+ const state = rt.state;
96
+ return {
97
+ active: Boolean(state),
98
+ interval: state ? formatInterval(state.intervalMs) : formatInterval(DEFAULT_INTERVAL_MS),
99
+ nextIn: state ? formatCountdown(rt.nextFireAt - Date.now()) : "—",
100
+ tickCount: state?.tickCount ?? 0,
101
+ delivery: state ? "in-session steer" : "—",
102
+ prompt: state?.prompt ?? "",
103
+ };
104
+ }
105
+
106
+ function fireTick(ctx: ExtensionContext) {
107
+ if (!rt.state) return;
108
+ const s = rt.state;
109
+ s.tickCount += 1;
110
+ s.lastTickAt = Date.now();
111
+ // ADVANCE the schedule on every fire. Without this, `due` in
112
+ // scheduleNext() goes negative after the first interval and the timer
113
+ // collapses into a 1-second re-fire storm.
114
+ rt.nextFireAt = Date.now() + s.intervalMs;
115
+ pi.appendEntry(CUSTOM_TYPE, { ...s });
116
+ say(ctx, `${TAG} tick #${s.tickCount} — sent (steer)`, "info");
117
+ // The prompt fires verbatim. The one-line meta prefix
118
+ // (loopTickMessage) only carries provenance — that this is a
119
+ // scheduled tick, not something the user typed — so the model
120
+ // doesn't misread the repetition as a fresh intent. What the tick does
121
+ // is entirely the prompt's own business.
122
+ const content = loopTickMessage(s.prompt, s.tickCount);
123
+ // ONE delivery call: steer injects when busy and triggers a turn when
124
+ // idle, so there is no isIdle() check to race against a submission.
125
+ pi.sendMessage(
126
+ {
127
+ customType: TICK_CUSTOM_TYPE,
128
+ content,
129
+ display: true,
130
+ details: { tickCount: s.tickCount, intervalMs: s.intervalMs, scheduledAt: Date.now() },
131
+ },
132
+ { deliverAs: "steer", triggerTurn: true },
133
+ );
134
+ scheduleNext(ctx);
135
+ }
136
+
137
+ function scheduleNext(ctx: ExtensionContext) {
138
+ if (rt.timer) clearTimeout(rt.timer);
139
+ rt.timer = null;
140
+ if (!rt.state) return;
141
+ const due = rt.nextFireAt - Date.now();
142
+ rt.timer = setTimeout(() => fireTick(ctx), Math.max(1000, due));
143
+ rt.timer.unref?.();
144
+ }
145
+
146
+ function stopLoop(ctx: ExtensionContext, reason: string, announce = true) {
147
+ if (rt.timer) clearTimeout(rt.timer);
148
+ rt.timer = null;
149
+ const s = rt.state;
150
+ if (s) {
151
+ pi.appendEntry(CUSTOM_TYPE, { ...s, stopped: true });
152
+ }
153
+ rt.state = null;
154
+ if (announce) say(ctx, `${TAG} loop stopped (${reason})`, "info");
155
+ }
156
+
157
+ function armLoop(ctx: ExtensionContext, intervalMs: number, prompt: string) {
158
+ const state: LoopState = {
159
+ intervalMs,
160
+ prompt,
161
+ createdAt: Date.now(),
162
+ lastTickAt: 0,
163
+ tickCount: 0,
164
+ };
165
+ rt.state = state;
166
+ rt.nextFireAt = Date.now() + intervalMs;
167
+ pi.appendEntry(CUSTOM_TYPE, { ...state });
168
+ scheduleNext(ctx);
169
+
170
+ say(
171
+ ctx,
172
+ `${TAG} armed: every ${formatInterval(intervalMs)} — "${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}"`,
173
+ "info",
174
+ );
175
+ say(ctx, `${TAG} in-session steer active. Stop with /loop stop.`, "info");
176
+ }
177
+
178
+ function restoreFromSession(ctx: ExtensionContext) {
179
+ const entries = ctx.sessionManager.getEntries();
180
+ const s = readLoopState(entries);
181
+ if (!s) return;
182
+ rt.state = s;
183
+ rt.nextFireAt = Date.now() + s.intervalMs; // re-arm: next tick one interval from now
184
+ say(ctx, `${TAG} restored (every ${formatInterval(s.intervalMs)}, tick #${s.tickCount}) — re-arming`, "info");
185
+ scheduleNext(ctx);
186
+ }
187
+
188
+ async function editSchedule(ctx: ExtensionCommandContext, editing: boolean): Promise<boolean> {
189
+ const existing = editing ? rt.state : null;
190
+ let intervalMs: number | undefined;
191
+ let proposed = existing ? formatInterval(existing.intervalMs) : formatInterval(DEFAULT_INTERVAL_MS);
192
+ while (intervalMs === undefined) {
193
+ const raw = await ctx.ui.input("Loop interval", proposed);
194
+ if (raw === undefined) return false;
195
+ const parsed = parseInterval(raw.trim());
196
+ if (Number.isNaN(parsed)) {
197
+ say(ctx, "Interval must look like 30s, 10m, 2h, or 1d.", "error");
198
+ proposed = raw;
199
+ continue;
200
+ }
201
+ intervalMs = parsed;
202
+ }
203
+
204
+ const editedPrompt = await ctx.ui.editor("Recurring prompt", existing?.prompt ?? "");
205
+ if (editedPrompt === undefined) return false;
206
+ const prompt = editedPrompt.trim();
207
+ if (!prompt) {
208
+ say(ctx, "Recurring prompt cannot be empty.", "error");
209
+ return false;
210
+ }
211
+
212
+ if (editing && rt.state) stopLoop(ctx, "schedule replaced", false);
213
+ armLoop(ctx, intervalMs, prompt);
214
+ return true;
215
+ }
216
+
217
+ async function openLoopPanel(ctx: ExtensionCommandContext): Promise<void> {
218
+ if (ctx.mode !== "tui") {
219
+ say(ctx, loopStatusText());
220
+ return;
221
+ }
222
+
223
+ let initialKey = rt.state ? "edit" : "start";
224
+ for (;;) {
225
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
226
+ let result: PanelResult | undefined;
227
+ try {
228
+ result = await ctx.ui.custom<PanelResult>(
229
+ (tui, theme, keybindings, done) => {
230
+ const panel = new SettingsPanel({
231
+ theme,
232
+ keybindings,
233
+ initialKey,
234
+ snapshot: () => buildLoopPanelSnapshot(panelView()),
235
+ apply: () => "This row is read-only",
236
+ activate: (key) => ["start", "edit", "stop"].includes(key)
237
+ ? { kind: "close", action: key }
238
+ : { kind: "error", message: `Unknown loop action: ${key}` },
239
+ requestRender: () => tui.requestRender(),
240
+ done,
241
+ });
242
+ refreshTimer = setInterval(() => {
243
+ panel.refresh();
244
+ tui.requestRender();
245
+ }, 1_000);
246
+ return panel;
247
+ },
248
+ {
249
+ overlay: true,
250
+ overlayOptions: {
251
+ anchor: "center",
252
+ width: 76,
253
+ minWidth: 44,
254
+ maxHeight: "90%",
255
+ margin: 1,
256
+ },
257
+ },
258
+ );
259
+ } finally {
260
+ if (refreshTimer) clearInterval(refreshTimer);
261
+ }
262
+
263
+ if (!result?.action) return;
264
+ if (result.action === "start" || result.action === "edit") {
265
+ await editSchedule(ctx, result.action === "edit");
266
+ initialKey = rt.state ? "edit" : "start";
267
+ continue;
268
+ }
269
+ if (result.action === "stop") {
270
+ const confirmed = await ctx.ui.confirm("Stop recurring loop?", "No further scheduled ticks will be delivered.");
271
+ if (confirmed && rt.state) stopLoop(ctx, "stopped from control panel");
272
+ initialKey = rt.state ? "edit" : "start";
273
+ }
274
+ }
275
+ }
276
+
277
+ // LR2: ticks render as collapsible cards — collapsed shows the tick
278
+ // number + the prompt's first line (after the provenance header),
279
+ // expanded shows the full content byte-for-byte.
280
+ pi.registerMessageRenderer(TICK_CUSTOM_TYPE, (message, options) => {
281
+ const d = (message.details ?? {}) as { tickCount?: number };
282
+ const raw = message.content;
283
+ const text =
284
+ typeof raw === "string" ? raw
285
+ : Array.isArray(raw) ? raw.map((b) => (b as { text?: string })?.text ?? "").join("\n")
286
+ : "";
287
+ const firstLine = text.split("\n").slice(1).find((l) => l.trim().length > 0) ?? "";
288
+ const header = `↻ loop tick #${d.tickCount ?? "?"}${firstLine ? ` · ${firstLine}` : ""}`;
289
+ return new Text(options.expanded ? `${header}\n${text}` : header, options.outputPad, 0);
290
+ });
291
+
292
+ pi.on("session_start", async (_event, ctx) => {
293
+ restoreFromSession(ctx);
294
+ });
295
+
296
+ pi.on("agent_settled", async (_event, ctx) => {
297
+ // Catch-up: if a tick's fire time passed while we were busy, fire now
298
+ // (no backlog — at most one catch-up tick). Same single steer call;
299
+ // no isIdle gate — steer handles busy and idle alike.
300
+ if (rt.state && rt.nextFireAt <= Date.now()) {
301
+ fireTick(ctx);
302
+ }
303
+ });
304
+
305
+ pi.on("session_shutdown", async () => {
306
+ if (rt.timer) clearTimeout(rt.timer);
307
+ rt.timer = null;
308
+ });
309
+
310
+ pi.registerCommand("loop", {
311
+ description: "Open recurring-loop controls or schedule with /loop <interval> <prompt...>",
312
+ getArgumentCompletions: (prefix) => {
313
+ const values = ["status", "stop", "30s ", "5m ", "10m ", "1h "];
314
+ const matches = values.filter((value) => value.startsWith(prefix));
315
+ return matches.length ? matches.map((value) => ({ value, label: value })) : null;
316
+ },
317
+ handler: async (args, ctx) => {
318
+ const a = (args ?? "").trim();
319
+ if (!a) {
320
+ await openLoopPanel(ctx);
321
+ return;
322
+ }
323
+ if (a === "status") {
324
+ say(ctx, loopStatusText());
325
+ return;
326
+ }
327
+ if (a === "stop") {
328
+ stopLoop(ctx, "user requested");
329
+ return;
330
+ }
331
+ const parsed = parseArgs(a);
332
+ if (typeof parsed === "string") {
333
+ say(ctx, `${TAG} ${parsed}`, "error");
334
+ return;
335
+ }
336
+ if (rt.state) {
337
+ say(ctx, `${TAG} a loop is already active — stop it first with /loop stop`, "warning");
338
+ return;
339
+ }
340
+ armLoop(ctx, parsed.intervalMs, parsed.prompt);
341
+ },
342
+ });
343
+
344
+ // The model cannot type slash commands — this tool is how the LLM arms
345
+ // or stops a loop (e.g. the shepherd skill). Same code path as /loop.
346
+ pi.registerTool({
347
+ name: "loop",
348
+ label: "Loop",
349
+ description:
350
+ "Arm or stop a recurring /loop in this session: re-runs a prompt verbatim on a fixed interval via an in-session steer delivery (one call — wakes an idle agent, injects into a busy one). Use it to start unattended periodic work (e.g. the shepherd skill) or to stop it. action: start (needs interval + prompt), status, stop.",
351
+ parameters: Type.Object({
352
+ action: Type.Union([
353
+ Type.Literal("start"),
354
+ Type.Literal("status"),
355
+ Type.Literal("stop"),
356
+ ]),
357
+ interval: Type.Optional(Type.String({ description: "e.g. 30s, 5m, 2h, 1d. Default 10m, min 15s." })),
358
+ prompt: Type.Optional(Type.String({ description: "The prompt to fire each tick, verbatim. Required for action=start." })),
359
+ }),
360
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
361
+ if (params.action === "start") {
362
+ const parsed = parseArgs(`${params.interval ?? ""} ${params.prompt ?? ""}`.trim());
363
+ if (typeof parsed === "string") {
364
+ return { content: [{ type: "text" as const, text: `Cannot start loop: ${parsed}` }], details: {} };
365
+ }
366
+ if (rt.state) {
367
+ return { content: [{ type: "text" as const, text: "A loop is already active. Stop it first (action: stop)." }], details: {} };
368
+ }
369
+ armLoop(ctx, parsed.intervalMs, parsed.prompt);
370
+ return {
371
+ content: [{
372
+ type: "text" as const,
373
+ text: `Loop armed: every ${formatInterval(parsed.intervalMs)}, prompt "${parsed.prompt}". It re-fires in this session via steer delivery. Stop with action "stop".`,
374
+ }],
375
+ details: {},
376
+ };
377
+ }
378
+ if (params.action === "stop") {
379
+ if (!rt.state) {
380
+ return { content: [{ type: "text" as const, text: "No active loop." }], details: {} };
381
+ }
382
+ stopLoop(ctx, "stopped via loop tool");
383
+ return { content: [{ type: "text" as const, text: "Loop stopped." }], details: {} };
384
+ }
385
+ return { content: [{ type: "text" as const, text: loopStatusText() }], details: {} };
386
+ },
387
+ });
388
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@maheidem/pi-loop",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Pi extension: /loop \u2014 a generic recurring-prompt loop (Claude Code /loop style) delivered as in-session steer custom messages. Skills (e.g. shepherd) are consumers of /loop, not part of it.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "extension",
9
+ "loop",
10
+ "cron",
11
+ "recurring"
12
+ ],
13
+ "files": [
14
+ "index.ts",
15
+ "state.ts",
16
+ "ui/settings-panel.ts",
17
+ "ui/loop-panel.ts",
18
+ "skills/",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc -p tsconfig.json",
24
+ "test:unit": "tsx --test *.test.ts ui/*.test.ts",
25
+ "test:e2e": "node e2e.test.mjs",
26
+ "test": "npm run test:unit && npm run test:e2e",
27
+ "prepack": "npm run typecheck && npm test"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-coding-agent": ">=0.84.0",
31
+ "typebox": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "^0.84.4",
35
+ "@earendil-works/pi-tui": "^0.84.4",
36
+ "@types/node": "^22.0.0",
37
+ "tsx": "^4.20.0",
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./index.ts"
43
+ ]
44
+ },
45
+ "license": "MIT",
46
+ "author": "maheidem",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/Maheidem/pi-coder-management.git",
53
+ "directory": "custom-extensions/loop"
54
+ },
55
+ "homepage": "https://github.com/Maheidem/pi-coder-management/tree/main/custom-extensions/loop#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/Maheidem/pi-coder-management/issues"
58
+ }
59
+ }
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: shepherd
3
+ description: Start a recurring shepherd loop over a goal using the loop tool — it re-injects the checkpoint prompt every interval (wakes an idle session, steers a busy one), so the work keeps running without the user babysitting it. Use when the user says "shepherd this", wants unattended goal watching, or asks for a recurring watch/act/unblock loop.
4
+ ---
5
+
6
+ # Shepherd
7
+
8
+ You are the **shepherd** of a goal: a recurring loop that watches, acts,
9
+ unblocks, and understands problems so the goal keeps running unattended —
10
+ instead of the user babysitting it turn by turn.
11
+
12
+ This skill is the pi port of the Claude Code `shepherd` plugin: same role,
13
+ same fixed 10-minute cadence, built on the `loop` extension (the generic
14
+ `/loop` mechanism).
15
+
16
+ ## How it works
17
+
18
+ The `loop` extension provides a `loop` **tool** (same mechanism behind the
19
+ user's `/loop` command): an in-session timer delivers each tick with a single
20
+ steer call — it wakes the session when idle and is absorbed at your next
21
+ model call when you are busy. Each tick arrives as your prompt verbatim with
22
+ a one-line `[loop tick #N · scheduled by /loop, not typed by the user]`
23
+ provenance header, rendered as a compact ↻ card.
24
+
25
+ ## Usage
26
+
27
+ 1. If no goal was given (the `User:` args below are empty), ask the user
28
+ what goal to shepherd, then continue.
29
+
30
+ 2. Start the loop by **calling the `loop` tool** (do NOT type `/loop` —
31
+ you cannot; the tool is your way in). Fixed **10-minute** interval, do
32
+ not self-pace. Arguments:
33
+
34
+ - `action`: `start`
35
+ - `interval`: `10m`
36
+ - `prompt`: the shepherd prompt below, with `<GOAL>` replaced by the
37
+ goal verbatim:
38
+
39
+ ```
40
+ You will be the shepherd of this goal: <GOAL>. You need to keep a close watch. You need to take action. You need to take agency. You need to unblock the execution. Understand problems. And act as someone truly useful to make sure this thing keeps on running. If work is incomplete or stuck, continue it now. If everything is done and verified, say so in one line and tell the user to run /loop stop.
41
+ ```
42
+
43
+ The tool confirms the arm (it also emits a `[loop] armed` notice in
44
+ the session). Then continue working on the goal — do not wait for
45
+ ticks.
46
+
47
+ 3. On every tick you will receive your prompt (with the loop provenance
48
+ header). When you do:
49
+ - Review what has happened since the last tick (transcript, files, tests).
50
+ - If work is incomplete or stuck: **continue it now**. Take action.
51
+ - Diagnose drift, breakage, or stalls; fix what you can.
52
+ - If everything is done and verified: say so in one line, and tell the
53
+ user they can stop the loop (`/loop stop`), or stop it yourself via
54
+ the `loop` tool (`action: stop`).
55
+
56
+ ## Doc-keeping variant
57
+
58
+ If the user asks for the docs variant (or the goal is long-running enough
59
+ that documentation drift is a real risk), use this prompt instead — it adds
60
+ the doc-keeping duty from the Claude Code `shepherd-docs` command:
61
+
62
+ ```
63
+ You will be the shepherd of this goal: <GOAL>. You need to keep a close watch. You need to take action. You need to take agency. You need to unblock the execution. Understand problems. And act as someone truly useful to make sure this thing keeps on running. Also, make sure you take notes and update any documentation that needs updating as we go. For anything—roadmap, issues, to-do, discoveries—all the project documentation, make sure you keep that up to date. If everything is done and verified, say so in one line and tell the user to run /loop stop.
64
+ ```
65
+
66
+ ## Rules
67
+
68
+ - Use the fixed **10-minute** interval — do not self-pace.
69
+ - Do not start new initiatives outside the goal's scope on a tick.
70
+ - If the `loop` tool is not in your tool list, the loop extension is not
71
+ loaded in this session — tell the user to `/reload` in the session and
72
+ retry; do not try to arm the loop any other way.
73
+ - The loop is session-scoped: it lives while this session exists (the
74
+ external runner self-exits when the session stops advancing). Re-run
75
+ this skill in a later session to keep watching.
package/state.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * loop — state helpers.
3
+ *
4
+ * Pure, pi-free logic: interval parsing, the tick provenance line, and
5
+ * reading the loop state out of session entries. Kept dependency-free so it
6
+ * can be unit-tested with `node --test` without loading the extension
7
+ * runtime.
8
+ */
9
+
10
+ export const CUSTOM_TYPE = "loop";
11
+ /** Earlier versions used a different entry type; still read for compat. */
12
+ export const LEGACY_CUSTOM_TYPES = ["shepherd-loop"];
13
+ const KNOWN_TYPES: string[] = [CUSTOM_TYPE, ...LEGACY_CUSTOM_TYPES];
14
+
15
+ export const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
16
+ export const MIN_INTERVAL_MS = 15 * 1000;
17
+
18
+ /** Parse an interval token like `30s`, `5m`, `2h`, `1d` into milliseconds. */
19
+ export function parseInterval(token: string | undefined, fallback = DEFAULT_INTERVAL_MS): number {
20
+ if (!token) return fallback;
21
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$/i.exec(token);
22
+ if (!m) return NaN;
23
+ const n = parseFloat(m[1]);
24
+ const unit = m[2].toLowerCase();
25
+ const mult = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit]!;
26
+ return Math.max(MIN_INTERVAL_MS, Math.round(n * mult));
27
+ }
28
+
29
+ /** Human-readable form of a millisecond interval (for confirmations). */
30
+ export function formatInterval(ms: number): string {
31
+ if (ms % 86_400_000 === 0) return `${ms / 86_400_000}d`;
32
+ if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`;
33
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
34
+ return `${Math.round(ms / 1000)}s`;
35
+ }
36
+
37
+ /**
38
+ * Build the message injected on each tick: a single provenance meta line +
39
+ * the prompt verbatim. This is meta, NOT role framing: it tells the model
40
+ * the message is a scheduled /loop tick (and which one) so it doesn't
41
+ * misread the repetition as a fresh user intent. What the tick should DO is
42
+ * entirely up to the prompt.
43
+ */
44
+ export function loopTickMessage(prompt: string, tickNumber: number): string {
45
+ return `[loop tick #${tickNumber} · scheduled by /loop, not typed by the user]\n${prompt}`;
46
+ }
47
+
48
+ export interface LoopState {
49
+ /** interval in ms */
50
+ intervalMs: number;
51
+ /** the user's prompt, verbatim */
52
+ prompt: string;
53
+ /** when this loop was armed (epoch ms) */
54
+ createdAt: number;
55
+ /** last tick fired (epoch ms), 0 if none yet */
56
+ lastTickAt: number;
57
+ /** number of ticks fired so far */
58
+ tickCount: number;
59
+ /** legacy field from the removed external runner (≤0.6). Read-compat only: ignored on restore, never written. */
60
+ runnerPid?: number;
61
+ }
62
+
63
+ export const isLoopState = (v: unknown): v is LoopState => {
64
+ if (!v || typeof v !== "object") return false;
65
+ const o = v as Record<string, unknown>;
66
+ return (
67
+ typeof o.intervalMs === "number" &&
68
+ typeof o.prompt === "string" &&
69
+ typeof o.createdAt === "number"
70
+ );
71
+ };
72
+
73
+ /**
74
+ * Extract the most recent active loop state from session entries.
75
+ * Entries are append-ordered; an entry with `data.stopped === true` (or
76
+ * missing data) closes the loop. Accepts the current entry type plus the
77
+ * legacy one (read-only compat, see LEGACY_CUSTOM_TYPES).
78
+ */
79
+ export function readLoopState(entries: Array<{ type?: string; customType?: string; data?: unknown }>): LoopState | null {
80
+ let active: LoopState | null = null;
81
+ for (const e of entries) {
82
+ if (e.type !== "custom" || !KNOWN_TYPES.includes(e.customType ?? "")) continue;
83
+ const d = e.data as (LoopState & { stopped?: boolean }) | undefined;
84
+ if (!d || d.stopped) {
85
+ active = null;
86
+ } else if (isLoopState(d)) {
87
+ active = d;
88
+ }
89
+ }
90
+ return active;
91
+ }
@@ -0,0 +1,87 @@
1
+ import type { PanelSnapshot } from "./settings-panel.ts";
2
+
3
+ export interface LoopPanelView {
4
+ active: boolean;
5
+ interval: string;
6
+ nextIn: string;
7
+ tickCount: number;
8
+ delivery: string;
9
+ prompt: string;
10
+ }
11
+
12
+ export function formatCountdown(milliseconds: number): string {
13
+ const seconds = Math.max(0, Math.ceil(milliseconds / 1000));
14
+ if (seconds < 60) return `${seconds}s`;
15
+ const minutes = Math.floor(seconds / 60);
16
+ const remainder = seconds % 60;
17
+ if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`;
18
+ const hours = Math.floor(minutes / 60);
19
+ const minuteRemainder = minutes % 60;
20
+ return minuteRemainder ? `${hours}h ${minuteRemainder}m` : `${hours}h`;
21
+ }
22
+
23
+ function promptPreview(prompt: string): string {
24
+ const oneLine = prompt.replace(/\s*\n\s*/g, " ↵ ").replace(/\s+/g, " ").trim();
25
+ if (!oneLine) return "(none)";
26
+ return oneLine.length > 240 ? `${oneLine.slice(0, 239)}…` : oneLine;
27
+ }
28
+
29
+ export function buildLoopPanelSnapshot(view: LoopPanelView): PanelSnapshot {
30
+ const statusRows: PanelSnapshot["sections"][number]["rows"] = [
31
+ {
32
+ key: "state",
33
+ label: "State",
34
+ value: view.active ? "active" : "inactive",
35
+ valueStyle: view.active ? "success" : "muted",
36
+ kind: "info",
37
+ },
38
+ ];
39
+
40
+ if (view.active) {
41
+ statusRows.push(
42
+ { key: "interval", label: "Interval", value: view.interval, valueStyle: "text", kind: "info" },
43
+ { key: "next", label: "Next tick", value: view.nextIn, valueStyle: "accent", kind: "info" },
44
+ { key: "ticks", label: "Ticks sent", value: String(view.tickCount), valueStyle: "text", kind: "info" },
45
+ { key: "delivery", label: "Delivery", value: view.delivery, valueStyle: "muted", kind: "info" },
46
+ );
47
+ }
48
+
49
+ return {
50
+ title: "Recurring Loop",
51
+ summaryLines: [
52
+ view.active
53
+ ? `A prompt is scheduled every ${view.interval}; next tick in ${view.nextIn}.`
54
+ : "No recurring prompt is armed for this session.",
55
+ ],
56
+ sections: [
57
+ { title: "Status", rows: statusRows },
58
+ ...(view.active
59
+ ? [{
60
+ title: "Prompt",
61
+ rows: [{ key: "prompt", label: "Recurring prompt", value: promptPreview(view.prompt), valueStyle: "muted" as const, kind: "info" as const }],
62
+ }]
63
+ : []),
64
+ {
65
+ title: "Actions",
66
+ rows: view.active
67
+ ? [
68
+ { key: "edit", label: "Edit schedule", value: "open…", kind: "action" },
69
+ { key: "stop", label: "Stop loop", value: "confirm…", valueStyle: "warning", kind: "action" },
70
+ ]
71
+ : [
72
+ { key: "start", label: "Start a loop", value: "open…", kind: "action" },
73
+ ],
74
+ },
75
+ ],
76
+ detailLines: ["Ticks are session-scoped and stop with the session (in-session steer delivery)."],
77
+ idleMessage: "Use nested /loop arguments for scripts",
78
+ shortcuts: view.active
79
+ ? [
80
+ { key: "e", label: "edit", action: "edit" },
81
+ { key: "x", label: "stop", action: "stop" },
82
+ ]
83
+ : [
84
+ { key: "s", label: "start", action: "start" },
85
+ ],
86
+ };
87
+ }
@@ -0,0 +1,393 @@
1
+ /** pi-extension-builder SettingsPanel v0.1.0 — canonical source and vendored primitive. */
2
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ Input,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ type Component,
9
+ type Focusable,
10
+ type KeyId,
11
+ } from "@earendil-works/pi-tui";
12
+
13
+ export type PanelRowKind = "toggle" | "input" | "cycle" | "action" | "info";
14
+ export type PanelValueStyle = "accent" | "success" | "warning" | "error" | "muted" | "text";
15
+
16
+ export interface PanelRow {
17
+ key: string;
18
+ label: string;
19
+ value: string;
20
+ kind: PanelRowKind;
21
+ rawValue?: string;
22
+ choices?: string[];
23
+ inputHint?: string;
24
+ valueStyle?: PanelValueStyle;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ export interface PanelSection {
29
+ title: string;
30
+ rows: PanelRow[];
31
+ }
32
+
33
+ export interface PanelShortcut {
34
+ key: KeyId;
35
+ label: string;
36
+ action: string;
37
+ }
38
+
39
+ export interface PanelSnapshot {
40
+ title: string;
41
+ summaryLines?: string[];
42
+ sections: PanelSection[];
43
+ detailLines?: string[];
44
+ idleMessage?: string;
45
+ shortcuts?: PanelShortcut[];
46
+ }
47
+
48
+ export type PanelActionResult =
49
+ | { kind: "updated"; message?: string }
50
+ | { kind: "close"; action: string }
51
+ | { kind: "error"; message: string }
52
+ | { kind: "none" };
53
+
54
+ export interface PanelResult {
55
+ action?: string;
56
+ }
57
+
58
+ export interface SettingsPanelHost {
59
+ theme: Theme;
60
+ keybindings: KeybindingsManager;
61
+ initialKey?: string;
62
+ snapshot(): PanelSnapshot;
63
+ /** Apply a canonical setting value. Return an error string or null. */
64
+ apply(key: string, rawValue: string): string | null;
65
+ activate(key: string): PanelActionResult | void;
66
+ requestRender(): void;
67
+ done(result: PanelResult): void;
68
+ }
69
+
70
+ type FlashKind = "error" | "success" | "info";
71
+ interface Flash {
72
+ kind: FlashKind;
73
+ text: string;
74
+ }
75
+
76
+ /**
77
+ * Reusable, synchronous control panel.
78
+ *
79
+ * Business logic stays in the host. The panel owns only navigation, editing,
80
+ * rendering, and transient feedback. Async or destructive actions should
81
+ * close with an action result and be handled by the command adapter.
82
+ */
83
+ export class SettingsPanel implements Component, Focusable {
84
+ private readonly host: SettingsPanelHost;
85
+ private snapshotValue: PanelSnapshot;
86
+ private cursor = 0;
87
+ private editingKey: string | null = null;
88
+ private input: Input | null = null;
89
+ private flash: Flash | null = null;
90
+ private _focused = false;
91
+
92
+ constructor(host: SettingsPanelHost) {
93
+ this.host = host;
94
+ this.snapshotValue = host.snapshot();
95
+ if (host.initialKey) this.focusRow(host.initialKey);
96
+ this.clampCursor();
97
+ }
98
+
99
+ get focused(): boolean {
100
+ return this._focused;
101
+ }
102
+
103
+ set focused(value: boolean) {
104
+ this._focused = value;
105
+ if (this.input) this.input.focused = value;
106
+ }
107
+
108
+ invalidate(): void {
109
+ this.input?.invalidate();
110
+ }
111
+
112
+ handleInput(data: string): void {
113
+ if (this.input) {
114
+ this.input.handleInput(data);
115
+ this.host.requestRender();
116
+ return;
117
+ }
118
+
119
+ const kb = this.host.keybindings;
120
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "q")) {
121
+ this.host.done({});
122
+ return;
123
+ }
124
+
125
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "k")) {
126
+ this.move(-1);
127
+ } else if (kb.matches(data, "tui.select.down") || matchesKey(data, "j")) {
128
+ this.move(1);
129
+ } else if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "space")) {
130
+ this.activateCurrent();
131
+ } else {
132
+ const shortcut = this.snapshotValue.shortcuts?.find((candidate) => matchesKey(data, candidate.key));
133
+ if (!shortcut) return;
134
+ this.runAction(shortcut.action, shortcut.label);
135
+ }
136
+
137
+ this.host.requestRender();
138
+ }
139
+
140
+ render(width: number): string[] {
141
+ const t = this.host.theme;
142
+ const lines: string[] = [this.topBorder(width, this.snapshotValue.title)];
143
+
144
+ for (const line of this.snapshotValue.summaryLines ?? []) {
145
+ lines.push(this.boxLine(t.fg("muted", ` ${line}`), width));
146
+ }
147
+
148
+ const selectedKey = this.selectableRows()[this.cursor]?.key;
149
+ for (const section of this.snapshotValue.sections) {
150
+ lines.push(this.boxLine(t.fg("accent", t.bold(` ${section.title}`)), width));
151
+ for (const row of section.rows) {
152
+ lines.push(this.renderRow(row, row.key === selectedKey, width));
153
+ }
154
+ }
155
+
156
+ for (const line of this.snapshotValue.detailLines ?? []) {
157
+ lines.push(this.boxLine(t.fg("dim", ` ${line}`), width));
158
+ }
159
+
160
+ lines.push(this.boxLine(this.renderMessageLine(), width));
161
+ const shortcutLine = this.renderShortcutLine();
162
+ if (shortcutLine) lines.push(this.boxLine(t.fg("dim", ` ${shortcutLine}`), width));
163
+ lines.push(this.boxLine(t.fg("dim", ` ${this.renderNavigationLine()}`), width));
164
+ lines.push(this.bottomBorder(width));
165
+ return lines;
166
+ }
167
+
168
+ private allRows(): PanelRow[] {
169
+ return this.snapshotValue.sections.flatMap((section) => section.rows);
170
+ }
171
+
172
+ private selectableRows(): PanelRow[] {
173
+ return this.allRows().filter((row) => row.kind !== "info" && !row.disabled);
174
+ }
175
+
176
+ private focusRow(key: string): void {
177
+ const index = this.selectableRows().findIndex((row) => row.key === key);
178
+ if (index >= 0) this.cursor = index;
179
+ }
180
+
181
+ private clampCursor(): void {
182
+ const rows = this.selectableRows();
183
+ this.cursor = Math.max(0, Math.min(this.cursor, Math.max(0, rows.length - 1)));
184
+ }
185
+
186
+ /** Refresh derived rows while preserving the current selection when possible. */
187
+ refresh(preferredKey?: string): void {
188
+ const currentKey = preferredKey ?? this.selectableRows()[this.cursor]?.key;
189
+ this.snapshotValue = this.host.snapshot();
190
+ if (currentKey) this.focusRow(currentKey);
191
+ this.clampCursor();
192
+ }
193
+
194
+ private move(delta: number): void {
195
+ const rows = this.selectableRows();
196
+ if (!rows.length) return;
197
+ this.cursor = (this.cursor + delta + rows.length) % rows.length;
198
+ this.flash = null;
199
+ }
200
+
201
+ private activateCurrent(): void {
202
+ const row = this.selectableRows()[this.cursor];
203
+ if (!row) return;
204
+
205
+ if (row.kind === "input") {
206
+ this.startEdit(row.key, row.rawValue ?? "");
207
+ return;
208
+ }
209
+
210
+ if (row.kind === "toggle") {
211
+ this.applyValue(row, row.rawValue === "true" ? "false" : "true");
212
+ return;
213
+ }
214
+
215
+ if (row.kind === "cycle") {
216
+ const choices = row.choices ?? [];
217
+ if (!choices.length) return;
218
+ const current = Math.max(0, choices.indexOf(row.rawValue ?? ""));
219
+ this.applyValue(row, choices[(current + 1) % choices.length] ?? choices[0] ?? "");
220
+ return;
221
+ }
222
+
223
+ if (row.kind === "action") this.runAction(row.key, row.label);
224
+ }
225
+
226
+ private applyValue(row: PanelRow, value: string): void {
227
+ const error = this.host.apply(row.key, value);
228
+ if (error) {
229
+ this.flash = { kind: "error", text: error };
230
+ return;
231
+ }
232
+
233
+ this.refresh(row.key);
234
+ const fresh = this.allRows().find((candidate) => candidate.key === row.key);
235
+ this.flash = { kind: "success", text: `${row.label}: ${fresh?.value ?? value}` };
236
+ }
237
+
238
+ private runAction(key: string, label: string): void {
239
+ const result = this.host.activate(key) ?? { kind: "none" as const };
240
+ if (result.kind === "close") {
241
+ this.host.done({ action: result.action });
242
+ return;
243
+ }
244
+ if (result.kind === "error") {
245
+ this.flash = { kind: "error", text: result.message };
246
+ return;
247
+ }
248
+ if (result.kind === "updated") {
249
+ this.refresh(key);
250
+ this.flash = { kind: "success", text: result.message ?? `${label} updated` };
251
+ return;
252
+ }
253
+ this.flash = { kind: "info", text: label };
254
+ }
255
+
256
+ private startEdit(key: string, initialValue: string): void {
257
+ this.editingKey = key;
258
+ this.input = new Input();
259
+ this.input.focused = this._focused;
260
+ this.input.setValue(initialValue);
261
+ // A fresh Input keeps its cursor at 0 after setValue(); move to End so a
262
+ // prefilled setting edits naturally.
263
+ this.input.handleInput("\x1b[F");
264
+ this.flash = null;
265
+
266
+ this.input.onSubmit = (value) => {
267
+ const row = this.allRows().find((candidate) => candidate.key === key);
268
+ if (!row) {
269
+ this.cancelEdit();
270
+ return;
271
+ }
272
+ const canonical = value.trim();
273
+ const error = this.host.apply(key, canonical);
274
+ if (error) {
275
+ this.flash = { kind: "error", text: error };
276
+ this.host.requestRender();
277
+ return;
278
+ }
279
+ this.input = null;
280
+ this.editingKey = null;
281
+ this.refresh(key);
282
+ const fresh = this.allRows().find((candidate) => candidate.key === key);
283
+ this.flash = { kind: "success", text: `${row.label}: ${fresh?.value ?? canonical}` };
284
+ this.host.requestRender();
285
+ };
286
+
287
+ this.input.onEscape = () => {
288
+ this.cancelEdit();
289
+ this.host.requestRender();
290
+ };
291
+ }
292
+
293
+ private cancelEdit(): void {
294
+ this.input = null;
295
+ this.editingKey = null;
296
+ this.flash = { kind: "info", text: "Edit cancelled" };
297
+ }
298
+
299
+ private renderRow(row: PanelRow, selected: boolean, width: number): string {
300
+ const t = this.host.theme;
301
+ const innerWidth = Math.max(1, width - 2);
302
+ const selectable = row.kind !== "info" && !row.disabled;
303
+ const prefix = selected ? t.fg("accent", " › ") : " ";
304
+ const labelColor = row.disabled ? "dim" : selected ? "accent" : row.kind === "info" ? "muted" : "text";
305
+ const label = t.fg(labelColor, row.label);
306
+
307
+ if (this.editingKey === row.key && this.input) {
308
+ const left = `${prefix}${label}: `;
309
+ const available = Math.max(1, innerWidth - visibleWidth(left) - 1);
310
+ this.input.focused = this._focused;
311
+ const inputLine = this.input.render(available)[0] ?? "";
312
+ return this.boxLine(`${left}${inputLine}`, width);
313
+ }
314
+
315
+ const value = t.fg(this.valueColor(row), row.value);
316
+ const left = `${selectable ? prefix : " "}${label}`;
317
+ const gap = Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(value) - 2);
318
+ return this.boxLine(`${left}${" ".repeat(gap)}${value} `, width);
319
+ }
320
+
321
+ private renderMessageLine(): string {
322
+ const t = this.host.theme;
323
+ if (this.flash) {
324
+ const color = this.flash.kind === "error" ? "error" : this.flash.kind === "success" ? "success" : "muted";
325
+ return t.fg(color, ` ${this.flash.text}`);
326
+ }
327
+ if (this.editingKey) {
328
+ const row = this.allRows().find((candidate) => candidate.key === this.editingKey);
329
+ return t.fg("muted", ` ${row?.inputHint ?? "Enter saves · Esc cancels"}`);
330
+ }
331
+ return t.fg("dim", ` ${this.snapshotValue.idleMessage ?? "Changes save immediately"}`);
332
+ }
333
+
334
+ private renderShortcutLine(): string | null {
335
+ const shortcuts = this.snapshotValue.shortcuts ?? [];
336
+ if (!shortcuts.length) return null;
337
+ return shortcuts.map((shortcut) => `${shortcut.key} ${shortcut.label}`).join(" · ");
338
+ }
339
+
340
+ private renderNavigationLine(): string {
341
+ const kb = this.host.keybindings;
342
+ const up = this.bindingText(kb.getKeys("tui.select.up"), "↑");
343
+ const down = this.bindingText(kb.getKeys("tui.select.down"), "↓");
344
+ const confirm = this.bindingText(kb.getKeys("tui.select.confirm"), "enter");
345
+ const cancel = this.bindingText(kb.getKeys("tui.select.cancel"), "esc");
346
+ return `${up}/${down}/jk move · ${confirm} select · ${cancel}/q close`;
347
+ }
348
+
349
+ private bindingText(keys: readonly string[], fallback: string): string {
350
+ const first = keys[0];
351
+ if (!first) return fallback;
352
+ return first
353
+ .replace(/^up$/, "↑")
354
+ .replace(/^down$/, "↓")
355
+ .replace(/^left$/, "←")
356
+ .replace(/^right$/, "→")
357
+ .replace(/^escape$/, "esc")
358
+ .replace(/^return$/, "enter");
359
+ }
360
+
361
+ private valueColor(row: PanelRow): Parameters<Theme["fg"]>[0] {
362
+ if (row.valueStyle) return row.valueStyle;
363
+ if (row.disabled) return "dim";
364
+ if (row.kind === "toggle") return row.rawValue === "true" ? "success" : "muted";
365
+ if (row.kind === "action") return "accent";
366
+ return "text";
367
+ }
368
+
369
+ private boxLine(content: string, width: number): string {
370
+ const t = this.host.theme;
371
+ if (width <= 1) return truncateToWidth(content, Math.max(1, width), "", true);
372
+ const innerWidth = Math.max(0, width - 2);
373
+ const clipped = truncateToWidth(content, innerWidth, "…", true);
374
+ const padded = clipped + " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
375
+ return t.fg("border", "│") + padded + t.fg("border", "│");
376
+ }
377
+
378
+ private topBorder(width: number, title: string): string {
379
+ const t = this.host.theme;
380
+ if (width <= 1) return t.fg("borderAccent", "─".repeat(Math.max(1, width)));
381
+ const innerWidth = Math.max(0, width - 2);
382
+ const styledTitle = t.fg("accent", t.bold(` ${title} `));
383
+ const clippedTitle = truncateToWidth(styledTitle, innerWidth, "", false);
384
+ const tail = "─".repeat(Math.max(0, innerWidth - visibleWidth(clippedTitle)));
385
+ return t.fg("border", "╭") + clippedTitle + t.fg("border", `${tail}╮`);
386
+ }
387
+
388
+ private bottomBorder(width: number): string {
389
+ const t = this.host.theme;
390
+ if (width <= 1) return t.fg("border", "─".repeat(Math.max(1, width)));
391
+ return t.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
392
+ }
393
+ }