@dungle-scrubs/tallow 0.8.13 → 0.8.15
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/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +14 -4
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +103 -2
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/sdk.d.ts +80 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +481 -31
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/context-budget-guard.test.ts +236 -0
- package/extensions/_shared/context-budget-interop.ts +162 -0
- package/extensions/ask-user-question-tool/__tests__/render-regression.test.ts +203 -0
- package/extensions/ask-user-question-tool/index.ts +70 -9
- package/extensions/background-task-tool/index.ts +10 -2
- package/extensions/bash-tool-enhanced/index.ts +10 -2
- package/extensions/plan-mode-tool/__tests__/utils.test.ts +180 -0
- package/extensions/plan-mode-tool/extension.json +1 -0
- package/extensions/plan-mode-tool/index.ts +33 -0
- package/extensions/plan-mode-tool/utils.ts +60 -0
- package/extensions/web-fetch-tool/__tests__/adaptive-cap.test.ts +148 -0
- package/extensions/web-fetch-tool/index.ts +140 -9
- package/extensions/wezterm-pane-control/__tests__/index.test.ts +23 -2
- package/extensions/wezterm-pane-control/index.ts +65 -1
- package/package.json +4 -4
- package/skills/tallow-expert/SKILL.md +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for ask_user_question custom UI rendering.
|
|
3
|
+
*
|
|
4
|
+
* Validates multiline option content never leaks embedded newlines into
|
|
5
|
+
* render rows and that repeated arrow navigation rerenders remain stable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
|
|
11
|
+
import askUserQuestion from "../index.js";
|
|
12
|
+
|
|
13
|
+
/** Shape of the custom component returned by `ctx.ui.custom(...)`. */
|
|
14
|
+
interface RenderComponentLike {
|
|
15
|
+
handleInput: (data: string) => void;
|
|
16
|
+
invalidate: () => void;
|
|
17
|
+
render: (width: number) => string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Driver returned by {@link createInteractiveContextHarness}. */
|
|
21
|
+
interface InteractiveContextHarness {
|
|
22
|
+
ctx: ExtensionContext;
|
|
23
|
+
getComponent: () => RenderComponentLike;
|
|
24
|
+
getRenderRequestCount: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Raw terminal sequence for the Down arrow key. */
|
|
28
|
+
const KEY_DOWN = "\u001b[B";
|
|
29
|
+
/** Raw terminal sequence for Escape key. */
|
|
30
|
+
const KEY_ESCAPE = "\u001b";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds an interactive context stub and captures the custom UI component.
|
|
34
|
+
*
|
|
35
|
+
* The returned context implements `ctx.ui.custom(...)` by instantiating the
|
|
36
|
+
* component immediately and resolving when the component calls `done(...)`.
|
|
37
|
+
*
|
|
38
|
+
* @returns Harness with context and captured component accessors
|
|
39
|
+
*/
|
|
40
|
+
function createInteractiveContextHarness(): InteractiveContextHarness {
|
|
41
|
+
let component: RenderComponentLike | null = null;
|
|
42
|
+
let renderRequestCount = 0;
|
|
43
|
+
|
|
44
|
+
const ctx = {
|
|
45
|
+
hasUI: true,
|
|
46
|
+
cwd: process.cwd(),
|
|
47
|
+
ui: {
|
|
48
|
+
setWorkingMessage() {},
|
|
49
|
+
async custom(factory: unknown) {
|
|
50
|
+
const createComponent = factory as (
|
|
51
|
+
tui: unknown,
|
|
52
|
+
theme: unknown,
|
|
53
|
+
keybindings: unknown,
|
|
54
|
+
done: (value: unknown) => void
|
|
55
|
+
) => RenderComponentLike;
|
|
56
|
+
|
|
57
|
+
return await new Promise((resolve) => {
|
|
58
|
+
component = createComponent(
|
|
59
|
+
{
|
|
60
|
+
requestRender() {
|
|
61
|
+
renderRequestCount += 1;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
bold: (value: string) => value,
|
|
66
|
+
fg: (_color: string, value: string) => value,
|
|
67
|
+
},
|
|
68
|
+
{},
|
|
69
|
+
(value: unknown) => {
|
|
70
|
+
resolve(value);
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
} as unknown as ExtensionContext["ui"],
|
|
76
|
+
} as ExtensionContext;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ctx,
|
|
80
|
+
getComponent() {
|
|
81
|
+
if (!component) {
|
|
82
|
+
throw new Error("Custom component was not created yet");
|
|
83
|
+
}
|
|
84
|
+
return component;
|
|
85
|
+
},
|
|
86
|
+
getRenderRequestCount() {
|
|
87
|
+
return renderRequestCount;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ensures the current microtask queue has drained.
|
|
94
|
+
* @returns Promise resolved on the next microtask tick
|
|
95
|
+
*/
|
|
96
|
+
async function nextTick(): Promise<void> {
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("ask_user_question render regression", () => {
|
|
101
|
+
test("renders newline-safe rows for multiline labels and descriptions", async () => {
|
|
102
|
+
const harness = ExtensionHarness.create();
|
|
103
|
+
await harness.loadExtension(askUserQuestion);
|
|
104
|
+
|
|
105
|
+
const tool = harness.tools.get("ask_user_question");
|
|
106
|
+
expect(tool).toBeDefined();
|
|
107
|
+
if (!tool) {
|
|
108
|
+
throw new Error("ask_user_question tool is not registered");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const interactive = createInteractiveContextHarness();
|
|
112
|
+
const runPromise = tool.execute(
|
|
113
|
+
"test-id",
|
|
114
|
+
{
|
|
115
|
+
question: "Pick one option",
|
|
116
|
+
options: [
|
|
117
|
+
{
|
|
118
|
+
description: "first description line\nsecond description line",
|
|
119
|
+
label: "Option A\nExtra Label",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
description: "single line",
|
|
123
|
+
label: "Option B",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
new AbortController().signal,
|
|
128
|
+
() => {},
|
|
129
|
+
interactive.ctx
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await nextTick();
|
|
133
|
+
const component = interactive.getComponent();
|
|
134
|
+
|
|
135
|
+
const firstRender = component.render(44);
|
|
136
|
+
component.handleInput(KEY_DOWN);
|
|
137
|
+
const secondRender = component.render(44);
|
|
138
|
+
|
|
139
|
+
component.handleInput(KEY_ESCAPE);
|
|
140
|
+
const result = await runPromise;
|
|
141
|
+
|
|
142
|
+
expect(firstRender.every((line) => !line.includes("\n"))).toBe(true);
|
|
143
|
+
expect(secondRender.every((line) => !line.includes("\n"))).toBe(true);
|
|
144
|
+
expect(result.content[0]?.text).toBe("User cancelled the selection");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("keeps line count stable during repeated down-arrow rerenders", async () => {
|
|
148
|
+
const harness = ExtensionHarness.create();
|
|
149
|
+
await harness.loadExtension(askUserQuestion);
|
|
150
|
+
|
|
151
|
+
const tool = harness.tools.get("ask_user_question");
|
|
152
|
+
expect(tool).toBeDefined();
|
|
153
|
+
if (!tool) {
|
|
154
|
+
throw new Error("ask_user_question tool is not registered");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const interactive = createInteractiveContextHarness();
|
|
158
|
+
const runPromise = tool.execute(
|
|
159
|
+
"test-id",
|
|
160
|
+
{
|
|
161
|
+
question: "Navigate options",
|
|
162
|
+
options: [
|
|
163
|
+
{
|
|
164
|
+
description: "Line 1\nLine 2\nLine 3",
|
|
165
|
+
label: "Alpha",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
description: "Desc B",
|
|
169
|
+
label: "Bravo",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
description: "Desc C",
|
|
173
|
+
label: "Charlie",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
new AbortController().signal,
|
|
178
|
+
() => {},
|
|
179
|
+
interactive.ctx
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
await nextTick();
|
|
183
|
+
const component = interactive.getComponent();
|
|
184
|
+
|
|
185
|
+
const width = 46;
|
|
186
|
+
const lineCounts: number[] = [component.render(width).length];
|
|
187
|
+
const renderSnapshots: string[][] = [component.render(width)];
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < 8; i++) {
|
|
190
|
+
component.handleInput(KEY_DOWN);
|
|
191
|
+
const snapshot = component.render(width);
|
|
192
|
+
lineCounts.push(snapshot.length);
|
|
193
|
+
renderSnapshots.push(snapshot);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
component.handleInput(KEY_ESCAPE);
|
|
197
|
+
await runPromise;
|
|
198
|
+
|
|
199
|
+
expect(new Set(lineCounts).size).toBe(1);
|
|
200
|
+
expect(renderSnapshots.flat().every((line) => !line.includes("\n"))).toBe(true);
|
|
201
|
+
expect(interactive.getRenderRequestCount()).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
matchesKey,
|
|
14
14
|
Text,
|
|
15
15
|
truncateToWidth,
|
|
16
|
+
visibleWidth,
|
|
16
17
|
wrapTextWithAnsi,
|
|
17
18
|
} from "@mariozechner/pi-tui";
|
|
18
19
|
import { Type } from "@sinclair/typebox";
|
|
@@ -48,6 +49,50 @@ const QuestionParams = Type.Object({
|
|
|
48
49
|
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Splits text into visual lines while normalizing LF/CRLF line endings.
|
|
54
|
+
* @param text - Source text that may contain LF or CRLF newlines
|
|
55
|
+
* @returns Normalized visual lines without embedded newline characters
|
|
56
|
+
*/
|
|
57
|
+
function splitVisualLines(text: string): string[] {
|
|
58
|
+
return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Appends wrapped, newline-safe lines with first-line and continuation prefixes.
|
|
63
|
+
* @param lines - Output line buffer
|
|
64
|
+
* @param width - Total render width
|
|
65
|
+
* @param text - Raw text that may include LF/CRLF newlines
|
|
66
|
+
* @param firstPrefix - Prefix applied to the first rendered line
|
|
67
|
+
* @param continuationPrefix - Prefix applied to wrapped/continued lines
|
|
68
|
+
* @param style - Style function applied to each visual line before wrapping
|
|
69
|
+
* @returns Nothing
|
|
70
|
+
*/
|
|
71
|
+
function pushWrappedPrefixedLines(
|
|
72
|
+
lines: string[],
|
|
73
|
+
width: number,
|
|
74
|
+
text: string,
|
|
75
|
+
firstPrefix: string,
|
|
76
|
+
continuationPrefix: string,
|
|
77
|
+
style: (line: string) => string
|
|
78
|
+
): void {
|
|
79
|
+
const safeWidth = Math.max(1, width);
|
|
80
|
+
let currentPrefix = firstPrefix;
|
|
81
|
+
|
|
82
|
+
for (const visualLine of splitVisualLines(text)) {
|
|
83
|
+
const prefixWidth = Math.max(visibleWidth(currentPrefix), visibleWidth(continuationPrefix));
|
|
84
|
+
const contentWidth = Math.max(1, safeWidth - prefixWidth);
|
|
85
|
+
const wrapped = wrapTextWithAnsi(style(visualLine), contentWidth);
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
88
|
+
const prefix = i === 0 ? currentPrefix : continuationPrefix;
|
|
89
|
+
lines.push(truncateToWidth(prefix + wrapped[i], safeWidth));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
currentPrefix = continuationPrefix;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
51
96
|
/**
|
|
52
97
|
* Registers the ask_user_question tool with Pi.
|
|
53
98
|
* Provides an interactive UI for asking users questions with selectable options.
|
|
@@ -118,6 +163,7 @@ WHEN NOT TO USE:
|
|
|
118
163
|
let optionIndex = 0;
|
|
119
164
|
let editMode = false;
|
|
120
165
|
let cachedLines: string[] | undefined;
|
|
166
|
+
let cachedWidth: number | undefined;
|
|
121
167
|
|
|
122
168
|
const editorTheme: EditorTheme = {
|
|
123
169
|
borderColor: (s) => theme.fg("accent", s),
|
|
@@ -147,6 +193,7 @@ WHEN NOT TO USE:
|
|
|
147
193
|
*/
|
|
148
194
|
function refresh() {
|
|
149
195
|
cachedLines = undefined;
|
|
196
|
+
cachedWidth = undefined;
|
|
150
197
|
tui.requestRender();
|
|
151
198
|
}
|
|
152
199
|
|
|
@@ -200,7 +247,7 @@ WHEN NOT TO USE:
|
|
|
200
247
|
* @returns Array of rendered lines
|
|
201
248
|
*/
|
|
202
249
|
function render(width: number): string[] {
|
|
203
|
-
if (cachedLines) return cachedLines;
|
|
250
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
204
251
|
|
|
205
252
|
const lines: string[] = [];
|
|
206
253
|
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
|
@@ -216,19 +263,31 @@ WHEN NOT TO USE:
|
|
|
216
263
|
const opt = allOptions[i];
|
|
217
264
|
const selected = i === optionIndex;
|
|
218
265
|
const isOther = opt.isOther === true;
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
266
|
+
const numberPrefix = `${i + 1}. `;
|
|
267
|
+
const continuationPrefix = ` ${" ".repeat(numberPrefix.length)}`;
|
|
268
|
+
const hasEditMarker = isOther && editMode;
|
|
269
|
+
const labelText = `${numberPrefix}${opt.label}${hasEditMarker ? " ✎" : ""}`;
|
|
270
|
+
|
|
271
|
+
if (hasEditMarker || selected) {
|
|
272
|
+
pushWrappedPrefixedLines(
|
|
273
|
+
lines,
|
|
274
|
+
width,
|
|
275
|
+
labelText,
|
|
276
|
+
selected ? theme.fg("accent", "> ") : " ",
|
|
277
|
+
continuationPrefix,
|
|
278
|
+
(value) => theme.fg("accent", value)
|
|
279
|
+
);
|
|
225
280
|
} else {
|
|
226
|
-
|
|
281
|
+
pushWrappedPrefixedLines(lines, width, labelText, " ", continuationPrefix, (value) =>
|
|
282
|
+
theme.fg("text", value)
|
|
283
|
+
);
|
|
227
284
|
}
|
|
228
285
|
|
|
229
286
|
// Show description if present
|
|
230
287
|
if (opt.description) {
|
|
231
|
-
|
|
288
|
+
pushWrappedPrefixedLines(lines, width, opt.description, " ", " ", (value) =>
|
|
289
|
+
theme.fg("muted", value)
|
|
290
|
+
);
|
|
232
291
|
}
|
|
233
292
|
}
|
|
234
293
|
|
|
@@ -248,6 +307,7 @@ WHEN NOT TO USE:
|
|
|
248
307
|
}
|
|
249
308
|
add(theme.fg("accent", "─".repeat(width)));
|
|
250
309
|
|
|
310
|
+
cachedWidth = width;
|
|
251
311
|
cachedLines = lines;
|
|
252
312
|
return lines;
|
|
253
313
|
}
|
|
@@ -256,6 +316,7 @@ WHEN NOT TO USE:
|
|
|
256
316
|
render,
|
|
257
317
|
invalidate: () => {
|
|
258
318
|
cachedLines = undefined;
|
|
319
|
+
cachedWidth = undefined;
|
|
259
320
|
},
|
|
260
321
|
handleInput,
|
|
261
322
|
};
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
onInteropEvent,
|
|
46
46
|
} from "../_shared/interop-events.js";
|
|
47
47
|
import { registerPid, unregisterPid } from "../_shared/pid-registry.js";
|
|
48
|
-
import { enforceExplicitPolicy, recordAudit } from "../_shared/shell-policy.js";
|
|
48
|
+
import { enforceExplicitPolicy, evaluateCommand, recordAudit } from "../_shared/shell-policy.js";
|
|
49
49
|
import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
|
|
50
50
|
import {
|
|
51
51
|
appendSection,
|
|
@@ -1666,9 +1666,17 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1666
1666
|
const command = (event.input as Record<string, unknown>).command as string | undefined;
|
|
1667
1667
|
if (!command) return;
|
|
1668
1668
|
|
|
1669
|
-
|
|
1669
|
+
const verdict = evaluateCommand(command, "bg_bash", ctx.cwd);
|
|
1670
|
+
const blocked = await enforceExplicitPolicy(command, "bg_bash", ctx.cwd, ctx.hasUI, (msg) =>
|
|
1670
1671
|
ctx.ui.confirm("Shell Policy", msg)
|
|
1671
1672
|
);
|
|
1673
|
+
if (blocked) {
|
|
1674
|
+
return blocked;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (ctx.hasUI && verdict.allowed && verdict.requiresConfirmation) {
|
|
1678
|
+
ctx.ui.notify("✅ Shell action approved — starting background task", "info");
|
|
1679
|
+
}
|
|
1672
1680
|
});
|
|
1673
1681
|
|
|
1674
1682
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { Text } from "@mariozechner/pi-tui";
|
|
28
28
|
import { getIcon } from "../_icons/index.js";
|
|
29
29
|
import { INTEROP_API_CHANNELS } from "../_shared/interop-events.js";
|
|
30
|
-
import { enforceExplicitPolicy, recordAudit } from "../_shared/shell-policy.js";
|
|
30
|
+
import { enforceExplicitPolicy, evaluateCommand, recordAudit } from "../_shared/shell-policy.js";
|
|
31
31
|
import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
|
|
32
32
|
import type { PromotedTaskHandle } from "../background-task-tool/index.js";
|
|
33
33
|
import {
|
|
@@ -653,9 +653,17 @@ export default function bashLive(pi: ExtensionAPI): void {
|
|
|
653
653
|
const command = (event.input as { command?: string }).command;
|
|
654
654
|
if (!command) return;
|
|
655
655
|
|
|
656
|
-
|
|
656
|
+
const verdict = evaluateCommand(command, "bash", ctx.cwd);
|
|
657
|
+
const blocked = await enforceExplicitPolicy(command, "bash", ctx.cwd, ctx.hasUI, (msg) =>
|
|
657
658
|
ctx.ui.confirm("Shell Policy", msg)
|
|
658
659
|
);
|
|
660
|
+
if (blocked) {
|
|
661
|
+
return blocked;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (ctx.hasUI && verdict.allowed && verdict.requiresConfirmation) {
|
|
665
|
+
ctx.ui.notify("✅ Shell action approved — running command", "info");
|
|
666
|
+
}
|
|
659
667
|
});
|
|
660
668
|
|
|
661
669
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
cleanStepText,
|
|
4
|
+
detectPlanIntent,
|
|
4
5
|
extractDoneSteps,
|
|
5
6
|
extractTodoItems,
|
|
6
7
|
isPlanModeToolAllowed,
|
|
7
8
|
isSafeCommand,
|
|
8
9
|
markCompletedSteps,
|
|
9
10
|
PLAN_MODE_ALLOWED_TOOLS,
|
|
11
|
+
stripPlanIntent,
|
|
10
12
|
type TodoItem,
|
|
11
13
|
} from "../utils.js";
|
|
12
14
|
|
|
@@ -256,3 +258,181 @@ describe("markCompletedSteps", () => {
|
|
|
256
258
|
expect(items[0].completed).toBe(true);
|
|
257
259
|
});
|
|
258
260
|
});
|
|
261
|
+
|
|
262
|
+
describe("detectPlanIntent", () => {
|
|
263
|
+
// ── True positives ──────────────────────────────────────────────
|
|
264
|
+
test("detects 'plan only'", () => {
|
|
265
|
+
expect(detectPlanIntent("plan only")).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("detects 'plan-only' (hyphenated)", () => {
|
|
269
|
+
expect(detectPlanIntent("this is plan-only")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("detects 'just plan'", () => {
|
|
273
|
+
expect(detectPlanIntent("just plan for now")).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("detects 'only plan'", () => {
|
|
277
|
+
expect(detectPlanIntent("only plan, don't execute")).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("detects 'plan mode' as directive", () => {
|
|
281
|
+
expect(detectPlanIntent("plan mode please")).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("detects 'planning mode'", () => {
|
|
285
|
+
expect(detectPlanIntent("planning mode please")).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("detects 'don't implement'", () => {
|
|
289
|
+
expect(detectPlanIntent("don't implement yet")).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("detects curly apostrophe 'don\u2019t implement'", () => {
|
|
293
|
+
expect(detectPlanIntent("don\u2019t implement yet")).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("detects 'do not implement'", () => {
|
|
297
|
+
expect(detectPlanIntent("do not implement")).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("detects 'don't code yet'", () => {
|
|
301
|
+
expect(detectPlanIntent("don't code yet")).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("detects 'don't make changes'", () => {
|
|
305
|
+
expect(detectPlanIntent("don't make changes")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("detects 'do not make changes'", () => {
|
|
309
|
+
expect(detectPlanIntent("do not make changes")).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("detects 'no implementation yet'", () => {
|
|
313
|
+
expect(detectPlanIntent("no implementation yet")).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("detects 'no changes first'", () => {
|
|
317
|
+
expect(detectPlanIntent("no changes first")).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("detects 'read-only mode'", () => {
|
|
321
|
+
expect(detectPlanIntent("read-only mode")).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("detects 'read only mode' (no hyphen)", () => {
|
|
325
|
+
expect(detectPlanIntent("read only mode")).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("detects 'this is plan'", () => {
|
|
329
|
+
expect(detectPlanIntent("this is plan")).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("detects 'this is planning'", () => {
|
|
333
|
+
expect(detectPlanIntent("this is planning")).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("detects 'plan first'", () => {
|
|
337
|
+
expect(detectPlanIntent("plan first")).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("detects 'plan before'", () => {
|
|
341
|
+
expect(detectPlanIntent("plan before implementing")).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("detects the exact user complaint: 'not yet, this is plan only'", () => {
|
|
345
|
+
expect(detectPlanIntent("not yet, this is plan only")).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("is case-insensitive", () => {
|
|
349
|
+
expect(detectPlanIntent("Plan Only")).toBe(true);
|
|
350
|
+
expect(detectPlanIntent("PLAN MODE")).toBe(true);
|
|
351
|
+
expect(detectPlanIntent("DON'T IMPLEMENT")).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("detects intent mixed with a request", () => {
|
|
355
|
+
expect(detectPlanIntent("don't implement, just review the auth flow")).toBe(true);
|
|
356
|
+
expect(detectPlanIntent("analyze the database schema, plan only")).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ── True negatives ──────────────────────────────────────────────
|
|
360
|
+
test("does NOT match 'make a plan for the API' (noun usage)", () => {
|
|
361
|
+
expect(detectPlanIntent("make a plan for the API")).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("does NOT match 'what does plan mode do?' (question about plan mode)", () => {
|
|
365
|
+
expect(detectPlanIntent("what does plan mode do?")).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("does NOT match 'how does plan mode work?' (question)", () => {
|
|
369
|
+
expect(detectPlanIntent("how does plan mode work?")).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("does NOT match 'execute the plan' (opposite intent)", () => {
|
|
373
|
+
expect(detectPlanIntent("execute the plan")).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("does NOT match 'the implementation plan looks good' (plan as noun)", () => {
|
|
377
|
+
expect(detectPlanIntent("the implementation plan looks good")).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("does NOT match 'plan' alone (too ambiguous)", () => {
|
|
381
|
+
expect(detectPlanIntent("plan")).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("does NOT match empty string", () => {
|
|
385
|
+
expect(detectPlanIntent("")).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("does NOT match 'the plan is to refactor auth' (noun usage)", () => {
|
|
389
|
+
expect(detectPlanIntent("the plan is to refactor auth")).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("does NOT match 'I planned the migration' (past tense)", () => {
|
|
393
|
+
expect(detectPlanIntent("I planned the migration")).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe("stripPlanIntent", () => {
|
|
398
|
+
test("strips 'don't implement' and keeps the request", () => {
|
|
399
|
+
expect(stripPlanIntent("don't implement, just review the auth flow")).toBe(
|
|
400
|
+
"just review the auth flow"
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("returns original when stripping leaves empty string", () => {
|
|
405
|
+
expect(stripPlanIntent("plan only")).toBe("plan only");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("strips 'this is plan only' prefix from mixed input", () => {
|
|
409
|
+
expect(stripPlanIntent("this is plan only, analyze the database schema")).toBe(
|
|
410
|
+
"analyze the database schema"
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("strips 'plan mode' from mixed input", () => {
|
|
415
|
+
expect(stripPlanIntent("plan mode — review the auth module")).toBe("review the auth module");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("strips 'do not make changes' and cleans punctuation", () => {
|
|
419
|
+
expect(stripPlanIntent("do not make changes, review the config")).toBe("review the config");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("cleans up double spaces after stripping", () => {
|
|
423
|
+
expect(stripPlanIntent("please plan only review auth")).toBe("please review auth");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("handles multiple intent phrases in one message", () => {
|
|
427
|
+
const result = stripPlanIntent("plan only, don't implement, analyze the code");
|
|
428
|
+
expect(result).toBe("analyze the code");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("returns original when entire message is intent", () => {
|
|
432
|
+
expect(stripPlanIntent("just plan")).toBe("just plan");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("returns original for empty string", () => {
|
|
436
|
+
expect(stripPlanIntent("")).toBe("");
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -31,11 +31,13 @@ import {
|
|
|
31
31
|
import { Type } from "@sinclair/typebox";
|
|
32
32
|
import { getIcon } from "../_icons/index.js";
|
|
33
33
|
import {
|
|
34
|
+
detectPlanIntent,
|
|
34
35
|
extractTodoItems,
|
|
35
36
|
isPlanModeToolAllowed,
|
|
36
37
|
isSafeCommand,
|
|
37
38
|
markCompletedSteps,
|
|
38
39
|
PLAN_MODE_ALLOWED_TOOLS,
|
|
40
|
+
stripPlanIntent,
|
|
39
41
|
type TodoItem,
|
|
40
42
|
} from "./utils.js";
|
|
41
43
|
|
|
@@ -443,6 +445,37 @@ Use action "enable" to enter plan mode, "disable" to exit, or "status" to check
|
|
|
443
445
|
}
|
|
444
446
|
});
|
|
445
447
|
|
|
448
|
+
// Auto-enable plan mode when user expresses planning intent in natural language
|
|
449
|
+
pi.on("input", async (event, ctx) => {
|
|
450
|
+
// No-op if already in plan mode or execution mode
|
|
451
|
+
if (planModeEnabled || executionMode) {
|
|
452
|
+
return { action: "continue" as const };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!detectPlanIntent(event.text)) {
|
|
456
|
+
return { action: "continue" as const };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Auto-enable plan mode
|
|
460
|
+
planModeEnabled = true;
|
|
461
|
+
captureNormalModeTools();
|
|
462
|
+
applyPlanModeTools();
|
|
463
|
+
updateStatus(ctx);
|
|
464
|
+
persistState();
|
|
465
|
+
|
|
466
|
+
ctx.ui?.notify(
|
|
467
|
+
"Plan mode auto-enabled (detected planning intent). Use /plan-mode or Ctrl+Alt+P to disable.",
|
|
468
|
+
"info"
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// Strip the plan-intent phrase, keep the actual request
|
|
472
|
+
const stripped = stripPlanIntent(event.text);
|
|
473
|
+
if (stripped !== event.text) {
|
|
474
|
+
return { action: "transform" as const, text: stripped };
|
|
475
|
+
}
|
|
476
|
+
return { action: "continue" as const };
|
|
477
|
+
});
|
|
478
|
+
|
|
446
479
|
// Filter out stale plan mode context when not in plan mode
|
|
447
480
|
pi.on("context", async (event) => {
|
|
448
481
|
if (planModeEnabled) return;
|