@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.
@@ -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 prefix = selected ? theme.fg("accent", "> ") : " ";
220
-
221
- if (isOther && editMode) {
222
- add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
223
- } else if (selected) {
224
- add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
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
- add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
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
- add(` ${theme.fg("muted", opt.description)}`);
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
- return enforceExplicitPolicy(command, "bg_bash", ctx.cwd, ctx.hasUI, (msg) =>
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
- return enforceExplicitPolicy(command, "bash", ctx.cwd, ctx.hasUI, (msg) =>
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
+ });
@@ -10,6 +10,7 @@
10
10
  "agent_end",
11
11
  "before_agent_start",
12
12
  "context",
13
+ "input",
13
14
  "session_start",
14
15
  "tool_call",
15
16
  "tool_result",
@@ -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;