gentle-pi 4.4.0 → 4.5.1
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/README.md +2 -2
- package/bin/gentle-shell.mjs +37 -4
- package/docs/gentle-music.md +28 -4
- package/docs/gentle-shell.md +5 -5
- package/docs/readme-reference.md +61 -46
- package/extensions/gentle-agents.ts +14 -2
- package/extensions/gentle-ai.ts +1677 -1240
- package/extensions/gentle-music.ts +83 -8
- package/extensions/gentle-shell.ts +88 -8
- package/lib/command-palette-catalog.ts +1 -2
- package/lib/gentle-shell-bundled.ts +18 -8
- package/lib/gentle-shell-launcher.ts +2 -0
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/shell-bar.ts +2 -4
- package/lib/shell-sidebar-layout.ts +1 -1
- package/lib/vim-editor-adapter.ts +3 -2
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +18 -8
- package/runtime/gentle-shell-launcher.mjs +2 -0
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/installer-downloads.mjs +24 -0
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +3 -1
- package/tests/bundled-install.test.ts +273 -2
- package/tests/command-palette.test.ts +3 -3
- package/tests/design-canvas-server.test.ts +6 -2
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +1 -1
- package/tests/gentle-agents.test.ts +4 -4
- package/tests/gentle-ai.test.ts +810 -609
- package/tests/gentle-music.test.ts +153 -1
- package/tests/gentle-shell-bundled.test.ts +20 -0
- package/tests/gentle-shell-launcher.test.ts +1 -0
- package/tests/gentle-shell.test.ts +44 -19
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/persona-reply-language.test.ts +42 -0
- package/tests/pi-update.test.ts +5 -0
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/shell-bar.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +17 -0
- package/tests/vim-editor-adapter.test.ts +2 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
2
|
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { delimiter, join } from "node:path";
|
|
4
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type { Component } from "@earendil-works/pi-tui";
|
|
7
|
+
import { runningBundledPrefix } from "../lib/gentle-shell-bundled.ts";
|
|
6
8
|
|
|
7
9
|
// Gentle Music: /gentle:music hands the whole terminal to gentle-stream's own
|
|
8
10
|
// TUI, the way Pi's external editor (ctrl+g) does: stop Pi's TUI, run the
|
|
@@ -12,12 +14,22 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
12
14
|
// `gentle-stream ctl …` with captured output and never take the terminal.
|
|
13
15
|
//
|
|
14
16
|
// Runtime imports stay node-only so the command loads without Pi's packages.
|
|
17
|
+
//
|
|
18
|
+
// A bundled Gentle Shell without any player offers to install one inside its
|
|
19
|
+
// own folder (scripts/bundled-install.mjs installMusicPlayer): the pinned uv,
|
|
20
|
+
// a uv-managed Python 3.13 and gentle-stream, all under the prefix. Other
|
|
21
|
+
// installs keep the manual hint.
|
|
15
22
|
|
|
16
23
|
export const MUSIC_COMMAND_NAME = "gentle:music";
|
|
17
24
|
export const GENTLE_STREAM_SPEC = "gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream";
|
|
18
25
|
export const MUSIC_CONTROL_COMMANDS = ["next", "pause", "play", "stop", "status"] as const;
|
|
19
26
|
export type MusicControlCommand = (typeof MUSIC_CONTROL_COMMANDS)[number];
|
|
20
27
|
|
|
28
|
+
export const MUSIC_INSTALL_TITLE = "Install the music player?";
|
|
29
|
+
export const MUSIC_INSTALL_OFFER = "Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder.";
|
|
30
|
+
// gentle-stream needs POSIX (fcntl locks, an AF_UNIX control socket, process
|
|
31
|
+
// groups), so a native Windows Pi can neither install nor run it; WSL is Linux.
|
|
32
|
+
export const MUSIC_WINDOWS_UNSUPPORTED = "/gentle:music is not available on Windows yet: the gentle-stream player runs on macOS and Linux. It works when Gentle Shell runs inside WSL.";
|
|
21
33
|
const UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/";
|
|
22
34
|
const CTL_TIMEOUT_MS = 30_000;
|
|
23
35
|
const SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
@@ -65,8 +77,19 @@ interface TerminalOwner {
|
|
|
65
77
|
requestRender(force?: boolean): void;
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/** The player a bundled Gentle Shell installs inside its prefix on request. */
|
|
81
|
+
export interface BundledMusicPlayer {
|
|
82
|
+
gentleStream: string;
|
|
83
|
+
installed(): boolean;
|
|
84
|
+
install(progress: (line: string) => void): Promise<{ gentleStream: string; samples: boolean }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export interface GentleMusicDeps {
|
|
69
88
|
which?: MusicWhich;
|
|
89
|
+
/** The bundled install's player, or undefined outside a bundled install. */
|
|
90
|
+
bundled?: () => Promise<BundledMusicPlayer | undefined>;
|
|
91
|
+
/** The platform Pi runs on; gentle-stream does not run on native Windows. */
|
|
92
|
+
platform?: NodeJS.Platform;
|
|
70
93
|
spawn?: MusicSpawn;
|
|
71
94
|
signals?: MusicSignals;
|
|
72
95
|
usage?: MusicUsage;
|
|
@@ -95,10 +118,11 @@ export function findOnPath(name: string, env: NodeJS.ProcessEnv = process.env, p
|
|
|
95
118
|
return undefined;
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
/** gentle-stream on PATH, else the published spec through uvx, else through `uv tool run`. */
|
|
99
|
-
export function resolveGentleStream(which: MusicWhich): GentleStreamCommand | undefined {
|
|
121
|
+
/** gentle-stream on PATH, else the one installed inside the bundled prefix, else the published spec through uvx, else through `uv tool run`. */
|
|
122
|
+
export function resolveGentleStream(which: MusicWhich, bundledPlayer?: string): GentleStreamCommand | undefined {
|
|
100
123
|
const installed = which("gentle-stream");
|
|
101
124
|
if (installed) return { command: installed, prefix: [] };
|
|
125
|
+
if (bundledPlayer) return { command: bundledPlayer, prefix: [] };
|
|
102
126
|
const uvx = which("uvx");
|
|
103
127
|
if (uvx) return { command: uvx, prefix: ["--from", GENTLE_STREAM_SPEC, "gentle-stream"] };
|
|
104
128
|
const uv = which("uv");
|
|
@@ -144,6 +168,22 @@ export function formatMusicStatus(stdout: string): string | undefined {
|
|
|
144
168
|
return `♪ ${[...parts, playing === true ? "playing" : "paused"].join(" · ")}`;
|
|
145
169
|
}
|
|
146
170
|
|
|
171
|
+
/** The running bundled install's player (GENTLE_SHELL_PREFIX, checked by runningBundledPrefix). */
|
|
172
|
+
async function defaultBundled(): Promise<BundledMusicPlayer | undefined> {
|
|
173
|
+
const prefix = runningBundledPrefix(process.env);
|
|
174
|
+
if (prefix === undefined) return undefined;
|
|
175
|
+
const { installMusicPlayer, installedMusicPlayer, prefixLayout } = await import("../scripts/bundled-install.mjs");
|
|
176
|
+
const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: prefix });
|
|
177
|
+
return {
|
|
178
|
+
gentleStream: layout.gentleStream,
|
|
179
|
+
installed: () => installedMusicPlayer(layout),
|
|
180
|
+
install: async (progress) => {
|
|
181
|
+
const result = await installMusicPlayer({ layout, platform: process.platform, arch: process.arch, env: process.env, out: progress });
|
|
182
|
+
return { gentleStream: result.gentleStream, samples: result.samples };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
const defaultSpawn: MusicSpawn = (command, args, options) => nodeSpawn(command, [...args], { stdio: options.stdio === "inherit" ? "inherit" : [...options.stdio] });
|
|
148
188
|
const defaultSignals: MusicSignals = { on: (signal, listener) => process.on(signal, listener), off: (signal, listener) => process.off(signal, listener) };
|
|
149
189
|
const USAGE_KEY = Symbol.for("gentle-pi.gentle-music.usage");
|
|
@@ -223,6 +263,37 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {}
|
|
|
223
263
|
const signals = deps.signals ?? defaultSignals;
|
|
224
264
|
const usage = deps.usage ?? processUsage();
|
|
225
265
|
const shutdownTimeoutMs = deps.shutdownTimeoutMs ?? SHUTDOWN_TIMEOUT_MS;
|
|
266
|
+
const bundled = deps.bundled ?? defaultBundled;
|
|
267
|
+
const platform = deps.platform ?? process.platform;
|
|
268
|
+
|
|
269
|
+
const findPlayer = async (): Promise<{ stream?: GentleStreamCommand; offer?: BundledMusicPlayer }> => {
|
|
270
|
+
let player: BundledMusicPlayer | undefined;
|
|
271
|
+
try {
|
|
272
|
+
player = await bundled();
|
|
273
|
+
} catch {
|
|
274
|
+
player = undefined;
|
|
275
|
+
}
|
|
276
|
+
const stream = resolveGentleStream(which, player?.installed() ? player.gentleStream : undefined);
|
|
277
|
+
return stream ? { stream } : { offer: player };
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** The bundled offer: undefined when declined or failed (after telling the user). */
|
|
281
|
+
const offerInstall = async (ctx: ExtensionContext, player: BundledMusicPlayer): Promise<GentleStreamCommand | undefined> => {
|
|
282
|
+
if ((await ctx.ui.confirm(MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER)) !== true) {
|
|
283
|
+
ctx.ui.notify(installHint(), "warning");
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const result = await player.install((line) => ctx.ui.notify(line, "info"));
|
|
288
|
+
ctx.ui.notify(result.samples
|
|
289
|
+
? "♪ gentle-stream installed inside Gentle Shell."
|
|
290
|
+
: "♪ gentle-stream installed inside Gentle Shell without the sample voices (they need the system FluidSynth library).", "info");
|
|
291
|
+
return { command: result.gentleStream, prefix: [] };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
ctx.ui.notify(`/gentle:music could not install gentle-stream: ${errorMessage(error)}`, "error");
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
226
297
|
|
|
227
298
|
const openTui = async (ctx: ExtensionContext, stream: GentleStreamCommand, mood: string | undefined) => {
|
|
228
299
|
const args = [...stream.prefix, ...musicTuiArgs({ model: ctx.model, mood })];
|
|
@@ -261,15 +332,19 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {}
|
|
|
261
332
|
description: "Open gentle-stream full screen (optional mood text); b returns with the music playing, q stops it. next, pause, play, stop, status control the music without leaving Pi.",
|
|
262
333
|
handler: async (args, ctx) => {
|
|
263
334
|
if (!ctx.hasUI) return;
|
|
264
|
-
if (
|
|
265
|
-
ctx.ui.notify(
|
|
335
|
+
if (platform === "win32") {
|
|
336
|
+
ctx.ui.notify(MUSIC_WINDOWS_UNSUPPORTED, "warning");
|
|
266
337
|
return;
|
|
267
338
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
ctx.ui.notify(installHint(), "warning");
|
|
339
|
+
if (ctx.mode !== "tui") {
|
|
340
|
+
ctx.ui.notify("/gentle:music needs the interactive terminal.", "warning");
|
|
271
341
|
return;
|
|
272
342
|
}
|
|
343
|
+
const found = await findPlayer();
|
|
344
|
+
let stream = found.stream;
|
|
345
|
+
if (!stream && found.offer) stream = await offerInstall(ctx, found.offer);
|
|
346
|
+
else if (!stream) ctx.ui.notify(installHint(), "warning");
|
|
347
|
+
if (!stream) return;
|
|
273
348
|
usage.used = true;
|
|
274
349
|
const request = parseMusicArgs(args);
|
|
275
350
|
if (request.kind === "ctl") await control(ctx, stream, request.command);
|
|
@@ -281,7 +356,7 @@ export default function gentleMusic(pi: ExtensionAPI, deps: GentleMusicDeps = {}
|
|
|
281
356
|
// Switching or reloading sessions keeps the same process, so the music plays on.
|
|
282
357
|
pi.on("session_shutdown", async (event) => {
|
|
283
358
|
if ((event as { reason?: string }).reason !== "quit" || !usage.used) return;
|
|
284
|
-
const stream =
|
|
359
|
+
const { stream } = await findPlayer();
|
|
285
360
|
if (!stream) return;
|
|
286
361
|
usage.used = false;
|
|
287
362
|
await runCaptured(spawn, stream.command, [...stream.prefix, "ctl", "stop"], shutdownTimeoutMs);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isHomeOrBroadUserDirectory } from "../lib/bounded-writer-admission.ts";
|
|
3
|
-
import { Editor, Image, decodeKittyPrintable, getCapabilities, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Editor, Image, decodeKittyPrintable, getCapabilities, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import { readClipboardImage, type ClipboardImage } from "../lib/clipboard-image.ts";
|
|
5
5
|
import { describeAttachment, ImageAttachments } from "../lib/image-attachments.ts";
|
|
6
6
|
import { execFile, spawnSync } from "node:child_process";
|
|
@@ -17,7 +17,7 @@ import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, Worktree
|
|
|
17
17
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
18
18
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
19
19
|
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
|
|
20
|
-
import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
20
|
+
import { CARD_STYLE, CARD_TONE, cardStyle, panelHeaderRow, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
21
21
|
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
|
|
22
22
|
import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
|
|
23
23
|
import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
|
|
@@ -125,7 +125,9 @@ import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type
|
|
|
125
125
|
import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
|
|
126
126
|
import { sessionChangeRoots } from "../lib/session-changes.ts";
|
|
127
127
|
import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
|
|
128
|
-
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
128
|
+
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, jobsCollapseKey, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
129
|
+
import { JOBS_FINISHED_TTL_MS, jobsCardTone, renderJobsCard } from "../lib/jobs-widget.ts";
|
|
130
|
+
import { NativePointerRegion } from "../lib/native-pointer-region.ts";
|
|
129
131
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
130
132
|
import { SelectionEngine } from "../lib/selection-engine.ts";
|
|
131
133
|
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
|
|
@@ -2048,6 +2050,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2048
2050
|
let currentContext: ExtensionContext | undefined;
|
|
2049
2051
|
let review: ReviewSidebarSnapshot | undefined;
|
|
2050
2052
|
let runningJobs: RunningJobDisplay[] | undefined;
|
|
2053
|
+
let jobsCollapsed = false;
|
|
2051
2054
|
const redrawReview = () => {
|
|
2052
2055
|
renderHost?.invalidateSidebar?.();
|
|
2053
2056
|
renderHost?.requestRender();
|
|
@@ -2063,7 +2066,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2063
2066
|
const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
|
|
2064
2067
|
if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
|
|
2065
2068
|
if (!isJobsSidebarSnapshot(event.snapshot)) return;
|
|
2066
|
-
runningJobs = event.snapshot.jobs.map(({ id, label, startedAt }) => ({ id, label, startedAt }));
|
|
2069
|
+
runningJobs = event.snapshot.jobs.map(({ id, label, startedAt, status, endedAt, exitCode }) => ({ id, label, startedAt, status, endedAt, exitCode }));
|
|
2067
2070
|
redrawReview();
|
|
2068
2071
|
});
|
|
2069
2072
|
pi.on("session_tree", () => {
|
|
@@ -2156,7 +2159,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2156
2159
|
pi, ctx, footerData,
|
|
2157
2160
|
usage: () => usage.get(ctx.model?.provider ?? ""),
|
|
2158
2161
|
profile: () => deps.activeProfile(),
|
|
2159
|
-
jobs
|
|
2162
|
+
// The compact bar and header derive ⧗ N jobs from model.jobs.length,
|
|
2163
|
+
// so this input is running-only: the snapshot carries the finished tail.
|
|
2164
|
+
jobs: () => runningJobs?.filter((job) => job.status === undefined),
|
|
2160
2165
|
review: () => review,
|
|
2161
2166
|
tracker: () => tracker,
|
|
2162
2167
|
visualSettings: () => visualSettings,
|
|
@@ -2194,6 +2199,68 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2194
2199
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings),
|
|
2195
2200
|
invalidate() {},
|
|
2196
2201
|
});
|
|
2202
|
+
// The Jobs card: its own rail section, like Agents and Todos, fed by the
|
|
2203
|
+
// same running-job display snapshot the Status card used to print. The
|
|
2204
|
+
// rail is natively scrollable, so it lists every running job; there is
|
|
2205
|
+
// no above-editor copy — regular mode keeps the compact bar count.
|
|
2206
|
+
let jobsHovered = false;
|
|
2207
|
+
let jobsPainted = false;
|
|
2208
|
+
let jobsHeaderRow = 0;
|
|
2209
|
+
const jobsRailCard: Component = {
|
|
2210
|
+
render(width: number) {
|
|
2211
|
+
const jobs = runningJobs ?? [];
|
|
2212
|
+
if (jobs.length === 0) { jobsPainted = false; return []; }
|
|
2213
|
+
const lines = renderJobsCard(jobs, theme, width, deps.now(), { collapsed: jobsCollapsed, collapseKey: jobsKey, collapseControl: true, hovered: jobsHovered });
|
|
2214
|
+
jobsPainted = lines.length > 0;
|
|
2215
|
+
jobsHeaderRow = jobsPainted ? panelHeaderRow(theme, width, jobsCardTone(jobs)) : 0;
|
|
2216
|
+
return lines;
|
|
2217
|
+
},
|
|
2218
|
+
invalidate() {
|
|
2219
|
+
jobsHovered = false;
|
|
2220
|
+
},
|
|
2221
|
+
};
|
|
2222
|
+
const jobsRegion = new NativePointerRegion(jobsRailCard, {
|
|
2223
|
+
onHover(event) {
|
|
2224
|
+
const next = event.y === jobsHeaderRow;
|
|
2225
|
+
if (next === jobsHovered) return { handled: true };
|
|
2226
|
+
jobsHovered = next;
|
|
2227
|
+
return { handled: true, render: true };
|
|
2228
|
+
},
|
|
2229
|
+
onLeave() {
|
|
2230
|
+
if (!jobsHovered) return;
|
|
2231
|
+
jobsHovered = false;
|
|
2232
|
+
tui.requestRender();
|
|
2233
|
+
},
|
|
2234
|
+
onClick(event) {
|
|
2235
|
+
if (event.button !== "left" || event.y !== jobsHeaderRow) return undefined;
|
|
2236
|
+
jobsCollapsed = !jobsCollapsed;
|
|
2237
|
+
invalidateSidebar(tui);
|
|
2238
|
+
tui.requestRender();
|
|
2239
|
+
return { handled: true, render: true };
|
|
2240
|
+
},
|
|
2241
|
+
});
|
|
2242
|
+
const jobsRail: Component & { digest(): string; dispose(): void } = {
|
|
2243
|
+
digest: () => JSON.stringify([jobsCollapsed, jobsHovered, runningJobs, Math.floor(deps.now() / 1000)]),
|
|
2244
|
+
render: (width) => jobsRegion.render(width),
|
|
2245
|
+
handleMouse: (event) => jobsRegion.handleMouse(event),
|
|
2246
|
+
invalidate() {
|
|
2247
|
+
jobsRegion.invalidate();
|
|
2248
|
+
},
|
|
2249
|
+
dispose() {
|
|
2250
|
+
jobsRegion.dispose();
|
|
2251
|
+
},
|
|
2252
|
+
};
|
|
2253
|
+
const jobsPart = sidebarPart(tui, "jobs", { render: () => [], invalidate() {}, dispose() {} }, jobsRail);
|
|
2254
|
+
// Elapsed time moves while a job runs, and a finished tail must clear
|
|
2255
|
+
// itself once its TTL expires with nothing running behind it; one frame
|
|
2256
|
+
// a second is enough, and the digest's second bucket revalidates the
|
|
2257
|
+
// cached section.
|
|
2258
|
+
const jobsTimer = setInterval(() => {
|
|
2259
|
+
const now = deps.now();
|
|
2260
|
+
const fresh = (runningJobs ?? []).some((job) => job.status === undefined || now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS);
|
|
2261
|
+
if (jobsPainted || fresh) tui.requestRender();
|
|
2262
|
+
}, 1_000);
|
|
2263
|
+
jobsTimer.unref?.();
|
|
2197
2264
|
// The header row carries everything that ticks every frame (model,
|
|
2198
2265
|
// effort, context, cost, usage) plus session identity and the jobs count
|
|
2199
2266
|
// when Status is absent. Opaque statuses and working state stay excluded.
|
|
@@ -2229,15 +2296,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2229
2296
|
}), { placement: "belowEditor" });
|
|
2230
2297
|
return { ...part,
|
|
2231
2298
|
render(width: number) {
|
|
2232
|
-
|
|
2233
|
-
|
|
2299
|
+
// Running-only: a finished tail must never paint a ⧗ 0 jobs row.
|
|
2300
|
+
const compactRunningJobs = runningJobs?.filter((job) => job.status === undefined);
|
|
2301
|
+
if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && compactRunningJobs?.length) {
|
|
2302
|
+
return [truncateToWidth(theme.fg("muted", shellJobsCount(compactRunningJobs.length)), width, "…")];
|
|
2234
2303
|
}
|
|
2235
2304
|
const rows = part.render(width);
|
|
2236
2305
|
// Preserve a shrinkable exterior dock row, outside prompt/completion geometry.
|
|
2237
2306
|
return rows.length === 0 && (tui as TUI & { mode?: string }).mode === "fullscreen"
|
|
2238
2307
|
&& resolvePromptLayout(width, cardStyle(), theme.bg?.bind(theme)).background ? [""] : rows;
|
|
2239
2308
|
},
|
|
2240
|
-
dispose() { ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
|
|
2309
|
+
dispose() { clearInterval(jobsTimer); jobsPart.dispose(); ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
|
|
2241
2310
|
});
|
|
2242
2311
|
void refreshUsage(ctx, true);
|
|
2243
2312
|
const ownsPrompt = installPrompt(
|
|
@@ -2358,6 +2427,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2358
2427
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
2359
2428
|
});
|
|
2360
2429
|
}
|
|
2430
|
+
const jobsKey = jobsCollapseKey(env);
|
|
2431
|
+
if (jobsKey) {
|
|
2432
|
+
pi.registerShortcut(jobsKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
2433
|
+
description: "Collapse or expand the jobs card",
|
|
2434
|
+
handler: async () => {
|
|
2435
|
+
jobsCollapsed = !jobsCollapsed;
|
|
2436
|
+
if (sidebarTui) invalidateSidebar(sidebarTui);
|
|
2437
|
+
renderHost?.requestRender();
|
|
2438
|
+
},
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2361
2441
|
pi.registerCommand("gentle:customize", {
|
|
2362
2442
|
description: "Configure appearance, audio notifications, Vim editing, session permissions and global Auto/YOLO defaults, and prompt history capture.",
|
|
2363
2443
|
handler: async (_args, ctx) => {
|
|
@@ -20,8 +20,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
|
|
|
20
20
|
{
|
|
21
21
|
title: "Configuration",
|
|
22
22
|
items: [
|
|
23
|
-
{ command: "gentle:
|
|
24
|
-
{ command: "gentle:profiles", label: "Agent-model profiles" },
|
|
23
|
+
{ command: "gentle:profiles", label: "Models and profiles" },
|
|
25
24
|
{ command: "gentle:persona", label: "Switch persona" },
|
|
26
25
|
{ command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
|
|
27
26
|
{ command: "gentle:background-subagents", label: "Background subagents" },
|
|
@@ -568,6 +568,9 @@ export function withoutMissingBundledNpmCommand(settingsText: string | undefined
|
|
|
568
568
|
export interface BundledUpdatePlan {
|
|
569
569
|
// Pi's own update was asked for: redirect it to `gentle-shell upgrade`.
|
|
570
570
|
self: boolean;
|
|
571
|
+
// `--latest` asked for the newest published Pi in place of the release's
|
|
572
|
+
// verified one (scripts/bundled-install.mjs upgradePiLatest). Only set then.
|
|
573
|
+
latest?: boolean;
|
|
571
574
|
// What Pi still runs, if anything: the extension part of `--all`, or the
|
|
572
575
|
// whole command when it never updates Pi itself.
|
|
573
576
|
piArgs?: string[];
|
|
@@ -578,7 +581,9 @@ export interface BundledUpdatePlan {
|
|
|
578
581
|
// `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`,
|
|
579
582
|
// `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part
|
|
580
583
|
// is redirected; "all" still sends `update --extensions` (with the other
|
|
581
|
-
// flags) to Pi.
|
|
584
|
+
// flags) to Pi. `--latest` marks Pi's own part as the newest published Pi;
|
|
585
|
+
// with any form that is not Pi's own update it is unknown to Pi, so it passes
|
|
586
|
+
// through. Packages, models, help, and forms Pi refuses (conflicting or
|
|
582
587
|
// unknown options) reach Pi unchanged.
|
|
583
588
|
export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
|
|
584
589
|
const passThrough = { self: false, piArgs: passthrough };
|
|
@@ -587,8 +592,10 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
|
|
|
587
592
|
let self = false;
|
|
588
593
|
let extensions = false;
|
|
589
594
|
let all = false;
|
|
595
|
+
let latest = false;
|
|
590
596
|
for (const token of passthrough.slice(1)) {
|
|
591
597
|
if (token === "--self") self = true;
|
|
598
|
+
else if (token === "--latest") latest = true;
|
|
592
599
|
else if (token === "--extensions") extensions = true;
|
|
593
600
|
else if (token === "--all") all = true;
|
|
594
601
|
else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token);
|
|
@@ -600,19 +607,21 @@ export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan {
|
|
|
600
607
|
if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough;
|
|
601
608
|
self = true;
|
|
602
609
|
}
|
|
603
|
-
|
|
610
|
+
const marked = latest ? { latest: true } : {};
|
|
611
|
+
if (all || (self && extensions)) return { self: true, ...marked, piArgs: ["update", "--extensions", ...flags] };
|
|
604
612
|
if (extensions) return passThrough;
|
|
605
|
-
return { self: true };
|
|
613
|
+
return { self: true, ...marked };
|
|
606
614
|
}
|
|
607
615
|
|
|
608
|
-
// What `gentle-shell update` does after the redirected upgrade
|
|
609
|
-
// is undefined when it did not run): with
|
|
610
|
-
//
|
|
611
|
-
// extensions even when
|
|
616
|
+
// What `gentle-shell update` does after the redirected upgrade or the
|
|
617
|
+
// `--latest` Pi update (`upgradeCode` is undefined when it did not run): with
|
|
618
|
+
// nothing left for Pi it exits with that code; otherwise Pi still runs, so
|
|
619
|
+
// `update --all` updates the extensions even when that step could not complete.
|
|
612
620
|
export function bundledUpdateNext(plan: BundledUpdatePlan, upgradeCode: number | undefined): { exitCode: number } | { piArgs: string[]; note?: string } {
|
|
613
621
|
if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 };
|
|
614
622
|
if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) {
|
|
615
|
-
|
|
623
|
+
const step = plan.latest ? "latest Pi update" : "Gentle Shell upgrade";
|
|
624
|
+
return { piArgs: plan.piArgs, note: `gentle-shell: the ${step} did not complete; updating the extensions anyway.` };
|
|
616
625
|
}
|
|
617
626
|
return { piArgs: plan.piArgs };
|
|
618
627
|
}
|
|
@@ -623,6 +632,7 @@ export function bundledUpdateNotice(input: { piVersion: string; interactive: boo
|
|
|
623
632
|
input.interactive
|
|
624
633
|
? "Running 'gentle-shell upgrade'..."
|
|
625
634
|
: "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.",
|
|
635
|
+
"Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.",
|
|
626
636
|
];
|
|
627
637
|
}
|
|
628
638
|
|
|
@@ -1351,6 +1351,8 @@ export function helpText(): string {
|
|
|
1351
1351
|
" gentle-shell update [target] Run pi's own 'update' against the resolved home.",
|
|
1352
1352
|
" In a bundled install Pi ships pinned: 'update' without a package",
|
|
1353
1353
|
" runs 'gentle-shell upgrade' instead of updating Pi.",
|
|
1354
|
+
" 'update --latest' installs the newest published Pi instead,",
|
|
1355
|
+
" without the release's verified lockfile.",
|
|
1354
1356
|
" gentle-shell config Run pi's own 'config' against the resolved home.",
|
|
1355
1357
|
" gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
|
|
1356
1358
|
" gentle-shell mcp <command> Run pi's own 'mcp' (add, list, login, logout) against the",
|
|
@@ -2,10 +2,23 @@
|
|
|
2
2
|
export const JOBS_SIDEBAR_EVENT = "gentle-ai:jobs-sidebar";
|
|
3
3
|
export const JOBS_STATUS_KEY = "gentle-jobs";
|
|
4
4
|
|
|
5
|
+
const COLLAPSE_KEY_DEFAULT = "ctrl+shift+j";
|
|
6
|
+
|
|
7
|
+
export function jobsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
8
|
+
const value = env.GENTLE_PI_JOBS_KEY?.trim();
|
|
9
|
+
if (value === undefined) return COLLAPSE_KEY_DEFAULT;
|
|
10
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface RunningJobDisplay {
|
|
6
14
|
id: string;
|
|
7
15
|
label: string;
|
|
8
16
|
startedAt: number;
|
|
17
|
+
// Finished-tail fields; optional so older publishers that only list running
|
|
18
|
+
// jobs keep validating (the Agents-card pattern: results linger briefly).
|
|
19
|
+
status?: "exited" | "stopped" | "failed";
|
|
20
|
+
endedAt?: number;
|
|
21
|
+
exitCode?: number | null;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export interface JobsSidebarSnapshot {
|
|
@@ -14,7 +27,13 @@ export interface JobsSidebarSnapshot {
|
|
|
14
27
|
|
|
15
28
|
export function isJobsSidebarSnapshot(value: unknown): value is JobsSidebarSnapshot {
|
|
16
29
|
if (!value || typeof value !== "object" || !Array.isArray((value as JobsSidebarSnapshot).jobs)) return false;
|
|
17
|
-
return (value as JobsSidebarSnapshot).jobs.every((job) =>
|
|
18
|
-
|
|
19
|
-
typeof job.
|
|
30
|
+
return (value as JobsSidebarSnapshot).jobs.every((job) => {
|
|
31
|
+
if (!job || typeof job !== "object") return false;
|
|
32
|
+
if (typeof job.id !== "string" || job.id.length === 0 || typeof job.label !== "string") return false;
|
|
33
|
+
if (typeof job.startedAt !== "number" || !Number.isFinite(job.startedAt)) return false;
|
|
34
|
+
if (job.status !== undefined && job.status !== "exited" && job.status !== "stopped" && job.status !== "failed") return false;
|
|
35
|
+
if (job.endedAt !== undefined && (typeof job.endedAt !== "number" || !Number.isFinite(job.endedAt))) return false;
|
|
36
|
+
if (job.exitCode !== undefined && typeof job.exitCode !== "number" && job.exitCode !== null) return false;
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
20
39
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { JOB_GLYPH, formatJobDuration } from "./background-jobs-tools.ts";
|
|
3
|
+
import type { RunningJobDisplay } from "./jobs-sidebar-state.ts";
|
|
4
|
+
import { paintHoverable } from "./shell-hover.ts";
|
|
5
|
+
import { CARD_TONE, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
|
|
6
|
+
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// Gentle Jobs widget: the sidebar card for this session's background jobs.
|
|
9
|
+
// Reads the running-job display snapshot only (id, label, startedAt), the same
|
|
10
|
+
// data the Status card used to print, so drawing it costs nothing per event.
|
|
11
|
+
// One row per job: glyph, label, elapsed right-aligned. Jobs are transient —
|
|
12
|
+
// the card paints nothing while no job runs, and the overlay (/gentle:jobs)
|
|
13
|
+
// stays the full view with stop controls and output tails.
|
|
14
|
+
|
|
15
|
+
export const JOBS_GLYPH = JOB_GLYPH;
|
|
16
|
+
|
|
17
|
+
// Finished jobs linger like the Agents card's results: newest 3 for a minute,
|
|
18
|
+
// so an exit is visible long enough to read before the card empties itself.
|
|
19
|
+
export const JOBS_FINISHED_TTL_MS = 60_000;
|
|
20
|
+
const MAX_FINISHED = 3;
|
|
21
|
+
|
|
22
|
+
export interface JobsWidgetOptions {
|
|
23
|
+
collapsed: boolean;
|
|
24
|
+
collapseKey?: string;
|
|
25
|
+
// Paint the clickable "▾ Collapse" / "▸ Expand" control in the header, the
|
|
26
|
+
// same control the Agents and Todos cards carry.
|
|
27
|
+
collapseControl?: boolean;
|
|
28
|
+
hovered?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const GLYPH_ROLE = "accent";
|
|
32
|
+
const NAME_ROLE = "text";
|
|
33
|
+
const META_ROLE = "dim";
|
|
34
|
+
const GLYPH_GAP = " ";
|
|
35
|
+
const ELLIPSIS = "…";
|
|
36
|
+
|
|
37
|
+
// Running jobs newest first, matching the /gentle:jobs running group and the
|
|
38
|
+
// old Status group this card replaces; finished tails follow, newest first.
|
|
39
|
+
export function widgetJobs(jobs: readonly RunningJobDisplay[], now: number): RunningJobDisplay[] {
|
|
40
|
+
const running = jobs.filter((job) => job.status === undefined).sort((a, b) => b.startedAt - a.startedAt);
|
|
41
|
+
const finished = jobs.filter((job) => job.status !== undefined && now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS)
|
|
42
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)).slice(0, MAX_FINISHED);
|
|
43
|
+
return [...running, ...finished];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clean(text: string): string {
|
|
47
|
+
return sanitizeTerminalText(text).replace(/\s+/g, " ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clip(text: string, width: number): string {
|
|
51
|
+
if (width <= 0) return "";
|
|
52
|
+
if (visibleWidth(text) <= width) return text;
|
|
53
|
+
// Plain clip, mirroring the status bar: pi's truncateToWidth wraps the
|
|
54
|
+
// result in resets, which would end up inside a painted segment.
|
|
55
|
+
let clipped = "";
|
|
56
|
+
for (const char of text) {
|
|
57
|
+
if (visibleWidth(clipped + char) > width - 1) break;
|
|
58
|
+
clipped += char;
|
|
59
|
+
}
|
|
60
|
+
return `${clipped}${ELLIPSIS}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The /gentle:jobs glyph roles: running keeps the spinner, exits mirror the
|
|
64
|
+
// overlay's outcome marks.
|
|
65
|
+
function glyph(job: RunningJobDisplay): [string, string] {
|
|
66
|
+
if (job.status === "stopped") return ["–", "dim"];
|
|
67
|
+
if (job.status === "failed") return ["✗", "error"];
|
|
68
|
+
if (job.status === "exited") return job.exitCode === 0 ? ["✓", "success"] : ["✗", "error"];
|
|
69
|
+
return [JOBS_GLYPH, GLYPH_ROLE];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function counts(jobs: readonly RunningJobDisplay[]): string {
|
|
73
|
+
const running = jobs.filter((job) => job.status === undefined).length;
|
|
74
|
+
const finished = jobs.length - running;
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
if (running > 0) parts.push(running === 1 ? "1 running" : `${running} running`);
|
|
77
|
+
if (finished > 0) parts.push(finished === 1 ? "1 finished" : `${finished} finished`);
|
|
78
|
+
return parts.join(" · ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The tone the card paints, so a host can locate its header row. */
|
|
82
|
+
export function jobsCardTone(_jobs: readonly RunningJobDisplay[]): CardTone {
|
|
83
|
+
return CARD_TONE.INFO;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderJobsCard(jobs: readonly RunningJobDisplay[], theme: CardTheme, width: number, now: number, options: JobsWidgetOptions): string[] {
|
|
87
|
+
const shown = widgetJobs(jobs, now);
|
|
88
|
+
if (shown.length === 0) return [];
|
|
89
|
+
const tone = jobsCardTone(shown);
|
|
90
|
+
const inner = panelInnerWidth(theme, width, tone);
|
|
91
|
+
const listed = options.collapsed ? [shown[0]!] : shown;
|
|
92
|
+
const body = listed.map((job) => {
|
|
93
|
+
// A finished row freezes its elapsed at the end; a running one ticks.
|
|
94
|
+
const [mark, role] = glyph(job);
|
|
95
|
+
const elapsed = formatJobDuration(Math.max(0, (job.status === undefined ? now : job.endedAt ?? now) - job.startedAt));
|
|
96
|
+
const label = clip(clean(job.label), Math.max(0, inner - visibleWidth(elapsed) - GLYPH_GAP.length - 1));
|
|
97
|
+
const head = `${theme.fg(role, mark)}${GLYPH_GAP}${theme.fg(NAME_ROLE, label)}`;
|
|
98
|
+
const tail = theme.fg(META_ROLE, " ".repeat(Math.max(1, inner - visibleWidth(head) - visibleWidth(elapsed))) + elapsed);
|
|
99
|
+
return `${head}${tail}`;
|
|
100
|
+
});
|
|
101
|
+
if (options.collapsed && shown.length > 1) body.push(theme.fg(META_ROLE, `${ELLIPSIS} ${shown.length - 1} more`));
|
|
102
|
+
const icon = options.collapsed ? "▸" : "▾";
|
|
103
|
+
const control = `${icon} ${width >= 28 ? (options.collapsed ? "Expand" : "Collapse") : ""}`.trimEnd();
|
|
104
|
+
const title = options.collapseControl ? `Jobs ${paintHoverable(theme, control, options.hovered ?? false, "accent")}` : "Jobs";
|
|
105
|
+
const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : undefined;
|
|
106
|
+
return renderCard(
|
|
107
|
+
{ title, subtitle: counts(shown), body, tone, glyph: JOBS_GLYPH },
|
|
108
|
+
theme,
|
|
109
|
+
width,
|
|
110
|
+
{ expanded: true, hint, panel: true },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -252,7 +252,7 @@ export function inheritedProfileDriftNotice(options: SessionProfileOptions): str
|
|
|
252
252
|
if (seen.has(key)) return undefined;
|
|
253
253
|
seen.add(key);
|
|
254
254
|
notified.set(options.sessionId!, seen);
|
|
255
|
-
return `el Gentleman: the default profile changed to ${describe(current)}, this session keeps ${describe(resolution.profile)}.
|
|
255
|
+
return `el Gentleman: the default profile changed to ${describe(current)}, this session keeps ${describe(resolution.profile)}. Open /gentle:profiles and choose Use in this session to adopt one.`;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
// gentle-shell#2228: an explicit binding re-copies the profile definition at
|
|
@@ -280,7 +280,7 @@ function bindingDefinitionDriftNotice(
|
|
|
280
280
|
if (seen.has(key)) return undefined;
|
|
281
281
|
seen.add(key);
|
|
282
282
|
notified.set(options.sessionId!, seen);
|
|
283
|
-
return `el Gentleman: the profile definition changed to "${resolution.name}" (profiles.json), this session keeps the binding from selection.
|
|
283
|
+
return `el Gentleman: the profile definition changed to "${resolution.name}" (profiles.json), this session keeps the binding from selection. Open /gentle:profiles and choose Use in this session to adopt one.`;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
/** Testing-only reset of every frozen snapshot and drift memory. */
|
package/lib/shell-bar.ts
CHANGED
|
@@ -251,10 +251,8 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
251
251
|
...(model.review.scope !== REVIEW_SCOPE_UNAVAILABLE ? [label(sanitizeStatus(model.review.scope))] : []),
|
|
252
252
|
],
|
|
253
253
|
}] : []),
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
lines: [...model.jobs].sort((a, b) => b.startedAt - a.startedAt).map((job) => value(sanitizeStatus(job.label))),
|
|
257
|
-
}] : []),
|
|
254
|
+
// Running jobs live in their own sidebar card (extensions/gentle-shell.ts
|
|
255
|
+
// registers the "jobs" rail part); the compact bar keeps the count.
|
|
258
256
|
{ title: "Integrations", lines: model.statuses.length
|
|
259
257
|
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
|
|
260
258
|
: [label("No status reported")] },
|
|
@@ -255,7 +255,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
|
|
|
255
255
|
const preparedHeaderLines = [...(headerPart?.render(Math.max(0, width - HEADER_RIGHT_INSET)) ?? [])];
|
|
256
256
|
const headerActive = headerPart !== undefined && preparedHeaderLines.some((line) => line.trim() !== "");
|
|
257
257
|
const contentWidth = scroll.getContentWidth(RAIL_WIDTH);
|
|
258
|
-
const sections = ["footer", "agents", "todo"].filter((key) => key !== "todo" || state.visibility?.todo !== false).map((key) => {
|
|
258
|
+
const sections = ["footer", "jobs", "agents", "todo"].filter((key) => key !== "todo" || state.visibility?.todo !== false).map((key) => {
|
|
259
259
|
const component = state.parts.get(key);
|
|
260
260
|
if (!component) {
|
|
261
261
|
sectionCache.delete(key);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
|
|
4
|
-
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0 and 1.
|
|
4
|
+
// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0, 1.1.0, 1.2.0, 1.3.0 and 1.4.0:
|
|
5
5
|
// editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry,
|
|
6
6
|
// autocomplete cancellation and { state, pastes, pasteCounter } undo restoration;
|
|
7
7
|
// undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical
|
|
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
|
|
|
17
17
|
// markers resolved by the host TUI (including hardware-cursor suppression).
|
|
18
18
|
// Keep these markers intact and rearm selection after their reverse-off;
|
|
19
19
|
// state, undo, paste, layout and autocomplete contracts remain unchanged.
|
|
20
|
+
// 1.3.0 and 1.4.0 ship editor.js, undo-stack.js and keys.js byte-identical to 1.2.0.
|
|
20
21
|
// Never silently adapt another build: undo also owns the paste registry.
|
|
21
22
|
interface Position { line: number; col: number }
|
|
22
23
|
interface State { lines: string[]; cursorLine: number; cursorCol: number }
|
|
@@ -57,7 +58,7 @@ interface PrivateEditor {
|
|
|
57
58
|
|
|
58
59
|
// The single audited-release source for the adapter and Gentle Shell's runtime
|
|
59
60
|
// identity gates. Add a release only after re-auditing the files named above.
|
|
60
|
-
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"] as const);
|
|
61
|
+
export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"] as const);
|
|
61
62
|
export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
|
|
62
63
|
|
|
63
64
|
export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
|