fisi-dock 0.0.0-stage → 0.3.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.
@@ -0,0 +1,1031 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { ciLive, gitPushTool, readCi, type CiRuns } from "../lib/ci.ts";
6
+ import { dockStatePath, readDockEnabled, readDockTheme, readDockThemeApplied, writeDockEnabled } from "../lib/dock-state.ts";
7
+ import { headerPath, renderHeaderLine, renderNarrowHeader } from "../lib/header.ts";
8
+ import { readHeadsUpGit, readOddPlans } from "../lib/heads-up.ts";
9
+ import {
10
+ agentsFromJobs,
11
+ projectName,
12
+ sessionCost,
13
+ sessionTokens,
14
+ sessionUsage,
15
+ todoFromProgress,
16
+ todoFromState,
17
+ todoStateFromEntries,
18
+ todoStateFromToolEnd,
19
+ type LiveParts,
20
+ } from "../lib/live-data.ts";
21
+ import { createLiveStore, createSerialRefresh, createSpinnerClock, readGit, type LiveStore } from "../lib/live-store.ts";
22
+ import { installLoadedBox } from "../lib/loaded-box.ts";
23
+ import { MCP_GRACE_MS, mcpConfigPaths, mcpServerStates, readMcpServerNames } from "../lib/mcp.ts";
24
+ import { memoryCallEnd, memoryInfo, NO_MEMORY_CALLS } from "../lib/memory.ts";
25
+ import { readSessionProfile } from "../lib/profile.ts";
26
+ import { createOddTracker, type OddTracker } from "../lib/odd-timeline.ts";
27
+ import { readOrbMotion } from "../lib/orbs.ts";
28
+ import { renderAgentsCard, renderDock, renderPanel, renderTodoCard } from "../lib/panel.ts";
29
+ import { estimateSessionCost, registryPrices, type ModelFinder } from "../lib/pricing.ts";
30
+ import { createWaveClock, type WaveClock } from "../lib/label-wave.ts";
31
+ import { createPromptBox, type EditorHost, type PromptBoxInstall } from "../lib/prompt-box.ts";
32
+ import { renderPromptLine } from "../lib/prompt-line.ts";
33
+ import { reduceReviews, reviewSnapshot, withTrail, type RddRow } from "../lib/rdd.ts";
34
+ import { createReviewModeReader, gentleAiBinary, REVIEW_MODE_TIMEOUT_MS } from "../lib/rdd-mode.ts";
35
+ import { trailEnd, trailStart, type ReviewTrail } from "../lib/rdd-detail.ts";
36
+ import { createSlotTakeover, EMPTIED_PARTS, emptyPart, railOwnsHost, SIDEBAR_STATE } from "../lib/slot.ts";
37
+ import { renderStrip } from "../lib/strip.ts";
38
+ import { agentHomeDir, createSubagentTracker, NOTIFICATION_SOURCE_EVENT, readPresenceTasks } from "../lib/subagents.ts";
39
+ import { frameUserLines, installUserFrame, USER_FRAME_HOOK } from "../lib/user-frame.ts";
40
+ import { renderWelcomeCard, shellVersion } from "../lib/welcome-card.ts";
41
+
42
+ const WIDGET_KEY = "fisi-dock";
43
+ const PROMPT_LINE_KEY = "fisi-dock-prompt-line";
44
+ const STRIP_KEY = "fisi-dock-strip";
45
+ const STATUS_SLOT = "footer";
46
+ /** Gentle Shell's startup banner command: its presence says the rose banner is there to replace. */
47
+ const GENTLE_BANNER_COMMAND = "gentle:banner";
48
+ /** The welcome card takes the startup header back at most this many times per session (S1). */
49
+ const WELCOME_RECLAIMS = 5;
50
+ /** A header call Pi dropped is tried again this often, this many times (S1). */
51
+ const WELCOME_RETRY_MS = 50;
52
+ const WELCOME_TRIES = 40;
53
+ /** Card renders that look for Pi's loaded-resources container before giving up for the session. */
54
+ const LOADED_BOX_TRIES = 20;
55
+ const HEADER_SLOT = "header";
56
+ /** Pi's theme while Fisi Dock is on (themes/Fisi-Dock.json) (S95). */
57
+ const FISI_DOCK_THEME = "Fisi-Dock";
58
+ // Off puts back the theme in use before /fisi-dock on (S7); without one, or when it
59
+ // is gone, Gentle Shell's own theme: gentle-pi's DEFAULT_THEME_NAME
60
+ // (scripts/install-tui-mode-setting.mjs, themes/Gentleman-Cute.json).
61
+ const GENTLE_SHELL_THEME = "Gentleman-Cute";
62
+ const OFF_NOTICE = "Fisi Dock is off · /fisi-dock on turns it back on";
63
+ const USAGE_NOTICE = "Usage: /fisi-dock on | off (alone: pick on or off)";
64
+ // Gentle Shell's permission mode (S84): its lib/yolo-session-policy.ts answers
65
+ // this pi.events request with an adapter (readSnapshot/readMode, observe,
66
+ // dispose), the same discovery its own /gentle:customize uses; nothing is
67
+ // imported. The mode is `normal`, `auto`, `yolo` or `super-yolo`.
68
+ const HOST_UI_CHANNEL = "gentle:yolo:host-ui";
69
+
70
+ // Gentle Shell's pi.events channels (lib/shell-todo.ts, lib/jobs-sidebar-state.ts,
71
+ // lib/review-sidebar-state.ts), restated so nothing is imported from it.
72
+ const TODO_PROGRESS_EVENT = "gentle-pi:todo:progress";
73
+ const JOBS_SIDEBAR_EVENT = "gentle-ai:jobs-sidebar";
74
+ const REVIEW_SIDEBAR_EVENT = "gentle-ai:review-sidebar";
75
+ const GIT_TIMEOUT_MS = 3_000;
76
+ /** Idle sessions re-read git, plans and MCP state this often (S34a). */
77
+ const IDLE_REFRESH_MS = 10_000;
78
+ /** gh talks to GitHub, so it gets more time than git. */
79
+ const GH_TIMEOUT_MS = 15_000;
80
+ /** CI (S64): gh is read this often while a check runs or waits, and every CI_IDLE_MS otherwise. */
81
+ const CI_LIVE_MS = 30_000;
82
+ const CI_IDLE_MS = 180_000;
83
+ /** Reads after a bash `git push` ends: the runs take a moment to show up in gh. */
84
+ const CI_PUSH_DELAYS_MS = [5_000, 20_000];
85
+ /** Bash calls whose start said `git push`, by toolCallId, kept until they end; at the cap the oldest gives way (S66). */
86
+ const PUSH_CALLS_CAP = 32;
87
+
88
+ type Host = { terminal?: unknown; requestRender(): void };
89
+ type Keys = { matches(data: string, keybinding: string): boolean };
90
+ /** Gentle Shell's YoloUiAdapter, the members Fisi Dock uses, restated. */
91
+ type ModeAdapter = {
92
+ readSnapshot?(): Promise<{ mode?: unknown } | undefined>;
93
+ readMode?(): Promise<unknown>;
94
+ observe(refresh: () => void): () => void;
95
+ dispose(): void;
96
+ };
97
+
98
+ function isModeAdapter(value: unknown): value is ModeAdapter {
99
+ if (!value || typeof value !== "object") return false;
100
+ const adapter = value as Partial<ModeAdapter>;
101
+ return typeof adapter.observe === "function" && typeof adapter.dispose === "function" && (typeof adapter.readSnapshot === "function" || typeof adapter.readMode === "function");
102
+ }
103
+
104
+ /** Rows the overlay may use when the terminal does not say (36: the phone pane, L51). */
105
+ const DOCK_FALLBACK_ROWS = 36;
106
+ /** Screen rows above the rail panel that are not its own: Gentle Shell's top row and the gap under it. */
107
+ const RAIL_TOP_ROWS = 2;
108
+
109
+ /** The terminal height in rows, or undefined when the terminal does not say. */
110
+ function terminalRows(tui: Host): number | undefined {
111
+ const value = Math.floor(Number((tui.terminal as { rows?: unknown } | undefined)?.rows));
112
+ return Number.isFinite(value) && value > 0 ? value : undefined;
113
+ }
114
+
115
+ /**
116
+ * The /dock overlay: the live panel, scrolled with the selection keys when it
117
+ * is taller than the screen; Esc (Pi's cancel binding) closes it.
118
+ */
119
+ function dockOverlay(session: LiveSession, tui: Host, keys: Keys, done: () => void) {
120
+ let offset = 0;
121
+ const rows = () => terminalRows(tui) ?? DOCK_FALLBACK_ROWS;
122
+ const is = (data: string, keybinding: string) => attempt(() => keys.matches(data, keybinding)) === true;
123
+ const scroll = (step: number) => {
124
+ offset = Math.max(0, offset + step);
125
+ };
126
+ return {
127
+ render: (width: number) => {
128
+ const view = renderDock(session.store.model(), width, rows(), offset);
129
+ offset = view.offset;
130
+ return view.lines;
131
+ },
132
+ handleInput: (data: string) => {
133
+ if (is(data, "tui.select.cancel") || data === "\x1b") return done();
134
+ const page = Math.max(1, rows() - 2);
135
+ const step = is(data, "tui.select.up") ? -1 : is(data, "tui.select.down") ? 1 : is(data, "tui.select.pageUp") ? -page : is(data, "tui.select.pageDown") ? page : 0;
136
+ if (step === 0) return;
137
+ scroll(step);
138
+ tui.requestRender();
139
+ },
140
+ // The wheel, or a finger swipe on a phone terminal, scrolls the panel (S59).
141
+ // Every other pointer event stops here, so a tap never reaches the
142
+ // conversation behind the dock.
143
+ handleMouse: (event: { type?: string; wheelDelta?: number }) => {
144
+ if (event.type !== "wheel" || !event.wheelDelta) return { handled: true, render: false };
145
+ scroll(Math.trunc(event.wheelDelta));
146
+ return { handled: true, render: true };
147
+ },
148
+ invalidate() {},
149
+ };
150
+ }
151
+
152
+ const LOADED_MARKER = Symbol.for("fisi-dock");
153
+
154
+ function setLoaded(loaded: boolean): void {
155
+ (globalThis as Record<symbol, unknown>)[LOADED_MARKER] = loaded;
156
+ }
157
+
158
+ function attempt<T>(read: () => T): T | undefined {
159
+ try {
160
+ return read();
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ interface LiveSession {
167
+ store: LiveStore;
168
+ /** The session working directory, or "" when unreadable. */
169
+ cwd: string;
170
+ /** ODD phase timeline of the current request. */
171
+ odd: OddTracker;
172
+ wave: WaveClock;
173
+ setHost(host: Host | undefined): void;
174
+ /** Patch Pi's user message class once one is on screen (S45); repaints once when it does. */
175
+ frameUsers(): void;
176
+ /** Pi's user message, boxed only while Gentle Shell's sidebar state is on the host (S45). */
177
+ frameUser(render: (width: number) => string[], width: number): string[];
178
+ /** Context usage, cost, tokens and the ≈ estimate: cheap reads of Pi state. */
179
+ readPi(): void;
180
+ /** The todo snapshot replayed from the session branch. */
181
+ readTodo(): void;
182
+ readGit(): void;
183
+ /** Unfinished plans in the project's odd/tasks (heads-up). */
184
+ readPlans(): void;
185
+ /** MCP server names from Pi's mcp.json files, then their state (S32); none configured when there is no config. */
186
+ readMcp(): void;
187
+ /** Asks Gentle Shell for its permission-mode adapter once, then re-reads the mode (S84). */
188
+ readMode(): void;
189
+ /** Reads the RDD switch with `gentle-ai review mode status` (S85); one call at a time, a failure keeps the last known mode. */
190
+ readRddMode(): void;
191
+ /** Seeds the unfinished subagent tasks from Gentle Shell's presence activity file once (S87, after a reload). */
192
+ readSubagents(): void;
193
+ /** Gentle Shell's live permission mode, undefined until known. */
194
+ permissionMode(): string | undefined;
195
+ /** A run settled (true) or a new request started (false): todo-left warning. */
196
+ setSettled(settled: boolean): void;
197
+ /** A tool started: the review trail (S62). */
198
+ toolStart(event: { toolName?: string; toolCallId?: string; args?: unknown }): void;
199
+ toolEnd(event: { toolName?: string; toolCallId?: string; args?: unknown; isError?: boolean; result?: unknown }): void;
200
+ dispose(): void;
201
+ }
202
+
203
+ /**
204
+ * Live data for one session. Every source is optional: a failing read leaves
205
+ * its field out. Reads happen on triggers, never per frame; a change repaints.
206
+ */
207
+ function startLive(pi: ExtensionAPI, ctx: ExtensionContext): LiveSession {
208
+ const sessionId = attempt(() => ctx.sessionManager.getSessionId()) ?? "";
209
+ const cwd = attempt(() => ctx.sessionManager.getCwd()) ?? attempt(() => ctx.cwd) ?? "";
210
+ const store = createLiveStore(projectName(cwd), Date.now, { motion: () => readOrbMotion(process.env, homedir()) });
211
+ const startedAt = Date.now();
212
+ let host: Host | undefined;
213
+ let disposed = false;
214
+ let reviews: RddRow[] = [];
215
+ // What the current review is doing (S62), from the review tools the agent
216
+ // calls; consumed once its row finishes, so the next review starts clean.
217
+ let trail: ReviewTrail | undefined;
218
+ const followTrail = (next: ReviewTrail | undefined) => {
219
+ if (next === trail) return;
220
+ trail = next;
221
+ const rows = withTrail(reviews, trail);
222
+ if (rows === reviews) return;
223
+ reviews = rows;
224
+ apply({ reviews });
225
+ };
226
+ const apply = (patch: Partial<LiveParts>) => {
227
+ if (disposed) return;
228
+ const wasRunning = store.runActive();
229
+ // A turn starting or ending repaints even when the drawn model stays, so the strip follows it.
230
+ if (store.update(patch) || store.runActive() !== wasRunning) attempt(() => host?.requestRender());
231
+ // A run starting or settling may leave the drawn model as it was and still
232
+ // start or stop the todo spinner (L61), so this runs on every patch.
233
+ spinner.sync(store.spinning(), store.entering());
234
+ };
235
+ // Running spinners (S31): a wall-clock timer only while one is drawn; each
236
+ // new frame changes the store digest, so the rail memo and the strip repaint.
237
+ // A running or waiting review keeps it going too, so its m:ss ticks (S50),
238
+ // and a row's entrance runs it at the entrance step until it ends (S54).
239
+ const spinner = createSpinnerClock({
240
+ now: Date.now,
241
+ motion: () => readOrbMotion(process.env, homedir()),
242
+ onFrame: (frame) => apply({ spinner: frame }),
243
+ });
244
+ // The prompt label's color wave (S90): by the clock, only while Gentle Shell scans its label.
245
+ const wave = createWaveClock({
246
+ now: Date.now,
247
+ motion: () => readOrbMotion(process.env, homedir()),
248
+ requestRender: () => attempt(() => host?.requestRender()),
249
+ });
250
+ const on = (channel: string, handler: (data: unknown) => void) =>
251
+ attempt(() => pi.events.on(channel, (data) => void attempt(() => handler(data))));
252
+ // Agents (S35, S87): Gentle Shell's background jobs from its jobs snapshot,
253
+ // then this session's subagent tasks, tracked from its status events, the
254
+ // subagent_run/subagent_continue calls and results, and the presence file at
255
+ // start. Finished tasks fold after the jobs' tail, on the next event, tool
256
+ // end, turn end or idle tick; no timer of their own.
257
+ const subagents = createSubagentTracker(sessionId, Date.now);
258
+ let jobs: LiveParts["agents"];
259
+ /** The ODD tracker's hook, set once it exists: a step held past agent_end closes when no subagent runs (S91). */
260
+ let agentsChanged = () => {};
261
+ const subagentsBusy = () => subagents.agents(Date.now()).some((task) => task.state === "running" || task.state === "queued" || task.state === "waiting");
262
+ const syncAgents = () => {
263
+ const tasks = subagents.agents(Date.now());
264
+ apply({ agents: jobs === undefined && tasks.length === 0 ? undefined : [...(jobs ?? []), ...tasks] });
265
+ agentsChanged();
266
+ };
267
+ const pruneAgents = () => {
268
+ if (subagents.prune(Date.now())) syncAgents();
269
+ };
270
+ const unsubscribe = [
271
+ on(TODO_PROGRESS_EVENT, (data) => apply({ todo: todoFromProgress(data, sessionId, store.model().todo) })),
272
+ on(JOBS_SIDEBAR_EVENT, (data) => {
273
+ const agents = agentsFromJobs(data, sessionId);
274
+ if (agents === undefined) return;
275
+ jobs = agents;
276
+ syncAgents();
277
+ }),
278
+ on(NOTIFICATION_SOURCE_EVENT, (data) => {
279
+ if (subagents.status(data)) syncAgents();
280
+ }),
281
+ // One RDD row per review of this session (S50).
282
+ on(REVIEW_SIDEBAR_EVENT, (data) => {
283
+ const snapshot = reviewSnapshot(data, sessionId);
284
+ if (!snapshot) return;
285
+ const next = reduceReviews(reviews, snapshot, Date.now(), trail);
286
+ if (next === reviews) return;
287
+ reviews = next;
288
+ if (next.at(-1)?.endedAt !== undefined) trail = undefined;
289
+ apply({ reviews });
290
+ }),
291
+ ];
292
+ // Each orb-frame tick only rebuilds this small view; the store digest
293
+ // changes when the shown orb frame or m:ss changes, and only then is a
294
+ // repaint requested. Gentle Shell's animation level is read per request.
295
+ const odd = createOddTracker({
296
+ sessionId,
297
+ cwd: cwd || undefined,
298
+ now: Date.now,
299
+ motion: () => readOrbMotion(process.env, homedir()),
300
+ busy: subagentsBusy,
301
+ onChange: (view) => apply({ odd: view }),
302
+ });
303
+ agentsChanged = () => odd.busyChanged();
304
+ // CI (S64): gh for the branch of the last git read, serialized like git. A
305
+ // failed or timed-out read keeps what is shown; a read with no runs, or no
306
+ // branch, removes the section. A known branch different from the last one
307
+ // clears the runs and reads the new branch's; a git read whose branch command
308
+ // failed is no change, so the branch, the runs and their schedule stay (S66).
309
+ let branch: string | undefined;
310
+ let gitRead = false;
311
+ let ciRuns: CiRuns | undefined;
312
+ let ciReadAt = Date.now();
313
+ const ci = createSerialRefresh(async () => {
314
+ if (disposed || !cwd) return;
315
+ const at = branch;
316
+ const read = at === undefined ? null : await readCi((args) => pi.exec("gh", args, { cwd, timeout: GH_TIMEOUT_MS }), at, ciRuns);
317
+ ciReadAt = Date.now();
318
+ if (disposed || read === undefined || branch !== at) return;
319
+ ciRuns = read ?? undefined;
320
+ apply({ ci: ciRuns });
321
+ });
322
+ const git = createSerialRefresh(async () => {
323
+ if (disposed || !cwd) return;
324
+ const exec = (args: string[]) => pi.exec("git", args, { cwd, timeout: GIT_TIMEOUT_MS });
325
+ const session = await readGit(exec);
326
+ apply(session);
327
+ const first = !gitRead;
328
+ const moved = session.branch !== undefined && session.branch !== branch;
329
+ gitRead = true;
330
+ if (moved) branch = session.branch;
331
+ if (moved && ciRuns) {
332
+ ciRuns = undefined;
333
+ apply({ ci: undefined });
334
+ }
335
+ if (first || moved) ci();
336
+ if (!disposed) apply(await readHeadsUpGit(exec, cwd, session.branch));
337
+ });
338
+ // RDD switch (S85): official Gentle Shell publishes no idle mode, so Gentle
339
+ // AI's read-only status command answers, at session start and after each
340
+ // turn, through its own serial reader (never two calls in flight). No
341
+ // binary, a failed, timed-out or unreadable read: the last known mode stays,
342
+ // unknown at first, so the section waits for a review event.
343
+ const rddBinary = attempt(() => gentleAiBinary(process.env, { homedir: attempt(() => homedir()) }));
344
+ const rddMode = createReviewModeReader({
345
+ exec: (command, args) => pi.exec(command, args, { ...(cwd ? { cwd } : {}), timeout: REVIEW_MODE_TIMEOUT_MS }),
346
+ binary: rddBinary,
347
+ cwd,
348
+ onMode: (mode) => {
349
+ if (!disposed) apply({ rdd: mode });
350
+ },
351
+ });
352
+ const ciTimer = setInterval(() => {
353
+ if (disposed || !gitRead) return;
354
+ if (ciLive(store.model().ci) || Date.now() - ciReadAt >= CI_IDLE_MS) ci();
355
+ }, CI_LIVE_MS);
356
+ (ciTimer as { unref?(): void }).unref?.();
357
+ const pushCalls = new Set<string>();
358
+ const pushTimers = new Set<ReturnType<typeof setTimeout>>();
359
+ const afterPush = () => {
360
+ for (const ms of CI_PUSH_DELAYS_MS) {
361
+ const timer = setTimeout(() => {
362
+ pushTimers.delete(timer);
363
+ ci();
364
+ }, ms);
365
+ timer.unref?.();
366
+ pushTimers.add(timer);
367
+ }
368
+ };
369
+ const plans = createSerialRefresh(async () => {
370
+ if (disposed || !cwd) return;
371
+ apply({ plans: await readOddPlans(cwd) });
372
+ });
373
+ // MCP (S32): names from the config files, state from Pi's tool list, which is
374
+ // cheap to read, so every Pi read refreshes it. Pi connects the servers. No
375
+ // config is no server: the muted `mcp none configured` row.
376
+ let mcpNames: string[] | undefined;
377
+ const mcpState = () => {
378
+ if (!mcpNames) return;
379
+ apply({ mcp: mcpServerStates(mcpNames, attempt(() => pi.getAllTools()), Date.now() - startedAt >= MCP_GRACE_MS) });
380
+ };
381
+ const mcp = createSerialRefresh(async () => {
382
+ if (disposed) return;
383
+ mcpNames = (await readMcpServerNames(mcpConfigPaths(cwd, attempt(() => homedir()) ?? "", process.env))) ?? [];
384
+ mcpState();
385
+ });
386
+ // Memory (design session-section/02): Engram is gentle-engram's mem_* tools,
387
+ // not an MCP server. Installed from Pi's tool list; saves and failures from
388
+ // the tool ends Pi sends. "connected" means the tools are loaded and the last
389
+ // mem_* call did not fail: nothing is spawned and no server is asked.
390
+ let memoryCalls = NO_MEMORY_CALLS;
391
+ const memoryState = () => apply({ memory: memoryInfo(attempt(() => pi.getAllTools()), memoryCalls) });
392
+ // User message box (S45): Pi's class is patched as soon as one user message
393
+ // is in the tree (a new session has none until the first prompt), retried on
394
+ // setHost, agent_start, turn_start and message_end. The patch is per class,
395
+ // so once it lands this session stops walking the tree and repaints once to
396
+ // box the messages already on screen.
397
+ let framed = false;
398
+ const frameUsers = () => {
399
+ if (framed || disposed || !host) return;
400
+ const tui = host;
401
+ if (attempt(() => installUserFrame(tui)) !== true) return;
402
+ framed = true;
403
+ attempt(() => tui.requestRender());
404
+ };
405
+ // Agent-model profile (S99): an in-process read of Gentle Shell's registries,
406
+ // with the Pi reads and on the idle refresh, so a /gentle:profiles switch or
407
+ // clear shows within one tick.
408
+ const readProfile = () => apply({ profile: attempt(() => readSessionProfile(sessionId)) });
409
+ // Idle refresh (S34a): with no run active and our panel, strip or header on
410
+ // screen (Gentle Shell's sidebar state on the host terminal), git changes,
411
+ // heads-up git data, plans and the MCP and Engram state are re-read. Through the same serial
412
+ // helpers as the run triggers, so reads never overlap.
413
+ const idle = setInterval(() => {
414
+ if (disposed || store.runActive()) return;
415
+ if (!(host?.terminal as Record<symbol, unknown> | undefined)?.[SIDEBAR_STATE]) return;
416
+ git();
417
+ plans();
418
+ mcpState();
419
+ memoryState();
420
+ readProfile();
421
+ pruneAgents();
422
+ }, IDLE_REFRESH_MS);
423
+ (idle as { unref?(): void }).unref?.();
424
+ // Permission mode (S84): Gentle Shell answers the request synchronously
425
+ // within the emit; a reply after that, or a second one, is disposed unused.
426
+ // The mode is read through the adapter, never guessed; the adapter's
427
+ // observers report a change (/gentle:mode, /gentle:yolo), and each turn end
428
+ // re-reads it too. No answer (no Gentle Shell, an older one): no mode.
429
+ let modeAdapter: ModeAdapter | undefined;
430
+ let unobserveMode: (() => void) | undefined;
431
+ let permissionMode: string | undefined;
432
+ const readModeNow = createSerialRefresh(async () => {
433
+ const adapter = modeAdapter;
434
+ if (!adapter || disposed) return;
435
+ const snapshot = adapter.readSnapshot ? await adapter.readSnapshot() : { mode: await adapter.readMode!() };
436
+ if (disposed || adapter !== modeAdapter) return;
437
+ const next = typeof snapshot?.mode === "string" ? snapshot.mode : undefined;
438
+ if (next === permissionMode) return;
439
+ permissionMode = next;
440
+ attempt(() => host?.requestRender());
441
+ });
442
+ const readMode = () => {
443
+ if (disposed) return;
444
+ if (modeAdapter) return readModeNow();
445
+ const respond = (value: unknown) => {
446
+ if (disposed || modeAdapter || !isModeAdapter(value)) {
447
+ attempt(() => (value as ModeAdapter | undefined)?.dispose?.());
448
+ return;
449
+ }
450
+ modeAdapter = value;
451
+ unobserveMode = attempt(() => value.observe(() => readModeNow()));
452
+ readModeNow();
453
+ };
454
+ attempt(() => (pi.events as { emit?(channel: string, data: unknown): void }).emit?.(HOST_UI_CHANNEL, { context: ctx, respond }));
455
+ };
456
+ return {
457
+ store,
458
+ cwd,
459
+ odd,
460
+ wave,
461
+ setHost: (next) => {
462
+ host = next;
463
+ frameUsers();
464
+ },
465
+ frameUsers,
466
+ // Boxed only with Gentle Shell (its sidebar state on the host terminal), like
467
+ // every other Fisi Dock part; before any widget has rendered there is no
468
+ // host yet, so Pi's own lines show until the first frame.
469
+ frameUser: (render, width) => {
470
+ const terminal = attempt(() => host?.terminal as Record<symbol, unknown> | undefined);
471
+ return terminal?.[SIDEBAR_STATE] ? frameUserLines(render, width) : render(width);
472
+ },
473
+ readPi: () => {
474
+ const entries = attempt(() => ctx.sessionManager.getEntries());
475
+ // Subscriptions (claude-bridge) record tokens at cost 0 (S37): the
476
+ // estimate prices them with Pi's registry (ModelRegistry.find -> Model.cost).
477
+ // SAFETY: Pi's ModelRegistry has find(provider, id) returning a Model with `cost`, the only
478
+ // member ModelFinder uses; registryPrices also tolerates a missing or failing find.
479
+ const prices = registryPrices(attempt(() => ctx.modelRegistry as unknown as ModelFinder));
480
+ apply({
481
+ usage: attempt(() => sessionUsage(ctx.getContextUsage())) ?? {},
482
+ cost: entries === undefined ? undefined : attempt(() => sessionCost(entries)),
483
+ tokens: entries === undefined ? undefined : attempt(() => sessionTokens(entries)),
484
+ estimate: entries === undefined ? undefined : attempt(() => estimateSessionCost(entries, prices)),
485
+ });
486
+ readProfile();
487
+ mcpState();
488
+ memoryState();
489
+ },
490
+ readTodo: () => {
491
+ const manager = ctx.sessionManager as { getBranch?(): unknown[]; getEntries(): unknown[] };
492
+ apply({ todo: attempt(() => todoFromState(todoStateFromEntries(manager.getBranch?.() ?? manager.getEntries()))) });
493
+ },
494
+ readGit: git,
495
+ readPlans: plans,
496
+ readMcp: mcp,
497
+ readMode,
498
+ readRddMode: rddMode,
499
+ // One bounded read of this session's presence activity file (S87): tasks
500
+ // still running when Fisi Dock loaded (a /reload) show from it; events
501
+ // that arrived first win, and a missing or unreadable file changes nothing.
502
+ readSubagents: () => {
503
+ const home = agentHomeDir(process.env, attempt(() => homedir()) ?? "");
504
+ void readPresenceTasks(home, sessionId).then((tasks) => {
505
+ if (!disposed && subagents.seed(tasks)) syncAgents();
506
+ }).catch(() => {});
507
+ },
508
+ permissionMode: () => permissionMode,
509
+ setSettled: (settled) => {
510
+ apply({ runSettled: settled });
511
+ // No tool call is in flight once the run settled: a subagent call still unmatched is stale (S87).
512
+ if (settled && attempt(() => subagents.settle()) === true) syncAgents();
513
+ },
514
+ toolStart: (event) => {
515
+ attempt(() => followTrail(trailStart(trail, event?.toolName ?? "", event?.args)));
516
+ // A subagent_run / subagent_continue: a running row from its start, by its call id, until
517
+ // Gentle Shell names its task (S87); its args name the task reported next.
518
+ const callId = typeof event?.toolCallId === "string" ? event.toolCallId : undefined;
519
+ if (attempt(() => subagents.call(event?.toolName, event?.args, callId)) === true) syncAgents();
520
+ // Pi's tool end has no arguments: a `git push` is remembered by its call id.
521
+ if (typeof event?.toolCallId === "string" && attempt(() => gitPushTool(event.toolName, event.args))) {
522
+ // Bounded, and a new push is never ignored: the oldest id gives way (S66).
523
+ if (pushCalls.size >= PUSH_CALLS_CAP) pushCalls.delete(pushCalls.values().next().value!);
524
+ pushCalls.add(event.toolCallId);
525
+ }
526
+ },
527
+ toolEnd: (event) => {
528
+ const pushed = (typeof event?.toolCallId === "string" && pushCalls.delete(event.toolCallId)) || attempt(() => gitPushTool(event?.toolName, event?.args)) === true;
529
+ if (pushed && !disposed) afterPush();
530
+ const state = attempt(() => todoStateFromToolEnd(event));
531
+ if (state) apply({ todo: todoFromState(state) });
532
+ const calls = attempt(() => memoryCallEnd(memoryCalls, event)) ?? memoryCalls;
533
+ if (calls !== memoryCalls) {
534
+ memoryCalls = calls;
535
+ memoryState();
536
+ }
537
+ attempt(() => followTrail(trailEnd(trail, event?.toolName ?? "", (event?.result as { details?: unknown } | undefined)?.details, event?.isError)));
538
+ // The subagent result names the task id and its agent, label, model and effort; one without a task id ends its call (S87).
539
+ if (attempt(() => subagents.result(event?.toolName, event?.result, event?.isError, typeof event?.toolCallId === "string" ? event.toolCallId : undefined)) === true) syncAgents();
540
+ },
541
+ dispose: () => {
542
+ clearInterval(idle);
543
+ clearInterval(ciTimer);
544
+ for (const timer of pushTimers) clearTimeout(timer);
545
+ pushTimers.clear();
546
+ pushCalls.clear();
547
+ odd.dispose();
548
+ spinner.dispose();
549
+ wave.dispose();
550
+ disposed = true;
551
+ attempt(() => unobserveMode?.());
552
+ attempt(() => modeAdapter?.dispose());
553
+ modeAdapter = undefined;
554
+ host = undefined;
555
+ for (const off of unsubscribe) attempt(() => off?.());
556
+ },
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Fisi Dock: a side panel for Gentle Shell. It replaces the parts in Gentle
562
+ * Shell's fullscreen rail Status slot and top header row, empties its other
563
+ * rail sections and its status bar, and gives the originals back on dispose.
564
+ * Without Gentle Shell (no shared sidebar state) it paints nothing.
565
+ */
566
+ export default function fisiDock(pi: ExtensionAPI): void {
567
+ // `/fisi-dock off` (S95) is remembered in a state file outside the
568
+ // repository; while off Fisi Dock stays dormant and Gentle Shell keeps
569
+ // everything, from the start of each session.
570
+ const statePath = () => dockStatePath(process.env, attempt(() => homedir()) ?? "");
571
+ let enabled = readDockEnabled(statePath());
572
+ // Says Fisi Dock is loaded (S24). Globals survive /reload, so the marker
573
+ // follows the session: a removed (or dormant) Fisi Dock never leaves it set.
574
+ setLoaded(enabled);
575
+ let release: (() => void) | undefined;
576
+ let live: LiveSession | undefined;
577
+ // The prompt box (S88): Gentle Shell's editor factory wrapped while this session runs.
578
+ let promptBox: PromptBoxInstall | undefined;
579
+ // Gentle Shell is loaded when its banner command is registered. A host without
580
+ // getCommands cannot say, and counts as Gentle Shell, as before.
581
+ const gentleShellLoaded = () => {
582
+ const commands = attempt(() => pi.getCommands());
583
+ return !Array.isArray(commands) || commands.some((command) => command?.name === GENTLE_BANNER_COMMAND);
584
+ };
585
+ // Gives Pi's startup header back when the welcome card holds it.
586
+ let dropWelcome: (() => void) | undefined;
587
+
588
+ // The startup header is the welcome card instead of Gentle Shell's rose (S1).
589
+ // Gentle Shell sets its banner a little after session_start, and Pi disposes
590
+ // the header it replaces, so a card disposed by someone else takes the slot
591
+ // back on the next microtask (a few times per session, never a loop). Pi drops
592
+ // a call made before its own header exists, so that one is tried again.
593
+ const installWelcome = (ctx: ExtensionContext, session: LiveSession) => {
594
+ // The card names Gentle Shell and stands in for its banner, so without Gentle
595
+ // Shell Pi keeps its own header.
596
+ if (!gentleShellLoaded()) return () => {};
597
+ const home = agentHomeDir(process.env, attempt(() => homedir()) ?? "");
598
+ const version = shellVersion(attempt(() => readFileSync(join(dirname(home), "config.json"), "utf8")), home);
599
+ let card: object | undefined;
600
+ let reclaims = 0;
601
+ let tries = 0;
602
+ let timer: ReturnType<typeof setTimeout> | undefined;
603
+ // Pi's list of loaded skills, prompts and extensions under the card is the
604
+ // loaded box (design start-screen/02) while the card holds the header: on its
605
+ // first renders the card looks for Pi's container beside it in the TUI tree
606
+ // and wraps that instance's render; Pi's own listing shows otherwise, and
607
+ // comes back when the card goes (off, shutdown).
608
+ let restoreLoaded: (() => void) | undefined;
609
+ let loadedTries = 0;
610
+ const findLoaded = (tui: unknown, component: object) => {
611
+ if (restoreLoaded || loadedTries >= LOADED_BOX_TRIES || live !== session) return;
612
+ loadedTries++;
613
+ restoreLoaded = attempt(() => installLoadedBox(tui, component, { active: () => card !== undefined && live === session }));
614
+ };
615
+ const install = () => {
616
+ timer = undefined;
617
+ if (live !== session) return;
618
+ attempt(() =>
619
+ ctx.ui.setHeader((tui: unknown) => {
620
+ const component = {
621
+ render: (width: number) => {
622
+ findLoaded(tui, component);
623
+ const { project, branch } = session.store.model().session;
624
+ return renderWelcomeCard({ version, project, branch }, width);
625
+ },
626
+ invalidate() {},
627
+ dispose: () => {
628
+ if (card !== component) return;
629
+ card = undefined;
630
+ if (live === session && reclaims++ < WELCOME_RECLAIMS) queueMicrotask(install);
631
+ },
632
+ };
633
+ card = component;
634
+ return component;
635
+ }),
636
+ );
637
+ if (!card && tries++ < WELCOME_TRIES) timer = setTimeout(install, WELCOME_RETRY_MS);
638
+ };
639
+ install();
640
+ return () => {
641
+ if (timer) clearTimeout(timer);
642
+ timer = undefined;
643
+ attempt(() => restoreLoaded?.());
644
+ restoreLoaded = undefined;
645
+ if (!card) return;
646
+ card = undefined;
647
+ attempt(() => ctx.ui.setHeader(undefined));
648
+ };
649
+ };
650
+
651
+ const stop = () => {
652
+ // Only our own hook: after /reload a newer Fisi Dock may already own it.
653
+ const scope = globalThis as Record<symbol, unknown>;
654
+ if (live && scope[USER_FRAME_HOOK] === live.frameUser) delete scope[USER_FRAME_HOOK];
655
+ // Gentle Shell's factory back, only while ours is still the current one.
656
+ attempt(() => promptBox?.restore());
657
+ promptBox = undefined;
658
+ attempt(() => dropWelcome?.());
659
+ dropWelcome = undefined;
660
+ const done = release;
661
+ release = undefined;
662
+ done?.();
663
+ live?.dispose();
664
+ live = undefined;
665
+ };
666
+
667
+ // Narrow screens (S23 B): the full panel over everything, on demand. It reads
668
+ // the same live store as the rail, so it updates while open.
669
+ pi.registerCommand("dock", {
670
+ description: "Show the full Fisi Dock panel over the screen (for narrow screens without the side rail); Esc closes it",
671
+ handler: async (_args, ctx) => {
672
+ if (!enabled) {
673
+ attempt(() => ctx.ui.notify(OFF_NOTICE, "info"));
674
+ return;
675
+ }
676
+ const session = live;
677
+ if (!session || !ctx.hasUI) {
678
+ attempt(() => ctx.ui.notify("Fisi Dock: no live session to show yet", "info"));
679
+ return;
680
+ }
681
+ await ctx.ui.custom<void>(
682
+ (tui, _theme, keybindings, done) => {
683
+ session.setHost(tui as Host);
684
+ // SAFETY: Pi's KeybindingsManager has matches(data, keybinding): boolean, the only member Keys uses.
685
+ return dockOverlay(session, tui as Host, keybindings as unknown as Keys, () => done());
686
+ },
687
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } },
688
+ );
689
+ },
690
+ });
691
+
692
+ // Fisi Dock's whole session: live data, the prompt box and its widgets, which
693
+ // take Gentle Shell's rail parts and top row over. Run at session_start and
694
+ // by `/fisi-dock on`; stop() undoes all of it.
695
+ const start = (ctx: ExtensionContext) => {
696
+ const session = startLive(pi, ctx);
697
+ live = session;
698
+ dropWelcome = installWelcome(ctx, session);
699
+ // Pi's patched user message render asks this hook; without it, Pi's own lines.
700
+ (globalThis as Record<symbol, unknown>)[USER_FRAME_HOOK] = session.frameUser;
701
+ session.readPi();
702
+ session.readTodo();
703
+ session.readGit();
704
+ session.readPlans();
705
+ session.readMcp();
706
+ session.readMode();
707
+ session.readRddMode();
708
+ session.readSubagents();
709
+ // Rows already there now (replayed todo, running jobs) stay still; new ones enter (S54).
710
+ session.store.loaded();
711
+ // Permission mode, model and effort (S84, S88): `SUPER-YOLO · opus-5.5 · high`,
712
+ // the mode only when it is not normal, read per frame.
713
+ const labels = () => {
714
+ const model = ctx.model;
715
+ return { mode: session.permissionMode(), modelId: model?.id, effort: model?.reasoning ? pi.getThinkingLevel() : undefined };
716
+ };
717
+ // The prompt box (S88): with Gentle Shell's marked editor factory installed,
718
+ // a wrapper factory creates its editor and closes its panel-chrome rows into
719
+ // a box with the labels on the top rule, only while Gentle Shell's sidebar
720
+ // state is on the host (wide or narrow). Gentle Shell installs its editor in
721
+ // its own session_start, so when it is not there yet the check runs again
722
+ // on a microtask and at each turn end (which also follows a Gentle Shell
723
+ // reinstall); never a timer. Without Gentle Shell, or with someone else's
724
+ // editor, nothing is installed and the line below stays the fallback.
725
+ const box = createPromptBox(ctx.ui as unknown as EditorHost, {
726
+ painting: (tui) => Boolean(((tui as Host).terminal as Record<symbol, unknown> | undefined)?.[SIDEBAR_STATE]),
727
+ labels,
728
+ waveAt: () => live?.wave.at(),
729
+ onScanning: (scanning) => live?.wave.report(scanning),
730
+ });
731
+ promptBox = box;
732
+ if (!box.ensure()) {
733
+ queueMicrotask(() => {
734
+ if (promptBox === box) box.ensure();
735
+ });
736
+ }
737
+ // The same labels under Gentle Shell's prompt (design 07 input bar, S84):
738
+ // an additive line below the editor, shown only when Gentle Shell's shared
739
+ // sidebar state proves Gentle Shell is loaded, and only while the box above
740
+ // does not already carry them (S88). Gentle Shell's own status bar, which
741
+ // showed the same, is emptied (S84, EMPTIED_PARTS).
742
+ ctx.ui.setWidget(
743
+ PROMPT_LINE_KEY,
744
+ (tui: Host) => ({
745
+ render: (width: number) => {
746
+ const terminal = tui.terminal as Record<symbol, unknown> | undefined;
747
+ // Only with Gentle Shell; neither of our top rows (wide or narrow) shows the model.
748
+ if (live !== session || !terminal?.[SIDEBAR_STATE] || box.boxed()) return [];
749
+ return renderPromptLine(labels(), width);
750
+ },
751
+ invalidate() {},
752
+ }),
753
+ { placement: "belowEditor" },
754
+ );
755
+ // Narrow screens (S23): whenever Gentle Shell is loaded but its rail does not
756
+ // own the screen (narrow fullscreen, regular mode, Status moved), the panel
757
+ // is not visible, so a one or two line strip above the prompt stands in,
758
+ // followed by Fisi Dock's agents card (S35) and todo card (S28). The card
759
+ // headers have the agent count and `d/t`, so the strip leaves those
760
+ // segments out while a card shows (S8). Agent times tick on the spinner's
761
+ // repaints; the second is part of the memo key while agents run.
762
+ ctx.ui.setWidget(
763
+ STRIP_KEY,
764
+ (tui: Host) => {
765
+ session.setHost(tui);
766
+ let drawn: { key: string; lines: string[] } | undefined;
767
+ return {
768
+ render: (width: number) => {
769
+ const terminal = tui.terminal as Record<symbol, unknown> | undefined;
770
+ if (live !== session || !terminal?.[SIDEBAR_STATE] || railOwnsHost(terminal)) return [];
771
+ const model = session.store.model();
772
+ const now = Date.now();
773
+ const running = model.agents?.some((agent) => agent.state === "running") ?? false;
774
+ const key = `${width}:${session.store.digest()}:${running ? Math.floor(now / 1000) : ""}`;
775
+ if (drawn?.key !== key) {
776
+ const agents = renderAgentsCard(model.agents, width, model.spinner, now);
777
+ const todo = renderTodoCard(model.todo, width, model.spinner);
778
+ drawn = { key, lines: [...renderStrip(model, width, { todo: todo.length === 0, agents: agents.length === 0 }), ...agents, ...todo] };
779
+ }
780
+ return drawn.lines;
781
+ },
782
+ invalidate() {},
783
+ };
784
+ },
785
+ { placement: "aboveEditor" },
786
+ );
787
+ ctx.ui.setWidget(WIDGET_KEY, (tui: Host) => {
788
+ // Live content; the panel paints with its own design palette, not Pi
789
+ // theme roles. Frames read the cached digest and reuse the last lines.
790
+ session.setHost(tui);
791
+ let drawn: { key: string; lines: string[] } | undefined;
792
+ // Short screens (S34b, S38): the panel fits the rows under the top row, so the
793
+ // rows are part of the memo key and of the digest Gentle Shell caches on.
794
+ const maxRows = () => {
795
+ const rows = terminalRows(tui);
796
+ return rows === undefined ? undefined : Math.max(0, rows - RAIL_TOP_ROWS);
797
+ };
798
+ const rail = {
799
+ digest: () => `${session.store.digest()}:${maxRows() ?? ""}`,
800
+ render: (width: number) => {
801
+ const rows = maxRows();
802
+ const key = `${width}:${rows ?? ""}:${session.store.digest()}`;
803
+ if (drawn?.key !== key) drawn = { key, lines: renderPanel(session.store.model(), width, { maxRows: rows }) };
804
+ return drawn.lines;
805
+ },
806
+ // Gentle Shell main wraps rail parts in its own card on a transparent surface:
807
+ // it would take our first row as the card title and clip every row two columns
808
+ // short, cutting our right border. Loose rows skip the card and are only
809
+ // clipped to the rail, wider than we draw. The 4.6.0 release never asks.
810
+ cardGroups: (width: number) => ({ groups: [], loose: rail.render(width).map((_, index) => index) }),
811
+ invalidate() {},
812
+ };
813
+ // The top row shows only the path (S4) while our panel is on screen. In
814
+ // narrow fullscreen it is the only status row, so it carries project,
815
+ // branch, context, cost and MCP dots in our style (S33) instead of
816
+ // Gentle Shell's. Without Gentle Shell's sidebar state it stays Gentle Shell's.
817
+ const path = headerPath(session.cwd, attempt(() => homedir()));
818
+ /** Pi TUI's TuiMouseEventResult, passed back untouched (pi-tui types are not a dependency here). */
819
+ type MouseResult = object;
820
+ type HeaderPart = { render?(width: number): string[]; digest?(): string; handleMouse?(event: unknown): MouseResult | undefined };
821
+ const gentleHeader = () => headerSlot.original() as HeaderPart | undefined;
822
+ const mode = () => (railOwnsHost(tui.terminal) ? "wide" : (tui.terminal as Record<symbol, unknown> | undefined)?.[SIDEBAR_STATE] ? "narrow" : "gentle");
823
+ const header = {
824
+ digest: () => {
825
+ const current = mode();
826
+ if (current === "wide") return `fisi:${path}`;
827
+ return current === "narrow" ? `fisi-narrow:${session.store.digest()}` : `gs:${attempt(() => gentleHeader()?.digest?.()) ?? ""}`;
828
+ },
829
+ render: (width: number) => {
830
+ const current = mode();
831
+ if (current === "wide") return renderHeaderLine(path, width);
832
+ return current === "narrow" ? renderNarrowHeader(session.store.model(), width) : (attempt(() => gentleHeader()?.render?.(width)) ?? []);
833
+ },
834
+ invalidate() {},
835
+ // Gentle Shell's header opens /gentle:usage on click; ours have nothing to click.
836
+ handleMouse: (event: unknown) => (mode() === "gentle" ? attempt(() => gentleHeader()?.handleMouse?.(event)) : undefined),
837
+ };
838
+ const headerSlot = createSlotTakeover(HEADER_SLOT, header);
839
+ // Gentle Shell's todo, jobs and agents sections and its status bar are
840
+ // emptied the same way (S83, S84): only the panel shows in the rail, and
841
+ // the line under the prompt is the one place with the mode, model and effort.
842
+ const slots = [createSlotTakeover(STATUS_SLOT, rail), headerSlot, ...EMPTIED_PARTS.map((key) => createSlotTakeover(key, emptyPart()))];
843
+ const reassert = () => {
844
+ if (slots.map((slot) => slot.assert(tui.terminal)).some(Boolean)) tui.requestRender();
845
+ };
846
+ const restore = () => {
847
+ if (slots.map((slot) => slot.restore(tui.terminal)).some(Boolean)) tui.requestRender();
848
+ };
849
+ release = () => {
850
+ restore();
851
+ // A replaced session may already have torn its UI down.
852
+ try {
853
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
854
+ ctx.ui.setWidget(PROMPT_LINE_KEY, undefined);
855
+ ctx.ui.setWidget(STRIP_KEY, undefined);
856
+ } catch {
857
+ // Deliberately ignored: the UI is already gone, and teardown must never throw into Pi.
858
+ }
859
+ };
860
+ reassert();
861
+ // The widget itself paints nothing in its normal position. Its render
862
+ // runs every frame, which is where Fisi Dock notices that Gentle Shell
863
+ // re-registered one of its parts and puts ours back.
864
+ // A frame after this session stopped (off, shutdown) takes nothing back.
865
+ return {
866
+ render: () => {
867
+ if (live === session) reassert();
868
+ return [];
869
+ },
870
+ invalidate() {},
871
+ dispose: restore,
872
+ };
873
+ });
874
+ };
875
+
876
+ // The first start applies Fisi Dock's theme once (T9), also on an install from
877
+ // before this step: the theme in use is remembered first, so off puts it back,
878
+ // and the step counts as done only once Pi took Fisi-Dock, so a theme picked
879
+ // later in /settings stays. A delegated child never touches the user's theme, and
880
+ // a state that cannot be written changes no theme it could not give back.
881
+ const applyThemeOnce = (ctx: ExtensionContext) => {
882
+ const path = statePath();
883
+ // Plain Pi gets no panel, so it keeps its theme; the step stays pending.
884
+ if (process.env.GENTLE_PI_AGENTS_CHILD === "1" || readDockThemeApplied(path) || !gentleShellLoaded()) return;
885
+ if (attempt(() => ctx.ui.getTheme(FISI_DOCK_THEME)) === undefined) return;
886
+ const inUse = attempt(() => ctx.ui.theme.name);
887
+ if (inUse === FISI_DOCK_THEME) return void writeDockEnabled(path, true, readDockTheme(path), true);
888
+ const remembered = typeof inUse === "string" && inUse !== "" ? inUse : readDockTheme(path);
889
+ if (!writeDockEnabled(path, true, remembered)) return;
890
+ if (!attempt(() => ctx.ui.setTheme(FISI_DOCK_THEME))?.success) return;
891
+ writeDockEnabled(path, true, remembered, true);
892
+ const back = remembered ? `your theme ${remembered}` : `theme ${GENTLE_SHELL_THEME}`;
893
+ attempt(() => ctx.ui.notify(`Fisi Dock applied its theme · /fisi-dock off puts ${back} back`, "info"));
894
+ };
895
+
896
+ pi.on("session_start", (_event, ctx) => {
897
+ stop();
898
+ enabled = readDockEnabled(statePath());
899
+ setLoaded(enabled);
900
+ if (!enabled || !ctx.hasUI) return;
901
+ applyThemeOnce(ctx);
902
+ start(ctx);
903
+ });
904
+
905
+ // On/off inside Gentle Shell (S95): Pi cannot unload an extension mid-session,
906
+ // so off is the unload path (stop) plus a dormant flag, and on is the
907
+ // session_start path again. The choice is saved; the theme follows it: on
908
+ // remembers the theme in use (never Fisi-Dock itself) and off puts it back (S7).
909
+ // An on that set Fisi-Dock also counts as the first start's theme step (T9).
910
+ pi.registerCommand("fisi-dock", {
911
+ description: "Turn Fisi Dock on or off (remembered across restarts; off gives Gentle Shell its panel and you your theme back); alone, pick on or off",
912
+ getArgumentCompletions: (prefix) => {
913
+ const items = ["on", "off"].filter((value) => value.startsWith(prefix.trim().toLowerCase()));
914
+ return items.length > 0 ? items.map((value) => ({ value, label: value })) : null;
915
+ },
916
+ handler: async (args, ctx) => {
917
+ const notify = (message: string, type: "info" | "warning" = "info") => void attempt(() => ctx.ui.notify(message, type));
918
+ let choice = args.trim().toLowerCase();
919
+ if (choice === "") {
920
+ // Alone (S98): a picker titled with the current state, the change first,
921
+ // so Enter switches it; Esc changes nothing. Without a picker, the state.
922
+ const status = () => notify(enabled ? "Fisi Dock is on · /fisi-dock off gives Gentle Shell its panel back" : OFF_NOTICE);
923
+ if (!ctx.hasUI) return status();
924
+ let picked: string | undefined;
925
+ try {
926
+ picked = await ctx.ui.select(enabled ? "Fisi Dock is on" : "Fisi Dock is off", enabled ? ["off", "on"] : ["on", "off"]);
927
+ } catch {
928
+ return status();
929
+ }
930
+ if (picked === undefined) return;
931
+ choice = picked;
932
+ }
933
+ if (choice !== "on" && choice !== "off") return notify(USAGE_NOTICE, "warning");
934
+ const next = choice === "on";
935
+ if (next === enabled) return notify(`Fisi Dock is already ${choice}`);
936
+ enabled = next;
937
+ const path = statePath();
938
+ const inUse = attempt(() => ctx.ui.theme.name);
939
+ const remembered = next && typeof inUse === "string" && inUse !== "" && inUse !== FISI_DOCK_THEME ? inUse : readDockTheme(path);
940
+ const applied = readDockThemeApplied(path);
941
+ const saved = writeDockEnabled(path, next, remembered, applied);
942
+ stop();
943
+ setLoaded(next);
944
+ if (next && ctx.hasUI) start(ctx);
945
+ // A widget that never rendered left no release behind: remove ours here too.
946
+ if (!next) for (const key of [WIDGET_KEY, PROMPT_LINE_KEY, STRIP_KEY]) attempt(() => ctx.ui.setWidget(key, undefined));
947
+ // Pi's setter reports { success }; a setter that throws counts as not
948
+ // changed too. A remembered theme Pi no longer knows is not tried, since
949
+ // Pi's setter falls to its system theme on an unknown name.
950
+ const setTheme = (name: string) => attempt(() => ctx.ui.setTheme(name)) ?? { success: false };
951
+ const known = (name: string) => attempt(() => ctx.ui.getTheme(name)) !== undefined;
952
+ let themeName = next ? FISI_DOCK_THEME : (remembered ?? GENTLE_SHELL_THEME);
953
+ let lost: string | undefined;
954
+ let theme = !next && remembered !== undefined && !known(remembered) ? { success: false } : setTheme(themeName);
955
+ if (!next && themeName !== GENTLE_SHELL_THEME && !theme.success) {
956
+ lost = `theme ${themeName} not available${theme.error ? `: ${theme.error}` : ""}`;
957
+ themeName = GENTLE_SHELL_THEME;
958
+ theme = setTheme(themeName);
959
+ }
960
+ if (next && theme.success && !applied && saved) writeDockEnabled(path, next, remembered, true);
961
+ const back = themeName === remembered ? `your theme ${themeName}` : `theme ${themeName}`;
962
+ const notes = [
963
+ next
964
+ ? `Fisi Dock on${theme.success ? ` · theme ${FISI_DOCK_THEME}` : ""}`
965
+ : `Fisi Dock off · Gentle Shell's panel ${theme.success ? `and ${back} are` : "is"} back; /fisi-dock on turns it back on`,
966
+ ...(lost ? [lost] : []),
967
+ ...(theme.success ? [] : [`theme not changed: ${theme.error ?? "unknown theme"}`]),
968
+ ...(saved ? [] : [`not saved to ${path}, so a restart keeps the previous choice`]),
969
+ ];
970
+ notify(notes.join(" · "), saved && !lost && theme.success ? "info" : "warning");
971
+ },
972
+ });
973
+
974
+ // Refresh triggers: Pi state after every turn and model change, git once per
975
+ // turn and run (serialized), the todo snapshot as soon as its tool returns.
976
+ pi.on("turn_end", () => {
977
+ live?.odd.sample();
978
+ live?.readPi();
979
+ live?.readGit();
980
+ live?.readMode();
981
+ live?.readRddMode();
982
+ // Gentle Shell's editor arrived after session_start, or it reinstalled its own (S88).
983
+ attempt(() => promptBox?.ensure());
984
+ });
985
+ pi.on("agent_end", () => {
986
+ live?.odd.runEnd();
987
+ live?.readPi();
988
+ live?.readGit();
989
+ });
990
+
991
+ // ODD timeline (S6): a request runs from agent_start to agent_settled; an
992
+ // agent_start before the previous run settled (retry, queued work) continues
993
+ // the same timeline. Gentle Shell's phase registry is sampled on these
994
+ // events and on the tracker's orb-frame tick while a step is active.
995
+ pi.on("agent_start", () => {
996
+ live?.frameUsers();
997
+ live?.odd.runStart();
998
+ live?.setSettled(false);
999
+ });
1000
+ // Heads-up (S14): open todo tasks count as left over only once the run
1001
+ // settled; plans are rescanned then, since the run may have edited them.
1002
+ pi.on("agent_settled", () => {
1003
+ live?.odd.runSettled();
1004
+ live?.setSettled(true);
1005
+ live?.readPlans();
1006
+ });
1007
+ pi.on("turn_start", () => {
1008
+ live?.frameUsers();
1009
+ live?.odd.sample();
1010
+ });
1011
+ pi.on("message_end", () => live?.frameUsers());
1012
+ pi.on("tool_execution_start", (event) => {
1013
+ live?.odd.toolStart(event);
1014
+ live?.toolStart(event);
1015
+ });
1016
+ pi.on("model_select", () => live?.readPi());
1017
+ pi.on("session_compact", () => live?.readPi());
1018
+ pi.on("session_tree", () => {
1019
+ live?.readPi();
1020
+ live?.readTodo();
1021
+ });
1022
+ pi.on("tool_execution_end", (event) => {
1023
+ live?.odd.sample();
1024
+ live?.toolEnd(event);
1025
+ });
1026
+
1027
+ pi.on("session_shutdown", () => {
1028
+ stop();
1029
+ setLoaded(false);
1030
+ });
1031
+ }