gentle-pi 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import gentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
5
|
+
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
6
|
+
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
7
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
8
|
+
|
|
9
|
+
// The Gentle Shell extension wires the pure bar renderer into pi's footer
|
|
10
|
+
// slot. These tests drive it with a fake ExtensionAPI and context.
|
|
11
|
+
|
|
12
|
+
initTheme("dark");
|
|
13
|
+
|
|
14
|
+
const plainTheme = {
|
|
15
|
+
fg(_color: string, value: string) {
|
|
16
|
+
return value;
|
|
17
|
+
},
|
|
18
|
+
bold(value: string) {
|
|
19
|
+
return value;
|
|
20
|
+
},
|
|
21
|
+
bg(_color: string, value: string) {
|
|
22
|
+
return value;
|
|
23
|
+
},
|
|
24
|
+
getBgAnsi() {
|
|
25
|
+
return "";
|
|
26
|
+
},
|
|
27
|
+
} satisfies ShellBarTheme & { bg(color: string, value: string): string; getBgAnsi(): string };
|
|
28
|
+
|
|
29
|
+
interface FakeUi {
|
|
30
|
+
footerFactory: unknown;
|
|
31
|
+
editorFactory: unknown;
|
|
32
|
+
widgets: Map<string, unknown>;
|
|
33
|
+
widgetSets: number;
|
|
34
|
+
workingVisible: boolean | undefined;
|
|
35
|
+
notices: string[];
|
|
36
|
+
overlay: unknown;
|
|
37
|
+
overlayView: { render(width: number): string[] } | undefined;
|
|
38
|
+
closeOverlay: (() => void) | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GitScript {
|
|
42
|
+
numstat: string;
|
|
43
|
+
porcelain: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CommandRegistration {
|
|
47
|
+
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ShortcutRegistration {
|
|
51
|
+
handler: (ctx: ExtensionContext) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
|
|
55
|
+
const renderers = new Map<string, MessageRenderer>();
|
|
56
|
+
|
|
57
|
+
function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]): { pi: ExtensionAPI; handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>; git: string[][]; commands: Map<string, CommandRegistration>; shortcuts: Map<string, ShortcutRegistration> } {
|
|
58
|
+
const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
|
|
59
|
+
const commands = new Map<string, CommandRegistration>();
|
|
60
|
+
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
61
|
+
const git: string[][] = [];
|
|
62
|
+
let round = 0;
|
|
63
|
+
const pi = {
|
|
64
|
+
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
|
|
65
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
66
|
+
},
|
|
67
|
+
registerCommand(name: string, registration: CommandRegistration) {
|
|
68
|
+
commands.set(name, registration);
|
|
69
|
+
},
|
|
70
|
+
registerShortcut(key: string, registration: ShortcutRegistration) {
|
|
71
|
+
shortcuts.set(key, registration);
|
|
72
|
+
},
|
|
73
|
+
registerMessageRenderer(type: string, renderer: MessageRenderer) {
|
|
74
|
+
renderers.set(type, renderer);
|
|
75
|
+
},
|
|
76
|
+
getThinkingLevel() {
|
|
77
|
+
return "medium";
|
|
78
|
+
},
|
|
79
|
+
async exec(_command: string, args: string[]) {
|
|
80
|
+
git.push(args);
|
|
81
|
+
const isNumstat = args.includes("diff");
|
|
82
|
+
const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
|
|
83
|
+
return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
|
|
84
|
+
},
|
|
85
|
+
} as unknown as ExtensionAPI;
|
|
86
|
+
return { pi, handlers, git, commands, shortcuts };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
|
|
90
|
+
for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi } {
|
|
94
|
+
const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
|
|
95
|
+
const ctx = {
|
|
96
|
+
hasUI: options.hasUI ?? true,
|
|
97
|
+
hasPendingMessages: () => options.pending ?? false,
|
|
98
|
+
cwd: "/repo",
|
|
99
|
+
model: { id: "gpt-5.5", provider: "openai-codex", reasoning: true, contextWindow: 272_000 },
|
|
100
|
+
sessionManager: {
|
|
101
|
+
getCwd: () => "/repo",
|
|
102
|
+
getSessionName: () => "Release notes",
|
|
103
|
+
getEntries: () => options.entries ?? [],
|
|
104
|
+
},
|
|
105
|
+
modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token },
|
|
106
|
+
getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
|
|
107
|
+
ui: {
|
|
108
|
+
theme: plainTheme,
|
|
109
|
+
setFooter(factory: unknown) {
|
|
110
|
+
ui.footerFactory = factory;
|
|
111
|
+
},
|
|
112
|
+
setEditorComponent(factory: unknown) {
|
|
113
|
+
ui.editorFactory = factory;
|
|
114
|
+
},
|
|
115
|
+
getEditorComponent() {
|
|
116
|
+
return ui.editorFactory;
|
|
117
|
+
},
|
|
118
|
+
setWidget(key: string, content: unknown) {
|
|
119
|
+
ui.widgetSets += 1;
|
|
120
|
+
if (content === undefined) ui.widgets.delete(key);
|
|
121
|
+
else ui.widgets.set(key, content);
|
|
122
|
+
},
|
|
123
|
+
notify(message: string) {
|
|
124
|
+
ui.notices.push(message);
|
|
125
|
+
},
|
|
126
|
+
setWorkingVisible(visible: boolean) {
|
|
127
|
+
ui.workingVisible = visible;
|
|
128
|
+
},
|
|
129
|
+
custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[] }) {
|
|
130
|
+
ui.overlay = factory;
|
|
131
|
+
return new Promise<null>((resolve) => {
|
|
132
|
+
ui.closeOverlay = () => resolve(null);
|
|
133
|
+
ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(null));
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
} as unknown as ExtensionContext;
|
|
138
|
+
return { ctx, ui };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assistantEntry(usage: { input: number; output: number; cost: number }) {
|
|
142
|
+
return {
|
|
143
|
+
type: "message",
|
|
144
|
+
message: {
|
|
145
|
+
role: "assistant",
|
|
146
|
+
usage: { input: usage.input, output: usage.output, cacheRead: 0, cacheWrite: 0, cost: { total: usage.cost } },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test("buildShellBarModel reads session, model, and footer data", () => {
|
|
152
|
+
const { pi } = fakePi();
|
|
153
|
+
const { ctx } = fakeContext({
|
|
154
|
+
entries: [assistantEntry({ input: 1000, output: 200, cost: 0.5 }), assistantEntry({ input: 500, output: 100, cost: 0.25 }), { type: "message", message: { role: "user" } }],
|
|
155
|
+
});
|
|
156
|
+
const footerData = {
|
|
157
|
+
getGitBranch: () => "main",
|
|
158
|
+
getExtensionStatuses: () => new Map([["mcp", "MCP: 3 servers enabled"]]),
|
|
159
|
+
getAvailableProviderCount: () => 1,
|
|
160
|
+
onBranchChange: () => () => {},
|
|
161
|
+
};
|
|
162
|
+
const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" });
|
|
163
|
+
assert.equal(built.cwd, "/repo");
|
|
164
|
+
assert.equal(built.branch, "main");
|
|
165
|
+
assert.equal(built.sessionName, "Release notes");
|
|
166
|
+
assert.equal(built.modelId, "gpt-5.5");
|
|
167
|
+
assert.equal(built.effort, "medium");
|
|
168
|
+
assert.equal(built.contextPercent, 45);
|
|
169
|
+
assert.equal(built.costTotal, 0.75);
|
|
170
|
+
assert.equal(built.subscription, true);
|
|
171
|
+
assert.deepEqual(built.statuses, ["MCP: 3 servers enabled"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("buildShellBarModel shortens the home directory and hides effort for non-reasoning models", () => {
|
|
175
|
+
const { pi } = fakePi();
|
|
176
|
+
const { ctx } = fakeContext();
|
|
177
|
+
(ctx as unknown as { model: { reasoning: boolean } }).model.reasoning = false;
|
|
178
|
+
(ctx.sessionManager as unknown as { getCwd: () => string }).getCwd = () => "/home/alan/work/gentle-pi";
|
|
179
|
+
const footerData = {
|
|
180
|
+
getGitBranch: () => null,
|
|
181
|
+
getExtensionStatuses: () => new Map(),
|
|
182
|
+
getAvailableProviderCount: () => 1,
|
|
183
|
+
onBranchChange: () => () => {},
|
|
184
|
+
};
|
|
185
|
+
const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" });
|
|
186
|
+
assert.equal(built.cwd, "~/work/gentle-pi");
|
|
187
|
+
assert.equal(built.effort, undefined);
|
|
188
|
+
assert.equal(built.branch, null);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("gentleShell installs the footer on session_start when a UI exists", () => {
|
|
192
|
+
const { pi, handlers } = fakePi();
|
|
193
|
+
gentleShell(pi, {});
|
|
194
|
+
const { ctx, ui } = fakeContext();
|
|
195
|
+
for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
|
|
196
|
+
assert.equal(typeof ui.footerFactory, "function");
|
|
197
|
+
|
|
198
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[] };
|
|
199
|
+
const component = factory(
|
|
200
|
+
{ requestRender() {} },
|
|
201
|
+
plainTheme,
|
|
202
|
+
{ getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} },
|
|
203
|
+
);
|
|
204
|
+
const lines = component.render(120);
|
|
205
|
+
assert.equal(lines.length, 1);
|
|
206
|
+
assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("gentleShell stays out of the way without a UI or when disabled", () => {
|
|
210
|
+
const disabled = fakePi();
|
|
211
|
+
gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
|
|
212
|
+
assert.equal(disabled.handlers.size, 0);
|
|
213
|
+
|
|
214
|
+
const headless = fakePi();
|
|
215
|
+
gentleShell(headless.pi, {});
|
|
216
|
+
const { ctx, ui } = fakeContext({ hasUI: false });
|
|
217
|
+
for (const handler of headless.handlers.get("session_start") ?? []) handler({}, ctx);
|
|
218
|
+
assert.equal(ui.footerFactory, undefined);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const fakeTui = { terminal: { rows: 40, columns: 120 }, requestRender() {} };
|
|
222
|
+
const editorTheme = { borderColor: (text: string) => text, selectList: {} };
|
|
223
|
+
const fakeKeybindings = { matches: () => false };
|
|
224
|
+
|
|
225
|
+
function installedPrompt(ctx: ExtensionContext, ui: FakeUi, handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>): GentlePromptEditor {
|
|
226
|
+
for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
|
|
227
|
+
const factory = ui.editorFactory as (tui: unknown, theme: unknown, keybindings: unknown) => GentlePromptEditor;
|
|
228
|
+
return factory(fakeTui, editorTheme, fakeKeybindings);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
|
|
232
|
+
const { pi, handlers } = fakePi();
|
|
233
|
+
gentleShell(pi, {});
|
|
234
|
+
const { ctx, ui } = fakeContext();
|
|
235
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
236
|
+
assert.equal(ui.workingVisible, false, "pi's own Working row must be hidden");
|
|
237
|
+
editor.focused = true;
|
|
238
|
+
const lines = editor.render(60).map(stripAnsi);
|
|
239
|
+
assert.match(lines[0], /^╭─ ✿ ─+╮$/);
|
|
240
|
+
assert.ok(editor.render(60).every((line) => line.endsWith("\x1b[49m")), "prompt lines carry the panel background");
|
|
241
|
+
assert.match(lines[1], /^│.*type, or \/ for commands +│$/);
|
|
242
|
+
assert.match(lines[lines.length - 1], /^╰─+╯$/);
|
|
243
|
+
editor.setText("hola");
|
|
244
|
+
assert.doesNotMatch(editor.render(60).map(stripAnsi)[1], /type, or/);
|
|
245
|
+
editor.dispose();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("gentleShell shows working while the agent runs and queued when messages wait", () => {
|
|
249
|
+
const { pi, handlers } = fakePi();
|
|
250
|
+
gentleShell(pi, {});
|
|
251
|
+
const pending = { value: false };
|
|
252
|
+
const { ctx, ui } = fakeContext();
|
|
253
|
+
(ctx as unknown as { hasPendingMessages: () => boolean }).hasPendingMessages = () => pending.value;
|
|
254
|
+
const editor = installedPrompt(ctx, ui, handlers);
|
|
255
|
+
|
|
256
|
+
for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
|
|
257
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working ─+╮$/);
|
|
258
|
+
pending.value = true;
|
|
259
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
|
|
260
|
+
for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
|
|
261
|
+
pending.value = false;
|
|
262
|
+
assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ ─+╮$/);
|
|
263
|
+
editor.dispose();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("gentleShell leaves an editor another extension already installed", () => {
|
|
267
|
+
const { pi, handlers } = fakePi();
|
|
268
|
+
gentleShell(pi, {});
|
|
269
|
+
const theirs = () => ({});
|
|
270
|
+
const { ctx, ui } = fakeContext({ editorFactory: theirs });
|
|
271
|
+
for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
|
|
272
|
+
assert.equal(ui.editorFactory, theirs);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
|
|
276
|
+
|
|
277
|
+
function renderFooter(ui: FakeUi): string {
|
|
278
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[] };
|
|
279
|
+
return factory(fakeTui, plainTheme, footerData).render(160)[0];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
test("gentleShell shows working-tree changes in a widget below the editor and in the bar", async () => {
|
|
283
|
+
const { pi, handlers, git } = fakePi([
|
|
284
|
+
{ numstat: "", porcelain: "" },
|
|
285
|
+
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
286
|
+
]);
|
|
287
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
288
|
+
const { ctx, ui } = fakeContext();
|
|
289
|
+
await fire(handlers, "session_start", ctx);
|
|
290
|
+
assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
|
|
291
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), false);
|
|
292
|
+
assert.doesNotMatch(renderFooter(ui), /±/);
|
|
293
|
+
|
|
294
|
+
await fire(handlers, "tool_execution_end", ctx);
|
|
295
|
+
const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
|
|
296
|
+
const [line] = factory(fakeTui, plainTheme).render(120);
|
|
297
|
+
assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
|
|
298
|
+
assert.match(renderFooter(ui), /main ±1/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
|
|
302
|
+
const { pi, handlers, commands } = fakePi([
|
|
303
|
+
{ numstat: "", porcelain: "" },
|
|
304
|
+
{ numstat: "", porcelain: "" },
|
|
305
|
+
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
306
|
+
]);
|
|
307
|
+
gentleShell(pi, {});
|
|
308
|
+
const { ctx, ui } = fakeContext();
|
|
309
|
+
await fire(handlers, "session_start", ctx);
|
|
310
|
+
const command = commands.get("gentle:changes");
|
|
311
|
+
assert.ok(command, "command not registered");
|
|
312
|
+
|
|
313
|
+
await command.handler("", ctx);
|
|
314
|
+
assert.deepEqual(ui.notices, ["No changes in the working tree."]);
|
|
315
|
+
assert.equal(ui.overlay, undefined);
|
|
316
|
+
|
|
317
|
+
await fire(handlers, "tool_execution_end", ctx);
|
|
318
|
+
const opened = command.handler("", ctx);
|
|
319
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
320
|
+
assert.equal(typeof ui.overlay, "function");
|
|
321
|
+
ui.closeOverlay?.();
|
|
322
|
+
await opened;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
|
|
326
|
+
const calls: string[][] = [];
|
|
327
|
+
const git = async (args: string[]) => {
|
|
328
|
+
calls.push(args);
|
|
329
|
+
return { stdout: "@@ -0,0 +1 @@\n+hello", code: args.includes("--no-index") ? 1 : 0 };
|
|
330
|
+
};
|
|
331
|
+
assert.match(await loadFileDiff(git, { path: "lib/a.ts", added: 1, deleted: 0, status: CHANGE_STATUS.MODIFIED }), /\+hello/);
|
|
332
|
+
assert.match(await loadFileDiff(git, { path: "notes.md", added: 0, deleted: 0, status: CHANGE_STATUS.UNTRACKED }), /\+hello/);
|
|
333
|
+
assert.deepEqual(calls, [
|
|
334
|
+
["diff", "HEAD", "--", "lib/a.ts"],
|
|
335
|
+
["diff", "--no-index", "--", "/dev/null", "notes.md"],
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("openInExternalEditor stops the TUI around the editor and honors $VISUAL over $EDITOR", () => {
|
|
340
|
+
const events: string[] = [];
|
|
341
|
+
const host = { stop: () => events.push("stop"), start: () => events.push("start"), requestRender: (force?: boolean) => events.push(`render:${force}`) };
|
|
342
|
+
const spawn = ((command: string, args: string[]) => {
|
|
343
|
+
events.push(`spawn:${command} ${args.join(" ")}`);
|
|
344
|
+
return { status: 0 } as ReturnType<typeof import("node:child_process").spawnSync>;
|
|
345
|
+
}) as typeof import("node:child_process").spawnSync;
|
|
346
|
+
assert.equal(openInExternalEditor(host, "lib/a.ts", { VISUAL: "nvim -u none", EDITOR: "vi" }, spawn), true);
|
|
347
|
+
assert.deepEqual(events, ["stop", "spawn:nvim -u none lib/a.ts", "start", "render:true"]);
|
|
348
|
+
assert.equal(openInExternalEditor(host, "lib/a.ts", {}, spawn), false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("changesShortcut defaults to alt+g and can be overridden or disabled", () => {
|
|
352
|
+
assert.equal(changesShortcut({}), "alt+g");
|
|
353
|
+
assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "ctrl+shift+g" }), "ctrl+shift+g");
|
|
354
|
+
assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "off" }), undefined);
|
|
355
|
+
assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "" }), undefined);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("gentleShell binds the changes shortcut to the same handler as the command", async () => {
|
|
359
|
+
const { pi, handlers, shortcuts } = fakePi();
|
|
360
|
+
gentleShell(pi, {});
|
|
361
|
+
const { ctx, ui } = fakeContext();
|
|
362
|
+
await fire(handlers, "session_start", ctx);
|
|
363
|
+
const shortcut = shortcuts.get("alt+g");
|
|
364
|
+
assert.ok(shortcut, "alt+g not registered");
|
|
365
|
+
await shortcut.handler(ctx);
|
|
366
|
+
assert.deepEqual(ui.notices, ["No changes in the working tree."]);
|
|
367
|
+
|
|
368
|
+
const silent = fakePi();
|
|
369
|
+
gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
|
|
370
|
+
assert.equal(silent.shortcuts.size, 0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("gentleShell keeps the open overlay in sync with git while it stays open", async () => {
|
|
374
|
+
const { pi, handlers, commands } = fakePi([
|
|
375
|
+
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
376
|
+
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
377
|
+
{ numstat: "10\t0\tlib/b.ts\n3\t1\tlib/c.ts\n", porcelain: "A lib/b.ts\0 M lib/c.ts\0" },
|
|
378
|
+
]);
|
|
379
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
|
|
380
|
+
const { ctx, ui } = fakeContext();
|
|
381
|
+
await fire(handlers, "session_start", ctx);
|
|
382
|
+
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
384
|
+
const plain = ui.overlayView!.render(100).map(stripAnsi);
|
|
385
|
+
assert.match(plain[0], /2 files · \+13 −1/);
|
|
386
|
+
assert.match(plain[2], /lib\/c\.ts/);
|
|
387
|
+
assert.match(renderFooter(ui), /main ±2/);
|
|
388
|
+
ui.closeOverlay?.();
|
|
389
|
+
await open;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("gentleShell watches git in the background so the widget and bar follow external edits", async () => {
|
|
393
|
+
const { pi, handlers, git } = fakePi([
|
|
394
|
+
{ numstat: "", porcelain: "" },
|
|
395
|
+
{ numstat: "", porcelain: "" },
|
|
396
|
+
{ numstat: "3\t1\tlib/c.ts\n", porcelain: " M lib/c.ts\0" },
|
|
397
|
+
]);
|
|
398
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "5" });
|
|
399
|
+
const { ctx, ui } = fakeContext();
|
|
400
|
+
await fire(handlers, "session_start", ctx);
|
|
401
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), false);
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
403
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), true);
|
|
404
|
+
assert.match(renderFooter(ui), /main ±1/);
|
|
405
|
+
assert.ok(git.length >= 6, "background watch should keep polling git");
|
|
406
|
+
|
|
407
|
+
const widgetSetsBefore = ui.widgetSets;
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
409
|
+
assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
|
|
410
|
+
await fire(handlers, "session_shutdown", ctx);
|
|
411
|
+
const gitCallsAfterShutdown = git.length;
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
413
|
+
assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
|
|
417
|
+
const USAGE_PAYLOAD = { plan_type: "pro", rate_limit: { primary_window: { used_percent: 40, limit_window_seconds: 604_800, reset_at: 1_788_777_491 } } };
|
|
418
|
+
|
|
419
|
+
function fakeFetch(payload: unknown = USAGE_PAYLOAD, ok = true) {
|
|
420
|
+
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
421
|
+
const fetchFn = (async (url: string | URL, init?: RequestInit) => {
|
|
422
|
+
calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
|
|
423
|
+
return { ok, json: async () => payload } as Response;
|
|
424
|
+
}) as typeof fetch;
|
|
425
|
+
return { fetchFn, calls };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
test("fetchCodexUsage sends the token and account id and parses the payload", async () => {
|
|
429
|
+
const { fetchFn, calls } = fakeFetch();
|
|
430
|
+
const usage = await fetchCodexUsage(JWT, fetchFn, 1_788_600_000_000);
|
|
431
|
+
assert.equal(usage?.plan, "pro");
|
|
432
|
+
assert.equal(calls[0].url, "https://chatgpt.com/backend-api/wham/usage");
|
|
433
|
+
assert.equal(calls[0].headers.Authorization, `Bearer ${JWT}`);
|
|
434
|
+
assert.equal(calls[0].headers["chatgpt-account-id"], "acct-1");
|
|
435
|
+
|
|
436
|
+
const plain = fakeFetch();
|
|
437
|
+
assert.equal(await fetchCodexUsage("sk-plain-api-key", plain.fetchFn, 0), undefined);
|
|
438
|
+
assert.equal(plain.calls.length, 0, "a non-OAuth key must not be sent anywhere");
|
|
439
|
+
assert.equal(await fetchCodexUsage(JWT, fakeFetch({}, false).fetchFn, 0), undefined);
|
|
440
|
+
assert.equal(await fetchCodexUsage(undefined, plain.fetchFn, 0), undefined);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("gentleShell fetches Codex usage on session start and shows it in the bar", async () => {
|
|
444
|
+
const { pi, handlers } = fakePi();
|
|
445
|
+
const { fetchFn, calls } = fakeFetch();
|
|
446
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
|
|
447
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
448
|
+
await fire(handlers, "session_start", ctx);
|
|
449
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
450
|
+
assert.equal(calls.length, 1);
|
|
451
|
+
assert.match(renderFooter(ui), /\$0\.000 sub ⟡ codex week ▰▰▰▱▱▱▱▱ 40%/);
|
|
452
|
+
|
|
453
|
+
await fire(handlers, "agent_end", ctx);
|
|
454
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
455
|
+
assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("gentleShell records SSE rate-limit headers from provider responses", async () => {
|
|
459
|
+
const { pi, handlers } = fakePi();
|
|
460
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
|
|
461
|
+
const { ctx, ui } = fakeContext();
|
|
462
|
+
await fire(handlers, "session_start", ctx);
|
|
463
|
+
for (const handler of handlers.get("after_provider_response") ?? []) {
|
|
464
|
+
handler({ status: 200, headers: { "x-codex-primary-used-percent": "62", "x-codex-primary-window-minutes": "300", "x-codex-secondary-used-percent": "31", "x-codex-secondary-window-minutes": "10080" } }, ctx);
|
|
465
|
+
}
|
|
466
|
+
assert.match(renderFooter(ui), /codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%/);
|
|
467
|
+
|
|
468
|
+
(ctx as unknown as { model: { provider: string } }).model.provider = "anthropic";
|
|
469
|
+
for (const handler of handlers.get("after_provider_response") ?? []) {
|
|
470
|
+
handler({ status: 200, headers: { "anthropic-ratelimit-unified-5h-utilization": "0.25", "anthropic-ratelimit-unified-7d-utilization": "0.9" } }, ctx);
|
|
471
|
+
}
|
|
472
|
+
assert.match(renderFooter(ui), /claude 5h ▰▰▱▱▱▱▱▱ 25% · week 90%/);
|
|
473
|
+
assert.doesNotMatch(renderFooter(ui), /codex/);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("gentleShell registers /gentle:usage and opens the subscriptions overlay", async () => {
|
|
477
|
+
const { pi, handlers, commands } = fakePi();
|
|
478
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
|
|
479
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
480
|
+
await fire(handlers, "session_start", ctx);
|
|
481
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
482
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
483
|
+
const plain = ui.overlayView!.render(90).map(stripAnsi);
|
|
484
|
+
assert.match(plain[0], /Subscriptions/);
|
|
485
|
+
assert.match(plain[1], /✿ openai-codex · pro/);
|
|
486
|
+
ui.closeOverlay?.();
|
|
487
|
+
await opened;
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("gentleShell draws the review preflight message as a Gentle card", () => {
|
|
491
|
+
const { pi } = fakePi();
|
|
492
|
+
gentleShell(pi, {});
|
|
493
|
+
const renderer = renderers.get("gentle-pi.review-preflight");
|
|
494
|
+
assert.ok(renderer, "renderer not registered");
|
|
495
|
+
const message = { customType: "gentle-pi.review-preflight", content: "Receipt-driven development is enabled.\n\nCall the gentle_review tool." };
|
|
496
|
+
const expanded = renderer(message, { expanded: true }, plainTheme).render(80).map(stripAnsi);
|
|
497
|
+
assert.match(expanded[0], /^╭─ ✿ Gentle AI · review preflight ─+ .*collapse ╮$/);
|
|
498
|
+
assert.match(expanded[1], /^│ Receipt-driven development is enabled\. +│$/);
|
|
499
|
+
assert.ok(expanded.some((line) => line.includes("gentle_review")));
|
|
500
|
+
const collapsed = renderer({ ...message, content: [{ type: "text", text: message.content }] }, { expanded: false }, plainTheme).render(80).map(stripAnsi);
|
|
501
|
+
assert.equal(collapsed.length, 3);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("gentleShell keeps a dev-binary override visible above the editor for the whole session", async () => {
|
|
505
|
+
const { pi, handlers } = fakePi();
|
|
506
|
+
const deps = { fetch: fakeFetch({}, false).fetchFn, now: () => 0, devBinary: () => ({ state: "active" as const, path: "/Users/me/go/bin/gentle-ai", sha256: "6e53bfc6305a3949deadbeef" }) };
|
|
507
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, deps);
|
|
508
|
+
const { ctx, ui } = fakeContext();
|
|
509
|
+
await fire(handlers, "session_start", ctx);
|
|
510
|
+
const factory = ui.widgets.get("gentle-shell-dev-binary") as (tui: unknown, theme: unknown) => { render(width: number): string[] };
|
|
511
|
+
assert.ok(factory, "dev binary widget missing");
|
|
512
|
+
const lines = factory(fakeTui, plainTheme).render(100).map(stripAnsi);
|
|
513
|
+
assert.match(lines[0], /^╭─ ✿ Gentle AI · dev binary override · field-test only ─+╮$/);
|
|
514
|
+
assert.match(lines[1], /^│ \/Users\/me\/go\/bin\/gentle-ai · sha256:6e53bfc6305a3949 +│$/);
|
|
515
|
+
assert.match(lines[2], /^╰─+╯$/);
|
|
516
|
+
assert.equal(lines[3], "", "a blank line keeps the card off the prompt frame");
|
|
517
|
+
await fire(handlers, "agent_start", ctx);
|
|
518
|
+
assert.equal(ui.widgets.has("gentle-shell-dev-binary"), false, "the startup notice leaves with the first prompt");
|
|
519
|
+
|
|
520
|
+
const clean = fakePi();
|
|
521
|
+
gentleShell(clean.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { ...deps, devBinary: () => undefined });
|
|
522
|
+
const fresh = fakeContext();
|
|
523
|
+
await fire(clean.handlers, "session_start", fresh.ctx);
|
|
524
|
+
assert.equal(fresh.ui.widgets.has("gentle-shell-dev-binary"), false);
|
|
525
|
+
|
|
526
|
+
assert.equal(devBinaryCard({ state: "invalid", reason: "binary missing" }).tone, "error");
|
|
527
|
+
});
|