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.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/bin/gentle-shell.mjs +37 -4
  3. package/docs/gentle-music.md +28 -4
  4. package/docs/gentle-shell.md +5 -5
  5. package/docs/readme-reference.md +61 -46
  6. package/extensions/gentle-agents.ts +14 -2
  7. package/extensions/gentle-ai.ts +1677 -1240
  8. package/extensions/gentle-music.ts +83 -8
  9. package/extensions/gentle-shell.ts +88 -8
  10. package/lib/command-palette-catalog.ts +1 -2
  11. package/lib/gentle-shell-bundled.ts +18 -8
  12. package/lib/gentle-shell-launcher.ts +2 -0
  13. package/lib/jobs-sidebar-state.ts +22 -3
  14. package/lib/jobs-widget.ts +112 -0
  15. package/lib/session-profile-freeze.ts +2 -2
  16. package/lib/shell-bar.ts +2 -4
  17. package/lib/shell-sidebar-layout.ts +1 -1
  18. package/lib/vim-editor-adapter.ts +3 -2
  19. package/package.json +1 -1
  20. package/runtime/gentle-shell-bundled.mjs +18 -8
  21. package/runtime/gentle-shell-launcher.mjs +2 -0
  22. package/scripts/bundled-install.mjs +209 -13
  23. package/scripts/installer-downloads.mjs +24 -0
  24. package/scripts/installer-preflight.mjs +1 -1
  25. package/scripts/pi-update.mjs +3 -1
  26. package/tests/bundled-install.test.ts +273 -2
  27. package/tests/command-palette.test.ts +3 -3
  28. package/tests/design-canvas-server.test.ts +6 -2
  29. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +1 -1
  30. package/tests/gentle-agents.test.ts +4 -4
  31. package/tests/gentle-ai.test.ts +810 -609
  32. package/tests/gentle-music.test.ts +153 -1
  33. package/tests/gentle-shell-bundled.test.ts +20 -0
  34. package/tests/gentle-shell-launcher.test.ts +1 -0
  35. package/tests/gentle-shell.test.ts +44 -19
  36. package/tests/installer-downloads.test.ts +32 -0
  37. package/tests/installer-server.test.ts +2 -2
  38. package/tests/jobs-widget.test.ts +138 -0
  39. package/tests/model-profiles-preservation.test.ts +2 -2
  40. package/tests/package-manifest.test.ts +2 -2
  41. package/tests/persona-reply-language.test.ts +42 -0
  42. package/tests/pi-update.test.ts +5 -0
  43. package/tests/runtime-harness.mjs +188 -211
  44. package/tests/session-profile-freeze.test.ts +6 -4
  45. package/tests/shell-bar.test.ts +4 -4
  46. package/tests/shell-sidebar-layout.test.ts +17 -0
  47. 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 (ctx.mode !== "tui") {
265
- ctx.ui.notify("/gentle:music needs the interactive terminal.", "warning");
335
+ if (platform === "win32") {
336
+ ctx.ui.notify(MUSIC_WINDOWS_UNSUPPORTED, "warning");
266
337
  return;
267
338
  }
268
- const stream = resolveGentleStream(which);
269
- if (!stream) {
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 = resolveGentleStream(which);
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: () => runningJobs,
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
- if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && runningJobs?.length) {
2233
- return [truncateToWidth(theme.fg("muted", shellJobsCount(runningJobs.length)), width, "…")];
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:models", label: "Assign models and effort" },
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. Packages, models, help, and forms Pi refuses (conflicting or
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
- if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] };
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 (`upgradeCode`
609
- // is undefined when it did not run): with nothing left for Pi it exits with the
610
- // upgrade's code; otherwise Pi still runs, so `update --all` updates the
611
- // extensions even when the upgrade could not complete.
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
- return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." };
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) => job && typeof job === "object" &&
18
- typeof job.id === "string" && job.id.length > 0 && typeof job.label === "string" &&
19
- typeof job.startedAt === "number" && Number.isFinite(job.startedAt));
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)}. Press Enter on a profile in /gentle:profiles to adopt it.`;
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. Press Enter on a profile in /gentle:profiles to adopt it.`;
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
- ...(model.jobs?.length ? [{
255
- title: "Jobs",
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.2.0:
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 {