gentle-pi 2.6.4 → 3.0.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 +25 -7
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +41 -19
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +289 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +123 -97
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +74 -113
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +19 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +88 -0
- package/lib/session-changes.ts +147 -0
- package/lib/shell-bar.ts +24 -10
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +5 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +18 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +321 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +883 -356
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +338 -205
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +119 -31
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +86 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +114 -0
- package/tests/shell-bar.test.ts +35 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync, execFile } from "node:child_process";
|
|
3
|
-
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
|
-
import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
8
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";
|
|
9
|
+
import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
10
10
|
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
11
11
|
import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
12
12
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
@@ -63,12 +63,25 @@ interface ShortcutRegistration {
|
|
|
63
63
|
type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
|
|
64
64
|
const renderers = new Map<string, MessageRenderer>();
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
|
|
67
|
+
|
|
68
|
+
const DEFAULT_COMMANDS: SlashCommandInfo[] = [
|
|
69
|
+
{ name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
70
|
+
{ name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
71
|
+
{ name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
72
|
+
{ name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
73
|
+
{ name: "gentle:commands", description: "Open the command palette", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
74
|
+
{ name: "gentle:not-in-catalog", description: "Not a curated command", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
75
|
+
{ name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commandsList: SlashCommandInfo[] = DEFAULT_COMMANDS) {
|
|
67
79
|
const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
|
|
68
80
|
const commands = new Map<string, CommandRegistration>();
|
|
69
81
|
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
70
82
|
const git: string[][] = [];
|
|
71
83
|
let entries: unknown[] = [];
|
|
84
|
+
const sentMessages: Array<{ content: string; options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean } }> = [];
|
|
72
85
|
const tools = new Map<string, { renderShell?: string; execute(id: string, params: unknown, signal: undefined, update: undefined, ctx: ExtensionContext): Promise<unknown> }>();
|
|
73
86
|
const listeners = new Map<string, (data: unknown) => void>();
|
|
74
87
|
let round = 0;
|
|
@@ -94,6 +107,12 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
|
|
|
94
107
|
getThinkingLevel() {
|
|
95
108
|
return "medium";
|
|
96
109
|
},
|
|
110
|
+
getCommands() {
|
|
111
|
+
return commandsList;
|
|
112
|
+
},
|
|
113
|
+
sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }) {
|
|
114
|
+
sentMessages.push({ content, options });
|
|
115
|
+
},
|
|
97
116
|
async exec(_command: string, args: string[]) {
|
|
98
117
|
git.push(args);
|
|
99
118
|
if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
|
|
@@ -102,7 +121,7 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
|
|
|
102
121
|
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
103
122
|
},
|
|
104
123
|
} as unknown as ExtensionAPI;
|
|
105
|
-
return { pi, handlers, git, commands, shortcuts, tools };
|
|
124
|
+
return { pi, handlers, git, commands, shortcuts, tools, sentMessages };
|
|
106
125
|
}
|
|
107
126
|
|
|
108
127
|
async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
|
|
@@ -149,11 +168,15 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
|
|
|
149
168
|
setWorkingVisible(visible: boolean) {
|
|
150
169
|
ui.workingVisible = visible;
|
|
151
170
|
},
|
|
152
|
-
|
|
171
|
+
// Generic over the result type: real callers resolve `custom` with
|
|
172
|
+
// whatever their `done` callback is given (see ExtensionUIContext.custom
|
|
173
|
+
// in pi-coding-agent), not always null. `closeOverlay` stays a
|
|
174
|
+
// null-resolving escape hatch for tests that only need to end the wait.
|
|
175
|
+
custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
|
|
153
176
|
ui.overlay = factory;
|
|
154
|
-
return new Promise<null>((resolve) => {
|
|
177
|
+
return new Promise<T | null>((resolve) => {
|
|
155
178
|
ui.closeOverlay = () => resolve(null);
|
|
156
|
-
ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(
|
|
179
|
+
ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, (value: T) => resolve(value));
|
|
157
180
|
resolveOverlay();
|
|
158
181
|
});
|
|
159
182
|
},
|
|
@@ -232,7 +255,8 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
|
|
|
232
255
|
|
|
233
256
|
test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
|
|
234
257
|
const { pi, handlers } = fakePi();
|
|
235
|
-
|
|
258
|
+
let profile: string | undefined = "team";
|
|
259
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
|
|
236
260
|
const entries: unknown[] = [];
|
|
237
261
|
const { ctx, ui } = fakeContext({ entries });
|
|
238
262
|
await fire(handlers, "session_start", ctx);
|
|
@@ -248,6 +272,14 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
|
|
|
248
272
|
assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
|
|
249
273
|
assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
|
|
250
274
|
|
|
275
|
+
assert.match(rail.render(46).join("\n"), /Profile.*team/);
|
|
276
|
+
const beforeProfile = live();
|
|
277
|
+
profile = "other";
|
|
278
|
+
assert.notEqual(live(), beforeProfile);
|
|
279
|
+
assert.match(rail.render(46).join("\n"), /Profile.*other/);
|
|
280
|
+
profile = undefined;
|
|
281
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
|
|
282
|
+
|
|
251
283
|
const beforeModel = live();
|
|
252
284
|
(ctx.model as { id: string }).id = "gpt-5.6";
|
|
253
285
|
assert.notEqual(live(), beforeModel, "/model must change the digest");
|
|
@@ -273,10 +305,44 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
|
|
|
273
305
|
}
|
|
274
306
|
});
|
|
275
307
|
|
|
308
|
+
test("profile reader follows store changes and rejects missing or invalid active markers", (t) => {
|
|
309
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-"));
|
|
310
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
311
|
+
const path = join(root, "profiles.json");
|
|
312
|
+
const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
313
|
+
const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({
|
|
314
|
+
kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} },
|
|
315
|
+
}));
|
|
316
|
+
assert.equal(read(), undefined);
|
|
317
|
+
save("team");
|
|
318
|
+
assert.equal(read(), "team");
|
|
319
|
+
assert.equal(read(), "team");
|
|
320
|
+
save("other");
|
|
321
|
+
assert.equal(read(), "other");
|
|
322
|
+
const replacement = join(root, "replacement.json");
|
|
323
|
+
writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } }));
|
|
324
|
+
renameSync(replacement, path);
|
|
325
|
+
assert.equal(read(), "team", "atomic replacement refreshes the cached profile");
|
|
326
|
+
const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") });
|
|
327
|
+
assert.equal(isolated(), undefined);
|
|
328
|
+
assert.equal(read(), "team", "another shell's config home does not alter this cache");
|
|
329
|
+
save("missing");
|
|
330
|
+
assert.equal(read(), undefined);
|
|
331
|
+
save(undefined);
|
|
332
|
+
assert.equal(read(), undefined);
|
|
333
|
+
writeFileSync(path, "{broken");
|
|
334
|
+
assert.equal(read(), undefined);
|
|
335
|
+
save("team");
|
|
336
|
+
assert.equal(read(), "team");
|
|
337
|
+
rmSync(path);
|
|
338
|
+
assert.equal(read(), undefined);
|
|
339
|
+
});
|
|
340
|
+
|
|
276
341
|
test("gentleShell stays out of the way without a UI or when disabled", () => {
|
|
277
342
|
const disabled = fakePi();
|
|
278
343
|
gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
|
|
279
|
-
assert.equal(disabled.
|
|
344
|
+
assert.equal(disabled.commands.size, 0);
|
|
345
|
+
assert.ok(disabled.handlers.has("tool_call"), "capture remains available to headless children");
|
|
280
346
|
|
|
281
347
|
const headless = fakePi();
|
|
282
348
|
gentleShell(headless.pi, {});
|
|
@@ -339,15 +405,41 @@ test("gentleShell shows working while the agent runs and queued when messages wa
|
|
|
339
405
|
const editor = installedPrompt(ctx, ui, handlers);
|
|
340
406
|
|
|
341
407
|
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
342
|
-
assert.match(stripAnsi(editor.render(60)[0]), /^╭─
|
|
408
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ working… ─+╮$/);
|
|
343
409
|
pending.value = true;
|
|
344
410
|
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
|
|
345
411
|
for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
|
|
412
|
+
assert.match(stripAnsi(editor.render(60)[0]), /queued/, "low-level run end is not settled");
|
|
346
413
|
pending.value = false;
|
|
414
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
347
415
|
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ ─+╮$/);
|
|
348
416
|
editor.dispose();
|
|
349
417
|
});
|
|
350
418
|
|
|
419
|
+
test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
|
|
420
|
+
const delays: number[] = [];
|
|
421
|
+
let active = 0;
|
|
422
|
+
let unrefs = 0;
|
|
423
|
+
t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
|
|
424
|
+
delays.push(delay);
|
|
425
|
+
active++;
|
|
426
|
+
return { unref() { unrefs++; } };
|
|
427
|
+
});
|
|
428
|
+
t.mock.method(globalThis, "clearInterval", () => { active--; });
|
|
429
|
+
const { pi, handlers } = fakePi();
|
|
430
|
+
gentleShell(pi, {});
|
|
431
|
+
const { ctx, ui } = fakeContext();
|
|
432
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
433
|
+
assert.equal(active, 0);
|
|
434
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
435
|
+
assert.deepEqual(delays, [80]);
|
|
436
|
+
assert.equal(unrefs, 1);
|
|
437
|
+
for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
|
|
438
|
+
assert.equal(active, 0);
|
|
439
|
+
editor.dispose();
|
|
440
|
+
assert.equal(active, 0);
|
|
441
|
+
});
|
|
442
|
+
|
|
351
443
|
test("gentleShell leaves an editor another extension already installed", () => {
|
|
352
444
|
const { pi, handlers } = fakePi();
|
|
353
445
|
gentleShell(pi, {});
|
|
@@ -355,6 +447,26 @@ test("gentleShell leaves an editor another extension already installed", () => {
|
|
|
355
447
|
const { ctx, ui } = fakeContext({ editorFactory: theirs });
|
|
356
448
|
for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
|
|
357
449
|
assert.equal(ui.editorFactory, theirs);
|
|
450
|
+
assert.notEqual(ui.workingVisible, false, "a custom owner still needs native working feedback");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("Gentle replaces its retained factory on reload so new lifecycle handlers own the prompt", () => {
|
|
454
|
+
const first = fakePi();
|
|
455
|
+
gentleShell(first.pi, {});
|
|
456
|
+
const { ctx, ui } = fakeContext();
|
|
457
|
+
const oldEditor = installedPrompt(ctx, ui, first.handlers);
|
|
458
|
+
const previousFactory = ui.editorFactory;
|
|
459
|
+
const next = fakePi();
|
|
460
|
+
gentleShell(next.pi, {});
|
|
461
|
+
const editor = installedPrompt(ctx, ui, next.handlers);
|
|
462
|
+
try {
|
|
463
|
+
assert.notEqual(ui.editorFactory, previousFactory);
|
|
464
|
+
for (const handler of next.handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
465
|
+
assert.match(stripAnsi(editor.render(60)[0]), /working/);
|
|
466
|
+
} finally {
|
|
467
|
+
oldEditor.dispose();
|
|
468
|
+
editor.dispose();
|
|
469
|
+
}
|
|
358
470
|
});
|
|
359
471
|
|
|
360
472
|
const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
|
|
@@ -364,152 +476,94 @@ function renderFooter(ui: FakeUi): string {
|
|
|
364
476
|
return factory(fakeTui, plainTheme, footerData).render(160)[0];
|
|
365
477
|
}
|
|
366
478
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
await fire(handlers, "session_start", ctx);
|
|
375
|
-
assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
|
|
376
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), false);
|
|
377
|
-
assert.doesNotMatch(renderFooter(ui), /±/);
|
|
378
|
-
|
|
379
|
-
await fire(handlers, "tool_execution_end", ctx);
|
|
380
|
-
const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
|
|
381
|
-
const [line] = factory(fakeTui, plainTheme).render(120);
|
|
382
|
-
assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
|
|
383
|
-
assert.match(renderFooter(ui), /main ±1/);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
|
|
387
|
-
const { pi, handlers, commands } = fakePi([
|
|
388
|
-
{ numstat: "", porcelain: "" },
|
|
389
|
-
{ numstat: "", porcelain: "" },
|
|
390
|
-
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
391
|
-
]);
|
|
392
|
-
gentleShell(pi, {});
|
|
393
|
-
const { ctx, ui } = fakeContext();
|
|
394
|
-
await fire(handlers, "session_start", ctx);
|
|
395
|
-
const command = commands.get("gentle:changes");
|
|
396
|
-
assert.ok(command, "command not registered");
|
|
479
|
+
function sessionChange(ctx: ExtensionContext, id: string, root: string, path: string, before = "", after = "agent\n"): void {
|
|
480
|
+
const entries = ctx.sessionManager.getEntries() as any[];
|
|
481
|
+
entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
|
|
482
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
483
|
+
evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
|
|
484
|
+
} });
|
|
485
|
+
}
|
|
397
486
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
487
|
+
test("captured changes update the widget and bar without repository scans", async () => {
|
|
488
|
+
const { pi, handlers, git } = fakePi([{numstat:"999\t0\tforeign.ts\n",porcelain:"?? foreign.ts\0"}]);
|
|
489
|
+
gentleShell(pi,{});
|
|
490
|
+
const {ctx,ui}=fakeContext();
|
|
491
|
+
await fire(handlers,"session_start",ctx);
|
|
492
|
+
assert.equal(git.length,0);
|
|
493
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
494
|
+
sessionChange(ctx,"a","/repo","lib/b.ts","","one\ntwo\n");
|
|
495
|
+
pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
|
|
496
|
+
await new Promise(resolve=>setImmediate(resolve));
|
|
497
|
+
const factory=ui.widgets.get("gentle-shell-changes") as any;
|
|
498
|
+
assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
|
|
499
|
+
assert.match(renderFooter(ui),/main ±1/);
|
|
500
|
+
const rail = sidebarState(fakeTui as unknown as TUI).parts.get("footer")!;
|
|
501
|
+
const digest = rail.digest!();
|
|
502
|
+
assert.match(rail.render(46).join("\n"), /1 file · \+2 −0/);
|
|
503
|
+
sessionChange(ctx,"b","/repo","lib/b.ts","one\ntwo\n","one\ntwo\nthree\n");
|
|
504
|
+
await fire(handlers,"agent_end",ctx);
|
|
505
|
+
assert.notEqual(rail.digest!(), digest, "same-count line changes invalidate the unified Status");
|
|
506
|
+
assert.match(rail.render(46).join("\n"), /1 file · \+3 −0/);
|
|
507
|
+
assert.equal(git.length,0);
|
|
508
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
509
|
+
});
|
|
401
510
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
511
|
+
test("Changes opens only for captured mutations, not registered dirty roots", async () => {
|
|
512
|
+
const {pi,handlers,commands,tools,git}=fakePi();
|
|
513
|
+
gentleShell(pi,{});
|
|
514
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
515
|
+
await fire(handlers,"session_start",ctx);
|
|
516
|
+
await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
|
|
517
|
+
await commands.get("gentle:changes")!.handler("",ctx);
|
|
518
|
+
assert.match(ui.notices.join("\n"),/No captured agent changes/);
|
|
519
|
+
assert.equal(ui.overlay,undefined);
|
|
520
|
+
sessionChange(ctx,"a","/linked","file.ts");
|
|
521
|
+
await fire(handlers,"agent_end",ctx);
|
|
522
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
523
|
+
await overlayReady;
|
|
524
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
|
|
525
|
+
assert.equal(git.length,0);
|
|
526
|
+
ui.closeOverlay?.(); await opened;
|
|
527
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
408
528
|
});
|
|
409
529
|
|
|
410
|
-
test("
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
}
|
|
530
|
+
test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
|
|
531
|
+
const {pi,handlers,commands,git}=fakePi();
|
|
532
|
+
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
|
|
533
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
534
|
+
sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
|
|
535
|
+
sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
|
|
536
|
+
await fire(handlers,"session_start",ctx);
|
|
537
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
538
|
+
await overlayReady;
|
|
539
|
+
try {
|
|
540
|
+
ui.overlayView!.handleInput("\r"); ui.overlayView!.handleInput("j");
|
|
541
|
+
await new Promise(resolve=>setTimeout(resolve,10));
|
|
542
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/first/);
|
|
543
|
+
sessionChange(ctx,"b","/repo","same.ts","first\n","second\n");
|
|
544
|
+
pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
|
|
545
|
+
await new Promise(resolve=>setTimeout(resolve,20));
|
|
546
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/second/);
|
|
547
|
+
assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
|
|
548
|
+
assert.equal(git.length,0);
|
|
549
|
+
} finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
|
|
452
550
|
});
|
|
453
551
|
|
|
454
|
-
test("
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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);
|
|
552
|
+
test("new sessions ignore inherited captures, while reload restores the same session", async () => {
|
|
553
|
+
const h=fakePi(); gentleShell(h.pi,{});
|
|
554
|
+
const first=fakeContext();
|
|
555
|
+
sessionChange(first.ctx,"a","/repo","own.ts");
|
|
556
|
+
await fire(h.handlers,"session_start",first.ctx);
|
|
557
|
+
assert.match(renderFooter(first.ui),/±1/);
|
|
558
|
+
await fire(h.handlers,"session_start",first.ctx);
|
|
559
|
+
assert.match(renderFooter(first.ui),/±1/);
|
|
560
|
+
const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
|
|
561
|
+
(next.ctx.sessionManager as any).getSessionId=()=>"new-session";
|
|
562
|
+
await fire(h.handlers,"session_start",next.ctx);
|
|
563
|
+
h.pi.events.emit("gentle-pi:session-change",{sessionId:"shell-session"});
|
|
564
|
+
assert.doesNotMatch(renderFooter(next.ui),/±1/);
|
|
565
|
+
assert.equal(h.git.length,0);
|
|
566
|
+
await fire(h.handlers,"session_shutdown",next.ctx);
|
|
513
567
|
});
|
|
514
568
|
|
|
515
569
|
test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
|
|
@@ -550,10 +604,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
550
604
|
installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
|
|
551
605
|
await fire(h.handlers, "session_start", ctx);
|
|
552
606
|
t.after(() => fire(h.handlers, "session_shutdown", ctx));
|
|
553
|
-
|
|
554
|
-
const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
|
|
555
|
-
assert.match(rendered, /selected-only\.txt/);
|
|
556
|
-
assert.doesNotMatch(rendered, /foreign-only\.txt/);
|
|
607
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), false, "preexisting dirty files are not agent changes");
|
|
557
608
|
const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
|
|
558
609
|
assert.match(diff, /\+selected change/);
|
|
559
610
|
assert.doesNotMatch(diff, /foreign change/);
|
|
@@ -567,12 +618,8 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
567
618
|
assert.equal(largeDiff.code, 0);
|
|
568
619
|
assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
|
|
569
620
|
assert.match(largeDiff.stdout, /\+selected final marker/);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
ui.overlayView!.handleInput("\r");
|
|
573
|
-
ui.overlayView!.handleInput("j");
|
|
574
|
-
ui.closeOverlay?.();
|
|
575
|
-
await open;
|
|
621
|
+
await h.commands.get("gentle:changes")!.handler("", ctx);
|
|
622
|
+
assert.equal(ui.overlay, undefined);
|
|
576
623
|
});
|
|
577
624
|
|
|
578
625
|
test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
|
|
@@ -644,55 +691,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
|
|
|
644
691
|
const shortcut = shortcuts.get("alt+g");
|
|
645
692
|
assert.ok(shortcut, "alt+g not registered");
|
|
646
693
|
await shortcut.handler(ctx);
|
|
647
|
-
assert.
|
|
694
|
+
assert.match(ui.notices.join("\n"), /No captured agent changes/);
|
|
648
695
|
|
|
649
696
|
const silent = fakePi();
|
|
650
697
|
gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
|
|
651
|
-
assert.equal(silent.shortcuts.
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("gentleShell keeps the open overlay in sync with git while it stays open", async () => {
|
|
655
|
-
const { pi, handlers, commands } = fakePi([
|
|
656
|
-
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
657
|
-
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
658
|
-
{ numstat: "10\t0\tlib/b.ts\n3\t1\tlib/c.ts\n", porcelain: "A lib/b.ts\0 M lib/c.ts\0" },
|
|
659
|
-
]);
|
|
660
|
-
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
|
|
661
|
-
const { ctx, ui } = fakeContext();
|
|
662
|
-
await fire(handlers, "session_start", ctx);
|
|
663
|
-
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
664
|
-
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
665
|
-
ui.overlayView!.handleInput("\r");
|
|
666
|
-
const plain = ui.overlayView!.render(100).map(stripAnsi);
|
|
667
|
-
assert.match(plain[2], /2 files · \+13 −1/);
|
|
668
|
-
assert.match(plain[3], /lib\/c\.ts/);
|
|
669
|
-
assert.match(renderFooter(ui), /main ±2/);
|
|
670
|
-
ui.closeOverlay?.();
|
|
671
|
-
await open;
|
|
698
|
+
assert.equal(silent.shortcuts.has("alt+g"), false, "the changes shortcut must not register when disabled");
|
|
672
699
|
});
|
|
673
700
|
|
|
674
|
-
test("
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), true);
|
|
686
|
-
assert.match(renderFooter(ui), /main ±1/);
|
|
687
|
-
assert.ok(git.length >= 6, "background watch should keep polling git");
|
|
688
|
-
|
|
689
|
-
const widgetSetsBefore = ui.widgetSets;
|
|
690
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
691
|
-
assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
|
|
692
|
-
await fire(handlers, "session_shutdown", ctx);
|
|
693
|
-
const gitCallsAfterShutdown = git.length;
|
|
694
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
695
|
-
assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
|
|
701
|
+
test("external edits do not pollute Changes or trigger background Git scans", async () => {
|
|
702
|
+
const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
|
|
703
|
+
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
|
|
704
|
+
const {ctx,ui}=fakeContext();
|
|
705
|
+
await fire(handlers,"session_start",ctx);
|
|
706
|
+
await new Promise(resolve=>setTimeout(resolve,30));
|
|
707
|
+
await fire(handlers,"agent_end",ctx);
|
|
708
|
+
assert.equal(git.length,0);
|
|
709
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
710
|
+
assert.doesNotMatch(renderFooter(ui),/±/);
|
|
711
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
696
712
|
});
|
|
697
713
|
|
|
698
714
|
const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
|
|
@@ -816,3 +832,120 @@ test("gentleShell keeps a dev-binary override visible above the editor for the w
|
|
|
816
832
|
|
|
817
833
|
assert.equal(devBinaryCard({ state: "invalid", reason: "binary missing" }).tone, "error");
|
|
818
834
|
});
|
|
835
|
+
|
|
836
|
+
test("gentle:commands registers alt+k by default", () => {
|
|
837
|
+
const { pi, shortcuts } = fakePi();
|
|
838
|
+
gentleShell(pi, {});
|
|
839
|
+
assert.ok(shortcuts.has("alt+k"));
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("gentle:commands honors GENTLE_PI_COMMANDS_KEY", () => {
|
|
843
|
+
const { pi, shortcuts } = fakePi();
|
|
844
|
+
gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "ctrl+p" });
|
|
845
|
+
assert.ok(shortcuts.has("ctrl+p"));
|
|
846
|
+
assert.equal(shortcuts.has("alt+k"), false);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("GENTLE_PI_COMMANDS_KEY=off registers no command-palette shortcut", () => {
|
|
850
|
+
const { pi, shortcuts } = fakePi();
|
|
851
|
+
gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "off" });
|
|
852
|
+
assert.equal(shortcuts.has("alt+k"), false);
|
|
853
|
+
assert.ok(shortcuts.has("alt+g"), "the unrelated changes shortcut still registers");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("/gentle:commands shows only curated, registered commands, grouped, by their labels", async () => {
|
|
857
|
+
const { pi, commands } = fakePi();
|
|
858
|
+
gentleShell(pi, {});
|
|
859
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
860
|
+
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
861
|
+
await overlayReady;
|
|
862
|
+
const lines = ui.overlayView!.render(100);
|
|
863
|
+
const rendered = lines.join("\n");
|
|
864
|
+
assert.match(rendered, /Configuration/);
|
|
865
|
+
assert.match(rendered, /Session/);
|
|
866
|
+
assert.match(rendered, /Diagnostics/);
|
|
867
|
+
assert.match(rendered, /Skills/);
|
|
868
|
+
assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
|
|
869
|
+
assert.match(rendered, /Assign models and effort/);
|
|
870
|
+
assert.match(rendered, /Browse captured changes/);
|
|
871
|
+
assert.match(rendered, /Gentle AI status/);
|
|
872
|
+
assert.match(rendered, /Refresh skill registry/);
|
|
873
|
+
assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
|
|
874
|
+
assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
|
|
875
|
+
assert.doesNotMatch(rendered, /skill:foo/);
|
|
876
|
+
const changesLine = lines.find((line) => line.includes("Browse captured changes"));
|
|
877
|
+
assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse captured changes");
|
|
878
|
+
ui.overlayView!.handleInput("\x1b");
|
|
879
|
+
await opened;
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("selecting a command from the palette by its label sends the underlying command as a slash message", async () => {
|
|
883
|
+
const { pi, commands, sentMessages } = fakePi();
|
|
884
|
+
gentleShell(pi, {});
|
|
885
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
886
|
+
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
887
|
+
await overlayReady;
|
|
888
|
+
// "mod" only matches the "Assign models and effort" label (it contains
|
|
889
|
+
// "mod" via "models"); nothing else in the fixture does.
|
|
890
|
+
for (const ch of "mod") ui.overlayView!.handleInput(ch);
|
|
891
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
|
|
892
|
+
ui.overlayView!.handleInput("\r");
|
|
893
|
+
await opened;
|
|
894
|
+
assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test("escaping the palette sends no message", async () => {
|
|
898
|
+
const { pi, commands, sentMessages } = fakePi();
|
|
899
|
+
gentleShell(pi, {});
|
|
900
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
901
|
+
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
902
|
+
await overlayReady;
|
|
903
|
+
ui.overlayView!.handleInput("\x1b");
|
|
904
|
+
await opened;
|
|
905
|
+
assert.deepEqual(sentMessages, []);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("/gentle:commands notifies when nothing in the catalog is registered", async () => {
|
|
909
|
+
const { pi, commands } = fakePi(undefined, [
|
|
910
|
+
{ name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
|
|
911
|
+
{ name: "gentle:not-in-catalog", description: "Not curated", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
912
|
+
]);
|
|
913
|
+
gentleShell(pi, {});
|
|
914
|
+
const { ctx, ui } = fakeContext();
|
|
915
|
+
await commands.get("gentle:commands")!.handler("", ctx);
|
|
916
|
+
assert.match(ui.notices.join("\n"), /No Gentle commands are registered\./);
|
|
917
|
+
assert.equal(ui.overlay, undefined);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("/gentle:commands does nothing in a headless context", async () => {
|
|
921
|
+
const { pi, commands, sentMessages } = fakePi();
|
|
922
|
+
gentleShell(pi, {});
|
|
923
|
+
const { ctx, ui } = fakeContext({ hasUI: false });
|
|
924
|
+
await commands.get("gentle:commands")!.handler("", ctx);
|
|
925
|
+
assert.equal(ui.overlay, undefined);
|
|
926
|
+
assert.deepEqual(sentMessages, []);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
test("the alt+k shortcut opens the same command palette as the command", async () => {
|
|
930
|
+
const { pi, shortcuts } = fakePi();
|
|
931
|
+
gentleShell(pi, {});
|
|
932
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
933
|
+
const shortcut = shortcuts.get("alt+k");
|
|
934
|
+
assert.ok(shortcut, "alt+k not registered");
|
|
935
|
+
const opened = shortcut.handler(ctx);
|
|
936
|
+
await overlayReady;
|
|
937
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
|
|
938
|
+
ui.overlayView!.handleInput("\x1b");
|
|
939
|
+
await opened;
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("resolving the overlay through closeOverlay sends nothing and does not throw", async () => {
|
|
943
|
+
const { pi, commands, sentMessages } = fakePi();
|
|
944
|
+
gentleShell(pi, {});
|
|
945
|
+
const { ctx, ui, overlayReady } = fakeContext();
|
|
946
|
+
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
947
|
+
await overlayReady;
|
|
948
|
+
ui.closeOverlay?.();
|
|
949
|
+
await opened;
|
|
950
|
+
assert.deepEqual(sentMessages, []);
|
|
951
|
+
});
|