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
@@ -5,17 +5,27 @@ import { tmpdir } from "node:os";
5
5
  import { delimiter, join } from "node:path";
6
6
  import test, { type TestContext } from "node:test";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import gentleMusic, {
8
+ import gentleMusicExtension, {
9
9
  findOnPath,
10
10
  formatMusicStatus,
11
11
  GENTLE_STREAM_SPEC,
12
+ installHint,
12
13
  MUSIC_COMMAND_NAME,
14
+ MUSIC_INSTALL_OFFER,
15
+ MUSIC_WINDOWS_UNSUPPORTED,
16
+ MUSIC_INSTALL_TITLE,
13
17
  musicTuiArgs,
14
18
  parseMusicArgs,
15
19
  resolveGentleStream,
20
+ type BundledMusicPlayer,
16
21
  type MusicSpawn,
17
22
  } from "../extensions/gentle-music.ts";
18
23
 
24
+ // The music tests describe a supported (POSIX) host unless they pass `platform`,
25
+ // so they hold on every OS the suite runs on, Windows included.
26
+ const gentleMusic = (pi: Parameters<typeof gentleMusicExtension>[0], deps: Parameters<typeof gentleMusicExtension>[1] = {}) =>
27
+ gentleMusicExtension(pi, { platform: "linux", ...deps });
28
+
19
29
  // /gentle:music hands the terminal to gentle-stream's own TUI, the way Pi's
20
30
  // external editor does, and controls the background player with
21
31
  // `gentle-stream ctl …` without taking the terminal.
@@ -333,3 +343,145 @@ test("Windows .cmd and .bat shims are not picked, so resolution falls back to uv
333
343
  const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32"));
334
344
  assert.equal(resolved?.command, join(dir, "uv.EXE"));
335
345
  });
346
+
347
+ // S5: a bundled Gentle Shell offers to install the player inside its own
348
+ // folder; any other install keeps the manual hint.
349
+ const PREVIOUS_HINT = "/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (https://docs.astral.sh/uv/getting-started/installation/) and run /gentle:music again, or install gentle-stream with: uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream'";
350
+
351
+ function withConfirm(context: ReturnType<typeof fakeContext>, answer: boolean) {
352
+ const asked: Array<[string, string]> = [];
353
+ (context.ctx.ui as unknown as { confirm: (title: string, message: string) => Promise<boolean> }).confirm = async (title, message) => {
354
+ asked.push([title, message]);
355
+ return answer;
356
+ };
357
+ return asked;
358
+ }
359
+
360
+ function fakeBundled({ installed = false, fail }: { installed?: boolean; fail?: Error } = {}) {
361
+ const state = { installed, installs: 0 };
362
+ const player: BundledMusicPlayer = {
363
+ gentleStream: "/prefix/music/bin/gentle-stream",
364
+ installed: () => state.installed,
365
+ install: async (progress) => {
366
+ state.installs += 1;
367
+ progress("Downloading uv 0.13.6…");
368
+ if (fail) throw fail;
369
+ state.installed = true;
370
+ return { gentleStream: "/prefix/music/bin/gentle-stream", samples: true };
371
+ },
372
+ };
373
+ return { state, bundled: async () => player };
374
+ }
375
+
376
+ test("the manual hint outside a bundled install is unchanged", async () => {
377
+ assert.equal(installHint(), PREVIOUS_HINT);
378
+ const { pi, commands } = fakePi();
379
+ const { spawn, spawned } = fakeSpawn([]);
380
+ gentleMusic(pi, { which: onPath({}), spawn, bundled: async () => undefined });
381
+ const context = fakeContext();
382
+ const asked = withConfirm(context, true);
383
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
384
+ assert.deepEqual(asked, [], "no offer outside a bundled install");
385
+ assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
386
+ assert.equal(spawned.length, 0);
387
+ });
388
+
389
+ test("a bundled install without the player offers it; declining shows the hint and installs nothing", async () => {
390
+ const { pi, commands } = fakePi();
391
+ const { spawn, spawned } = fakeSpawn([]);
392
+ const { state, bundled } = fakeBundled();
393
+ gentleMusic(pi, { which: onPath({}), spawn, bundled });
394
+ const context = fakeContext();
395
+ const asked = withConfirm(context, false);
396
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
397
+ assert.deepEqual(asked, [[MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER]]);
398
+ assert.equal(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.");
399
+ assert.equal(state.installs, 0);
400
+ assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
401
+ assert.equal(spawned.length, 0);
402
+ });
403
+
404
+ test("accepting the offer installs the player with progress, then starts the music from the prefix", async () => {
405
+ const { pi, commands } = fakePi();
406
+ const context = fakeContext();
407
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
408
+ const { state, bundled } = fakeBundled();
409
+ gentleMusic(pi, { which: onPath({}), spawn, bundled, signals: fakeSignals().signals });
410
+ withConfirm(context, true);
411
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
412
+ assert.equal(state.installs, 1);
413
+ assert.deepEqual(context.notices.map(([, type]) => type), ["info", "info"]);
414
+ assert.match(context.notices[0]![0], /Downloading uv/);
415
+ assert.match(context.notices[1]![0], /installed/i);
416
+ assert.equal(spawned[0]!.command, "/prefix/music/bin/gentle-stream");
417
+ assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5", "--mood", "calm"]);
418
+ });
419
+
420
+ test("on Windows /gentle:music explains it is not available yet and never offers, installs or runs anything", async () => {
421
+ assert.equal(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.");
422
+ for (const args of ["", "calm", "status", "stop"]) {
423
+ const { pi, commands } = fakePi();
424
+ const context = fakeContext();
425
+ const asked = withConfirm(context, true);
426
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
427
+ const { state, bundled } = fakeBundled({ installed: true });
428
+ gentleMusic(pi, { which: onPath({ "gentle-stream": "C:\\bin\\gentle-stream.exe", uvx: "C:\\bin\\uvx.exe" }), spawn, bundled, signals: fakeSignals().signals, platform: "win32" });
429
+ await commands.get(MUSIC_COMMAND_NAME)!.handler(args, context.ctx);
430
+ assert.deepEqual(context.notices, [[MUSIC_WINDOWS_UNSUPPORTED, "warning"]], args);
431
+ assert.deepEqual(asked, [], args);
432
+ assert.equal(state.installs, 0, args);
433
+ assert.equal(spawned.length, 0, args);
434
+ }
435
+ });
436
+
437
+ test("on Windows the not-available notice wins over the interactive-terminal notice", async () => {
438
+ for (const mode of ["rpc", "json", "print"]) {
439
+ const { pi, commands } = fakePi();
440
+ const context = fakeContext(mode);
441
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
442
+ gentleMusic(pi, { which: onPath({ uvx: "C:\\bin\\uvx.exe" }), spawn, platform: "win32" });
443
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
444
+ assert.deepEqual(context.notices, [[MUSIC_WINDOWS_UNSUPPORTED, "warning"]], mode);
445
+ assert.equal(spawned.length, 0, mode);
446
+ }
447
+ });
448
+
449
+ test("an installed prefix player is used without asking; PATH gentle-stream still comes first", async () => {
450
+ const { bundled } = fakeBundled({ installed: true });
451
+ for (const [names, expected] of [[{}, "/prefix/music/bin/gentle-stream"], [{ uvx: "/bin/uvx" }, "/prefix/music/bin/gentle-stream"], [{ "gentle-stream": "/bin/gentle-stream" }, "/bin/gentle-stream"]] as const) {
452
+ const { pi, commands } = fakePi();
453
+ const context = fakeContext();
454
+ const asked = withConfirm(context, false);
455
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
456
+ gentleMusic(pi, { which: onPath(names), spawn, bundled, signals: fakeSignals().signals });
457
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
458
+ assert.deepEqual(asked, []);
459
+ assert.equal(spawned[0]!.command, expected);
460
+ assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5"]);
461
+ }
462
+ assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" }), "/prefix/music/bin/gentle-stream"), { command: "/prefix/music/bin/gentle-stream", prefix: [] });
463
+ });
464
+
465
+ test("a bundled install with uv on PATH keeps the uvx route and does not offer", async () => {
466
+ const { pi, commands } = fakePi();
467
+ const context = fakeContext();
468
+ const asked = withConfirm(context, true);
469
+ const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
470
+ gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, bundled: fakeBundled().bundled, signals: fakeSignals().signals });
471
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
472
+ assert.deepEqual(asked, []);
473
+ assert.equal(spawned[0]!.command, "/bin/uvx");
474
+ });
475
+
476
+ test("a failed install is reported and nothing starts", async () => {
477
+ const { pi, commands } = fakePi();
478
+ const { spawn, spawned } = fakeSpawn([]);
479
+ gentleMusic(pi, { which: onPath({}), spawn, bundled: fakeBundled({ fail: new Error("Download integrity mismatch") }).bundled });
480
+ const context = fakeContext();
481
+ withConfirm(context, true);
482
+ await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
483
+ assert.equal(spawned.length, 0);
484
+ const [message, type] = context.notices.at(-1)!;
485
+ assert.equal(type, "error");
486
+ assert.match(message, /could not install gentle-stream.*Download integrity mismatch/);
487
+ });
@@ -274,6 +274,18 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update
274
274
  }
275
275
  });
276
276
 
277
+ test("bundledUpdatePlan marks Pi's own update with --latest and keeps every other --latest form passing through", () => {
278
+ for (const args of [["update", "--latest"], ["update", "pi", "--latest"], ["update", "--latest", "self"], ["update", "--self", "--latest"]]) {
279
+ assert.deepEqual(bundledUpdatePlan(args), { self: true, latest: true }, args.join(" "));
280
+ }
281
+ assert.deepEqual(bundledUpdatePlan(["update", "--all", "--latest"]), { self: true, latest: true, piArgs: ["update", "--extensions"] });
282
+ assert.deepEqual(bundledUpdatePlan(["update", "--latest", "--self", "--extensions", "-a"]), { self: true, latest: true, piArgs: ["update", "--extensions", "-a"] });
283
+ for (const passthrough of [["update", "--extensions", "--latest"], ["update", "npm:x", "--latest"], ["update", "--models", "--latest"], ["update", "--all", "--self", "--latest"],
284
+ ["update", "pi", "npm:x", "--latest"], ["update", "--latest", "--bogus"], ["update", "--help", "--latest"]]) {
285
+ assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" "));
286
+ }
287
+ });
288
+
277
289
  test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => {
278
290
  assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] });
279
291
  assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 });
@@ -283,6 +295,9 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
283
295
  const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1);
284
296
  assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]);
285
297
  assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/);
298
+ const latest = bundledUpdateNext({ self: true, latest: true, piArgs: ["update", "--extensions"] }, 1);
299
+ assert.match("note" in latest ? String(latest.note) : "", /latest Pi update did not complete.*extensions/);
300
+ assert.deepEqual(bundledUpdateNext({ self: true, latest: true }, 1), { exitCode: 1 });
286
301
  });
287
302
 
288
303
  test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
@@ -290,6 +305,11 @@ test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell
290
305
  assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
291
306
  assert.match(interactive, /Running 'gentle-shell upgrade'/);
292
307
  assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
308
+ for (const interactive of [true, false]) {
309
+ const lines = bundledUpdateNotice({ piVersion: "1.2.0", interactive });
310
+ assert.equal(lines[0], "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release.");
311
+ assert.equal(lines.at(-1), "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.");
312
+ }
293
313
  });
294
314
 
295
315
  test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
@@ -214,6 +214,7 @@ test("help text documents the bundled upgrade, its rollback and the pinned Pi up
214
214
  assert.ok(text.includes(" gentle-shell upgrade --rollback"));
215
215
  assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s);
216
216
  assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/);
217
+ assert.match(text, /'update --latest' installs the newest published Pi instead,\n\s+without the release's verified lockfile\./);
217
218
  });
218
219
 
219
220
  test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => {
@@ -24,6 +24,7 @@ import type { ShellBarTheme } from "../lib/shell-bar.ts";
24
24
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
25
25
  import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
26
26
  import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
27
+ import { isJobsSidebarSnapshot } from "../lib/jobs-sidebar-state.ts";
27
28
  import type { NativeReviewCli } from "../lib/native-review-cli.ts";
28
29
  import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
29
30
  import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
@@ -111,7 +112,7 @@ const renderers = new Map<string, MessageRenderer>();
111
112
  const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
112
113
 
113
114
  const DEFAULT_COMMANDS: SlashCommandInfo[] = [
114
- { name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
115
+ { name: "gentle:profiles", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
115
116
  { name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
116
117
  { name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
117
118
  { name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
@@ -503,7 +504,7 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
503
504
  }
504
505
  });
505
506
 
506
- test("jobs snapshots populate Status, use one compact fallback, and reject stale sessions", async () => {
507
+ test("jobs snapshots feed their own rail card, keep one compact fallback, and reject stale sessions", async () => {
507
508
  const { pi, handlers } = fakePi();
508
509
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
509
510
  const { ctx, ui } = fakeContext();
@@ -515,35 +516,56 @@ test("jobs snapshots populate Status, use one compact fallback, and reject stale
515
516
  const component = factory(tui, plainTheme, data);
516
517
  const state = sidebarState(tui as unknown as TUI);
517
518
  const rail = state.parts.get("footer")!;
519
+ const jobs = state.parts.get("jobs")!;
518
520
  const header = state.parts.get("header")!;
519
521
  const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
520
522
  try {
521
523
  publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
522
- assert.match(rail.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
524
+ assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR 1860/, "the Status card never prints jobs; the jobs card owns them");
523
525
  assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
524
- assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "Jobs replaces the generic integration counter");
526
+ assert.match(jobs.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
527
+ publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }, { id: "job-0", label: "Build runtime", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
528
+ assert.match(jobs.render(60).join("\n"), /1 running · 1 finished/, "finished tails ride the same snapshot");
529
+ assert.match(jobs.render(60).join("\n"), /✓\s+Build runtime/);
530
+ // The compact count is running-only: a finished tail still paints the
531
+ // card, but the header's ⧗ N jobs derives from running entries alone.
532
+ publish("shell-session", [{ id: "job-2", label: "Fresh tail", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
533
+ tui.terminal.columns = 80;
534
+ assert.match(jobs.render(46).join("\n"), /Fresh tail/, "the finished tail still paints the card");
535
+ assert.doesNotMatch(header.render(80).join("\n"), /⧗ 1 job/, "the compact count is running-only");
536
+ tui.terminal.columns = 160;
537
+ publish("shell-session", [{ id: "job-0", label: "Stale tail", startedAt: 1, status: "exited", endedAt: 5, exitCode: 0 }]);
538
+ assert.doesNotMatch(jobs.render(46).join("\n"), /Stale tail/, "a finished job past the TTL paints nothing");
539
+ assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "the jobs counter never joins the integration statuses while display data exists");
525
540
  state.active = true;
526
541
  state.ownsHost = () => true;
527
- assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible Status card owns jobs details");
542
+ assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible rail owns job details");
528
543
  state.active = false;
529
544
  tui.terminal.columns = 80;
530
- assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count");
531
- const before = rail.digest?.();
545
+ publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
546
+ assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count of a running job");
547
+ const before = jobs.render(46).join("\n");
532
548
  publish("foreign", []);
533
549
  publish("shell-session", [{ label: "invalid" }]);
534
- assert.equal(rail.digest?.(), before);
550
+ assert.equal(jobs.render(46).join("\n"), before);
535
551
  publish("shell-session", []);
536
- assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR/);
552
+ assert.doesNotMatch(jobs.render(46).join("\n"), /Jobs|CI for PR/);
537
553
  assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
538
554
  publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
539
555
  await fire(handlers, "session_start", ctx);
540
- assert.doesNotMatch(rail.render(46).join("\n"), /Build runtime/);
556
+ assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /Build runtime/);
541
557
  await fire(handlers, "session_shutdown", ctx);
542
558
  publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
543
- assert.doesNotMatch(rail.render(46).join("\n"), /late/);
559
+ assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /late/);
544
560
  } finally { component.dispose(); }
545
561
  });
546
562
 
563
+ test("the jobs snapshot validator admits the finished-tail statuses", () => {
564
+ assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-1", label: "Spawn failed", startedAt: 1, status: "failed", endedAt: 60_000, exitCode: null }] }));
565
+ assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-2", label: "Stopped", startedAt: 1, status: "stopped", endedAt: 2 }] }));
566
+ assert.equal(isJobsSidebarSnapshot({ jobs: [{ id: "job-3", label: "Bad", startedAt: 1, status: "exploded" }] }), false);
567
+ });
568
+
547
569
  test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
548
570
  const home = scopedDoubleEscCancelConfigHome(t);
549
571
  writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
@@ -559,6 +581,9 @@ test("hidden Status leaves only a jobs count in regular bottom and fullscreen he
559
581
  pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
560
582
  assert.equal(component.render(80).join("\n"), "⧗ 1 job");
561
583
  assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
584
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }] } });
585
+ assert.doesNotMatch(component.render(80).join("\n"), /⧗/, "a finished-only tail never paints a ⧗ 0 jobs row");
586
+ pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
562
587
  tui.mode = "fullscreen";
563
588
  const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
564
589
  assert.match(header.render(160).join("\n"), /⧗ 1 job/);
@@ -2168,7 +2193,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing
2168
2193
  fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false,
2169
2194
  now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => INSTALLED_PI,
2170
2195
  });
2171
- const entries = ["gentle:vim", "gentle:models", "skill:example"];
2196
+ const entries = ["gentle:vim", "gentle:profiles", "skill:example"];
2172
2197
  const requests: string[] = [];
2173
2198
  editor.setAutocompleteProvider({
2174
2199
  async getSuggestions(lines: string[], line: number, col: number) {
@@ -5668,11 +5693,11 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
5668
5693
  assert.match(rendered, /Diagnostics/);
5669
5694
  assert.match(rendered, /Skills/);
5670
5695
  assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
5671
- assert.match(rendered, /Assign models and effort/);
5696
+ assert.match(rendered, /Models and profiles/);
5672
5697
  assert.match(rendered, /Browse changes/);
5673
5698
  assert.match(rendered, /Gentle AI status/);
5674
5699
  assert.match(rendered, /Refresh skill registry/);
5675
- assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
5700
+ assert.doesNotMatch(rendered, /gentle:profiles|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
5676
5701
  assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
5677
5702
  assert.doesNotMatch(rendered, /skill:foo/);
5678
5703
  const changesLine = lines.find((line) => line.includes("Browse changes"));
@@ -5687,13 +5712,13 @@ test("selecting a command from the palette by its label sends the underlying com
5687
5712
  const { ctx, ui, overlayReady } = fakeContext();
5688
5713
  const opened = commands.get("gentle:commands")!.handler("", ctx);
5689
5714
  await overlayReady;
5690
- // "mod" only matches the "Assign models and effort" label (it contains
5691
- // "mod" via "models"); nothing else in the fixture does.
5715
+ // "mod" only matches the "Models and profiles" label (it contains "mod"
5716
+ // via "Models"); nothing else in the fixture does.
5692
5717
  for (const ch of "mod") ui.overlayView!.handleInput(ch);
5693
- assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
5718
+ assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
5694
5719
  ui.overlayView!.handleInput("\r");
5695
5720
  await opened;
5696
- assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]);
5721
+ assert.deepEqual(sentMessages, [{ content: "/gentle:profiles", options: { expandPromptTemplates: true } }]);
5697
5722
  });
5698
5723
 
5699
5724
  test("escaping the palette sends no message", async () => {
@@ -5736,7 +5761,7 @@ test("the alt+k shortcut opens the same command palette as the command", async (
5736
5761
  assert.ok(shortcut, "alt+k not registered");
5737
5762
  const opened = shortcut.handler(ctx);
5738
5763
  await overlayReady;
5739
- assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
5764
+ assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
5740
5765
  ui.overlayView!.handleInput("\x1b");
5741
5766
  await opened;
5742
5767
  });
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { artifactFor, uvPinVersion } from "../scripts/installer-downloads.mjs";
4
+
5
+ // The uv the /gentle:music offer installs inside a bundled prefix (S5): the
6
+ // astral-sh/uv release archives, each re-hashed after download before pinning.
7
+ const uvPins = {
8
+ "darwin-arm64": ["uv-aarch64-apple-darwin.tar.gz", "60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472", 14235670],
9
+ "darwin-x64": ["uv-x86_64-apple-darwin.tar.gz", "63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02", 17004934],
10
+ "linux-x64": ["uv-x86_64-unknown-linux-gnu.tar.gz", "f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645", 16799024],
11
+ "linux-arm64": ["uv-aarch64-unknown-linux-gnu.tar.gz", "229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d", 15794886],
12
+ "win32-x64": ["uv-x86_64-pc-windows-msvc.zip", "a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f", 15239500],
13
+ "win32-arm64": ["uv-aarch64-pc-windows-msvc.zip", "c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c", 15439263],
14
+ } as const;
15
+
16
+ test("fixed uv descriptors are the astral-sh/uv release archives and checksums, and nothing else", () => {
17
+ assert.equal(uvPinVersion, "0.13.6");
18
+ for (const [target, [asset, sha256, size]] of Object.entries(uvPins)) {
19
+ const [platform, arch] = target.split("-");
20
+ const artifact = artifactFor("uv", platform, arch);
21
+ assert.equal(artifact.name, "uv");
22
+ assert.equal(artifact.version, "0.13.6");
23
+ assert.equal(artifact.url, `https://github.com/astral-sh/uv/releases/download/0.13.6/${asset}`);
24
+ assert.equal(artifact.integrity, `sha256-${sha256}`);
25
+ assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
26
+ assert.deepEqual(artifact.redirectHosts, ["objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
27
+ assert.ok(Object.isFrozen(artifact));
28
+ }
29
+ for (const [platform, arch] of [["linux", "ia32"], ["freebsd", "x64"], ["win32", "ia32"], ["darwin", "ppc64"]]) {
30
+ assert.throws(() => artifactFor("uv", platform, arch), /Unsupported/);
31
+ }
32
+ });
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
306
306
  const view = await plan(port, await login());
307
307
  assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
308
308
  assert.equal(view.blockers[0].guidance,
309
- "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.4.0 or newer. Nothing was replaced. " +
310
- "Update it with `pnpm add -g gentle-pi@4.4.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
309
+ "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.5.1 or newer. Nothing was replaced. " +
310
+ "Update it with `pnpm add -g gentle-pi@4.5.1`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
311
311
  assert.deepEqual(view.actions, []);
312
312
  } finally {
313
313
  await host.close("test");
@@ -0,0 +1,138 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { after, before } from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { JOBS_GLYPH, renderJobsCard, widgetJobs } from "../lib/jobs-widget.ts";
5
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ // The Jobs sidebar card replaces the Jobs group the Status card used to print.
9
+ // Rendering is pure, so it verifies without a TUI: newest job first, one row
10
+ // per running job with its elapsed time, collapsible like Agents and Todos.
11
+
12
+ // Width math counts every painted character, so these assertions use a plain
13
+ // theme; colored roles are covered by the widget's paint calls, not here.
14
+ const plainTheme = {
15
+ fg(_color: string, text: string) {
16
+ return text;
17
+ },
18
+ bold(text: string) {
19
+ return text;
20
+ },
21
+ };
22
+
23
+ const jobs = [
24
+ { id: "job-2", label: "Watcher CI ronda 2", startedAt: 40_000 },
25
+ { id: "job-1", label: "Build runtime", startedAt: 10_000 },
26
+ ];
27
+
28
+ // The card style defaults to float; these assertions pin the outlined (neon)
29
+ // panels unless a test switches the style itself.
30
+ const initialCardStyle = cardStyle();
31
+ before(() => setCardStyle(CARD_STYLE.NEON));
32
+ after(() => setCardStyle(initialCardStyle));
33
+
34
+ test("the jobs card lists running jobs newest first with elapsed time", () => {
35
+ const lines = renderJobsCard(jobs, plainTheme, 50, 100_000, { collapsed: false, collapseControl: true });
36
+ const text = lines.join("\n");
37
+ assert.match(text, new RegExp(`${JOBS_GLYPH} Jobs`));
38
+ assert.match(text, /2 running/);
39
+ const watcher = text.indexOf("Watcher CI ronda 2");
40
+ const build = text.indexOf("Build runtime");
41
+ assert.ok(watcher >= 0, "the newest job paints");
42
+ assert.ok(build > watcher, "newest first, matching the /gentle:jobs running group");
43
+ assert.match(text, /1m 0s/, "the 60s job shows its elapsed time");
44
+ assert.match(text, /1m 30s/, "the 90s job shows its elapsed time");
45
+ assert.match(text, /▾ Collapse/);
46
+ for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
47
+ });
48
+
49
+ test("an empty job list paints nothing", () => {
50
+ assert.deepEqual(renderJobsCard([], plainTheme, 50, 100_000, { collapsed: false }), []);
51
+ });
52
+
53
+ test("a collapsed jobs card keeps the newest job and folds the rest", () => {
54
+ // Wide enough for the header to also fit the "ctrl+shift+j expand" hint.
55
+ const lines = renderJobsCard(jobs, plainTheme, 64, 100_000, { collapsed: true, collapseKey: "ctrl+shift+j", collapseControl: true });
56
+ const text = lines.join("\n");
57
+ assert.match(text, /Watcher CI ronda 2/);
58
+ assert.doesNotMatch(text, /Build runtime/);
59
+ assert.match(text, /… 1 more/);
60
+ assert.match(text, /ctrl\+shift\+j expand/);
61
+ assert.match(text, /▸ Expand/);
62
+ });
63
+
64
+ test("the collapse control is opt-in and the sort never mutates the snapshot", () => {
65
+ const snapshot = [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }];
66
+ const lines = renderJobsCard(snapshot, plainTheme, 50, 100_000, { collapsed: false });
67
+ assert.doesNotMatch(lines.join("\n"), /Collapse|Expand/);
68
+ const sorted = widgetJobs(snapshot, 100_000);
69
+ assert.deepEqual(snapshot, [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]);
70
+ assert.equal(sorted[0]?.id, "job-1");
71
+ });
72
+
73
+ // Finished tails mirror the Agents card's results: an exit lingers for a
74
+ // minute with the /gentle:jobs outcome glyph, its elapsed frozen at the end.
75
+
76
+ const exitedJob = { id: "job-1", label: "Build runtime", startedAt: 10_000, status: "exited" as const, endedAt: 70_000, exitCode: 0 as number | null };
77
+
78
+ function finishedJobs(): Array<typeof exitedJob> {
79
+ return [1, 2, 3, 4].map((n) => ({ ...exitedJob, id: `job-${n}`, label: `Job ${n}`, startedAt: n * 1_000, endedAt: 100_000 + n * 1_000 }));
80
+ }
81
+
82
+ test("a finished job lingers with a ✓ row and its elapsed frozen at the end", () => {
83
+ const lines = renderJobsCard([exitedJob], plainTheme, 50, 100_000, { collapsed: false });
84
+ const text = lines.join("\n");
85
+ assert.match(text, /✓\s+Build runtime/);
86
+ assert.match(text, /1m 0s/, "elapsed freezes at endedAt instead of ticking to 1m 30s");
87
+ assert.match(text, /1 finished/);
88
+ for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
89
+ });
90
+
91
+ test("a nonzero exit paints ✗ and a stopped job paints –", () => {
92
+ const lines = renderJobsCard([
93
+ { ...exitedJob, exitCode: 1 },
94
+ { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
95
+ ], plainTheme, 50, 100_000, { collapsed: false });
96
+ const text = lines.join("\n");
97
+ assert.match(text, /✗\s+Build runtime/);
98
+ assert.match(text, /–\s+Watcher CI/);
99
+ assert.match(text, /2 finished/);
100
+ });
101
+
102
+ test("a failed job rides the tail with the ✗ glyph", () => {
103
+ const lines = renderJobsCard([{ ...exitedJob, status: "failed" as const }], plainTheme, 50, 100_000, { collapsed: false });
104
+ const text = lines.join("\n");
105
+ assert.match(text, /✗\s+Build runtime/);
106
+ assert.match(text, /1m 0s/, "elapsed freezes at endedAt like the other outcomes");
107
+ assert.match(text, /1 finished/);
108
+ });
109
+
110
+ test("finished jobs past the TTL paint nothing", () => {
111
+ // now - endedAt lands exactly on the TTL: not fresh anymore.
112
+ assert.deepEqual(renderJobsCard([{ ...exitedJob, endedAt: 40_000 }], plainTheme, 50, 100_000, { collapsed: false }), []);
113
+ });
114
+
115
+ test("at most the three newest finished jobs survive", () => {
116
+ const jobs = finishedJobs();
117
+ const shown = widgetJobs(jobs, 105_000);
118
+ assert.deepEqual(shown.map((job) => job.id), ["job-4", "job-3", "job-2"]);
119
+ assert.deepEqual(jobs, finishedJobs(), "the TTL filter never mutates the snapshot");
120
+ });
121
+
122
+ test("running entries sort before the finished tail and never expire", () => {
123
+ const snapshot = [
124
+ { ...exitedJob, id: "done", label: "Old build", startedAt: 1_000 },
125
+ { id: "old", label: "Old run", startedAt: 1_000 },
126
+ { id: "new", label: "New run", startedAt: 50_000 },
127
+ ];
128
+ const shown = widgetJobs(snapshot, 100_000);
129
+ assert.deepEqual(shown.map((job) => job.id), ["new", "old", "done"]);
130
+ });
131
+
132
+ test("the subtitle mixes running and finished counts", () => {
133
+ const lines = renderJobsCard([
134
+ { id: "job-1", label: "New run", startedAt: 50_000 },
135
+ { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
136
+ ], plainTheme, 50, 100_000, { collapsed: false });
137
+ assert.match(lines.join("\n"), /1 running · 1 finished/);
138
+ });
@@ -7,7 +7,7 @@ import test from "node:test";
7
7
  import { applyModelConfig, applyModelConfigAsync, applySavedModelConfig } from "../extensions/gentle-ai.ts";
8
8
 
9
9
  // gentle-ai#4946: the activation sweep re-applies saved routing without user
10
- // consent. Persisted clear entries (an "inherit" saved from /gentle:models is
10
+ // consent. Persisted clear entries (an "inherit" saved from /gentle:profiles is
11
11
  // `{}`) must not delete `model_profiles` entries the user authored in the
12
12
  // agent-home `subagents.json`. Clearing stays owned by the consented flows
13
13
  // that issue it: the panel save and a confirmed profile apply.
@@ -66,7 +66,7 @@ function preservedProfilesFixture(t: test.TestContext) {
66
66
 
67
67
  test("startup sweep keeps user model_profiles entries for persisted clears", async (t) => {
68
68
  const fixture = preservedProfilesFixture(t);
69
- // Saved routing as /gentle:models leaves it after pressing "inherit" on
69
+ // Saved routing as /gentle:profiles leaves it after pressing "inherit" on
70
70
  // worker: a persisted clear entry. The sweep must not delete the entries.
71
71
  writeFileSync(fixture.globalModelsPath, JSON.stringify({ worker: {} }));
72
72
  const result = await applySavedModelConfig(fixture.context);
@@ -1793,9 +1793,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1793
1793
  assert.match(wrapper, /quietToolsEnabled/);
1794
1794
  });
1795
1795
 
1796
- test("Gentle Shell v4.4.0 package manifest declares the release version", () => {
1796
+ test("Gentle Shell v4.5.1 package manifest declares the release version", () => {
1797
1797
  const packageJson = readPackageJson();
1798
- assert.equal(packageJson.version, "4.4.0", "the release manifest must be explicitly pinned to v4.4.0");
1798
+ assert.equal(packageJson.version, "4.5.1", "the release manifest must be explicitly pinned to v4.5.1");
1799
1799
  assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
1800
1800
  assert.ok(packageJson.files?.includes("assets/"));
1801
1801
  assert.ok(packageJson.files?.includes("contracts/"));
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { __testing } from "../extensions/gentle-ai.ts";
4
+
5
+ // gentle-shell #55 / #57 / #125: the reply language is inferred every turn,
6
+ // never stored, so the persona prompt itself must say how to infer it. The
7
+ // rule lives on the single existing language-match bullet (the concept-level
8
+ // duplication guard allows exactly one), so these tests read the rendered
9
+ // prompt rather than a second rule.
10
+
11
+ for (const persona of ["gentleman", "neutral"] as const) {
12
+ test(`[${persona}] the reply language follows the user's latest message, never a Spanish default (#125, #57)`, () => {
13
+ const prompt = __testing.buildGentlePrompt(persona);
14
+ assert.match(prompt, /Always respond in the same language the user writes in\./);
15
+ assert.match(
16
+ prompt,
17
+ /latest message, never by earlier context or a compaction summary/,
18
+ `${persona} prompt must judge the reply language by the user's latest message, so a compaction summary cannot flip it (#57)`,
19
+ );
20
+ assert.match(
21
+ prompt,
22
+ /never default to Spanish: a message in English gets a reply in English/,
23
+ `${persona} prompt must forbid a Spanish default, so an English message never gets a Spanish reply (#125)`,
24
+ );
25
+ });
26
+ }
27
+
28
+ test("[neutral] the voseo prohibition covers regular and irregular forms, not only four irregular verbs (#55)", () => {
29
+ const prompt = __testing.buildGentlePrompt("neutral");
30
+ assert.match(prompt, /Do NOT use voseo/);
31
+ assert.match(prompt, /regular or irregular: use tú or usted forms, never vos forms/);
32
+ for (const pair of ["ajustas, not ajustás", "vives, not vivís", "mira, not mirá", "haz, not hacé"]) {
33
+ assert.ok(prompt.includes(pair), `neutral prompt must name the voseo replacement "${pair}"`);
34
+ }
35
+ });
36
+
37
+ test("[gentleman] Spanish replies keep Rioplatense voseo and get no neutral-only rule", () => {
38
+ const prompt = __testing.buildGentlePrompt("gentleman");
39
+ assert.match(prompt, /When the user writes Spanish, answer in natural Rioplatense Spanish with voseo\./);
40
+ assert.match(prompt, /Language: natural Rioplatense Spanish with voseo when the user writes Spanish\./);
41
+ assert.doesNotMatch(prompt, /never vos forms/, "gentleman prompt must not inherit the neutral voseo prohibition");
42
+ });