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.
- package/README.md +170 -12
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/orchestrator-delegation.md +19 -9
- package/assets/orchestrator.md +5 -5
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +898 -79
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +88 -12
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-integration-v2.ts +58 -8
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +87 -11
- package/runtime/review-integration-v2.mjs +58 -8
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +6 -2
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +28 -12
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +17 -6
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +3 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +25 -0
- package/tests/review-host-relay-routing.test.ts +20 -4
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
|
|
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
|
-
):
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
181
|
+
: new GentleAiCallCard();
|
|
67
182
|
if (state) state.lifecycleComponent = true;
|
|
68
|
-
|
|
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
|
+
}
|