gentle-pi 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -104,19 +104,37 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
104
104
  };
105
105
 
106
106
  const todoCard = (current: TodoSession, theme: Parameters<typeof renderTodoCard>[1], scrollable: boolean, spacer: boolean): Component & { dispose(): void } => {
107
+ let hovered = false;
107
108
  const card: Component = {
108
109
  render(width: number) {
109
110
  const lines = renderTodoCard(current.state, theme, width, {
110
111
  collapsed: current.collapsed,
111
112
  staleTurns: staleTurns(current.state, current.turn),
112
113
  collapseKey,
114
+ hovered,
113
115
  ...(scrollable ? { scrollable: true } : {}),
114
116
  });
115
117
  return spacer && lines.length > 0 ? [...lines, ""] : lines;
116
118
  },
117
- invalidate() {},
119
+ invalidate() {
120
+ hovered = false;
121
+ },
118
122
  };
119
123
  const region = new NativePointerRegion(card, {
124
+ onHover(event) {
125
+ // The region spans the whole card, but only the header row (y===0)
126
+ // is the clickable control, so a move elsewhere in the card clears
127
+ // hover exactly like leaving the region entirely would.
128
+ const next = event.y === 0;
129
+ if (next === hovered) return { handled: true };
130
+ hovered = next;
131
+ return { handled: true, render: true };
132
+ },
133
+ onLeave() {
134
+ if (!hovered) return;
135
+ hovered = false;
136
+ current.host?.requestRender();
137
+ },
120
138
  onClick(event) {
121
139
  if (event.button !== "left" || event.y !== 0) return undefined;
122
140
  toggle(current);
@@ -535,18 +535,39 @@ interface BoundedRowSection {
535
535
  tail?: boolean;
536
536
  }
537
537
 
538
+ // Cache the rendered preview slice per stable tool-result object: pi re-renders
539
+ // every visible card each frame, so re-tokenizing the full output text per pass is
540
+ // pure waste. Only the returned preview slice is retained, never the full wrapped
541
+ // text, so an arbitrarily large output cannot pin every wrapped line for the
542
+ // result object's lifetime.
543
+ const boundedRowsLineCache = new WeakMap<object, Array<{ text: string; width: number; rows: number; tail: boolean; lines: string[] } | undefined>>();
544
+
538
545
  class BoundedRows implements Component {
539
546
  private readonly sections: readonly BoundedRowSection[];
547
+ private readonly cacheKey: object | undefined;
540
548
 
541
- constructor(sections: readonly BoundedRowSection[]) {
549
+ constructor(sections: readonly BoundedRowSection[], cacheKey?: object) {
542
550
  this.sections = sections;
551
+ this.cacheKey = cacheKey;
543
552
  }
544
553
 
554
+ /** Renders each section through the wrapped-line cache, sliced to the section row budget; cache hits skip re-tokenizing and re-wrapping the section text. */
545
555
  render(width: number): string[] {
546
- return this.sections.flatMap(({ text, rows, tail = false }) => {
556
+ const cache = this.cacheKey ? boundedRowsLineCache.get(this.cacheKey) : undefined;
557
+ return this.sections.flatMap(({ text, rows, tail = false }, index) => {
547
558
  if (rows <= 0) return [];
559
+ const hit = cache?.[index];
560
+ if (hit && hit.text === text && hit.width === width && hit.rows === rows && hit.tail === tail) {
561
+ return [...hit.lines];
562
+ }
548
563
  const rendered = new Text(text, 0, 0).render(width);
549
- return tail ? rendered.slice(-rows) : rendered.slice(0, rows);
564
+ const sliced = tail ? rendered.slice(-rows) : rendered.slice(0, rows);
565
+ if (this.cacheKey) {
566
+ const slot = boundedRowsLineCache.get(this.cacheKey) ?? [];
567
+ slot[index] = { text, width, rows, tail, lines: sliced };
568
+ boundedRowsLineCache.set(this.cacheKey, slot);
569
+ }
570
+ return [...sliced];
550
571
  });
551
572
  }
552
573
 
@@ -641,8 +662,10 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
641
662
  }
642
663
  return new Text(formatToolCall(toolName, callArgs, theme), 0, 0);
643
664
  },
665
+ /** Builds the card component for this render pass; collapsed cards delegate to the wrapped-line cache keyed by the tool result object. */
644
666
  renderResult(result, options, theme, context) {
645
667
  const renderContext = context as ToolRenderContextLike | undefined;
668
+ const cacheKey = typeof result === "object" && result !== null ? result : undefined;
646
669
  const safeResult = sanitizedResult(result);
647
670
  const text = safeText(extractTextContent(safeResult));
648
671
  const isError = renderContext?.isError ?? options.isError ?? false;
@@ -661,7 +684,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
661
684
  return new BoundedRows([
662
685
  { text: theme.fg("warning", partialLabel(toolName, text)), rows: 1 },
663
686
  ...(visible ? [{ text: theme.fg("muted", visible), rows: PREVIEW_LINE_LIMIT, tail: true }] : []),
664
- ]);
687
+ ], cacheKey);
665
688
  }
666
689
  if (options.expanded && toolName === "read" && hasImageContent(safeResult) && officialRenderResult) {
667
690
  return officialRenderResult(
@@ -686,7 +709,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
686
709
  return new BoundedRows([
687
710
  { text: theme.fg(color, output.replace(/^\n/, "")), rows: PREVIEW_LINE_LIMIT, tail },
688
711
  ...(hint ? [{ text: theme.fg(color, hint.slice(1)), rows: 1 }] : []),
689
- ]);
712
+ ], cacheKey);
690
713
  }
691
714
  return new Text(hint ? theme.fg(color, hint.slice(1)) : "", 0, 0);
692
715
  },
@@ -5,6 +5,7 @@ import * as os from "node:os";
5
5
  import { execFile } from "node:child_process";
6
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
+ import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
8
9
 
9
10
  const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
10
11
  const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
@@ -691,6 +692,7 @@ export default function (pi: ExtensionAPI) {
691
692
 
692
693
  let tick = 0;
693
694
  let refreshStats = () => {};
695
+ let headerCache: { key: string; out: string[] } | null = null;
694
696
  const state = {
695
697
  timer: null as NodeJS.Timeout | null,
696
698
  mode: currentIntroMode() as IntroMode,
@@ -718,18 +720,27 @@ export default function (pi: ExtensionAPI) {
718
720
  setTimeout(() => {
719
721
  ctx.ui.setHeader((tui, theme) => {
720
722
  if (state.timer) clearInterval(state.timer);
723
+ headerCache = null;
721
724
 
722
725
  refreshStats = () => tui.requestRender();
726
+ // Capture once: a command changes the live prompt, not this intro.
727
+ const animationPolicy = resolveAnimationPolicy().policy;
723
728
  const animStart = Date.now();
724
- state.timer = setInterval(() => {
725
- tick++;
726
- const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
727
- if (finished || Date.now() - animStart > 5000) {
728
- clearInterval(state.timer!);
729
- state.timer = null;
730
- }
731
- try { tui.requestRender(); } catch { cleanup(); }
732
- }, 25);
729
+ if (animationPolicy === "potato") {
730
+ tick = Number.MAX_SAFE_INTEGER;
731
+ state.timer = null;
732
+ } else {
733
+ const performance = animationPolicy === "performance";
734
+ state.timer = setInterval(() => {
735
+ tick += performance ? 10 : 1;
736
+ const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
737
+ if (finished || Date.now() - animStart > 5000) {
738
+ clearInterval(state.timer!);
739
+ state.timer = null;
740
+ }
741
+ try { tui.requestRender(); } catch { cleanup(); }
742
+ }, performance ? 250 : 25);
743
+ }
733
744
 
734
745
  // Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
735
746
  const bootStart = Date.now();
@@ -753,8 +764,11 @@ export default function (pi: ExtensionAPI) {
753
764
  process.stdout.on("resize", resizeHandler);
754
765
 
755
766
  return {
767
+ /** Renders the persistent header grid; memoized per width, tick, mode and stats so static passes reuse the built lines. */
756
768
  render(width: number): string[] {
757
769
  if (state.mode === "skip") return [];
770
+ const headerKey = `${width}|${tick}|${state.mode}|${gitBranch}|${mcpServersCount}|${extensionsCount}|${packagesCount}|${sddAgentsCount}|${ctx.cwd}|${skills.length}|${customTools.length}`;
771
+ if (headerCache?.key === headerKey) return headerCache.out;
758
772
 
759
773
  const flashStartTick = 10;
760
774
  const roseOpacity = Math.min(1, tick / 10);
@@ -1050,9 +1064,10 @@ export default function (pi: ExtensionAPI) {
1050
1064
  out.push(truncateToWidth(line, Math.max(1, width), ""));
1051
1065
  }
1052
1066
 
1067
+ headerCache = { key: headerKey, out };
1053
1068
  return out;
1054
1069
  },
1055
- invalidate() {},
1070
+ invalidate() { headerCache = null; },
1056
1071
  dispose() {
1057
1072
  cleanup();
1058
1073
  },
@@ -1,5 +1,6 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
2
  import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
3
+ import { HOVER_ROLE, paintHoverable } from "./shell-hover.ts";
3
4
  import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
4
5
  import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
5
6
  import { formatElapsed } from "./agents-widget.ts";
@@ -42,7 +43,7 @@ const ROLE = {
42
43
  FRAME: "border",
43
44
  TITLE: "customMessageLabel",
44
45
  SELECTED: "accent",
45
- HOVER: "warning",
46
+ HOVER: HOVER_ROLE,
46
47
  NAME: "text",
47
48
  NAME_IDLE: "muted",
48
49
  META: "dim",
@@ -69,7 +70,7 @@ const GLYPH_ROLE: Record<string, string> = {
69
70
  [TASK_STATUS.CANCELLED]: "dim",
70
71
  [TASK_STATUS.TIMED_OUT]: "error",
71
72
  };
72
- export const SESSION_FINISHED_TTL_MS = 15 * 60_000;
73
+ const MAX_FINISHED_PER_SESSION = 200;
73
74
  const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
74
75
  const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
75
76
  const EMPTY_LIST = "no tasks yet";
@@ -428,7 +429,8 @@ export class AgentsView {
428
429
 
429
430
  private counts(): string {
430
431
  const active = this.tasks.filter((task) => !isFinished(task.status)).length;
431
- return `${active} active`;
432
+ const finished = this.tasks.length - active;
433
+ return `${active} active · ${finished} finished`;
432
434
  }
433
435
 
434
436
  private actionableTask(): TaskRecord | undefined {
@@ -470,8 +472,12 @@ export class AgentsView {
470
472
  }
471
473
 
472
474
  private inScope(task: TaskRecord, _now: number): boolean {
473
- return !isFinished(task.status) && (task.parentSessionId === this.deps.sessionId
474
- || (this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id)));
475
+ // A local task (this session's own, active or finished history) always
476
+ // belongs; refreshTasks() is the single authority on which finished ones
477
+ // survive its own retention cap. A remote (presence) task never keeps
478
+ // history -- it drops out of scope the moment it finishes.
479
+ if (task.parentSessionId === this.deps.sessionId) return true;
480
+ return !isFinished(task.status) && this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id);
475
481
  }
476
482
 
477
483
  private directoryRoot(): boolean {
@@ -534,8 +540,19 @@ export class AgentsView {
534
540
  }
535
541
 
536
542
  private refreshTasks(): void {
537
- this.tasks = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
538
- && !isFinished(task.status) && (this.deps.isLocalTask?.(task) ?? true));
543
+ // Finished history bypasses isLocalTask: that dep exists to keep an
544
+ // unrelated task resolveTask pulled in on demand (e.g. `subagent_result`
545
+ // for an id from another session) out of the live panel, never to hide
546
+ // this session's own finished subagents -- those belong here whether
547
+ // they were restored on resume or just finished live.
548
+ const local = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
549
+ && (isFinished(task.status) || (this.deps.isLocalTask?.(task) ?? true)));
550
+ const active = local.filter((task) => !isFinished(task.status));
551
+ const finished = local
552
+ .filter((task) => isFinished(task.status))
553
+ .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
554
+ .slice(0, MAX_FINISHED_PER_SESSION);
555
+ this.tasks = [...active, ...finished];
539
556
  if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
540
557
  const groups = this.sessionGroups();
541
558
  const present = new Set(groups.map((group) => group.id));
@@ -564,7 +581,15 @@ export class AgentsView {
564
581
  group.tasks.push(task);
565
582
  }
566
583
  for (const group of groups.values()) {
567
- group.tasks.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
584
+ // Active tasks first (newest created), then finished ones as history
585
+ // (newest ended first) -- a task never reorders within its own group
586
+ // just because it finished.
587
+ group.tasks.sort((a, b) => {
588
+ const finishedRank = Number(isFinished(a.status)) - Number(isFinished(b.status));
589
+ if (finishedRank !== 0) return finishedRank;
590
+ if (isFinished(a.status)) return (b.endedAt ?? 0) - (a.endedAt ?? 0);
591
+ return b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
592
+ });
568
593
  }
569
594
  if (this.scope === VIEW_SCOPE.ALL) {
570
595
  const localId = `session:${this.deps.sessionId}`;
@@ -637,16 +662,18 @@ export class AgentsView {
637
662
  const theme = this.deps.theme;
638
663
  this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
639
664
  const selected = entry.id === this.selectedRow()?.id;
640
- const hovered = entry.id === this.hoveredId;
641
- const emphasis = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
642
- const marker = emphasis ? theme.fg(emphasis, selected ? "▸" : "▹") : " ";
665
+ // Selection always outranks hover; the shared hover painter only
666
+ // applies once selection is ruled out.
667
+ const hovered = !selected && entry.id === this.hoveredId;
668
+ const markerRole = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
669
+ const marker = markerRole ? theme.fg(markerRole, selected ? "▸" : "▹") : " ";
643
670
  if (entry.kind === "heading") {
644
671
  const state = this.isExpanded(entry.group) ? "▾" : "▸";
645
- return `${marker}${theme.fg(emphasis ?? ROLE.SELECTED, state)} ${theme.fg(emphasis ?? ROLE.NAME_IDLE, this.groupHeading(entry.group))}`;
672
+ return `${marker}${paintHoverable(theme, state, hovered, ROLE.SELECTED)} ${paintHoverable(theme, this.groupHeading(entry.group), hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE)}`;
646
673
  }
647
674
  const task = entry.task;
648
675
  const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
649
- const name = theme.fg(emphasis ?? ROLE.NAME_IDLE, `Subagent ${task.agent}`);
676
+ const name = paintHoverable(theme, `Subagent ${task.agent}`, hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE);
650
677
  const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
651
678
  return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
652
679
  }
@@ -756,7 +783,7 @@ export class AgentsView {
756
783
  actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
757
784
  control.region.render(label.length);
758
785
  const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
759
- text += this.deps.theme.fg(!control.enabled ? ROLE.META : hovered ? ROLE.HOVER : ROLE.KEY, label);
786
+ text += control.enabled ? paintHoverable(this.deps.theme, label, hovered, ROLE.KEY) : this.deps.theme.fg(ROLE.META, label);
760
787
  }
761
788
  if (required > width) {
762
789
  const more = this.actionRegion("more", () => {
@@ -29,7 +29,28 @@ interface Columns {
29
29
  name: number;
30
30
  task: number;
31
31
  meta: number;
32
- fullMetrics: boolean;
32
+ // Columnar rows give each surviving metadata field its own fixed,
33
+ // right-aligned column sized to the widest value among shown tasks, so
34
+ // model·effort, tokens, cost, and elapsed line up vertically across rows.
35
+ // A narrow card may have dropped some of those columns and still be
36
+ // columnar; false means the clipped single-string fallback.
37
+ columnar: boolean;
38
+ // Populated only when columnar is true.
39
+ metaWidths?: MetaColumnWidths;
40
+ }
41
+
42
+ interface MetaFields {
43
+ exec: string;
44
+ tokens: string;
45
+ cost: string;
46
+ elapsed: string;
47
+ }
48
+
49
+ interface MetaColumnWidths {
50
+ exec: number;
51
+ tokens: number;
52
+ cost: number;
53
+ elapsed: number;
33
54
  }
34
55
 
35
56
  const LOOK: Record<TaskStatus, StatusLook> = {
@@ -150,11 +171,48 @@ function executionLabel(task: TaskRecord, width = Infinity): string {
150
171
  return clip(model, width - visibleWidth(suffix)) + suffix;
151
172
  }
152
173
 
153
- function meta(task: TaskRecord, now: number, fullMetrics: boolean): string {
154
- if (task.status === TASK_STATUS.QUEUED) return "queued";
155
- if (!fullMetrics) return executionLabel(task);
156
- const parts = [executionLabel(task), task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", elapsed(task, now)];
157
- return parts.filter((part) => part.length > 0).join(" · ");
174
+ // Narrow (non-columnar) rows keep the single-string contract: a queued
175
+ // task shows the bare word, everything else shows its exec label.
176
+ function narrowMetaText(task: TaskRecord): string {
177
+ return task.status === TASK_STATUS.QUEUED ? "queued" : executionLabel(task);
178
+ }
179
+
180
+ // Queued rows carry no model, token, or cost data yet, so every field but
181
+ // elapsed stays blank — and elapsed itself becomes the literal word "queued"
182
+ // rather than the empty string startedAt === null would otherwise produce.
183
+ function metaFields(task: TaskRecord, now: number): MetaFields {
184
+ if (task.status === TASK_STATUS.QUEUED) return { exec: "", tokens: "", cost: "", elapsed: "queued" };
185
+ return {
186
+ exec: executionLabel(task),
187
+ tokens: task.tokens > 0 ? formatTokens(task.tokens) : "",
188
+ cost: task.cost > 0 ? `$${task.cost.toFixed(2)}` : "",
189
+ elapsed: elapsed(task, now),
190
+ };
191
+ }
192
+
193
+ function metaColumnWidths(tasks: readonly TaskRecord[], now: number): MetaColumnWidths {
194
+ const fields = tasks.map((task) => metaFields(task, now));
195
+ const widest = (pick: (field: MetaFields) => string) => Math.max(0, ...fields.map((field) => visibleWidth(pick(field))));
196
+ return { exec: widest((field) => field.exec), tokens: widest((field) => field.tokens), cost: widest((field) => field.cost), elapsed: widest((field) => field.elapsed) };
197
+ }
198
+
199
+ // A column whose widest value is empty across every shown task (e.g. no task
200
+ // carries a cost yet) is dropped entirely, together with its separator —
201
+ // exactly like the old joined string dropped an empty field, but decided once
202
+ // for the whole card rather than per row, so the remaining columns still align.
203
+ function metaTotalWidth(widths: MetaColumnWidths): number {
204
+ const active = [widths.exec, widths.tokens, widths.cost, widths.elapsed].filter((width) => width > 0);
205
+ return active.reduce((sum, width) => sum + width, 0) + Math.max(0, active.length - 1) * visibleWidth(" · ");
206
+ }
207
+
208
+ function metaRowText(task: TaskRecord, now: number, widths: MetaColumnWidths): string {
209
+ const fields = metaFields(task, now);
210
+ const parts: string[] = [];
211
+ if (widths.exec > 0) parts.push(fields.exec.padStart(widths.exec));
212
+ if (widths.tokens > 0) parts.push(fields.tokens.padStart(widths.tokens));
213
+ if (widths.cost > 0) parts.push(fields.cost.padStart(widths.cost));
214
+ if (widths.elapsed > 0) parts.push(fields.elapsed.padStart(widths.elapsed));
215
+ return parts.join(" · ");
158
216
  }
159
217
 
160
218
  function taskText(task: TaskRecord): string {
@@ -163,27 +221,40 @@ function taskText(task: TaskRecord): string {
163
221
  return task.label;
164
222
  }
165
223
 
166
- // Narrow cards give up the task and usage columns before execution metadata.
224
+ // Narrow cards degrade per column, decided once for the whole card so the
225
+ // surviving columns keep lining up: the task text goes first, then the
226
+ // model·effort label, then tokens, then cost. Elapsed is the one value the
227
+ // reader cannot rebuild from anything else on screen, so it is the last to go
228
+ // (gentle-shell#1143). Only when even elapsed alone does not fit does the row
229
+ // fall back to the clipped single-string label.
167
230
  function columns(tasks: readonly TaskRecord[], inner: number, now: number): Columns {
168
231
  const name = Math.max(0, Math.min(NAME_MAX, inner - 3, Math.max(...tasks.map((task) => visibleWidth(task.agent)))));
169
232
  const fixed = 1 + GLYPH_GAP.length + name + COLUMN_GAP.length;
170
- const metaWidth = (fullMetrics: boolean) => Math.max(...tasks.map((task) => visibleWidth(meta(task, now, fullMetrics))));
171
- const full = metaWidth(true);
233
+ const metaWidths = metaColumnWidths(tasks, now);
234
+ const full = metaTotalWidth(metaWidths);
172
235
  const task = inner - fixed - full - COLUMN_GAP.length;
173
- if (task >= TASK_MIN) return { inner, name, meta: full, task, fullMetrics: true };
174
- return { inner, name, meta: Math.max(0, Math.min(inner - fixed, metaWidth(false))), task: 0, fullMetrics: false };
236
+ if (task >= TASK_MIN) return { inner, name, meta: full, task, columnar: true, metaWidths };
237
+ let widths = metaWidths;
238
+ for (const drop of ["exec", "tokens", "cost"] as const) {
239
+ if (metaTotalWidth(widths) <= inner - fixed) break;
240
+ widths = { ...widths, [drop]: 0 };
241
+ }
242
+ const meta = metaTotalWidth(widths);
243
+ if (meta > 0 && meta <= inner - fixed) return { inner, name, meta, task: 0, columnar: true, metaWidths: widths };
244
+ const narrow = Math.max(0, ...tasks.map((task) => visibleWidth(narrowMetaText(task))));
245
+ return { inner, name, meta: Math.max(0, Math.min(inner - fixed, narrow)), task: 0, columnar: false };
175
246
  }
176
247
 
177
248
  function row(task: TaskRecord, theme: CardTheme, cols: Columns, now: number, allowMetadataRow: boolean): string[] {
178
249
  const look = LOOK[task.status];
179
250
  const name = clip(task.agent, cols.name);
180
251
  const head = `${theme.fg(look.role, look.glyph)}${GLYPH_GAP}${theme.fg(NAME_ROLE, name)}${" ".repeat(cols.name - visibleWidth(name))}`;
181
- const metadata = cols.fullMetrics ? meta(task, now, true) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
252
+ const metadata = cols.columnar && cols.metaWidths ? metaRowText(task, now, cols.metaWidths) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
182
253
  const tail = theme.fg(META_ROLE, " ".repeat(Math.max(0, cols.meta - visibleWidth(metadata))) + metadata);
183
254
  if (cols.inner < 3) return [theme.fg(look.role, clip(look.glyph, cols.inner))];
184
255
  // The scrollable sidebar can preserve identity and execution metadata on
185
256
  // separate rows. The height-capped above-editor widget keeps its row budget.
186
- if (allowMetadataRow && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
257
+ if (allowMetadataRow && !cols.columnar && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
187
258
  return [head, theme.fg(META_ROLE, executionLabel(task, cols.inner))];
188
259
  }
189
260
  if (cols.task === 0) return [`${head}${" ".repeat(Math.max(0, cols.inner - visibleWidth(head) - visibleWidth(tail)))}${tail}`];
@@ -0,0 +1,52 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { gentlePiConfigHome } from "./agent-home.ts";
5
+
6
+ export const ANIMATION_POLICY = { QUALITY: "quality", PERFORMANCE: "performance", POTATO: "potato" } as const;
7
+ export type AnimationPolicy = (typeof ANIMATION_POLICY)[keyof typeof ANIMATION_POLICY];
8
+ const SOURCE = { GLOBAL: "global_file", DEFAULT: "default" } as const;
9
+ export const ANIMATION_SCHEMA = "gentle-pi.animations/v1";
10
+
11
+ interface AnimationOptions { gentlePiConfigHome?: string }
12
+ export interface AnimationResolution {
13
+ policy: AnimationPolicy;
14
+ source: (typeof SOURCE)[keyof typeof SOURCE];
15
+ malformed: boolean;
16
+ globalFile: string;
17
+ }
18
+
19
+ export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefined {
20
+ try {
21
+ const value: unknown = JSON.parse(raw);
22
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
23
+ if (!("schema" in value) || value.schema !== ANIMATION_SCHEMA || !("policy" in value) || Object.keys(value).length !== 2) return undefined;
24
+ return value.policy === "quality" || value.policy === "performance" || value.policy === "potato" ? value.policy : undefined;
25
+ } catch { return undefined; }
26
+ }
27
+
28
+ export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution {
29
+ const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json");
30
+ try {
31
+ const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8"));
32
+ return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile };
33
+ } catch (error) {
34
+ const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
35
+ return { policy: "quality", source: missing ? SOURCE.DEFAULT : SOURCE.GLOBAL, malformed: !missing, globalFile };
36
+ }
37
+ }
38
+
39
+ /** Same-directory rename prevents readers from seeing a partially written policy. */
40
+ export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string {
41
+ const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
42
+ const path = join(home, "animations.json");
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ mkdirSync(home, { recursive: true });
45
+ try {
46
+ writeFileSync(temporary, `${JSON.stringify({ schema: ANIMATION_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
47
+ renameSync(temporary, path);
48
+ } finally {
49
+ try { unlinkSync(temporary); } catch { /* Rename already consumed the temporary file. */ }
50
+ }
51
+ return path;
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { TaskRecord } from "./agents-protocol.ts";
2
+
3
+ const ACTION = { WARM: "warm", STOP: "stop" } as const;
4
+ type WarmingAction = (typeof ACTION)[keyof typeof ACTION];
5
+ interface WarmingDecision {
6
+ warmCost: number;
7
+ missCost: number;
8
+ continuationProbability: number;
9
+ action: WarmingAction;
10
+ }
11
+ interface WarmingOverride { action: WarmingAction }
12
+ interface WarmingEvents {
13
+ on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown;
14
+ }
15
+ export interface WarmingState {
16
+ sessionId: string | undefined;
17
+ ownedTaskIds: ReadonlySet<string>;
18
+ tasks: Array<Pick<TaskRecord, "id" | "parentSessionId" | "mode" | "status">>;
19
+ }
20
+
21
+ // Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises
22
+ // continuation probability to 1, not permission to spend without a benefit.
23
+ const MINIMUM_EXPECTED_SAVINGS = 0.05;
24
+
25
+ export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void {
26
+ pi.on("cache_warming_decision", (event) => {
27
+ const { sessionId, ownedTaskIds, tasks } = state();
28
+ if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId &&
29
+ ownedTaskIds.has(task.id) && task.mode === "background" &&
30
+ (task.status === "queued" || task.status === "running"))) return;
31
+ // Only native candidates arrive here: Pi owns opt-out, replay safety,
32
+ // provider TTLs, fixed horizons, scheduling and usage outside context.
33
+ const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 &&
34
+ Number.isFinite(event.missCost) && event.missCost > 0 &&
35
+ event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS;
36
+ return { action: economic ? ACTION.WARM : ACTION.STOP };
37
+ });
38
+ }
@@ -25,6 +25,8 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
25
25
  { command: "gentle:persona", label: "Switch persona" },
26
26
  { command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
27
27
  { command: "gentle:background-subagents", label: "Background subagents" },
28
+ { command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
29
+ { command: "gentle:animations", label: "Animation mode" },
28
30
  { command: "gentle:telemetry", label: "Telemetry" },
29
31
  { command: "gentle:banner", label: "Startup banner" },
30
32
  { command: "gentle:banner-color", label: "Banner color" },