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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +225 -2
- package/docs/reference.md +116 -0
- package/extensions/fisi-dock.ts +1031 -0
- package/lib/ci.ts +208 -0
- package/lib/dock-state.ts +49 -0
- package/lib/entrance.ts +68 -0
- package/lib/header.ts +86 -0
- package/lib/heads-up.ts +223 -0
- package/lib/label-wave.ts +113 -0
- package/lib/live-data.ts +430 -0
- package/lib/live-store.ts +227 -0
- package/lib/loaded-box.ts +315 -0
- package/lib/logo.ts +97 -0
- package/lib/mcp.ts +83 -0
- package/lib/memory.ts +45 -0
- package/lib/odd-detail.ts +243 -0
- package/lib/odd-inference.ts +248 -0
- package/lib/odd-timeline.ts +274 -0
- package/lib/orbs.ts +181 -0
- package/lib/palette.ts +84 -0
- package/lib/panel-model.ts +216 -0
- package/lib/panel.ts +872 -0
- package/lib/pricing.ts +121 -0
- package/lib/profile.ts +39 -0
- package/lib/prompt-box.ts +382 -0
- package/lib/prompt-line.ts +90 -0
- package/lib/rdd-detail.ts +163 -0
- package/lib/rdd-mode.ts +185 -0
- package/lib/rdd.ts +120 -0
- package/lib/slot.ts +85 -0
- package/lib/strip.ts +63 -0
- package/lib/subagents.ts +443 -0
- package/lib/text.ts +108 -0
- package/lib/user-frame.ts +102 -0
- package/lib/welcome-card.ts +91 -0
- package/package.json +63 -4
- package/themes/Fisi-Dock.json +90 -0
|
@@ -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
|
+
}
|