gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
2
6
|
import test from "node:test";
|
|
3
7
|
import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import
|
|
8
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
9
|
+
import installGentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
5
10
|
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
11
|
+
import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
6
12
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
7
13
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
8
14
|
|
|
@@ -11,6 +17,9 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
|
11
17
|
|
|
12
18
|
initTheme("dark");
|
|
13
19
|
|
|
20
|
+
const resolveWorktree = (path: string) => ({ root: path.startsWith("/repo") || path === "." ? "/repo" : path, commonDir: "/clone/git" });
|
|
21
|
+
const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, env, { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), ...deps });
|
|
22
|
+
|
|
14
23
|
const plainTheme = {
|
|
15
24
|
fg(_color: string, value: string) {
|
|
16
25
|
return value;
|
|
@@ -34,7 +43,7 @@ interface FakeUi {
|
|
|
34
43
|
workingVisible: boolean | undefined;
|
|
35
44
|
notices: string[];
|
|
36
45
|
overlay: unknown;
|
|
37
|
-
overlayView: { render(width: number): string[] } | undefined;
|
|
46
|
+
overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
|
|
38
47
|
closeOverlay: (() => void) | undefined;
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -54,16 +63,25 @@ interface ShortcutRegistration {
|
|
|
54
63
|
type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
|
|
55
64
|
const renderers = new Map<string, MessageRenderer>();
|
|
56
65
|
|
|
57
|
-
function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }])
|
|
66
|
+
function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
|
|
58
67
|
const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
|
|
59
68
|
const commands = new Map<string, CommandRegistration>();
|
|
60
69
|
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
61
70
|
const git: string[][] = [];
|
|
71
|
+
let entries: unknown[] = [];
|
|
72
|
+
const tools = new Map<string, { renderShell?: string; execute(id: string, params: unknown, signal: undefined, update: undefined, ctx: ExtensionContext): Promise<unknown> }>();
|
|
73
|
+
const listeners = new Map<string, (data: unknown) => void>();
|
|
62
74
|
let round = 0;
|
|
63
75
|
const pi = {
|
|
64
76
|
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
|
|
65
|
-
handlers.set(event, [...(handlers.get(event) ?? []),
|
|
77
|
+
handlers.set(event, [...(handlers.get(event) ?? []), (payload, ctx) => {
|
|
78
|
+
entries = ctx.sessionManager.getEntries();
|
|
79
|
+
return handler(payload, ctx);
|
|
80
|
+
}]);
|
|
66
81
|
},
|
|
82
|
+
appendEntry(customType: string, data: unknown) { entries.push({ type: "custom", customType, data }); },
|
|
83
|
+
events: { on(name: string, fn: (data: unknown) => void) { listeners.set(name, fn); return () => listeners.delete(name); }, emit(name: string, data: unknown) { listeners.get(name)?.(data); } },
|
|
84
|
+
registerTool(tool: { name: string }) { tools.set(tool.name, tool as never); },
|
|
67
85
|
registerCommand(name: string, registration: CommandRegistration) {
|
|
68
86
|
commands.set(name, registration);
|
|
69
87
|
},
|
|
@@ -78,20 +96,24 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]): { pi: E
|
|
|
78
96
|
},
|
|
79
97
|
async exec(_command: string, args: string[]) {
|
|
80
98
|
git.push(args);
|
|
99
|
+
if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
|
|
81
100
|
const isNumstat = args.includes("diff");
|
|
82
101
|
const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
|
|
83
102
|
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
84
103
|
},
|
|
85
104
|
} as unknown as ExtensionAPI;
|
|
86
|
-
return { pi, handlers, git, commands, shortcuts };
|
|
105
|
+
return { pi, handlers, git, commands, shortcuts, tools };
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
|
|
90
109
|
for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi } {
|
|
112
|
+
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
|
|
94
113
|
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
114
|
+
let resolveOverlay: () => void;
|
|
115
|
+
const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
|
|
116
|
+
const entries = options.entries ?? [];
|
|
95
117
|
const ctx = {
|
|
96
118
|
hasUI: options.hasUI ?? true,
|
|
97
119
|
hasPendingMessages: () => options.pending ?? false,
|
|
@@ -100,7 +122,8 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
|
|
|
100
122
|
sessionManager: {
|
|
101
123
|
getCwd: () => "/repo",
|
|
102
124
|
getSessionName: () => "Release notes",
|
|
103
|
-
getEntries: () =>
|
|
125
|
+
getEntries: () => entries,
|
|
126
|
+
getSessionId: () => "shell-session",
|
|
104
127
|
},
|
|
105
128
|
modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token },
|
|
106
129
|
getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
|
|
@@ -126,16 +149,17 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
|
|
|
126
149
|
setWorkingVisible(visible: boolean) {
|
|
127
150
|
ui.workingVisible = visible;
|
|
128
151
|
},
|
|
129
|
-
custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[] }) {
|
|
152
|
+
custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
|
|
130
153
|
ui.overlay = factory;
|
|
131
154
|
return new Promise<null>((resolve) => {
|
|
132
155
|
ui.closeOverlay = () => resolve(null);
|
|
133
156
|
ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(null));
|
|
157
|
+
resolveOverlay();
|
|
134
158
|
});
|
|
135
159
|
},
|
|
136
160
|
},
|
|
137
161
|
} as unknown as ExtensionContext;
|
|
138
|
-
return { ctx, ui };
|
|
162
|
+
return { ctx, ui, overlayReady };
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
function assistantEntry(usage: { input: number; output: number; cost: number }) {
|
|
@@ -206,6 +230,49 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
|
|
|
206
230
|
assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
|
|
207
231
|
});
|
|
208
232
|
|
|
233
|
+
test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
|
|
234
|
+
const { pi, handlers } = fakePi();
|
|
235
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
236
|
+
const entries: unknown[] = [];
|
|
237
|
+
const { ctx, ui } = fakeContext({ entries });
|
|
238
|
+
await fire(handlers, "session_start", ctx);
|
|
239
|
+
|
|
240
|
+
const statuses = new Map<string, string>();
|
|
241
|
+
const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
|
|
242
|
+
const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
243
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
|
|
244
|
+
const component = factory(tui, plainTheme, liveFooterData);
|
|
245
|
+
try {
|
|
246
|
+
const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail;
|
|
247
|
+
const live = () => rail.digest?.();
|
|
248
|
+
assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
|
|
249
|
+
assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
|
|
250
|
+
|
|
251
|
+
const beforeModel = live();
|
|
252
|
+
(ctx.model as { id: string }).id = "gpt-5.6";
|
|
253
|
+
assert.notEqual(live(), beforeModel, "/model must change the digest");
|
|
254
|
+
assert.match(rail.render(46).join("\n"), /gpt-5\.6/);
|
|
255
|
+
|
|
256
|
+
const beforeUsage = live();
|
|
257
|
+
(ctx as unknown as { getContextUsage: () => unknown }).getContextUsage = () => ({ tokens: 200_000, contextWindow: 272_000, percent: 74 });
|
|
258
|
+
assert.notEqual(live(), beforeUsage, "context usage must change the digest");
|
|
259
|
+
assert.match(rail.render(46).join("\n"), /74%/);
|
|
260
|
+
|
|
261
|
+
const beforeCost = live();
|
|
262
|
+
entries.push(assistantEntry({ input: 100, output: 20, cost: 0.42 }));
|
|
263
|
+
assert.notEqual(live(), beforeCost, "session cost must change the digest");
|
|
264
|
+
assert.match(rail.render(46).join("\n"), /\$0\.420/);
|
|
265
|
+
|
|
266
|
+
const beforeStatus = live();
|
|
267
|
+
statuses.set("mcp", "MCP: 3 servers enabled");
|
|
268
|
+
assert.notEqual(live(), beforeStatus, "extension statuses have no event and must change the digest");
|
|
269
|
+
assert.match(rail.render(46).join("\n"), /MCP: 3 servers enabled/);
|
|
270
|
+
assert.equal(live(), live(), "an unchanged digest still reuses the prepared rail");
|
|
271
|
+
} finally {
|
|
272
|
+
component.dispose();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
209
276
|
test("gentleShell stays out of the way without a UI or when disabled", () => {
|
|
210
277
|
const disabled = fakePi();
|
|
211
278
|
gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
|
|
@@ -237,7 +304,7 @@ test("gentleShell frames the editor with the petal prompt and a hint while empty
|
|
|
237
304
|
editor.focused = true;
|
|
238
305
|
const lines = editor.render(60).map(stripAnsi);
|
|
239
306
|
assert.match(lines[0], /^╭─ ✿ ─+╮$/);
|
|
240
|
-
assert.
|
|
307
|
+
assert.doesNotMatch(editor.render(60).join("\n"), /\x1b\[44m/, "prompt must not paint passive backgrounds");
|
|
241
308
|
assert.match(lines[1], /^│.*type, or \/ for commands +│$/);
|
|
242
309
|
assert.match(lines[lines.length - 1], /^╰─+╯$/);
|
|
243
310
|
editor.setText("hola");
|
|
@@ -245,6 +312,24 @@ test("gentleShell frames the editor with the petal prompt and a hint while empty
|
|
|
245
312
|
editor.dispose();
|
|
246
313
|
});
|
|
247
314
|
|
|
315
|
+
test("registered prompt stays transparent while idle, working, and queued", () => {
|
|
316
|
+
const { pi, handlers, tools } = fakePi();
|
|
317
|
+
gentleShell(pi, {});
|
|
318
|
+
const { ctx, ui } = fakeContext();
|
|
319
|
+
ctx.ui.theme = { ...plainTheme, getBgAnsi: () => "\x1b[44m" } as typeof ctx.ui.theme;
|
|
320
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
321
|
+
try {
|
|
322
|
+
for (const state of ["idle", "working", "queued"]) {
|
|
323
|
+
if (state === "working") for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
324
|
+
(ctx as unknown as { hasPendingMessages(): boolean }).hasPendingMessages = () => state === "queued";
|
|
325
|
+
for (const width of [8, 40, 80]) assert.doesNotMatch(editor.render(width).join("\n"), /\x1b\[44m/, state);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
editor.dispose();
|
|
329
|
+
}
|
|
330
|
+
assert.equal(tools.get("session_worktree_register")?.renderShell, "self");
|
|
331
|
+
});
|
|
332
|
+
|
|
248
333
|
test("gentleShell shows working while the agent runs and queued when messages wait", () => {
|
|
249
334
|
const { pi, handlers } = fakePi();
|
|
250
335
|
gentleShell(pi, {});
|
|
@@ -322,6 +407,174 @@ test("gentleShell registers /gentle:changes and opens the overlay only when ther
|
|
|
322
407
|
await opened;
|
|
323
408
|
});
|
|
324
409
|
|
|
410
|
+
test("changes overlay follows registered linked roots on open, refresh and polling and scopes diff git cwd", async () => {
|
|
411
|
+
const { pi, handlers, commands, tools } = fakePi();
|
|
412
|
+
let roots = ["/repo"];
|
|
413
|
+
const diffs: string[][] = [];
|
|
414
|
+
pi.exec = (async (_command: string, args: string[]) => {
|
|
415
|
+
let stdout = "";
|
|
416
|
+
if (args.includes("worktree")) stdout = roots.map((root) => `worktree ${root}\0branch refs/heads/${root.slice(1)}\0\0`).join("");
|
|
417
|
+
else if (args.includes("status")) stdout = " M same.ts\0";
|
|
418
|
+
else if (args.includes("--numstat")) stdout = "1\t1\tsame.ts\n";
|
|
419
|
+
else { diffs.push(args); stdout = `+${args[1]}`; }
|
|
420
|
+
return { stdout, stderr: "", code: 0, killed: false };
|
|
421
|
+
}) as ExtensionAPI["exec"];
|
|
422
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
|
|
423
|
+
const { ctx, ui } = fakeContext();
|
|
424
|
+
await fire(handlers, "session_start", ctx);
|
|
425
|
+
roots.push("/linked");
|
|
426
|
+
await tools.get("session_worktree_register")!.execute("register", { path: "/linked" }, undefined, undefined, ctx);
|
|
427
|
+
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
428
|
+
try {
|
|
429
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
430
|
+
assert.match(ui.overlayView!.render(120).join("\n"), /linked · linked/);
|
|
431
|
+
assert.equal(diffs.length, 0);
|
|
432
|
+
ui.overlayView!.handleInput("j");
|
|
433
|
+
ui.overlayView!.handleInput("\r");
|
|
434
|
+
ui.overlayView!.handleInput("j");
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
436
|
+
assert.deepEqual(diffs[0], ["-C", "/linked", "diff", "HEAD", "--", "same.ts"]);
|
|
437
|
+
ui.overlayView!.handleInput("\x1b[D");
|
|
438
|
+
roots.push("/new");
|
|
439
|
+
await tools.get("session_worktree_register")!.execute("register", { path: "/new" }, undefined, undefined, ctx);
|
|
440
|
+
ui.overlayView!.handleInput("r");
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
442
|
+
assert.match(ui.overlayView!.render(120).join("\n"), /new · new/);
|
|
443
|
+
roots.push("/polled");
|
|
444
|
+
await tools.get("session_worktree_register")!.execute("register", { path: "/polled" }, undefined, undefined, ctx);
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
446
|
+
assert.match(ui.overlayView!.render(120).join("\n"), /polled · polled/);
|
|
447
|
+
} finally {
|
|
448
|
+
ui.closeOverlay?.();
|
|
449
|
+
await open;
|
|
450
|
+
await fire(handlers, "session_shutdown", ctx);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("successful direct tools register whole roots, failures and shell contents do not, and all UI counts agree", async () => {
|
|
455
|
+
const { pi, handlers, tools, commands } = fakePi();
|
|
456
|
+
const queried: string[] = [];
|
|
457
|
+
pi.exec = (async (_command: string, args: string[]) => {
|
|
458
|
+
const root = args[1];
|
|
459
|
+
if (args.includes("status")) queried.push(root);
|
|
460
|
+
const stdout = args.includes("worktree") ? ["/repo", "/used", "/failed", "/opaque", "/hidden"].map((path) => `worktree ${path}\0\0`).join("")
|
|
461
|
+
: args.includes("status") ? " M preexisting.ts\0?? new.ts\0"
|
|
462
|
+
: args.includes("--numstat") ? "2\t1\tpreexisting.ts\n" : "+diff";
|
|
463
|
+
return { stdout, stderr: "", code: 0, killed: false };
|
|
464
|
+
}) as ExtensionAPI["exec"];
|
|
465
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
466
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
467
|
+
await fire(handlers, "session_start", ctx);
|
|
468
|
+
const emit = async (name: string, event: unknown) => {
|
|
469
|
+
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
|
470
|
+
};
|
|
471
|
+
const complete = async (toolName: string, input: unknown, isError = false) => {
|
|
472
|
+
await emit("tool_execution_start", { toolCallId: "call", toolName, args: input });
|
|
473
|
+
await emit("tool_result", { toolCallId: "call", toolName, input, isError: false });
|
|
474
|
+
await emit("tool_execution_end", { toolCallId: "call", toolName, isError });
|
|
475
|
+
};
|
|
476
|
+
await complete("read", { path: "/failed" }, true);
|
|
477
|
+
await complete("bash", { command: "cd /opaque && edit stuff", path: "/opaque" });
|
|
478
|
+
assert.deepEqual(new Set(queried), new Set(["/repo"]));
|
|
479
|
+
await complete("read", { path: "/used" });
|
|
480
|
+
await complete("write", { path: "/used" });
|
|
481
|
+
assert.deepEqual(new Set(queried), new Set(["/repo", "/used"]));
|
|
482
|
+
assert.equal(ctx.sessionManager.getEntries().length, 2, "multiple completed tools dedupe");
|
|
483
|
+
assert.match(renderFooter(ui), /±4/);
|
|
484
|
+
const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
|
|
485
|
+
assert.match(widget(fakeTui, plainTheme).render(140)[0], /4 files/);
|
|
486
|
+
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
487
|
+
await overlayReady;
|
|
488
|
+
assert.doesNotMatch(ui.overlayView!.render(140).join("\n"), /hidden|opaque|failed/);
|
|
489
|
+
ui.closeOverlay?.();
|
|
490
|
+
await open;
|
|
491
|
+
await tools.get("session_worktree_register")!.execute("explicit", { path: "/opaque" }, undefined, undefined, ctx);
|
|
492
|
+
assert.match(renderFooter(ui), /±6/);
|
|
493
|
+
await fire(handlers, "session_shutdown", ctx);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("session switching drops pending tool and foreign launch events while same-session entries restore", async () => {
|
|
497
|
+
const h = fakePi([{ numstat: "1\t0\ta.ts\n", porcelain: " M a.ts\0" }]);
|
|
498
|
+
gentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
499
|
+
const first = fakeContext();
|
|
500
|
+
await fire(h.handlers, "session_start", first.ctx);
|
|
501
|
+
for (const handler of h.handlers.get("tool_execution_start") ?? []) await handler({ toolCallId: "old", toolName: "read", args: { path: "/foreign" } }, first.ctx);
|
|
502
|
+
const next = fakeContext({ entries: [...first.ctx.sessionManager.getEntries()] });
|
|
503
|
+
(next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "new-session";
|
|
504
|
+
await fire(h.handlers, "session_start", next.ctx);
|
|
505
|
+
for (const handler of h.handlers.get("tool_result") ?? []) await handler({ toolCallId: "old", toolName: "read", input: { path: "/foreign" } }, next.ctx);
|
|
506
|
+
for (const handler of h.handlers.get("tool_execution_end") ?? []) await handler({ toolCallId: "old", toolName: "read", isError: false }, next.ctx);
|
|
507
|
+
const before = h.git.length;
|
|
508
|
+
h.pi.events.emit("gentle-pi:session-worktree-changed", { sessionId: "shell-session", root: "/foreign" });
|
|
509
|
+
assert.equal(h.git.length, before);
|
|
510
|
+
assert.match(renderFooter(next.ui), /±1/);
|
|
511
|
+
assert.ok(!h.git.some((args) => args[1] === "/foreign"));
|
|
512
|
+
await fire(h.handlers, "session_shutdown", next.ctx);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
|
|
516
|
+
const fixture = realpathSync(mkdtempSync(join(tmpdir(), "shell-git-routing-")));
|
|
517
|
+
t.after(() => rmSync(fixture, { recursive: true, force: true }));
|
|
518
|
+
const selected = join(fixture, "selected");
|
|
519
|
+
const foreign = join(fixture, "foreign");
|
|
520
|
+
const empty = join(fixture, "empty");
|
|
521
|
+
mkdirSync(empty);
|
|
522
|
+
writeFileSync(join(empty, "config"), "");
|
|
523
|
+
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
524
|
+
Object.assign(cleanEnv, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1" });
|
|
525
|
+
const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env: cleanEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
526
|
+
for (const root of [selected, foreign]) {
|
|
527
|
+
git(fixture, ["init", "--initial-branch=main", `--template=${empty}`, root]);
|
|
528
|
+
writeFileSync(join(root, "tracked.txt"), "before\n");
|
|
529
|
+
git(root, ["add", "tracked.txt"]);
|
|
530
|
+
git(root, ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "Fixture"]);
|
|
531
|
+
}
|
|
532
|
+
writeFileSync(join(selected, "tracked.txt"), "selected change\n");
|
|
533
|
+
writeFileSync(join(selected, "selected-only.txt"), "selected untracked\n");
|
|
534
|
+
writeFileSync(join(foreign, "tracked.txt"), "foreign change\n");
|
|
535
|
+
writeFileSync(join(foreign, "foreign-only.txt"), "foreign untracked\n");
|
|
536
|
+
const poisoned = { ...cleanEnv, GIT_DIR: join(foreign, ".git"), GIT_WORK_TREE: foreign, GIT_INDEX_FILE: join(foreign, ".git", "index") };
|
|
537
|
+
const h = fakePi();
|
|
538
|
+
// Pi exec has no env option and inherits routing. Model that boundary with
|
|
539
|
+
// a child-only env, without changing this test process's environment.
|
|
540
|
+
h.pi.exec = ((command: string, args: string[], options: { timeout?: number } = {}) => new Promise((resolve) => {
|
|
541
|
+
execFile(command, args, { env: poisoned, encoding: "utf8", timeout: options.timeout, maxBuffer: Infinity }, (error, stdout, stderr) => resolve({ stdout, stderr, code: error ? typeof error.code === "number" ? error.code : 1 : 0, killed: Boolean(error?.killed) }));
|
|
542
|
+
})) as ExtensionAPI["exec"];
|
|
543
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
544
|
+
(ctx as unknown as { cwd: string }).cwd = selected;
|
|
545
|
+
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => selected;
|
|
546
|
+
const run = shellGitRunner(selected, poisoned);
|
|
547
|
+
const discovery = await run(["worktree", "list", "--porcelain", "-z"]);
|
|
548
|
+
assert.match(discovery.stdout, new RegExp(`worktree ${selected}`));
|
|
549
|
+
assert.ok(!discovery.stdout.includes(foreign));
|
|
550
|
+
installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
|
|
551
|
+
await fire(h.handlers, "session_start", ctx);
|
|
552
|
+
t.after(() => fire(h.handlers, "session_shutdown", ctx));
|
|
553
|
+
const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
|
|
554
|
+
const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
|
|
555
|
+
assert.match(rendered, /selected-only\.txt/);
|
|
556
|
+
assert.doesNotMatch(rendered, /foreign-only\.txt/);
|
|
557
|
+
const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
|
|
558
|
+
assert.match(diff, /\+selected change/);
|
|
559
|
+
assert.doesNotMatch(diff, /foreign change/);
|
|
560
|
+
const untracked = await loadFileDiff(run, { path: "selected-only.txt", added: 1, deleted: 0, status: CHANGE_STATUS.UNTRACKED });
|
|
561
|
+
assert.match(untracked, /\+selected untracked/);
|
|
562
|
+
assert.equal((await run(["rev-parse", "--verify", "missing-ref"])).code, 128, "Git failure codes stay intact");
|
|
563
|
+
assert.equal(poisoned.GIT_DIR, join(foreign, ".git"), "caller environment is not mutated");
|
|
564
|
+
assert.equal((await shellGitRunner(selected, { PATH: empty })(["status"])).code, 1, "spawn errors remain failed results rather than uncaught exceptions");
|
|
565
|
+
writeFileSync(join(selected, "tracked.txt"), "selected large line\n".repeat(70_000) + "selected final marker\n");
|
|
566
|
+
const largeDiff = await run(["diff", "HEAD", "--", "tracked.txt"]);
|
|
567
|
+
assert.equal(largeDiff.code, 0);
|
|
568
|
+
assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
|
|
569
|
+
assert.match(largeDiff.stdout, /\+selected final marker/);
|
|
570
|
+
const open = h.commands.get("gentle:changes")!.handler("", ctx);
|
|
571
|
+
await overlayReady;
|
|
572
|
+
ui.overlayView!.handleInput("\r");
|
|
573
|
+
ui.overlayView!.handleInput("j");
|
|
574
|
+
ui.closeOverlay?.();
|
|
575
|
+
await open;
|
|
576
|
+
});
|
|
577
|
+
|
|
325
578
|
test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
|
|
326
579
|
const calls: string[][] = [];
|
|
327
580
|
const git = async (args: string[]) => {
|
|
@@ -336,6 +589,24 @@ test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked fi
|
|
|
336
589
|
]);
|
|
337
590
|
});
|
|
338
591
|
|
|
592
|
+
test("shell Git runner hides initial and repeated background polling children", async () => {
|
|
593
|
+
const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
|
|
594
|
+
const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
|
|
595
|
+
calls.push({ command, args, options });
|
|
596
|
+
callback(null, "", "");
|
|
597
|
+
}) as typeof import("node:child_process").execFile;
|
|
598
|
+
const git = shellGitRunner("/repo with spaces & metacharacters", { PATH: process.env.PATH }, run);
|
|
599
|
+
await git(["status", "--porcelain=v1", "-z"]);
|
|
600
|
+
await git(["status", "--porcelain=v1", "-z"]);
|
|
601
|
+
assert.equal(calls.length, 2, "the same safe runner serves startup and repeated polling");
|
|
602
|
+
for (const call of calls) {
|
|
603
|
+
assert.equal(call.command, "git");
|
|
604
|
+
assert.deepEqual(call.args, ["-C", "/repo with spaces & metacharacters", "status", "--porcelain=v1", "-z"]);
|
|
605
|
+
assert.equal(call.options.shell, false);
|
|
606
|
+
assert.equal(call.options.windowsHide, true);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
339
610
|
test("openInExternalEditor stops the TUI around the editor and honors $VISUAL over $EDITOR", () => {
|
|
340
611
|
const events: string[] = [];
|
|
341
612
|
const host = { stop: () => events.push("stop"), start: () => events.push("start"), requestRender: (force?: boolean) => events.push(`render:${force}`) };
|
|
@@ -348,6 +619,16 @@ test("openInExternalEditor stops the TUI around the editor and honors $VISUAL ov
|
|
|
348
619
|
assert.equal(openInExternalEditor(host, "lib/a.ts", {}, spawn), false);
|
|
349
620
|
});
|
|
350
621
|
|
|
622
|
+
test("external editor receives the selected worktree as process cwd", () => {
|
|
623
|
+
let cwd: string | undefined;
|
|
624
|
+
const spawn = ((_command: string, _args: string[], options: { cwd?: string }) => {
|
|
625
|
+
cwd = options.cwd;
|
|
626
|
+
return { status: 0 };
|
|
627
|
+
}) as typeof import("node:child_process").spawnSync;
|
|
628
|
+
openInExternalEditor({ stop() {}, start() {}, requestRender() {} }, "same.ts", { EDITOR: "vi" }, spawn, "/linked");
|
|
629
|
+
assert.equal(cwd, "/linked");
|
|
630
|
+
});
|
|
631
|
+
|
|
351
632
|
test("changesShortcut defaults to alt+g and can be overridden or disabled", () => {
|
|
352
633
|
assert.equal(changesShortcut({}), "alt+g");
|
|
353
634
|
assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "ctrl+shift+g" }), "ctrl+shift+g");
|
|
@@ -381,9 +662,10 @@ test("gentleShell keeps the open overlay in sync with git while it stays open",
|
|
|
381
662
|
await fire(handlers, "session_start", ctx);
|
|
382
663
|
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
383
664
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
665
|
+
ui.overlayView!.handleInput("\r");
|
|
384
666
|
const plain = ui.overlayView!.render(100).map(stripAnsi);
|
|
385
|
-
assert.match(plain[
|
|
386
|
-
assert.match(plain[
|
|
667
|
+
assert.match(plain[2], /2 files · \+13 −1/);
|
|
668
|
+
assert.match(plain[3], /lib\/c\.ts/);
|
|
387
669
|
assert.match(renderFooter(ui), /main ±2/);
|
|
388
670
|
ui.closeOverlay?.();
|
|
389
671
|
await open;
|
|
@@ -493,6 +775,10 @@ test("gentleShell draws the review preflight message as a Gentle card", () => {
|
|
|
493
775
|
const renderer = renderers.get("gentle-pi.review-preflight");
|
|
494
776
|
assert.ok(renderer, "renderer not registered");
|
|
495
777
|
const message = { customType: "gentle-pi.review-preflight", content: "Receipt-driven development is enabled.\n\nCall the gentle_review tool." };
|
|
778
|
+
const sentinelTheme = { ...plainTheme, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` };
|
|
779
|
+
for (const expanded of [true, false]) {
|
|
780
|
+
assert.doesNotMatch(renderer(message, { expanded }, sentinelTheme).render(80).join("\n"), /\x1b\[44m/);
|
|
781
|
+
}
|
|
496
782
|
const expanded = renderer(message, { expanded: true }, plainTheme).render(80).map(stripAnsi);
|
|
497
783
|
assert.match(expanded[0], /^╭─ ✿ Gentle AI · review preflight ─+ .*collapse ╮$/);
|
|
498
784
|
assert.match(expanded[1], /^│ Receipt-driven development is enabled\. +│$/);
|
|
@@ -514,6 +800,11 @@ test("gentleShell keeps a dev-binary override visible above the editor for the w
|
|
|
514
800
|
assert.match(lines[1], /^│ \/Users\/me\/go\/bin\/gentle-ai · sha256:6e53bfc6305a3949 +│$/);
|
|
515
801
|
assert.match(lines[2], /^╰─+╯$/);
|
|
516
802
|
assert.equal(lines[3], "", "a blank line keeps the card off the prompt frame");
|
|
803
|
+
const painted = factory(fakeTui, { ...plainTheme, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` }).render(100);
|
|
804
|
+
assert.equal(painted[0], lines[0], "top frame cells have no background");
|
|
805
|
+
assert.equal(painted[1], lines[1], "body interior remains transparent");
|
|
806
|
+
assert.equal(painted[2], lines[2], "bottom frame cells have no background");
|
|
807
|
+
assert.equal(painted[3], "", "external spacer has no background");
|
|
517
808
|
await fire(handlers, "agent_start", ctx);
|
|
518
809
|
assert.equal(ui.widgets.has("gentle-shell-dev-binary"), false, "the startup notice leaves with the first prompt");
|
|
519
810
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
4
5
|
import gentleTodo, { todoCollapseKey, todoEnabled } from "../extensions/gentle-todo.ts";
|
|
5
6
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
6
7
|
|
|
@@ -10,6 +11,7 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
|
10
11
|
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
11
12
|
|
|
12
13
|
interface Registered {
|
|
14
|
+
renderShell?: string;
|
|
13
15
|
execute(toolCallId: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
|
|
14
16
|
renderCall(args: unknown, theme: unknown): { render(width: number): string[] };
|
|
15
17
|
renderResult(result: unknown, options: { expanded: boolean }, theme: unknown): { render(width: number): string[] };
|
|
@@ -24,7 +26,6 @@ const plainTheme = {
|
|
|
24
26
|
},
|
|
25
27
|
};
|
|
26
28
|
const fakeTui = { requestRender() {} };
|
|
27
|
-
|
|
28
29
|
function fakePi() {
|
|
29
30
|
const handlers = new Map<string, Handler[]>();
|
|
30
31
|
const tools = new Map<string, Registered>();
|
|
@@ -49,22 +50,23 @@ function fakePi() {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function fakeContext(branch: unknown[] = [], hasUI = true) {
|
|
52
|
-
const widgets = new Map<string, (tui: unknown, theme: unknown) =>
|
|
53
|
+
const widgets = new Map<string, (tui: unknown, theme: unknown) => Component>();
|
|
53
54
|
const ctx = {
|
|
54
55
|
hasUI,
|
|
55
56
|
sessionManager: { getSessionId: () => "s1", getBranch: () => branch },
|
|
56
57
|
ui: {
|
|
57
|
-
setWidget(key: string, content: ((tui: unknown, theme: unknown) =>
|
|
58
|
+
setWidget(key: string, content: ((tui: unknown, theme: unknown) => Component) | undefined) {
|
|
58
59
|
if (content === undefined) widgets.delete(key);
|
|
59
60
|
else widgets.set(key, content);
|
|
60
61
|
},
|
|
61
62
|
},
|
|
62
63
|
} as unknown as ExtensionContext;
|
|
63
|
-
const
|
|
64
|
+
const widgetComponent = () => {
|
|
64
65
|
const factory = widgets.get("gentle-todo");
|
|
65
|
-
return factory
|
|
66
|
+
return factory?.(fakeTui, plainTheme);
|
|
66
67
|
};
|
|
67
|
-
|
|
68
|
+
const widget = () => widgetComponent()?.render(70).map(stripAnsi);
|
|
69
|
+
return { ctx, widgets, widget, widgetComponent };
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
test("todoEnabled and todoCollapseKey read their environment flags", () => {
|
|
@@ -79,6 +81,12 @@ test("todoEnabled and todoCollapseKey read their environment flags", () => {
|
|
|
79
81
|
assert.equal(off.tools.size, 0);
|
|
80
82
|
});
|
|
81
83
|
|
|
84
|
+
test("todo registration owns its transparent transcript shell", () => {
|
|
85
|
+
const { pi, tools } = fakePi();
|
|
86
|
+
gentleTodo(pi, {});
|
|
87
|
+
assert.equal(tools.get("todo")?.renderShell, "self");
|
|
88
|
+
});
|
|
89
|
+
|
|
82
90
|
test("the todo tool writes the list, shows the card after the call, and carries the snapshot in details", async () => {
|
|
83
91
|
const { pi, tools, fire } = fakePi();
|
|
84
92
|
gentleTodo(pi, {});
|
|
@@ -92,7 +100,7 @@ test("the todo tool writes the list, shows the card after the call, and carries
|
|
|
92
100
|
assert.equal((result.details.gentleTodo as { tasks: unknown[] }).tasks.length, 2);
|
|
93
101
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
94
102
|
const lines = widget()!;
|
|
95
|
-
assert.match(lines[0], /^╭─ ❀ Todos · 0 of 2
|
|
103
|
+
assert.match(lines[0], /^╭─ ❀ Todos ▾ Collapse · 0 of 2 ─+ ctrl\+shift\+t collapse ╮$/);
|
|
96
104
|
assert.match(lines[1], /◐ Write the parser · parsing/);
|
|
97
105
|
assert.match(lines[2], /○ Add tests/);
|
|
98
106
|
assert.equal(lines[lines.length - 1], "", "a blank line keeps the card off the prompt");
|
|
@@ -104,6 +112,41 @@ test("the todo tool writes the list, shows the card after the call, and carries
|
|
|
104
112
|
assert.equal(tool.renderResult({ content: [{ type: "text", text: "a\nb" }] }, { expanded: false }, plainTheme).render(40).join("|").trimEnd(), "a");
|
|
105
113
|
});
|
|
106
114
|
|
|
115
|
+
test("the Todo header is a fullscreen left-click control while non-click pointer events stay inert", async () => {
|
|
116
|
+
const { pi, tools, fire } = fakePi();
|
|
117
|
+
gentleTodo(pi, {});
|
|
118
|
+
const { ctx, widgetComponent } = fakeContext();
|
|
119
|
+
await fire("session_start", ctx);
|
|
120
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A", status: "in_progress" }, { title: "B" }] }, undefined, undefined, ctx);
|
|
121
|
+
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
122
|
+
|
|
123
|
+
const component = widgetComponent()!;
|
|
124
|
+
assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/);
|
|
125
|
+
const event = (type: "press" | "click", button: "left" | "right", y = 0) => ({
|
|
126
|
+
type, button, x: 1, y, screenX: 1, screenY: y, width: 70, height: 5, shift: false, alt: false, ctrl: false,
|
|
127
|
+
});
|
|
128
|
+
assert.equal(component.handleMouse?.(event("press", "left")), undefined);
|
|
129
|
+
assert.equal(component.handleMouse?.(event("click", "right")), undefined);
|
|
130
|
+
assert.equal(component.handleMouse?.(event("click", "left", 1)), undefined);
|
|
131
|
+
assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/, "only a left click on the header toggles");
|
|
132
|
+
assert.equal(component.handleMouse?.(event("click", "left"))?.handled, true);
|
|
133
|
+
assert.match(stripAnsi(component.render(70)[0]!), /Todos ▸ Expand/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the Todo header remains a static visible control without hover handling", async () => {
|
|
137
|
+
const { pi, tools, fire } = fakePi();
|
|
138
|
+
gentleTodo(pi, {});
|
|
139
|
+
const { ctx, widgetComponent } = fakeContext();
|
|
140
|
+
await fire("session_start", ctx);
|
|
141
|
+
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A" }] }, undefined, undefined, ctx);
|
|
142
|
+
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
143
|
+
const component = widgetComponent()!;
|
|
144
|
+
const move = { type: "move" as const, button: "none" as const, x: 1, y: 0, screenX: 1, screenY: 0, width: 70, height: 5, shift: false, alt: false, ctrl: false };
|
|
145
|
+
assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/);
|
|
146
|
+
assert.equal(component.handleMouse?.(move), undefined);
|
|
147
|
+
assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/);
|
|
148
|
+
});
|
|
149
|
+
|
|
107
150
|
test("every turn carries the open tasks in the system prompt and the card goes stale after two silent turns", async () => {
|
|
108
151
|
const { pi, tools, fire } = fakePi();
|
|
109
152
|
gentleTodo(pi, {});
|
|
@@ -120,7 +163,8 @@ test("every turn carries the open tasks in the system prompt and the card goes s
|
|
|
120
163
|
|
|
121
164
|
const stale = (await fire("before_agent_start", ctx, { systemPrompt: "base" })) as { systemPrompt: string };
|
|
122
165
|
assert.match(stale.systemPrompt, /stale: 2 turns without an update/);
|
|
123
|
-
assert.match(widget()![0], /
|
|
166
|
+
assert.match(widget()![0], /ctrl\+shift\+t collapse/);
|
|
167
|
+
assert.match(widget()![1], /stale · 2 turns/);
|
|
124
168
|
|
|
125
169
|
await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
|
|
126
170
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
@@ -143,7 +187,7 @@ test("a finished list stays for its turn and clears at the next, and the collaps
|
|
|
143
187
|
await tools.get("todo")!.execute("c2", { action: "write", tasks: [{ id: 1, title: "A", status: "done" }, { id: 2, title: "B", status: "done" }] }, undefined, undefined, ctx);
|
|
144
188
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
145
189
|
await fire("agent_end", ctx);
|
|
146
|
-
assert.match(widget()![0], /Todos · 2 of 2/, "the finished list is still visible at the end of its turn");
|
|
190
|
+
assert.match(widget()![0], /Todos ▾ Collapse · 2 of 2/, "the finished list is still visible at the end of its turn");
|
|
147
191
|
const next = await fire("before_agent_start", ctx, { systemPrompt: "base" });
|
|
148
192
|
assert.equal(next, undefined, "nothing open, nothing to add to the prompt");
|
|
149
193
|
assert.equal(widget(), undefined, "the card clears at the next turn");
|
|
@@ -160,7 +204,7 @@ test("session_start replays the list from the branch, rpiv-todo results included
|
|
|
160
204
|
const { ctx, widget } = fakeContext(branch);
|
|
161
205
|
await fire("session_start", ctx);
|
|
162
206
|
const lines = widget()!;
|
|
163
|
-
assert.match(lines[0], /Todos · 0 of 1/);
|
|
207
|
+
assert.match(lines[0], /Todos ▾ Collapse · 0 of 1/);
|
|
164
208
|
assert.match(lines[1], /◐ Old task · still going/);
|
|
165
209
|
const headless = fakeContext(branch, false);
|
|
166
210
|
await fire("session_start", headless.ctx);
|