gentle-pi 2.4.0 → 2.5.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 (116) hide show
  1. package/README.md +170 -12
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/orchestrator-delegation.md +19 -9
  4. package/assets/orchestrator.md +5 -5
  5. package/docs/delegated-verification.md +25 -0
  6. package/docs/telemetry.md +38 -0
  7. package/extensions/ask-user-choice.ts +26 -20
  8. package/extensions/codegraph-tools.ts +94 -5
  9. package/extensions/gentle-agents.ts +588 -0
  10. package/extensions/gentle-ai.ts +898 -79
  11. package/extensions/gentle-shell.ts +547 -0
  12. package/extensions/gentle-todo.ts +199 -0
  13. package/extensions/quiet-tools.ts +1 -1
  14. package/lib/agents-config.ts +318 -0
  15. package/lib/agents-history.ts +80 -0
  16. package/lib/agents-protocol.ts +429 -0
  17. package/lib/agents-runner.ts +490 -0
  18. package/lib/agents-transcript.ts +87 -0
  19. package/lib/agents-view.ts +557 -0
  20. package/lib/agents-widget.ts +222 -0
  21. package/lib/gentle-ai-renderer.ts +142 -26
  22. package/lib/native-choice-list.ts +194 -0
  23. package/lib/native-fullscreen-interaction.ts +47 -0
  24. package/lib/native-pointer-region.ts +164 -0
  25. package/lib/native-review-cli.ts +88 -12
  26. package/lib/review-candidate-view-owner.ts +177 -0
  27. package/lib/review-candidate-view.ts +127 -35
  28. package/lib/review-consent-ui.ts +65 -0
  29. package/lib/review-integration-v2.ts +58 -8
  30. package/lib/review-last-event-controller.ts +1 -0
  31. package/lib/review-relay-contract.ts +11 -0
  32. package/lib/review-repository.ts +2 -2
  33. package/lib/review-risk-assessment.ts +339 -0
  34. package/lib/review-session-standing-permission-ipc.ts +309 -0
  35. package/lib/review-session-standing-permission.ts +219 -0
  36. package/lib/shell-bar.ts +138 -0
  37. package/lib/shell-card.ts +136 -0
  38. package/lib/shell-changes-view.ts +205 -0
  39. package/lib/shell-changes.ts +210 -0
  40. package/lib/shell-gauge.ts +40 -0
  41. package/lib/shell-prompt.ts +119 -0
  42. package/lib/shell-todo.ts +280 -0
  43. package/lib/shell-usage-view.ts +76 -0
  44. package/lib/shell-usage.ts +246 -0
  45. package/lib/telemetry-trigger.ts +151 -0
  46. package/package.json +4 -4
  47. package/runtime/native-review-cli.mjs +87 -11
  48. package/runtime/review-integration-v2.mjs +58 -8
  49. package/runtime/review-relay-contract.mjs +11 -0
  50. package/runtime/review-risk-assessment.mjs +340 -0
  51. package/runtime/telemetry-trigger.mjs +152 -0
  52. package/scripts/build-runtime-modules.mjs +2 -0
  53. package/scripts/gentle-ai-installer.mjs +10 -10
  54. package/scripts/test-packed-runner.mjs +22 -0
  55. package/scripts/verify-package-files.mjs +6 -2
  56. package/skills/_shared/review-ledger-contract.md +3 -1
  57. package/tests/agents-config.test.ts +143 -0
  58. package/tests/agents-fake-child.ts +52 -0
  59. package/tests/agents-history.test.ts +54 -0
  60. package/tests/agents-protocol.test.ts +153 -0
  61. package/tests/agents-runner-process.test.ts +111 -0
  62. package/tests/agents-runner.test.ts +402 -0
  63. package/tests/agents-transcript.test.ts +30 -0
  64. package/tests/agents-view.test.ts +274 -0
  65. package/tests/agents-widget.test.ts +111 -0
  66. package/tests/ask-user-choice.test.ts +157 -3
  67. package/tests/codegraph-tools.test.ts +110 -1
  68. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  69. package/tests/fixtures/agents-process-child.mjs +23 -0
  70. package/tests/gentle-agents.test.ts +741 -0
  71. package/tests/gentle-ai-binary.test.ts +1 -1
  72. package/tests/gentle-ai-installer.test.ts +47 -47
  73. package/tests/gentle-ai-renderer.test.ts +65 -0
  74. package/tests/gentle-ai.test.ts +28 -12
  75. package/tests/gentle-card-text.ts +35 -0
  76. package/tests/gentle-shell.test.ts +527 -0
  77. package/tests/gentle-todo.test.ts +182 -0
  78. package/tests/native-choice-list.test.ts +202 -0
  79. package/tests/native-fullscreen-interaction.test.ts +125 -0
  80. package/tests/native-pointer-region.test.ts +245 -0
  81. package/tests/native-review-capability-contract.test.ts +16 -1
  82. package/tests/native-review-cli.test.ts +40 -0
  83. package/tests/native-review-consent.test.ts +91 -0
  84. package/tests/native-review-parity-runtime.test.ts +8 -2
  85. package/tests/native-review-parity.test.ts +29 -22
  86. package/tests/orchestrator-budget.test.ts +69 -0
  87. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  88. package/tests/package-manifest.test.ts +17 -6
  89. package/tests/quiet-tool-rendering.test.ts +96 -37
  90. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  91. package/tests/rdd-status-line.test.ts +286 -0
  92. package/tests/review-candidate-view.test.ts +452 -6
  93. package/tests/review-contract-prompt.test.ts +3 -0
  94. package/tests/review-controller-native-recovery.test.ts +29 -4
  95. package/tests/review-controller-native-routing.test.ts +321 -4
  96. package/tests/review-controller-workspace-root.test.ts +45 -2
  97. package/tests/review-controller.test.ts +25 -0
  98. package/tests/review-host-relay-routing.test.ts +20 -4
  99. package/tests/review-integration-v2.test.ts +112 -0
  100. package/tests/review-last-event-closure.test.ts +7 -2
  101. package/tests/review-relay-contract.test.ts +26 -0
  102. package/tests/review-repository.test.ts +28 -1
  103. package/tests/review-risk-assessment.test.ts +626 -0
  104. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  105. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  106. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  107. package/tests/review-session-standing-permission.test.ts +126 -0
  108. package/tests/shell-bar.test.ts +176 -0
  109. package/tests/shell-card.test.ts +118 -0
  110. package/tests/shell-changes-view.test.ts +146 -0
  111. package/tests/shell-changes.test.ts +182 -0
  112. package/tests/shell-prompt.test.ts +118 -0
  113. package/tests/shell-todo.test.ts +170 -0
  114. package/tests/shell-usage-view.test.ts +62 -0
  115. package/tests/shell-usage.test.ts +197 -0
  116. package/tests/telemetry-trigger.test.ts +349 -0
@@ -0,0 +1,222 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { isFinished, TASK_STATUS, type TaskRecord, type TaskStatus } from "./agents-protocol.ts";
3
+ import { formatTokens } from "./shell-bar.ts";
4
+ import { CARD_TONE, cardInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
5
+
6
+ // Gentle Agents widget: the card above the editor. Reads task records only
7
+ // (status, prompt, counters, timestamps), so drawing it costs nothing per
8
+ // event. One row per task: glyph, agent, task summary, then
9
+ // model · tokens · cost · time right-aligned.
10
+
11
+ export const AGENTS_GLYPH = "❀";
12
+
13
+ export interface AgentsWidgetOptions {
14
+ collapsed: boolean;
15
+ collapseKey?: string;
16
+ // Rows the card may spend on tasks; beyond that the rest fold into one
17
+ // "… N more" line so the card never pushes the editor off the screen.
18
+ maxRows?: number;
19
+ viewKey?: string;
20
+ }
21
+
22
+ interface StatusLook {
23
+ glyph: string;
24
+ role: string;
25
+ }
26
+
27
+ interface Columns {
28
+ inner: number;
29
+ name: number;
30
+ task: number;
31
+ meta: number;
32
+ withModel: boolean;
33
+ }
34
+
35
+ const LOOK: Record<TaskStatus, StatusLook> = {
36
+ [TASK_STATUS.QUEUED]: { glyph: "○", role: "muted" },
37
+ [TASK_STATUS.RUNNING]: { glyph: "◐", role: "accent" },
38
+ [TASK_STATUS.WAITING]: { glyph: "?", role: "warning" },
39
+ [TASK_STATUS.COMPLETED]: { glyph: "✓", role: "success" },
40
+ [TASK_STATUS.FAILED]: { glyph: "✗", role: "error" },
41
+ [TASK_STATUS.CANCELLED]: { glyph: "–", role: "dim" },
42
+ [TASK_STATUS.TIMED_OUT]: { glyph: "✗", role: "error" },
43
+ };
44
+ const FINISHED_TTL_MS = 60_000;
45
+ const MAX_FINISHED = 3;
46
+ const ROWS_MIN = 3;
47
+ const ROWS_MAX = 8;
48
+ const ROWS_RATIO = 0.25;
49
+ const SHOW_PRIORITY: Record<TaskStatus, number> = {
50
+ [TASK_STATUS.WAITING]: 0,
51
+ [TASK_STATUS.RUNNING]: 1,
52
+ [TASK_STATUS.QUEUED]: 2,
53
+ [TASK_STATUS.COMPLETED]: 3,
54
+ [TASK_STATUS.FAILED]: 3,
55
+ [TASK_STATUS.CANCELLED]: 3,
56
+ [TASK_STATUS.TIMED_OUT]: 3,
57
+ };
58
+ const NAME_MAX = 20;
59
+ const TASK_MIN = 12;
60
+ const GLYPH_GAP = " ";
61
+ const COLUMN_GAP = " ";
62
+ const NAME_ROLE = "text";
63
+ const TASK_ROLE = "muted";
64
+ const META_ROLE = "dim";
65
+ const ELLIPSIS = "…";
66
+
67
+ export function formatElapsed(ms: number): string {
68
+ const total = Math.max(0, Math.floor(ms / 1000));
69
+ if (total < 60) return `${total}s`;
70
+ const minutes = Math.floor(total / 60);
71
+ if (minutes < 60) return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
72
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
73
+ }
74
+
75
+ function clip(text: string, width: number): string {
76
+ if (visibleWidth(text) <= width) return text;
77
+ let out = "";
78
+ for (const char of text) {
79
+ if (visibleWidth(out + char) > width - 1) break;
80
+ out += char;
81
+ }
82
+ return `${out}${ELLIPSIS}`;
83
+ }
84
+
85
+ // Every active task plus the few that finished within the last minute, in
86
+ // the order they started, so a batch reads top to bottom like a timeline.
87
+ export function widgetTasks(tasks: readonly TaskRecord[], now: number): TaskRecord[] {
88
+ const active = tasks.filter((task) => !isFinished(task.status));
89
+ const finished = tasks
90
+ .filter((task) => isFinished(task.status) && task.endedAt !== null && now - task.endedAt < FINISHED_TTL_MS)
91
+ .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
92
+ .slice(0, MAX_FINISHED);
93
+ return [...active, ...finished].sort(startOrder);
94
+ }
95
+
96
+ // How long until the next finished row leaves the card, or undefined when no
97
+ // shown row is waiting to expire. The host asks for exactly one frame at that
98
+ // moment instead of ticking while the terminal is idle.
99
+ export function widgetExpiryMs(tasks: readonly TaskRecord[], now: number): number | undefined {
100
+ const deadlines = widgetTasks(tasks, now)
101
+ .filter((task) => isFinished(task.status) && task.endedAt !== null)
102
+ .map((task) => (task.endedAt ?? now) + FINISHED_TTL_MS - now);
103
+ return deadlines.length === 0 ? undefined : Math.max(1, Math.min(...deadlines));
104
+ }
105
+
106
+ // A quarter of the terminal, never fewer than three rows nor more than eight.
107
+ export function widgetRows(terminalRows: number | undefined): number {
108
+ if (terminalRows === undefined) return ROWS_MAX;
109
+ return Math.max(ROWS_MIN, Math.min(ROWS_MAX, Math.floor(terminalRows * ROWS_RATIO)));
110
+ }
111
+
112
+ function startOrder(a: TaskRecord, b: TaskRecord): number {
113
+ return (a.startedAt ?? a.createdAt) - (b.startedAt ?? b.createdAt);
114
+ }
115
+
116
+ // When the card overflows, questions and running work keep their rows first;
117
+ // what stays visible is still drawn in start order.
118
+ function visibleRows(shown: readonly TaskRecord[], maxRows: number | undefined): { listed: TaskRecord[]; hidden: number } {
119
+ if (maxRows === undefined || shown.length <= maxRows) return { listed: [...shown], hidden: 0 };
120
+ const kept = Math.max(1, maxRows - 1);
121
+ const listed = [...shown]
122
+ .sort((a, b) => SHOW_PRIORITY[a.status] - SHOW_PRIORITY[b.status] || startOrder(a, b))
123
+ .slice(0, kept)
124
+ .sort(startOrder);
125
+ return { listed, hidden: shown.length - listed.length };
126
+ }
127
+
128
+ function overflowRow(hidden: number, theme: CardTheme, viewKey: string | undefined): string {
129
+ const hint = viewKey ? ` · ${viewKey} to view` : "";
130
+ return theme.fg(META_ROLE, `${ELLIPSIS} ${hidden} more${hint}`);
131
+ }
132
+
133
+ function elapsed(task: TaskRecord, now: number): string {
134
+ return task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt);
135
+ }
136
+
137
+ function modelLabel(task: TaskRecord): string {
138
+ const id = task.model.includes("/") ? task.model.slice(task.model.lastIndexOf("/") + 1) : task.model;
139
+ return id === "default" ? "" : id;
140
+ }
141
+
142
+ function meta(task: TaskRecord, now: number, withModel: boolean): string {
143
+ if (task.status === TASK_STATUS.QUEUED) return "queued";
144
+ const parts = [withModel ? modelLabel(task) : "", task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", elapsed(task, now)];
145
+ return parts.filter((part) => part.length > 0).join(" · ");
146
+ }
147
+
148
+ function taskText(task: TaskRecord): string {
149
+ if (task.status === TASK_STATUS.WAITING) return task.lastStep;
150
+ if (task.error) return task.error;
151
+ return task.label;
152
+ }
153
+
154
+ // Narrow cards give up the task column first, then the model label.
155
+ function columns(tasks: readonly TaskRecord[], inner: number, now: number): Columns {
156
+ const name = Math.min(NAME_MAX, Math.max(...tasks.map((task) => visibleWidth(task.agent))));
157
+ const fixed = 1 + GLYPH_GAP.length + name + COLUMN_GAP.length;
158
+ const metaWidth = (withModel: boolean) => Math.max(...tasks.map((task) => visibleWidth(meta(task, now, withModel))));
159
+ const full = metaWidth(true);
160
+ const task = inner - fixed - full - COLUMN_GAP.length;
161
+ if (task >= TASK_MIN) return { inner, name, meta: full, task, withModel: true };
162
+ return { inner, name, meta: metaWidth(false), task: 0, withModel: false };
163
+ }
164
+
165
+ function row(task: TaskRecord, theme: CardTheme, cols: Columns, now: number): string[] {
166
+ const look = LOOK[task.status];
167
+ const name = clip(task.agent, cols.name);
168
+ const head = `${theme.fg(look.role, look.glyph)}${GLYPH_GAP}${theme.fg(NAME_ROLE, name)}${" ".repeat(cols.name - visibleWidth(name))}`;
169
+ const tail = theme.fg(META_ROLE, meta(task, now, cols.withModel).padStart(cols.meta));
170
+ if (cols.task === 0) return [`${head}${" ".repeat(Math.max(COLUMN_GAP.length, cols.inner - visibleWidth(head) - visibleWidth(tail)))}${tail}`];
171
+ const text = clip(taskText(task), cols.task);
172
+ return [`${head}${COLUMN_GAP}${theme.fg(TASK_ROLE, text)}${" ".repeat(cols.task - visibleWidth(text))}${COLUMN_GAP}${tail}`];
173
+ }
174
+
175
+ function counts(tasks: readonly TaskRecord[]): string {
176
+ const labels: Array<[TaskStatus, string]> = [
177
+ [TASK_STATUS.RUNNING, "active"],
178
+ [TASK_STATUS.WAITING, "waiting"],
179
+ [TASK_STATUS.QUEUED, "queued"],
180
+ [TASK_STATUS.COMPLETED, "done"],
181
+ [TASK_STATUS.FAILED, "failed"],
182
+ [TASK_STATUS.TIMED_OUT, "timed out"],
183
+ [TASK_STATUS.CANCELLED, "cancelled"],
184
+ ];
185
+ return labels
186
+ .map(([status, label]) => [tasks.filter((task) => task.status === status).length, label] as const)
187
+ .filter(([count]) => count > 0)
188
+ .map(([count, label]) => `${count} ${label}`)
189
+ .join(" · ");
190
+ }
191
+
192
+ function tone(tasks: readonly TaskRecord[]): CardTone {
193
+ if (tasks.some((task) => task.status === TASK_STATUS.WAITING)) return CARD_TONE.WARNING;
194
+ if (tasks.some((task) => task.status === TASK_STATUS.FAILED || task.status === TASK_STATUS.TIMED_OUT)) return CARD_TONE.ERROR;
195
+ return CARD_TONE.INFO;
196
+ }
197
+
198
+ // The batch clock: from the first start among the shown tasks until now, or
199
+ // until the last one ended when nothing is running.
200
+ function batchElapsed(tasks: readonly TaskRecord[], now: number): string | undefined {
201
+ const starts = tasks.map((task) => task.startedAt).filter((value): value is number => value !== null);
202
+ if (starts.length === 0) return undefined;
203
+ const active = tasks.some((task) => !isFinished(task.status));
204
+ const end = active ? now : Math.max(...tasks.map((task) => task.endedAt ?? now));
205
+ return formatElapsed(end - Math.min(...starts));
206
+ }
207
+
208
+ export function renderAgentsCard(tasks: readonly TaskRecord[], theme: CardTheme, width: number, now: number, options: AgentsWidgetOptions): string[] {
209
+ const shown = widgetTasks(tasks, now);
210
+ if (shown.length === 0) return [];
211
+ const cols = columns(shown, cardInnerWidth(width), now);
212
+ const { listed, hidden } = options.collapsed ? { listed: [shown[0]], hidden: 0 } : visibleRows(shown, options.maxRows);
213
+ const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : batchElapsed(shown, now);
214
+ const body = listed.flatMap((task) => row(task, theme, cols, now));
215
+ if (hidden > 0) body.push(overflowRow(hidden, theme, options.viewKey));
216
+ return renderCard(
217
+ { title: "Agents", subtitle: counts(shown), body, tone: tone(shown), glyph: AGENTS_GLYPH },
218
+ theme,
219
+ width,
220
+ { expanded: true, hint },
221
+ );
222
+ }
@@ -1,16 +1,21 @@
1
1
  import { keyHint, type AgentToolResult } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
2
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { CARD_TONE, cardBottom, cardInnerWidth, cardLine, cardTop, type Card, type CardTheme, type CardTone } from "./shell-card.ts";
3
4
  import { sanitizeTerminalText } from "./terminal-theme.ts";
4
5
 
5
- type GentleAiLifecycleColor = "warning" | "success" | "error" | "dim";
6
+ // Gentle AI tool cards: every call into the gentle-ai binary and every
7
+ // gentle_review tool draws the same card as the other Gentle notices. The
8
+ // call component owns the top rule; the result component closes the frame.
6
9
 
7
- export interface GentleAiRenderTheme {
8
- bold(value: string): string;
9
- fg(color: GentleAiLifecycleColor, value: string): string;
10
- }
10
+ export type GentleAiRenderTheme = CardTheme;
11
11
 
12
12
  export interface GentleAiRenderState {
13
- lifecycleComponent?: boolean; genericLocked?: boolean;
13
+ lifecycleComponent?: boolean;
14
+ genericLocked?: boolean;
15
+ /** Set by the result renderer once a final result exists, so a replayed
16
+ * call (which pi never marks as started) still shows its outcome. */
17
+ finished?: boolean;
18
+ failed?: boolean;
14
19
  }
15
20
 
16
21
  export interface GentleAiRenderContext {
@@ -18,10 +23,35 @@ export interface GentleAiRenderContext {
18
23
  executionStarted?: boolean;
19
24
  isPartial?: boolean;
20
25
  isError?: boolean;
26
+ expanded?: boolean;
21
27
  lastComponent?: unknown;
22
28
  state?: unknown;
29
+ invalidate?: () => void;
23
30
  }
24
31
 
32
+ const LIFECYCLE_STATUS = {
33
+ PREPARING: "preparing",
34
+ RUNNING: "running",
35
+ COMPLETED: "completed",
36
+ FAILED: "failed",
37
+ } as const;
38
+
39
+ type LifecycleStatus = (typeof LIFECYCLE_STATUS)[keyof typeof LIFECYCLE_STATUS];
40
+
41
+ const STATUS_TONE: Record<LifecycleStatus, CardTone> = {
42
+ [LIFECYCLE_STATUS.PREPARING]: CARD_TONE.WARNING,
43
+ [LIFECYCLE_STATUS.RUNNING]: CARD_TONE.WARNING,
44
+ [LIFECYCLE_STATUS.COMPLETED]: CARD_TONE.SUCCESS,
45
+ [LIFECYCLE_STATUS.FAILED]: CARD_TONE.ERROR,
46
+ };
47
+
48
+ const CARD_TITLE = "Gentle AI";
49
+ // The binary keeps its rose; Gentle Shell notices keep the flower.
50
+ const CARD_GLYPH = "\u{1F339}\uFE0E";
51
+ const DETAIL_ROLE = "dim";
52
+ const HIDDEN_ROLE = "dim";
53
+ const passthroughTheme: CardTheme = { fg: (_color, text) => text };
54
+
25
55
  export function getGentleAiRenderState(state: unknown): GentleAiRenderState | undefined {
26
56
  if (!state || typeof state !== "object" || Array.isArray(state)) return undefined;
27
57
  const rowState = state as Record<string, unknown>, existing = rowState.gentleAiRender;
@@ -29,19 +59,103 @@ export function getGentleAiRenderState(state: unknown): GentleAiRenderState | un
29
59
  return (rowState.gentleAiRender = {} as GentleAiRenderState);
30
60
  }
31
61
 
62
+ // The call row: the top rule, with the expand key at its right end once the
63
+ // tool finished, and the command when expanded. pi renders the result
64
+ // component right below it, and that one closes the frame.
65
+ // The call card owns the top rule. While the execution is still running it
66
+ // also closes the frame, because no result row exists yet; once a final
67
+ // result is in, the result card closes it instead.
68
+ export class GentleAiCallCard {
69
+ private card: Card = { title: CARD_TITLE, body: [], tone: CARD_TONE.WARNING };
70
+ private theme: CardTheme = passthroughTheme;
71
+ private detail: string | undefined;
72
+ private hint: string | undefined;
73
+ private open = true;
74
+
75
+ update(status: LifecycleStatus, operationPath: string, theme: CardTheme, detail?: string, hint?: string): void {
76
+ this.card = { title: CARD_TITLE, subtitle: `${status} · ${operationPath}`, body: [], tone: STATUS_TONE[status], glyph: CARD_GLYPH };
77
+ this.theme = theme;
78
+ this.detail = detail;
79
+ this.hint = hint;
80
+ this.open = status === LIFECYCLE_STATUS.RUNNING || status === LIFECYCLE_STATUS.PREPARING;
81
+ }
82
+
83
+ render(width: number): string[] {
84
+ const lines = [cardTop(this.card, this.theme, width, this.hint)];
85
+ if (this.detail) lines.push(cardLine(this.theme.fg(DETAIL_ROLE, this.detail), this.card.tone, this.theme, width));
86
+ if (this.open) lines.push(cardBottom(this.card.tone, this.theme, width));
87
+ return lines;
88
+ }
89
+
90
+ invalidate(): void {}
91
+ }
92
+
93
+ // The result rows: the body when expanded, a count-only line when collapsed
94
+ // (the call card carries the expand key and the text stays hidden), and
95
+ // always the bottom rule that closes the frame. The rail follows the outcome:
96
+ // amber while partial, green when done, red on error.
97
+ export class GentleAiResultCard {
98
+ private readonly text: string;
99
+ private readonly expanded: boolean;
100
+ private readonly tone: CardTone;
101
+ private readonly theme: CardTheme;
102
+ private readonly partial: boolean;
103
+
104
+ constructor(text: string, expanded: boolean, tone: CardTone, theme: CardTheme, partial = false) {
105
+ this.text = text;
106
+ this.expanded = expanded;
107
+ this.tone = tone;
108
+ this.theme = theme;
109
+ this.partial = partial;
110
+ }
111
+
112
+ render(width: number): string[] {
113
+ const lines: string[] = [];
114
+ if (this.text.length > 0) {
115
+ if (this.expanded) {
116
+ const innerWidth = cardInnerWidth(width);
117
+ for (const raw of this.text.split("\n")) {
118
+ for (const line of raw === "" ? [""] : wrapTextWithAnsi(raw, innerWidth)) lines.push(cardLine(line, this.tone, this.theme, width));
119
+ }
120
+ } else {
121
+ const count = this.text.split("\n").length;
122
+ lines.push(cardLine(this.theme.fg(HIDDEN_ROLE, `${count} ${count === 1 ? "line" : "lines"}`), this.tone, this.theme, width));
123
+ }
124
+ }
125
+ // A partial result sits under a running call card, which still closes the frame.
126
+ if (!this.partial) lines.push(cardBottom(this.tone, this.theme, width));
127
+ return lines;
128
+ }
129
+
130
+ invalidate(): void {}
131
+ }
132
+
32
133
  export interface GentleAiResultRenderOptions {
33
134
  expanded?: boolean;
135
+ isPartial?: boolean;
136
+ isError?: boolean;
34
137
  }
35
138
 
36
139
  export function renderGentleAiResult(
37
140
  result: AgentToolResult<unknown>,
38
141
  options: GentleAiResultRenderOptions,
39
- ): Text {
142
+ theme: CardTheme = passthroughTheme,
143
+ context?: GentleAiRenderContext,
144
+ ): GentleAiResultCard {
40
145
  const textItems = result.content.flatMap((content) => (content.type === "text" ? [sanitizeTerminalText(content.text)] : []));
41
- const text = textItems.join("\n");
42
- const hasExpandableText = textItems.some((item) => item.length > 0);
43
- const output = !hasExpandableText ? "" : options.expanded ? text : keyHint("app.tools.expand", "to expand");
44
- return new Text(output, 0, 0);
146
+ const text = textItems.some((item) => item.length > 0) ? textItems.join("\n") : "";
147
+ const tone = options.isError ? CARD_TONE.ERROR : options.isPartial ? CARD_TONE.WARNING : CARD_TONE.SUCCESS;
148
+ const state = getGentleAiRenderState(context?.state);
149
+ if (state && options.isPartial !== true) {
150
+ const changed = state.finished !== true || state.failed !== (options.isError === true);
151
+ state.finished = true;
152
+ state.failed = options.isError === true;
153
+ // pi's invalidate re-runs the tool display synchronously; called from
154
+ // inside this render it would nest a second call+result pair into the
155
+ // same container. Deferring it keeps one frame per execution.
156
+ if (changed) queueMicrotask(() => context?.invalidate?.());
157
+ }
158
+ return new GentleAiResultCard(text, options.expanded === true, tone, theme, options.isPartial === true);
45
159
  }
46
160
 
47
161
  export function renderGentleAiLifecycleCall(
@@ -49,22 +163,24 @@ export function renderGentleAiLifecycleCall(
49
163
  theme: GentleAiRenderTheme,
50
164
  context?: GentleAiRenderContext,
51
165
  detail?: string,
52
- ): Text {
53
- const status = context?.isError
54
- ? "failed"
55
- : context?.argsComplete === false
56
- ? "preparing"
57
- : !context?.executionStarted || context.isPartial
58
- ? "running"
59
- : "completed";
60
- const color = status === "preparing" || status === "running" ? "warning" : status === "completed" ? "success" : "error";
61
- const lines = [theme.fg(color, theme.bold(`🌹︎ Gentle AI · ${status} · ${operationPath}`))];
62
- if (detail) lines.push(theme.fg("dim", sanitizeTerminalText(detail)));
166
+ ): GentleAiCallCard {
167
+ // A finished execution is completed even when pi replays it without
168
+ // argsComplete (session reload); preparing only applies before it starts.
63
169
  const state = getGentleAiRenderState(context?.state);
64
- const component = context?.lastComponent instanceof Text && (!state || state.lifecycleComponent === true)
170
+ const finished = (context?.executionStarted === true && context.isPartial !== true) || state?.finished === true;
171
+ const failed = context?.isError === true || state?.failed === true;
172
+ const status: LifecycleStatus = failed
173
+ ? LIFECYCLE_STATUS.FAILED
174
+ : finished
175
+ ? LIFECYCLE_STATUS.COMPLETED
176
+ : context?.argsComplete === false
177
+ ? LIFECYCLE_STATUS.PREPARING
178
+ : LIFECYCLE_STATUS.RUNNING;
179
+ const component = context?.lastComponent instanceof GentleAiCallCard && (!state || state.lifecycleComponent === true)
65
180
  ? context.lastComponent
66
- : new Text("", 0, 0);
181
+ : new GentleAiCallCard();
67
182
  if (state) state.lifecycleComponent = true;
68
- component.setText(lines.join("\n"));
183
+ const hint = finished ? keyHint("app.tools.expand", context?.expanded ? "to collapse" : "to expand") : undefined;
184
+ component.update(status, operationPath, theme, detail ? sanitizeTerminalText(detail) : undefined, hint);
69
185
  return component;
70
186
  }
@@ -0,0 +1,194 @@
1
+ import {
2
+ Box,
3
+ Container,
4
+ KeybindingsManager,
5
+ isKeyRelease,
6
+ matchesKey,
7
+ Text,
8
+ type TuiMouseEvent,
9
+ type TuiMouseEventResult,
10
+ } from "@earendil-works/pi-tui";
11
+ import { NativePointerScope } from "./native-pointer-region.ts";
12
+
13
+ export interface NativeChoiceItem {
14
+ id: string;
15
+ label: string;
16
+ description?: string;
17
+ }
18
+
19
+ export interface NativeChoiceListTheme {
20
+ selectedPrefix(text: string): string;
21
+ selectedText(text: string): string;
22
+ description(text: string): string;
23
+ hoverBackground(text: string): string;
24
+ }
25
+
26
+ interface Row<T extends NativeChoiceItem> {
27
+ item: T;
28
+ text: Text;
29
+ box: Box;
30
+ }
31
+
32
+ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
33
+ onSelect?: (item: T) => void;
34
+ onCancel?: () => void;
35
+ private readonly rows: Row<T>[] = [];
36
+ private readonly items: readonly T[];
37
+ private readonly theme: NativeChoiceListTheme;
38
+ private readonly keybindings: KeybindingsManager | undefined;
39
+ private selected = 0;
40
+ private hovered: string | undefined;
41
+ private disabled = false;
42
+ private readonly pointerScope = new NativePointerScope();
43
+ private renderedWidth: number | undefined;
44
+
45
+ constructor(items: readonly T[], theme: NativeChoiceListTheme, keybindings?: KeybindingsManager) {
46
+ super();
47
+ this.items = items;
48
+ this.theme = theme;
49
+ this.keybindings = keybindings;
50
+ for (const item of items) this.addRow(item);
51
+ this.refreshRows();
52
+ }
53
+
54
+ getSelectedItem(): T | undefined {
55
+ return this.items[this.selected];
56
+ }
57
+
58
+ setSelectedIndex(index: number): void {
59
+ const next = Math.max(0, Math.min(this.items.length - 1, index));
60
+ if (next === this.selected) return;
61
+ this.selected = next;
62
+ this.refreshRows();
63
+ }
64
+
65
+ clearHover(): boolean {
66
+ if (!this.hovered) return false;
67
+ this.hovered = undefined;
68
+ this.refreshRows();
69
+ return true;
70
+ }
71
+
72
+ setDisabled(disabled: boolean): void {
73
+ this.disabled = disabled;
74
+ this.pointerScope.setDisabled(disabled);
75
+ if (disabled) this.clearHover();
76
+ }
77
+
78
+ createMouseObserver(requestRender: () => void) {
79
+ return this.pointerScope.createMouseObserver(requestRender);
80
+ }
81
+
82
+ override handleMouse(event: TuiMouseEvent) {
83
+ const result = super.handleMouse(event);
84
+ if (result || this.disabled || event.type !== "wheel" || !event.wheelDelta) return result;
85
+ const before = this.selected;
86
+ this.setSelectedIndex(this.selected + (event.wheelDelta < 0 ? -1 : 1));
87
+ return {
88
+ handled: true as const,
89
+ render: before !== this.selected,
90
+ target: {
91
+ component: this,
92
+ originX: event.screenX - event.x,
93
+ originY: event.screenY - event.y,
94
+ width: event.width,
95
+ height: event.height,
96
+ },
97
+ };
98
+ }
99
+
100
+ handleInput(data: string): void {
101
+ if (this.disabled || isKeyRelease(data)) return;
102
+ if (this.matches(data, "tui.select.up")) this.setSelectedIndex(this.selected - 1);
103
+ else if (this.matches(data, "tui.select.down")) this.setSelectedIndex(this.selected + 1);
104
+ else if (this.matches(data, "tui.select.confirm")) {
105
+ const item = this.getSelectedItem();
106
+ if (item) this.onSelect?.(item);
107
+ }
108
+ else if (this.matches(data, "tui.select.cancel")) this.onCancel?.();
109
+ }
110
+
111
+ override render(width: number): string[] {
112
+ if (this.renderedWidth !== width) {
113
+ this.renderedWidth = width;
114
+ this.pointerScope.invalidate();
115
+ }
116
+ return super.render(width);
117
+ }
118
+
119
+ override invalidate(): void {
120
+ this.renderedWidth = undefined;
121
+ this.pointerScope.invalidate();
122
+ super.invalidate();
123
+ }
124
+
125
+ private addRow(item: T): void {
126
+ const text = new Text("", 1, 0);
127
+ const box = new Box(0, 0, (value) => this.hovered === item.id ? this.theme.hoverBackground(value) : value);
128
+ box.addChild(text);
129
+ this.rows.push({ item, text, box });
130
+ this.addChild(this.pointerScope.wrap(box, {
131
+ onHover: (event) => this.handleRowMouse(item, event),
132
+ onLeave: () => this.clearHover(),
133
+ onPress: (event) => this.handleRowMouse(item, event),
134
+ onClick: (event) => this.handleRowMouse(item, event),
135
+ }));
136
+ }
137
+
138
+ private handleRowMouse(item: T, event: TuiMouseEvent): TuiMouseEventResult | undefined {
139
+ if (this.disabled) return undefined;
140
+ if (event.type === "move" && event.button === "none") {
141
+ const changed = this.setHover(item.id);
142
+ return { handled: true, render: changed };
143
+ }
144
+ if (event.button !== "left") return undefined;
145
+ if (event.type === "press") {
146
+ const changed = this.selectItem(item.id);
147
+ return { handled: true, focus: true, render: changed };
148
+ }
149
+ if (event.type === "click") {
150
+ this.selectItem(item.id);
151
+ this.onSelect?.(item);
152
+ return { handled: true };
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ private matches(
158
+ data: string,
159
+ binding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel",
160
+ ): boolean {
161
+ if (this.keybindings?.matches) return this.keybindings.matches(data, binding);
162
+ const key = binding === "tui.select.up" ? "up"
163
+ : binding === "tui.select.down" ? "down"
164
+ : binding === "tui.select.confirm" ? "enter" : "escape";
165
+ return matchesKey(data, key);
166
+ }
167
+
168
+ private selectItem(id: string): boolean {
169
+ const index = this.items.findIndex((item) => item.id === id);
170
+ if (index === -1 || index === this.selected) return false;
171
+ this.selected = index;
172
+ this.refreshRows();
173
+ return true;
174
+ }
175
+
176
+ private setHover(id: string): boolean {
177
+ if (this.hovered === id) return false;
178
+ this.hovered = id;
179
+ this.refreshRows();
180
+ return true;
181
+ }
182
+
183
+ private refreshRows(): void {
184
+ for (const [index, row] of this.rows.entries()) {
185
+ const prefix = index === this.selected ? this.theme.selectedPrefix("→ ") : " ";
186
+ const label = index === this.selected ? this.theme.selectedText(row.item.label) : row.item.label;
187
+ const text = row.item.description
188
+ ? `${prefix}${label}\n ${this.theme.description(row.item.description)}`
189
+ : `${prefix}${label}`;
190
+ row.text.setText(text);
191
+ row.box.invalidate();
192
+ }
193
+ }
194
+ }
@@ -0,0 +1,47 @@
1
+ import { Container, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+
3
+ export interface NativeFullscreenMouseObserver {
4
+ beforeMouse(event: TuiMouseEvent): void;
5
+ afterMouse(event: TuiMouseEvent): void;
6
+ }
7
+
8
+ export interface NativeFullscreenInteractionOptions {
9
+ keyboardTarget: Component;
10
+ requestRender(): void;
11
+ mouseObserver?: NativeFullscreenMouseObserver;
12
+ }
13
+
14
+ export class NativeFullscreenInteraction extends Container {
15
+ private readonly options: NativeFullscreenInteractionOptions;
16
+
17
+ constructor(options: NativeFullscreenInteractionOptions) {
18
+ super();
19
+ this.options = options;
20
+ }
21
+
22
+ handleInput(data: string): void {
23
+ if (!this.options.keyboardTarget.handleInput) return;
24
+ this.options.keyboardTarget.handleInput(data);
25
+ this.options.requestRender();
26
+ }
27
+
28
+ override handleMouse(event: TuiMouseEvent) {
29
+ this.options.mouseObserver?.beforeMouse(event);
30
+ try {
31
+ return super.handleMouse(event);
32
+ } finally {
33
+ this.options.mouseObserver?.afterMouse(event);
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Compose native Container mouse dispatch with one keyboard-owning control.
40
+ * Optional observers run around native delegation without rewriting its result,
41
+ * so native layout, targeting, focus, wheel, and click semantics stay intact.
42
+ */
43
+ export function createNativeFullscreenInteraction(
44
+ options: NativeFullscreenInteractionOptions,
45
+ ): NativeFullscreenInteraction {
46
+ return new NativeFullscreenInteraction(options);
47
+ }