@dungle-scrubs/tallow 0.8.21 → 0.8.23

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.
Files changed (217) hide show
  1. package/dist/cli.js +35 -4
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +1 -1
  5. package/dist/interactive-mode-patch.d.ts +2 -0
  6. package/dist/interactive-mode-patch.d.ts.map +1 -1
  7. package/dist/interactive-mode-patch.js +82 -0
  8. package/dist/interactive-mode-patch.js.map +1 -1
  9. package/dist/sdk.d.ts +17 -0
  10. package/dist/sdk.d.ts.map +1 -1
  11. package/dist/sdk.js +68 -1
  12. package/dist/sdk.js.map +1 -1
  13. package/dist/workspace-transition-relay.d.ts +40 -7
  14. package/dist/workspace-transition-relay.d.ts.map +1 -1
  15. package/dist/workspace-transition-relay.js +81 -16
  16. package/dist/workspace-transition-relay.js.map +1 -1
  17. package/extensions/__integration__/background-task-widget-ownership.test.ts +216 -0
  18. package/extensions/__integration__/claude-hooks-compat.test.ts +156 -0
  19. package/extensions/__integration__/slash-command-bridge.test.ts +169 -23
  20. package/extensions/_shared/atomic-write.ts +1 -1
  21. package/extensions/_shared/bordered-box.ts +102 -0
  22. package/extensions/_shared/interop-events.ts +5 -0
  23. package/extensions/_shared/pid-registry.ts +1 -1
  24. package/extensions/agent-commands-tool/index.ts +4 -1
  25. package/extensions/background-task-tool/__tests__/lifecycle.test.ts +50 -25
  26. package/extensions/background-task-tool/index.ts +139 -221
  27. package/extensions/bash-tool-enhanced/index.ts +1 -75
  28. package/extensions/cd-tool/index.ts +2 -2
  29. package/extensions/context-fork/spawn.ts +4 -1
  30. package/extensions/health/index.ts +6 -6
  31. package/extensions/hooks/__tests__/claude-compat.test.ts +35 -0
  32. package/extensions/hooks/__tests__/subprocess-hardening.test.ts +73 -0
  33. package/extensions/hooks/index.ts +27 -4
  34. package/extensions/loop/__tests__/loop.test.ts +168 -4
  35. package/extensions/loop/extension.json +6 -5
  36. package/extensions/loop/index.ts +242 -31
  37. package/extensions/plan-mode-tool/__tests__/agent-end-execution.test.ts +373 -0
  38. package/extensions/plan-mode-tool/index.ts +103 -41
  39. package/extensions/prompt-suggestions/__tests__/editor-compatibility.test.ts +42 -0
  40. package/extensions/prompt-suggestions/index.ts +41 -6
  41. package/extensions/slash-command-bridge/__tests__/slash-command-bridge.test.ts +267 -671
  42. package/extensions/slash-command-bridge/extension.json +1 -1
  43. package/extensions/slash-command-bridge/index.ts +230 -116
  44. package/extensions/subagent-tool/index.ts +2 -2
  45. package/extensions/subagent-tool/process.ts +4 -5
  46. package/extensions/tasks/commands/register-tasks-extension.ts +41 -0
  47. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +29 -24
  48. package/extensions/teams-tool/dashboard.ts +3 -5
  49. package/extensions/teams-tool/dispatch/auto-dispatch.ts +18 -1
  50. package/extensions/teams-tool/tools/teammate-tools.ts +9 -6
  51. package/extensions/wezterm-pane-control/__tests__/index.test.ts +88 -4
  52. package/extensions/wezterm-pane-control/index.ts +113 -8
  53. package/package.json +6 -4
  54. package/packages/tallow-tui/README.md +51 -0
  55. package/packages/tallow-tui/dist/autocomplete.d.ts +48 -0
  56. package/packages/tallow-tui/dist/autocomplete.d.ts.map +1 -0
  57. package/packages/tallow-tui/dist/autocomplete.js +564 -0
  58. package/packages/tallow-tui/dist/autocomplete.js.map +1 -0
  59. package/packages/tallow-tui/dist/border-styles.d.ts +32 -0
  60. package/packages/tallow-tui/dist/border-styles.d.ts.map +1 -0
  61. package/packages/tallow-tui/dist/border-styles.js +46 -0
  62. package/packages/tallow-tui/dist/border-styles.js.map +1 -0
  63. package/packages/tallow-tui/dist/components/bordered-box.d.ts +52 -0
  64. package/packages/tallow-tui/dist/components/bordered-box.d.ts.map +1 -0
  65. package/packages/tallow-tui/dist/components/bordered-box.js +89 -0
  66. package/packages/tallow-tui/dist/components/bordered-box.js.map +1 -0
  67. package/packages/tallow-tui/dist/components/box.d.ts +22 -0
  68. package/packages/tallow-tui/dist/components/box.d.ts.map +1 -0
  69. package/packages/tallow-tui/dist/components/box.js +104 -0
  70. package/packages/tallow-tui/dist/components/box.js.map +1 -0
  71. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts +22 -0
  72. package/packages/tallow-tui/dist/components/cancellable-loader.d.ts.map +1 -0
  73. package/packages/tallow-tui/dist/components/cancellable-loader.js +35 -0
  74. package/packages/tallow-tui/dist/components/cancellable-loader.js.map +1 -0
  75. package/packages/tallow-tui/dist/components/editor.d.ts +240 -0
  76. package/packages/tallow-tui/dist/components/editor.d.ts.map +1 -0
  77. package/packages/tallow-tui/dist/components/editor.js +1766 -0
  78. package/packages/tallow-tui/dist/components/editor.js.map +1 -0
  79. package/packages/tallow-tui/dist/components/image.d.ts +126 -0
  80. package/packages/tallow-tui/dist/components/image.d.ts.map +1 -0
  81. package/packages/tallow-tui/dist/components/image.js +245 -0
  82. package/packages/tallow-tui/dist/components/image.js.map +1 -0
  83. package/packages/tallow-tui/dist/components/input.d.ts +37 -0
  84. package/packages/tallow-tui/dist/components/input.d.ts.map +1 -0
  85. package/packages/tallow-tui/dist/components/input.js +439 -0
  86. package/packages/tallow-tui/dist/components/input.js.map +1 -0
  87. package/packages/tallow-tui/dist/components/loader.d.ts +88 -0
  88. package/packages/tallow-tui/dist/components/loader.d.ts.map +1 -0
  89. package/packages/tallow-tui/dist/components/loader.js +146 -0
  90. package/packages/tallow-tui/dist/components/loader.js.map +1 -0
  91. package/packages/tallow-tui/dist/components/markdown.d.ts +95 -0
  92. package/packages/tallow-tui/dist/components/markdown.d.ts.map +1 -0
  93. package/packages/tallow-tui/dist/components/markdown.js +633 -0
  94. package/packages/tallow-tui/dist/components/markdown.js.map +1 -0
  95. package/packages/tallow-tui/dist/components/select-list.d.ts +32 -0
  96. package/packages/tallow-tui/dist/components/select-list.d.ts.map +1 -0
  97. package/packages/tallow-tui/dist/components/select-list.js +156 -0
  98. package/packages/tallow-tui/dist/components/select-list.js.map +1 -0
  99. package/packages/tallow-tui/dist/components/settings-list.d.ts +50 -0
  100. package/packages/tallow-tui/dist/components/settings-list.d.ts.map +1 -0
  101. package/packages/tallow-tui/dist/components/settings-list.js +189 -0
  102. package/packages/tallow-tui/dist/components/settings-list.js.map +1 -0
  103. package/packages/tallow-tui/dist/components/spacer.d.ts +12 -0
  104. package/packages/tallow-tui/dist/components/spacer.d.ts.map +1 -0
  105. package/packages/tallow-tui/dist/components/spacer.js +23 -0
  106. package/packages/tallow-tui/dist/components/spacer.js.map +1 -0
  107. package/packages/tallow-tui/dist/components/text.d.ts +19 -0
  108. package/packages/tallow-tui/dist/components/text.d.ts.map +1 -0
  109. package/packages/tallow-tui/dist/components/text.js +91 -0
  110. package/packages/tallow-tui/dist/components/text.js.map +1 -0
  111. package/packages/tallow-tui/dist/components/truncated-text.d.ts +13 -0
  112. package/packages/tallow-tui/dist/components/truncated-text.d.ts.map +1 -0
  113. package/packages/tallow-tui/dist/components/truncated-text.js +51 -0
  114. package/packages/tallow-tui/dist/components/truncated-text.js.map +1 -0
  115. package/packages/tallow-tui/dist/editor-component.d.ts +50 -0
  116. package/packages/tallow-tui/dist/editor-component.d.ts.map +1 -0
  117. package/packages/tallow-tui/dist/editor-component.js +2 -0
  118. package/packages/tallow-tui/dist/editor-component.js.map +1 -0
  119. package/packages/tallow-tui/dist/fuzzy.d.ts +16 -0
  120. package/packages/tallow-tui/dist/fuzzy.d.ts.map +1 -0
  121. package/packages/tallow-tui/dist/fuzzy.js +107 -0
  122. package/packages/tallow-tui/dist/fuzzy.js.map +1 -0
  123. package/packages/tallow-tui/dist/index.d.ts +25 -0
  124. package/packages/tallow-tui/dist/index.d.ts.map +1 -0
  125. package/packages/tallow-tui/dist/index.js +35 -0
  126. package/packages/tallow-tui/dist/index.js.map +1 -0
  127. package/packages/tallow-tui/dist/keybindings.d.ts +39 -0
  128. package/packages/tallow-tui/dist/keybindings.d.ts.map +1 -0
  129. package/packages/tallow-tui/dist/keybindings.js +114 -0
  130. package/packages/tallow-tui/dist/keybindings.js.map +1 -0
  131. package/packages/tallow-tui/dist/keys.d.ts +168 -0
  132. package/packages/tallow-tui/dist/keys.d.ts.map +1 -0
  133. package/packages/tallow-tui/dist/keys.js +971 -0
  134. package/packages/tallow-tui/dist/keys.js.map +1 -0
  135. package/packages/tallow-tui/dist/kill-ring.d.ts +28 -0
  136. package/packages/tallow-tui/dist/kill-ring.d.ts.map +1 -0
  137. package/packages/tallow-tui/dist/kill-ring.js +44 -0
  138. package/packages/tallow-tui/dist/kill-ring.js.map +1 -0
  139. package/packages/tallow-tui/dist/stdin-buffer.d.ts +48 -0
  140. package/packages/tallow-tui/dist/stdin-buffer.d.ts.map +1 -0
  141. package/packages/tallow-tui/dist/stdin-buffer.js +317 -0
  142. package/packages/tallow-tui/dist/stdin-buffer.js.map +1 -0
  143. package/packages/tallow-tui/dist/terminal-image.d.ts +161 -0
  144. package/packages/tallow-tui/dist/terminal-image.d.ts.map +1 -0
  145. package/packages/tallow-tui/dist/terminal-image.js +460 -0
  146. package/packages/tallow-tui/dist/terminal-image.js.map +1 -0
  147. package/packages/tallow-tui/dist/terminal.d.ts +102 -0
  148. package/packages/tallow-tui/dist/terminal.d.ts.map +1 -0
  149. package/packages/tallow-tui/dist/terminal.js +263 -0
  150. package/packages/tallow-tui/dist/terminal.js.map +1 -0
  151. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts +14 -0
  152. package/packages/tallow-tui/dist/test-utils/capability-env.d.ts.map +1 -0
  153. package/packages/tallow-tui/dist/test-utils/capability-env.js +55 -0
  154. package/packages/tallow-tui/dist/test-utils/capability-env.js.map +1 -0
  155. package/packages/tallow-tui/dist/tui.d.ts +239 -0
  156. package/packages/tallow-tui/dist/tui.d.ts.map +1 -0
  157. package/packages/tallow-tui/dist/tui.js +1058 -0
  158. package/packages/tallow-tui/dist/tui.js.map +1 -0
  159. package/packages/tallow-tui/dist/undo-stack.d.ts +17 -0
  160. package/packages/tallow-tui/dist/undo-stack.d.ts.map +1 -0
  161. package/packages/tallow-tui/dist/undo-stack.js +25 -0
  162. package/packages/tallow-tui/dist/undo-stack.js.map +1 -0
  163. package/packages/tallow-tui/dist/utils.d.ts +96 -0
  164. package/packages/tallow-tui/dist/utils.d.ts.map +1 -0
  165. package/packages/tallow-tui/dist/utils.js +843 -0
  166. package/packages/tallow-tui/dist/utils.js.map +1 -0
  167. package/packages/tallow-tui/package.json +24 -0
  168. package/packages/tallow-tui/src/__tests__/__snapshots__/render.test.ts.snap +121 -0
  169. package/packages/tallow-tui/src/__tests__/editor-border.test.ts +72 -0
  170. package/packages/tallow-tui/src/__tests__/editor-change-listener.test.ts +121 -0
  171. package/packages/tallow-tui/src/__tests__/editor-ghost-text.test.ts +112 -0
  172. package/packages/tallow-tui/src/__tests__/fuzzy.test.ts +91 -0
  173. package/packages/tallow-tui/src/__tests__/image-component.test.ts +113 -0
  174. package/packages/tallow-tui/src/__tests__/keys.test.ts +141 -0
  175. package/packages/tallow-tui/src/__tests__/render.test.ts +179 -0
  176. package/packages/tallow-tui/src/__tests__/stdin-buffer.test.ts +82 -0
  177. package/packages/tallow-tui/src/__tests__/terminal-image.test.ts +363 -0
  178. package/packages/tallow-tui/src/__tests__/tui-diff-regression.test.ts +454 -0
  179. package/packages/tallow-tui/src/__tests__/tui-render-scheduling.test.ts +256 -0
  180. package/packages/tallow-tui/src/__tests__/utils.test.ts +259 -0
  181. package/packages/tallow-tui/src/autocomplete.ts +716 -0
  182. package/packages/tallow-tui/src/border-styles.ts +60 -0
  183. package/packages/tallow-tui/src/components/bordered-box.ts +113 -0
  184. package/packages/tallow-tui/src/components/box.ts +137 -0
  185. package/packages/tallow-tui/src/components/cancellable-loader.ts +40 -0
  186. package/packages/tallow-tui/src/components/editor.ts +2143 -0
  187. package/packages/tallow-tui/src/components/image.ts +315 -0
  188. package/packages/tallow-tui/src/components/input.ts +522 -0
  189. package/packages/tallow-tui/src/components/loader.ts +187 -0
  190. package/packages/tallow-tui/src/components/markdown.ts +780 -0
  191. package/packages/tallow-tui/src/components/select-list.ts +197 -0
  192. package/packages/tallow-tui/src/components/settings-list.ts +264 -0
  193. package/packages/tallow-tui/src/components/spacer.ts +28 -0
  194. package/packages/tallow-tui/src/components/text.ts +113 -0
  195. package/packages/tallow-tui/src/components/truncated-text.ts +65 -0
  196. package/packages/tallow-tui/src/editor-component.ts +92 -0
  197. package/packages/tallow-tui/src/fuzzy.ts +133 -0
  198. package/packages/tallow-tui/src/index.ts +118 -0
  199. package/packages/tallow-tui/src/keybindings.ts +183 -0
  200. package/packages/tallow-tui/src/keys.ts +1189 -0
  201. package/packages/tallow-tui/src/kill-ring.ts +46 -0
  202. package/packages/tallow-tui/src/stdin-buffer.ts +386 -0
  203. package/packages/tallow-tui/src/terminal-image.ts +619 -0
  204. package/packages/tallow-tui/src/terminal.ts +350 -0
  205. package/packages/tallow-tui/src/test-utils/capability-env.ts +56 -0
  206. package/packages/tallow-tui/src/tui.ts +1336 -0
  207. package/packages/tallow-tui/src/undo-stack.ts +28 -0
  208. package/packages/tallow-tui/src/utils.ts +948 -0
  209. package/packages/tallow-tui/tsconfig.build.json +21 -0
  210. package/runtime/agent-runner.ts +20 -0
  211. package/runtime/atomic-write.ts +8 -0
  212. package/runtime/otel.ts +12 -0
  213. package/runtime/resolve-module.ts +23 -0
  214. package/runtime/runtime-path-provider.ts +12 -0
  215. package/runtime/runtime-provenance.ts +17 -0
  216. package/runtime/workspace-transition-relay.ts +21 -0
  217. package/runtime/workspace-transition.ts +29 -0
@@ -0,0 +1,373 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
5
+ import planModeExtension from "../index.js";
6
+ import type { TodoItem } from "../utils.js";
7
+
8
+ const BASELINE_TOOLS = [
9
+ "read",
10
+ "bash",
11
+ "grep",
12
+ "find",
13
+ "ls",
14
+ "edit",
15
+ "write",
16
+ "subagent",
17
+ "bg_bash",
18
+ "questionnaire",
19
+ "plan_mode",
20
+ ] as const;
21
+
22
+ /**
23
+ * Register mock tools for the test session.
24
+ *
25
+ * @param pi - Extension API
26
+ */
27
+ function registerMockTools(pi: ExtensionAPI): void {
28
+ for (const name of [
29
+ "read",
30
+ "bash",
31
+ "grep",
32
+ "find",
33
+ "ls",
34
+ "edit",
35
+ "write",
36
+ "subagent",
37
+ "bg_bash",
38
+ "questionnaire",
39
+ ] as const) {
40
+ pi.registerTool({
41
+ name,
42
+ label: name,
43
+ description: `Mock ${name}`,
44
+ parameters: Type.Object({}),
45
+ async execute() {
46
+ return { content: [{ type: "text", text: `${name}-ok` }], details: {} };
47
+ },
48
+ });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Build persisted session entries that place the extension in execution mode.
54
+ *
55
+ * @param todos - Todo items for the plan
56
+ * @returns Array of session entries
57
+ */
58
+ function executionModeEntries(todos: TodoItem[]): unknown[] {
59
+ return [
60
+ {
61
+ type: "custom",
62
+ customType: "plan-mode",
63
+ data: {
64
+ enabled: false,
65
+ executing: true,
66
+ normalTools: [...BASELINE_TOOLS],
67
+ todos,
68
+ currentStepIndex: 0,
69
+ },
70
+ },
71
+ { type: "custom", customType: "plan-mode-execute" },
72
+ ];
73
+ }
74
+
75
+ /**
76
+ * Create an extension context with a configurable `select` stub.
77
+ *
78
+ * @param entries - Session entries for state restoration
79
+ * @param selectReturn - Value that ctx.ui.select() resolves to
80
+ * @returns Context and a record of select calls
81
+ */
82
+ function createUIContext(
83
+ entries: unknown[] = [],
84
+ selectReturn?: string
85
+ ): { ctx: ExtensionContext; selectCalls: Array<{ title: string; options: string[] }> } {
86
+ const selectCalls: Array<{ title: string; options: string[] }> = [];
87
+ const ctx = {
88
+ cwd: process.cwd(),
89
+ hasUI: true,
90
+ ui: {
91
+ notify() {},
92
+ setStatus() {},
93
+ setEditorComponent() {},
94
+ setWidget() {},
95
+ setWorkingMessage() {},
96
+ async select(title: string, options: string[]) {
97
+ selectCalls.push({ title, options });
98
+ return selectReturn;
99
+ },
100
+ async editor() {
101
+ return undefined;
102
+ },
103
+ theme: {
104
+ fg(_token: string, value: string) {
105
+ return value;
106
+ },
107
+ bg(_token: string, value: string) {
108
+ return value;
109
+ },
110
+ strikethrough(value: string) {
111
+ return value;
112
+ },
113
+ },
114
+ } as never,
115
+ sessionManager: {
116
+ getEntries() {
117
+ return entries;
118
+ },
119
+ } as never,
120
+ } as unknown as ExtensionContext;
121
+ return { ctx, selectCalls };
122
+ }
123
+
124
+ /**
125
+ * Create a headless context (no UI).
126
+ *
127
+ * @param entries - Session entries for state restoration
128
+ * @returns Extension context with hasUI=false
129
+ */
130
+ function createHeadlessContext(entries: unknown[] = []): ExtensionContext {
131
+ return {
132
+ cwd: process.cwd(),
133
+ hasUI: false,
134
+ ui: {
135
+ notify() {},
136
+ setStatus() {},
137
+ setEditorComponent() {},
138
+ setWidget() {},
139
+ setWorkingMessage() {},
140
+ theme: {
141
+ fg(_token: string, value: string) {
142
+ return value;
143
+ },
144
+ strikethrough(value: string) {
145
+ return value;
146
+ },
147
+ },
148
+ } as never,
149
+ sessionManager: {
150
+ getEntries() {
151
+ return entries;
152
+ },
153
+ } as never,
154
+ } as unknown as ExtensionContext;
155
+ }
156
+
157
+ const SAMPLE_TODOS: TodoItem[] = [
158
+ { step: 1, text: "Add error handling", completed: false },
159
+ { step: 2, text: "Write tests", completed: false },
160
+ { step: 3, text: "Update docs", completed: false },
161
+ ];
162
+
163
+ describe("agent_end execution mode — partial completion", () => {
164
+ let harness: ExtensionHarness;
165
+
166
+ beforeEach(async () => {
167
+ harness = ExtensionHarness.create();
168
+ await harness.loadExtension(registerMockTools);
169
+ await harness.loadExtension(planModeExtension);
170
+ harness.api.setActiveTools([...BASELINE_TOOLS]);
171
+ });
172
+
173
+ test("shows select menu when agent finishes with incomplete steps", async () => {
174
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
175
+ const entries = executionModeEntries(todos);
176
+ const { ctx, selectCalls } = createUIContext(entries, "Abort plan");
177
+
178
+ // Restore execution mode state
179
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
180
+
181
+ // Agent finishes a turn — no [DONE:n] markers
182
+ await harness.fireEvent(
183
+ "agent_end",
184
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Did some work." }] }] },
185
+ ctx
186
+ );
187
+
188
+ expect(selectCalls).toHaveLength(1);
189
+ expect(selectCalls[0].title).toContain("0/3");
190
+ expect(selectCalls[0].options).toEqual([
191
+ "Continue execution",
192
+ "Provide guidance",
193
+ "Mark plan as done",
194
+ "Abort plan",
195
+ ]);
196
+ });
197
+
198
+ test("shows correct count when some steps are completed", async () => {
199
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
200
+ todos[0].completed = true; // step 1 done
201
+ const entries = executionModeEntries(todos);
202
+ const { ctx, selectCalls } = createUIContext(entries, "Abort plan");
203
+
204
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
205
+ await harness.fireEvent(
206
+ "agent_end",
207
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Finished step 1." }] }] },
208
+ ctx
209
+ );
210
+
211
+ expect(selectCalls).toHaveLength(1);
212
+ expect(selectCalls[0].title).toContain("1/3");
213
+ });
214
+
215
+ test("'Continue execution' sends message with triggerTurn", async () => {
216
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
217
+ const entries = executionModeEntries(todos);
218
+ const { ctx } = createUIContext(entries, "Continue execution");
219
+
220
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
221
+ await harness.fireEvent(
222
+ "agent_end",
223
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Partial." }] }] },
224
+ ctx
225
+ );
226
+
227
+ const execMsg = harness.sentMessages.find((m) => m.customType === "plan-mode-execute");
228
+ expect(execMsg).toBeDefined();
229
+ expect(execMsg?.options?.triggerTurn).toBe(true);
230
+ expect(execMsg?.content).toContain("step 1");
231
+ });
232
+
233
+ test("'Mark plan as done' clears execution mode and restores tools", async () => {
234
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
235
+ const entries = executionModeEntries(todos);
236
+ const { ctx } = createUIContext(entries, "Mark plan as done");
237
+
238
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
239
+ await harness.fireEvent(
240
+ "agent_end",
241
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Done enough." }] }] },
242
+ ctx
243
+ );
244
+
245
+ // Should send plan-complete message
246
+ const completeMsg = harness.sentMessages.find((m) => m.customType === "plan-complete");
247
+ expect(completeMsg).toBeDefined();
248
+ expect(completeMsg?.options?.triggerTurn).toBe(false);
249
+
250
+ // Should restore full tool set
251
+ expect(harness.api.getActiveTools()).toEqual([...BASELINE_TOOLS]);
252
+
253
+ // Persisted state should reflect cleared execution mode
254
+ const lastEntry = harness.appendedEntries.findLast((e) => e.customType === "plan-mode");
255
+ expect(lastEntry?.data).toMatchObject({ executing: false, todos: [] });
256
+ });
257
+
258
+ test("'Abort plan' clears execution mode without completion message", async () => {
259
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
260
+ const entries = executionModeEntries(todos);
261
+ const { ctx } = createUIContext(entries, "Abort plan");
262
+
263
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
264
+ await harness.fireEvent(
265
+ "agent_end",
266
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Stopping." }] }] },
267
+ ctx
268
+ );
269
+
270
+ // Should NOT send plan-complete message
271
+ const completeMsg = harness.sentMessages.find((m) => m.customType === "plan-complete");
272
+ expect(completeMsg).toBeUndefined();
273
+
274
+ // Should restore full tool set
275
+ expect(harness.api.getActiveTools()).toEqual([...BASELINE_TOOLS]);
276
+
277
+ // Persisted state should reflect cleared execution mode
278
+ const lastEntry = harness.appendedEntries.findLast((e) => e.customType === "plan-mode");
279
+ expect(lastEntry?.data).toMatchObject({ executing: false, todos: [] });
280
+ });
281
+
282
+ test("headless mode (no UI) returns silently", async () => {
283
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
284
+ const entries = executionModeEntries(todos);
285
+ const ctx = createHeadlessContext(entries);
286
+
287
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
288
+ await harness.fireEvent(
289
+ "agent_end",
290
+ { messages: [{ role: "assistant", content: [{ type: "text", text: "Done." }] }] },
291
+ ctx
292
+ );
293
+
294
+ // No select calls, no crash, no sent messages
295
+ expect(harness.sentMessages).toHaveLength(0);
296
+ });
297
+
298
+ test("all steps completed via [DONE:n] triggers clean completion", async () => {
299
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
300
+ const entries = executionModeEntries(todos);
301
+ const { ctx, selectCalls } = createUIContext(entries);
302
+
303
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
304
+
305
+ // Simulate turn_end with all DONE markers
306
+ await harness.fireEvent(
307
+ "turn_end",
308
+ {
309
+ message: {
310
+ role: "assistant",
311
+ content: [{ type: "text", text: "[DONE:1] [DONE:2] [DONE:3]" }],
312
+ },
313
+ },
314
+ ctx
315
+ );
316
+
317
+ // Now agent_end fires — all steps are complete
318
+ await harness.fireEvent(
319
+ "agent_end",
320
+ {
321
+ messages: [
322
+ {
323
+ role: "assistant",
324
+ content: [{ type: "text", text: "[DONE:1] [DONE:2] [DONE:3]" }],
325
+ },
326
+ ],
327
+ },
328
+ ctx
329
+ );
330
+
331
+ // Should get "Plan Complete!" not the select menu
332
+ const completeMsg = harness.sentMessages.find((m) => m.customType === "plan-complete");
333
+ expect(completeMsg).toBeDefined();
334
+ expect(selectCalls).toHaveLength(0);
335
+ });
336
+ });
337
+
338
+ describe("execution mode widgets removed", () => {
339
+ let harness: ExtensionHarness;
340
+ let widgetCalls: Array<{ name: string; value: unknown }>;
341
+
342
+ beforeEach(async () => {
343
+ harness = ExtensionHarness.create();
344
+ await harness.loadExtension(registerMockTools);
345
+ await harness.loadExtension(planModeExtension);
346
+ harness.api.setActiveTools([...BASELINE_TOOLS]);
347
+ widgetCalls = [];
348
+ });
349
+
350
+ test("execution mode does not render banner or todo widgets", async () => {
351
+ const todos = SAMPLE_TODOS.map((t) => ({ ...t }));
352
+ const entries = executionModeEntries(todos);
353
+ const { ctx } = createUIContext(entries, "Abort plan");
354
+
355
+ // Intercept setWidget calls
356
+ ctx.ui.setWidget = ((name: string, value: unknown) => {
357
+ widgetCalls.push({ name, value });
358
+ }) as never;
359
+
360
+ await harness.fireEvent("session_start", { type: "session_start" }, ctx);
361
+
362
+ // All plan-banner and plan-todos calls should be clearing (undefined)
363
+ const bannerCalls = widgetCalls.filter((c) => c.name === "plan-banner");
364
+ const todoCalls = widgetCalls.filter((c) => c.name === "plan-todos");
365
+
366
+ for (const call of bannerCalls) {
367
+ expect(call.value).toBeUndefined();
368
+ }
369
+ for (const call of todoCalls) {
370
+ expect(call.value).toBeUndefined();
371
+ }
372
+ });
373
+ });
@@ -24,12 +24,14 @@ import {
24
24
  import {
25
25
  type EditorTheme,
26
26
  Key,
27
+ Loader,
27
28
  type TUI,
28
29
  truncateToWidth,
29
30
  visibleWidth,
30
31
  } from "@mariozechner/pi-tui";
31
32
  import { Type } from "@sinclair/typebox";
32
33
  import { getIcon } from "../_icons/index.js";
34
+ import { renderBorderedBox } from "../_shared/bordered-box.js";
33
35
  import {
34
36
  detectPlanIntent,
35
37
  extractTodoItems,
@@ -204,14 +206,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
204
206
  * @param ctx - The extension context
205
207
  */
206
208
  function updateStatus(ctx: ExtensionContext): void {
207
- // Footer status
208
- if (executionMode && todoItems.length > 0) {
209
- const completed = todoItems.filter((t) => t.completed).length;
210
- ctx.ui.setStatus(
211
- "plan-mode",
212
- ctx.ui.theme.fg("accent", `${getIcon("task_list")} ${completed}/${todoItems.length}`)
213
- );
214
- } else if (planModeEnabled) {
209
+ // Footer status — plan mode only; execution mode defers to tasks extension
210
+ if (planModeEnabled) {
215
211
  ctx.ui.setStatus(
216
212
  "plan-mode",
217
213
  ctx.ui.theme.fg("warning", `${getIcon("plan_mode")} PLAN MODE — read-only`)
@@ -229,14 +225,15 @@ export default function planModeExtension(pi: ExtensionAPI): void {
229
225
  ctx.ui.setEditorComponent(undefined);
230
226
  }
231
227
 
232
- // Full-width banner above editor using existing theme background tokens
233
- if (planModeEnabled || executionMode) {
228
+ // Full-width banner above editor plan mode only.
229
+ // Execution mode does not show a banner; the tasks extension owns progress tracking.
230
+ if (planModeEnabled) {
234
231
  ctx.ui.setWidget("plan-banner", (_tui, theme) => {
235
- const label = planModeEnabled ? " PLAN MODE — READ ONLY " : " EXECUTING PLAN ";
236
- const bg = planModeEnabled ? "customMessageBg" : "toolSuccessBg";
237
- const fg = planModeEnabled ? "customMessageLabel" : "success";
232
+ const label = " PLAN MODE — READ ONLY ";
238
233
  return {
239
- render: (width: number) => [theme.bg(bg, theme.fg(fg, label.padEnd(width)))],
234
+ render: (width: number) => [
235
+ theme.bg("customMessageBg", theme.fg("customMessageLabel", label.padEnd(width))),
236
+ ],
240
237
  invalidate() {},
241
238
  };
242
239
  });
@@ -244,21 +241,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
244
241
  ctx.ui.setWidget("plan-banner", undefined);
245
242
  }
246
243
 
247
- // Widget showing todo list
248
- if (executionMode && todoItems.length > 0) {
249
- const lines = todoItems.map((item) => {
250
- if (item.completed) {
251
- return (
252
- ctx.ui.theme.fg("success", "☑ ") +
253
- ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
254
- );
255
- }
256
- return `${ctx.ui.theme.fg("muted", `${getIcon("pending")} `)}${item.text}`;
257
- });
258
- ctx.ui.setWidget("plan-todos", lines);
259
- } else {
260
- ctx.ui.setWidget("plan-todos", undefined);
261
- }
244
+ // Todo list widget — plan mode only (execution delegates to tasks extension)
245
+ ctx.ui.setWidget("plan-todos", undefined);
262
246
  }
263
247
 
264
248
  /**
@@ -636,7 +620,77 @@ If you receive [PLAN GUIDANCE — Step n: ...], treat it as user steering for th
636
620
  currentStepIndex = null;
637
621
  restoreNormalModeTools();
638
622
  updateStatus(ctx);
639
- persistState(); // Save cleared state so resume doesn't restore old execution mode
623
+ persistState();
624
+ } else if (ctx.hasUI) {
625
+ // Agent finished its turn but not all steps are marked complete.
626
+ // Show progress summary and prompt for next action.
627
+ const completed = todoItems.filter((t) => t.completed);
628
+
629
+ updateStatus(ctx);
630
+ ctx.ui.setWorkingMessage(Loader.HIDE);
631
+
632
+ const choice = await ctx.ui.select(
633
+ `Plan execution paused (${completed.length}/${todoItems.length} done)`,
634
+ ["Continue execution", "Provide guidance", "Mark plan as done", "Abort plan"]
635
+ );
636
+
637
+ if (choice === "Continue execution") {
638
+ const nextStep = getCurrentStep();
639
+ const continueMessage =
640
+ nextStep !== null
641
+ ? `Continue executing the plan. Next: step ${nextStep.step}: ${nextStep.text}`
642
+ : "Continue executing the remaining plan steps.";
643
+ pi.sendMessage(
644
+ { customType: "plan-mode-execute", content: continueMessage, display: true },
645
+ { triggerTurn: true }
646
+ );
647
+ } else if (choice === "Provide guidance") {
648
+ const nextStep = getCurrentStep();
649
+ const stepLabel = nextStep ? `step ${nextStep.step} (${nextStep.text})` : "next step";
650
+ const guidance = await ctx.ui.editor(`Guidance for ${stepLabel}:`, "");
651
+ const trimmedGuidance = guidance?.trim();
652
+ if (trimmedGuidance && nextStep) {
653
+ pi.sendUserMessage(
654
+ [
655
+ `[PLAN GUIDANCE — Step ${nextStep.step}: ${nextStep.text}]`,
656
+ "",
657
+ "User guidance:",
658
+ trimmedGuidance,
659
+ ].join("\n")
660
+ );
661
+ } else if (trimmedGuidance) {
662
+ pi.sendUserMessage(trimmedGuidance);
663
+ } else {
664
+ ctx.ui.notify("No guidance provided. Plan unchanged.", "info");
665
+ }
666
+ } else if (choice === "Mark plan as done") {
667
+ const completedList = todoItems
668
+ .map((t) => (t.completed ? `~~${t.text}~~` : t.text))
669
+ .join("\n");
670
+ pi.sendMessage(
671
+ {
672
+ customType: "plan-complete",
673
+ content: `**Plan Complete!** ${getIcon("success")}\n\n${completedList}`,
674
+ display: true,
675
+ },
676
+ { triggerTurn: false }
677
+ );
678
+ executionMode = false;
679
+ todoItems = [];
680
+ currentStepIndex = null;
681
+ restoreNormalModeTools();
682
+ updateStatus(ctx);
683
+ persistState();
684
+ } else {
685
+ // Abort plan (or dismissed/escaped the select)
686
+ executionMode = false;
687
+ todoItems = [];
688
+ currentStepIndex = null;
689
+ restoreNormalModeTools();
690
+ updateStatus(ctx);
691
+ persistState();
692
+ ctx.ui.notify("Plan aborted.", "info");
693
+ }
640
694
  }
641
695
  return;
642
696
  }
@@ -652,27 +706,35 @@ If you receive [PLAN GUIDANCE — Step n: ...], treat it as user steering for th
652
706
  }
653
707
  }
654
708
 
655
- // Show plan steps and prompt for next action
709
+ // Show plan steps in a bordered widget above the editor
656
710
  if (todoItems.length > 0) {
657
- const todoListText = todoItems
658
- .map((t, i) => `${i + 1}. ${getIcon("pending")} ${t.text}`)
659
- .join("\n");
660
- pi.sendMessage(
661
- {
662
- customType: "plan-todo-list",
663
- content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
664
- display: true,
711
+ ctx.ui.setWidget("plan-steps", (_tui, theme) => ({
712
+ render(width: number): string[] {
713
+ const stepLines = todoItems.map(
714
+ (t) => `${theme.fg("muted", `${getIcon("pending")} `)}${t.text}`
715
+ );
716
+ return renderBorderedBox(stepLines, width, {
717
+ title: `PLAN (${todoItems.length} steps)`,
718
+ style: "rounded",
719
+ borderColorFn: (s: string) => theme.fg("warning", s),
720
+ titleColorFn: (s: string) => theme.fg("warning", s),
721
+ });
665
722
  },
666
- { triggerTurn: false }
667
- );
723
+ invalidate() {},
724
+ }));
668
725
  }
669
726
 
727
+ ctx.ui.setWorkingMessage(Loader.HIDE);
728
+
670
729
  const choice = await ctx.ui.select("Plan mode - what next?", [
671
730
  todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
672
731
  "Stay in plan mode",
673
732
  "Refine the plan",
674
733
  ]);
675
734
 
735
+ // Clear the plan steps widget after user makes a choice
736
+ ctx.ui.setWidget("plan-steps", undefined);
737
+
676
738
  if (choice?.startsWith("Execute")) {
677
739
  planModeEnabled = false;
678
740
  executionMode = todoItems.length > 0;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tests for prompt-suggestions editor capability detection.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { resolvePromptSuggestionEditor } from "../index.js";
7
+
8
+ describe("resolvePromptSuggestionEditor", () => {
9
+ test("returns editor when ghost-text APIs are available", () => {
10
+ const editor = {
11
+ addChangeListener() {},
12
+ getText() {
13
+ return "hello";
14
+ },
15
+ setGhostText() {},
16
+ };
17
+
18
+ expect(resolvePromptSuggestionEditor(editor)).toBe(editor);
19
+ });
20
+
21
+ test("returns null when setGhostText is missing", () => {
22
+ const editor = {
23
+ addChangeListener() {},
24
+ getText() {
25
+ return "hello";
26
+ },
27
+ };
28
+
29
+ expect(resolvePromptSuggestionEditor(editor)).toBeNull();
30
+ });
31
+
32
+ test("returns null when addChangeListener is missing", () => {
33
+ const editor = {
34
+ getText() {
35
+ return "hello";
36
+ },
37
+ setGhostText() {},
38
+ };
39
+
40
+ expect(resolvePromptSuggestionEditor(editor)).toBeNull();
41
+ });
42
+ });
@@ -46,6 +46,31 @@ const DEFAULT_DEBOUNCE_MS = 600;
46
46
  /** Max autocomplete calls per session (cost guardrail). */
47
47
  const MAX_CALLS_PER_SESSION = 200;
48
48
 
49
+ interface PromptSuggestionEditor {
50
+ addChangeListener(fn: (text: string) => void): void;
51
+ getText(): string;
52
+ setGhostText(text: string | null): void;
53
+ }
54
+
55
+ /**
56
+ * Resolve the subset of editor APIs required by prompt suggestions.
57
+ *
58
+ * Published installs may resolve upstream `@mariozechner/pi-tui`, whose
59
+ * editor does not implement ghost-text helpers. In that case, the
60
+ * extension should degrade safely instead of crashing.
61
+ *
62
+ * @param editor - Candidate editor instance
63
+ * @returns Prompt-suggestion editor surface, or null when unavailable
64
+ */
65
+ export function resolvePromptSuggestionEditor(editor: unknown): PromptSuggestionEditor | null {
66
+ if (!editor || typeof editor !== "object") return null;
67
+ const candidate = editor as Partial<PromptSuggestionEditor>;
68
+ if (typeof candidate.getText !== "function") return null;
69
+ if (typeof candidate.setGhostText !== "function") return null;
70
+ if (typeof candidate.addChangeListener !== "function") return null;
71
+ return candidate as PromptSuggestionEditor;
72
+ }
73
+
49
74
  // ─── Idle suggestions ────────────────────────────────────────────────────────
50
75
 
51
76
  /**
@@ -214,7 +239,7 @@ export default function promptSuggestions(pi: ExtensionAPI): void {
214
239
  const modelSetting = readSetting("prompt-suggestions.model", DEFAULT_AUTOCOMPLETE_MODEL);
215
240
 
216
241
  /** Reference to the editor instance for ghost text control. */
217
- let editorRef: CustomEditor | null = null;
242
+ let editorRef: PromptSuggestionEditor | null = null;
218
243
 
219
244
  /** Autocomplete engine instance, created after editor is available. */
220
245
  let engine: AutocompleteEngine | null = null;
@@ -275,8 +300,18 @@ export default function promptSuggestions(pi: ExtensionAPI): void {
275
300
 
276
301
  ctx.ui.setEditorComponent((tui, editorTheme: EditorTheme, keybindings) => {
277
302
  const editor = new CustomEditor(tui, editorTheme, keybindings);
278
- editorRef = editor;
279
-
303
+ const promptEditor = resolvePromptSuggestionEditor(editor);
304
+ if (!promptEditor) {
305
+ engine = null;
306
+ editorRef = null;
307
+ ctx.ui.notify(
308
+ "Prompt suggestions disabled: current editor runtime lacks ghost-text support.",
309
+ "warning"
310
+ );
311
+ return editor;
312
+ }
313
+
314
+ editorRef = promptEditor;
280
315
  engine = new AutocompleteEngine(
281
316
  {
282
317
  enabled: autocompleteEnabled,
@@ -286,12 +321,12 @@ export default function promptSuggestions(pi: ExtensionAPI): void {
286
321
  },
287
322
  ctx.modelRegistry,
288
323
  getCompletion,
289
- (text) => editor.setGhostText(text),
290
- () => editor.getText(),
324
+ (text) => promptEditor.setGhostText(text),
325
+ () => promptEditor.getText(),
291
326
  () => (sessionManagerRef ? buildConversationContext(sessionManagerRef) : null)
292
327
  );
293
328
 
294
- editor.addChangeListener((newText: string) => {
329
+ promptEditor.addChangeListener((newText: string) => {
295
330
  clearIdleTimer();
296
331
 
297
332
  if (newText.length === 0 && !engine?.busy) {