@selesai/code 0.1.19 → 0.2.1

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 (157) hide show
  1. package/dist/agents/architect.md +4 -3
  2. package/dist/agents/builder.md +5 -4
  3. package/dist/agents/commentator.md +3 -2
  4. package/dist/agents/explorer.md +4 -4
  5. package/dist/agents/recapper.md +4 -3
  6. package/dist/config.d.ts +6 -7
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +7 -9
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +3 -1
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/compaction/compaction.d.ts.map +1 -1
  14. package/dist/core/compaction/compaction.js +18 -24
  15. package/dist/core/compaction/compaction.js.map +1 -1
  16. package/dist/core/extensions/loader.d.ts.map +1 -1
  17. package/dist/core/extensions/loader.js +2 -0
  18. package/dist/core/extensions/loader.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +8 -1
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js.map +1 -1
  22. package/dist/core/skills.d.ts.map +1 -1
  23. package/dist/core/skills.js +25 -11
  24. package/dist/core/skills.js.map +1 -1
  25. package/dist/defaults/models.json +143 -39
  26. package/dist/defaults/settings.json +2 -29
  27. package/dist/extensions/package.json +5 -4
  28. package/dist/extensions/pi-intercom/index.ts +12 -5
  29. package/dist/extensions/pi-powerline-footer/index.ts +4 -1
  30. package/dist/extensions/pi-powerline-footer/tps.ts +286 -0
  31. package/dist/extensions/pi-powerline-footer/welcome.ts +47 -46
  32. package/dist/extensions/pi-subagents/README.md +12 -5
  33. package/dist/extensions/pi-subagents/agents/context-builder.md +2 -2
  34. package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
  35. package/dist/extensions/pi-subagents/package.json +8 -5
  36. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
  37. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
  38. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +1 -1
  39. package/dist/extensions/pi-subagents/src/agents/agents.ts +3 -3
  40. package/dist/extensions/pi-subagents/src/extension/control-notices.ts +1 -1
  41. package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
  42. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +1 -1
  43. package/dist/extensions/pi-subagents/src/extension/index.ts +29 -2
  44. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +2 -2
  45. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +72 -13
  46. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +1 -1
  47. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +1 -1
  48. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +85 -41
  49. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +1 -1
  50. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +1 -1
  51. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +46 -3
  52. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +2 -2
  53. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +1 -3
  54. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +2 -2
  55. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +18 -18
  56. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +24 -24
  57. package/dist/extensions/pi-subagents/src/runs/shared/spawn-timing.ts +46 -0
  58. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +2 -2
  59. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -4
  60. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +1 -1
  61. package/dist/extensions/pi-subagents/src/shared/types.ts +6 -6
  62. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -3
  63. package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -1
  64. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +1 -1
  65. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +1 -1
  66. package/dist/extensions/pi-subagents/src/tui/render.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/spawn-timing.test.ts +45 -0
  68. package/dist/extensions/pi-subagents/tsconfig.runner.json +17 -0
  69. package/dist/extensions/pi-web-agent/package.json +31 -0
  70. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -0
  71. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  72. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -0
  73. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -0
  74. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -0
  75. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +133 -0
  76. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -0
  77. package/dist/extensions/pi-web-agent/src/extension.ts +126 -0
  78. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -0
  79. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -0
  80. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -0
  81. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -0
  82. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -0
  83. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -0
  84. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -0
  85. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -0
  86. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -0
  87. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -0
  88. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -0
  89. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -0
  90. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -0
  91. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -0
  92. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -0
  93. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -0
  94. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -0
  95. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +208 -0
  96. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -0
  97. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -0
  98. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -0
  99. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -0
  100. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -0
  101. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -0
  102. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -0
  103. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -0
  104. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -0
  105. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -0
  106. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -0
  107. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -0
  108. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -0
  109. package/dist/extensions/pi-web-agent/src/types.ts +88 -0
  110. package/dist/extensions/ponytail/index.js +189 -0
  111. package/dist/extensions/ponytail/package.json +8 -0
  112. package/dist/extensions/ponytail/ponytail-config.cjs +122 -0
  113. package/dist/extensions/ponytail/ponytail-instructions.cjs +94 -0
  114. package/dist/extensions/ponytail/test/extension.test.js +167 -0
  115. package/dist/extensions/ponytail/test/helpers.test.js +92 -0
  116. package/dist/extensions/question/constants.ts +38 -0
  117. package/dist/extensions/question/dialog-adapter.ts +74 -0
  118. package/dist/extensions/question/helpers.ts +102 -0
  119. package/dist/extensions/question/index.ts +671 -0
  120. package/dist/extensions/question/navigation.ts +14 -0
  121. package/dist/extensions/question/package.json +18 -0
  122. package/dist/extensions/question/question-list.ts +498 -0
  123. package/dist/extensions/question/row-layout.ts +162 -0
  124. package/dist/extensions/question/schemas.ts +69 -0
  125. package/dist/extensions/question/selection-mode.ts +49 -0
  126. package/dist/extensions/question/shortcuts.ts +53 -0
  127. package/dist/extensions/question/tests/helpers.test.ts +178 -0
  128. package/dist/extensions/question/tests/question-list.test.ts +230 -0
  129. package/dist/extensions/question/tests/row-layout.test.ts +136 -0
  130. package/dist/extensions/question/tests/shortcuts.test.ts +85 -0
  131. package/dist/extensions/question/tests/ui-protocol.test.ts +143 -0
  132. package/dist/extensions/question/tui-adapter.ts +33 -0
  133. package/dist/extensions/question/types.ts +102 -0
  134. package/dist/extensions/question/ui-protocol.ts +46 -0
  135. package/dist/extensions/tokenin-onboarding.ts +265 -2
  136. package/dist/extensions/web-agent-onboarding.ts +222 -0
  137. package/dist/extensions/workflow/adapter.ts +588 -0
  138. package/dist/extensions/workflow/extension.ts +24 -0
  139. package/dist/extensions/workflow/modes/prototype.ts +156 -0
  140. package/dist/extensions/workflow/modes/quick.ts +138 -0
  141. package/dist/extensions/workflow/package.json +17 -0
  142. package/dist/extensions/workflow/state-machine.ts +386 -0
  143. package/dist/index.d.ts +1 -1
  144. package/dist/index.d.ts.map +1 -1
  145. package/dist/index.js +1 -1
  146. package/dist/index.js.map +1 -1
  147. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  148. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  149. package/dist/modes/interactive/interactive-mode.js +57 -13
  150. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  151. package/dist/utils/mime.d.ts.map +1 -1
  152. package/dist/utils/mime.js +24 -1
  153. package/dist/utils/mime.js.map +1 -1
  154. package/docs/docs.json +4 -0
  155. package/docs/workflows.md +206 -0
  156. package/package.json +5 -1
  157. package/README.md +0 -198
@@ -0,0 +1,671 @@
1
+ // Question extension entry point.
2
+ // Owns: QuestionComponent (mode-switching + editor lifecycle + box borders),
3
+ // tool registration, execute() orchestration via UIProtocol.
4
+
5
+ import type { ExtensionAPI, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ Container,
8
+ type Component,
9
+ Editor,
10
+ type EditorTheme,
11
+ Key,
12
+ Markdown,
13
+ matchesKey,
14
+ type OverlayHandle,
15
+ Spacer,
16
+ Text,
17
+ type TUI,
18
+ truncateToWidth,
19
+ wrapTextWithAnsi,
20
+ } from "@earendil-works/pi-tui";
21
+
22
+ import { BOX_BORDER_LEFT, BOX_BORDER_OVERHEAD, BOX_BORDER_RIGHT, OVERLAY_MAX_HEIGHT_RATIO, QUESTION_STATUS_KEY, QUESTION_VERSION } from "./constants.ts";
23
+ import { DialogFallback } from "./dialog-adapter.ts";
24
+ import { createFreeformResponse, createSelectionResponse, formatOptionsForMessage, formatResponseSummary, getOptionsFormatError, normalizeOptions, oneLine, safeMarkdownTheme } from "./helpers.ts";
25
+ import { QuestionList } from "./question-list.ts";
26
+ import { MultiSelect, SingleSelect } from "./selection-mode.ts";
27
+ import { resolveShortcuts } from "./shortcuts.ts";
28
+ import { QuestionParamsSchema } from "./schemas.ts";
29
+ import type { DisplayMode, QuestionDetails, QuestionMode, QuestionOption, QuestionResponse, RawOption, ResolvedShortcuts } from "./types.ts";
30
+ import { buildCustomUIOptions, createTUIProtocol } from "./tui-adapter.ts";
31
+ import type { CustomFactory, UIProtocol } from "./ui-protocol.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Box borders (manual ANSI components, self-contained)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ class BoxBorderTop implements Component {
38
+ constructor(
39
+ private color: (s: string) => string,
40
+ private title?: string,
41
+ private titleColor?: (s: string) => string,
42
+ ) {}
43
+ invalidate(): void {}
44
+ render(width: number): string[] {
45
+ const inner = Math.max(0, width - 2);
46
+ if (!this.title || inner < this.title.length + 4) {
47
+ return [this.color(`╭${"─".repeat(inner)}╮`)];
48
+ }
49
+ const label = ` ${this.title} `;
50
+ const remaining = inner - 1 - label.length;
51
+ const titleStyle = this.titleColor ?? this.color;
52
+ return [this.color("╭─") + titleStyle(label) + this.color("─".repeat(Math.max(0, remaining)) + "╮")];
53
+ }
54
+ }
55
+
56
+ class BoxBorderBottom implements Component {
57
+ constructor(
58
+ private color: (s: string) => string,
59
+ private label?: string,
60
+ private labelColor?: (s: string) => string,
61
+ ) {}
62
+ invalidate(): void {}
63
+ render(width: number): string[] {
64
+ const inner = Math.max(0, width - 2);
65
+ if (!this.label || inner < this.label.length + 4) {
66
+ return [this.color(`╰${"─".repeat(inner)}╯`)];
67
+ }
68
+ const tag = ` ${this.label} `;
69
+ const leftDashes = inner - tag.length - 1;
70
+ const style = this.labelColor ?? this.color;
71
+ return [this.color("╰" + "─".repeat(Math.max(0, leftDashes))) + style(tag) + this.color("─╯")];
72
+ }
73
+ }
74
+
75
+ function createEditorTheme(theme: Theme): EditorTheme {
76
+ return {
77
+ borderColor: (s) => theme.fg("accent", s),
78
+ selectList: {
79
+ selectedPrefix: (t) => theme.fg("accent", t),
80
+ selectedText: (t) => theme.fg("accent", t),
81
+ description: (t) => theme.fg("muted", t),
82
+ scrollInfo: (t) => theme.fg("dim", t),
83
+ noMatch: (t) => theme.fg("warning", t),
84
+ },
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Question component (Container-based layout)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ class QuestionComponent extends Container implements Component {
93
+ private mode: QuestionMode = "select";
94
+ private pendingSelections: string[] = [];
95
+ private freeformDraft = "";
96
+ private commentDraft = "";
97
+
98
+ private titleText: Text;
99
+ private questionText: Text;
100
+ private contextComponent?: Component;
101
+ private modeContainer: Container;
102
+ private helpText: Text;
103
+
104
+ private list?: QuestionList;
105
+ private editor?: Editor;
106
+
107
+ private _focused = false;
108
+
109
+ constructor(
110
+ private question: string,
111
+ private context: string | undefined,
112
+ private options: QuestionOption[],
113
+ private allowMultiple: boolean,
114
+ private allowFreeform: boolean,
115
+ private allowComment: boolean,
116
+ private displayMode: DisplayMode,
117
+ private tui: TUI,
118
+ private theme: Theme,
119
+ private keybindings: KeybindingsManager,
120
+ private shortcuts: ResolvedShortcuts,
121
+ private onDone: (result: QuestionResponse | null) => void,
122
+ ) {
123
+ super();
124
+
125
+ this.addChild(
126
+ new BoxBorderTop(
127
+ (s: string) => theme.fg("accent", s),
128
+ "question",
129
+ (s: string) => theme.fg("dim", theme.bold(s)),
130
+ ),
131
+ );
132
+ this.addChild(new Spacer(1));
133
+
134
+ this.titleText = new Text("", 1, 0);
135
+ this.addChild(this.titleText);
136
+ this.addChild(new Spacer(1));
137
+
138
+ this.questionText = new Text("", 1, 0);
139
+ this.addChild(this.questionText);
140
+
141
+ if (this.context) {
142
+ this.addChild(new Spacer(1));
143
+ const mdTheme = safeMarkdownTheme();
144
+ if (mdTheme) {
145
+ this.contextComponent = new Markdown(`**Context:**\n${this.context}`, 1, 0, mdTheme);
146
+ } else {
147
+ this.contextComponent = new Text(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`, 1, 0);
148
+ }
149
+ this.addChild(this.contextComponent);
150
+ }
151
+
152
+ this.addChild(new Spacer(1));
153
+
154
+ this.modeContainer = new Container();
155
+ this.addChild(this.modeContainer);
156
+
157
+ this.addChild(new Spacer(1));
158
+ this.helpText = new Text("", 1, 0);
159
+ this.addChild(this.helpText);
160
+
161
+ this.addChild(new Spacer(1));
162
+ this.addChild(new BoxBorderBottom((s: string) => theme.fg("accent", s), `v${QUESTION_VERSION}`, (s: string) => theme.fg("dim", s)));
163
+
164
+ this.updateStaticText();
165
+ this.showSelectMode();
166
+ }
167
+
168
+ get focused(): boolean {
169
+ return this._focused;
170
+ }
171
+ set focused(value: boolean) {
172
+ this._focused = value;
173
+ if (this.editor && (this.mode === "freeform" || this.mode === "comment")) {
174
+ (this.editor as any).focused = value;
175
+ }
176
+ }
177
+
178
+ override invalidate(): void {
179
+ super.invalidate();
180
+ this.updateStaticText();
181
+ this.updateHelpText();
182
+ }
183
+
184
+ override render(width: number): string[] {
185
+ const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
186
+
187
+ if (this.mode === "select" && !this.allowMultiple) {
188
+ const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * OVERLAY_MAX_HEIGHT_RATIO));
189
+ const staticLines = this.countStaticLines(innerWidth);
190
+ const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
191
+ this.ensureList().setMaxVisibleRows(availableOptionRows);
192
+ }
193
+
194
+ const rawLines = super.render(innerWidth);
195
+ const borderColor = (s: string) => this.theme.fg("accent", s);
196
+ const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
197
+ return rawLines.map((line, index) => {
198
+ if (index === 0) return new BoxBorderTop(borderColor, "question", titleColor).render(width)[0];
199
+ if (index === rawLines.length - 1)
200
+ return new BoxBorderBottom(borderColor, `v${QUESTION_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
201
+ const padded = truncateToWidth(line, innerWidth, "", true);
202
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
203
+ });
204
+ }
205
+
206
+ private countWrappedLines(text: string, width: number): number {
207
+ return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
208
+ }
209
+
210
+ private countStaticLines(width: number): number {
211
+ const titleLines = 1;
212
+ const questionLines = this.countWrappedLines(this.question, width);
213
+ const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
214
+ const helpLines = 1;
215
+ const borderLines = 2;
216
+ const spacerLines = this.context ? 6 : 5;
217
+ return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
218
+ }
219
+
220
+ private updateStaticText(): void {
221
+ const theme = this.theme;
222
+ const title = this.mode === "comment" ? "Optional comment" : "Question";
223
+ this.titleText.setText(theme.fg("accent", theme.bold(title)));
224
+ this.questionText.setText(theme.fg("text", theme.bold(this.question)));
225
+ if (this.contextComponent && this.context) {
226
+ const mdTheme = safeMarkdownTheme();
227
+ if (mdTheme && this.contextComponent instanceof Markdown) {
228
+ (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
229
+ } else {
230
+ (this.contextComponent as Text).setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
231
+ }
232
+ }
233
+ }
234
+
235
+ private updateHelpText(): void {
236
+ const theme = this.theme;
237
+ const kb = this.keybindings;
238
+ const overlayHint =
239
+ this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled
240
+ ? `${theme.fg("dim", this.shortcuts.overlayToggle.spec)}${theme.fg("muted", " hide")}`
241
+ : null;
242
+ const commentHint =
243
+ this.allowComment && !this.shortcuts.commentToggle.disabled
244
+ ? `${theme.fg("dim", this.shortcuts.commentToggle.spec)}${theme.fg("muted", " toggle context")}`
245
+ : null;
246
+
247
+ if (this.mode === "freeform" || this.mode === "comment") {
248
+ const hints = [
249
+ `${theme.fg("dim", kb.getKeys("tui.input.submit").join("/"))}${theme.fg("muted", this.mode === "comment" ? " submit/skip" : " submit")}`,
250
+ `${theme.fg("dim", kb.getKeys("tui.input.newLine").join("/"))}${theme.fg("muted", " newline")}`,
251
+ `${theme.fg("dim", "esc")}${theme.fg("muted", " back")}`,
252
+ overlayHint,
253
+ ]
254
+ .filter((h): h is string => !!h)
255
+ .join(" • ");
256
+ this.helpText.setText(theme.fg("dim", hints));
257
+ return;
258
+ }
259
+
260
+ if (this.allowMultiple) {
261
+ const hints = [
262
+ `${theme.fg("dim", "↑↓")}${theme.fg("muted", " navigate")}`,
263
+ `${theme.fg("dim", "space")}${theme.fg("muted", " toggle")}`,
264
+ commentHint,
265
+ overlayHint,
266
+ `${theme.fg("dim", kb.getKeys("tui.select.confirm").join("/"))}${theme.fg("muted", " submit")}`,
267
+ `${theme.fg("dim", kb.getKeys("tui.select.cancel").join("/"))}${theme.fg("muted", " cancel")}`,
268
+ ]
269
+ .filter((h): h is string => !!h)
270
+ .join(" • ");
271
+ this.helpText.setText(theme.fg("dim", hints));
272
+ } else {
273
+ const hints = [
274
+ `${theme.fg("dim", "type")}${theme.fg("muted", " filter")}`,
275
+ `${theme.fg("dim", kb.getKeys("tui.editor.deleteCharBackward").join("/"))}${theme.fg("muted", " erase")}`,
276
+ `${theme.fg("dim", "↑↓")}${theme.fg("muted", " navigate")}`,
277
+ commentHint,
278
+ overlayHint,
279
+ `${theme.fg("dim", kb.getKeys("tui.select.confirm").join("/"))}${theme.fg("muted", " select")}`,
280
+ `${theme.fg("dim", "esc")}${theme.fg("muted", " clear/cancel")}`,
281
+ ]
282
+ .filter((h): h is string => !!h)
283
+ .join(" • ");
284
+ this.helpText.setText(theme.fg("dim", hints));
285
+ }
286
+ }
287
+
288
+ private ensureList(): QuestionList {
289
+ if (this.list) return this.list;
290
+ const list = new QuestionList(
291
+ this.options,
292
+ this.allowMultiple ? MultiSelect : SingleSelect,
293
+ this.allowFreeform,
294
+ this.allowComment,
295
+ this.theme,
296
+ this.keybindings,
297
+ this.shortcuts.commentToggle,
298
+ );
299
+ list.onSubmit = (selections, commentEnabled) => this.handleSelectionSubmit(selections, commentEnabled);
300
+ list.onCancel = () => this.onDone(null);
301
+ list.onEnterFreeform = () => this.showFreeformMode();
302
+ this.list = list;
303
+ return list;
304
+ }
305
+
306
+ private ensureEditor(): Editor {
307
+ if (this.editor) return this.editor;
308
+ const editor = new Editor(this.tui, createEditorTheme(this.theme));
309
+ editor.onSubmit = (text: string) => this.handleEditorSubmit(text);
310
+ this.editor = editor;
311
+ return editor;
312
+ }
313
+
314
+ private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
315
+ if (this.allowComment && wantsComment) {
316
+ this.pendingSelections = selections;
317
+ this.commentDraft = "";
318
+ this.showCommentMode();
319
+ return;
320
+ }
321
+ this.onDone(createSelectionResponse(selections) ?? null);
322
+ }
323
+
324
+ private handleEditorSubmit(text: string): void {
325
+ if (this.mode === "freeform") {
326
+ const r = createFreeformResponse(text);
327
+ this.onDone(r ?? null);
328
+ return;
329
+ }
330
+ if (this.mode === "comment") {
331
+ this.commentDraft = text;
332
+ this.onDone(createSelectionResponse(this.pendingSelections, text) ?? null);
333
+ }
334
+ }
335
+
336
+ private showSelectMode(): void {
337
+ this.mode = "select";
338
+ this.pendingSelections = [];
339
+ this.modeContainer.clear();
340
+ this.modeContainer.addChild(this.ensureList());
341
+ this.updateHelpText();
342
+ this.invalidate();
343
+ this.tui.requestRender();
344
+ }
345
+
346
+ private showFreeformMode(): void {
347
+ this.mode = "freeform";
348
+ this.modeContainer.clear();
349
+ const editor = this.ensureEditor();
350
+ (editor as any).setText?.(this.freeformDraft);
351
+ (editor as any).focused = this._focused;
352
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
353
+ this.modeContainer.addChild(new Spacer(1));
354
+ this.modeContainer.addChild(editor);
355
+ this.updateHelpText();
356
+ this.invalidate();
357
+ this.tui.requestRender();
358
+ }
359
+
360
+ private showCommentMode(): void {
361
+ this.mode = "comment";
362
+ this.modeContainer.clear();
363
+ const editor = this.ensureEditor();
364
+ (editor as any).setText?.(this.commentDraft);
365
+ (editor as any).focused = this._focused;
366
+ const label = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
367
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(label)), 1, 0));
368
+ this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
369
+ this.modeContainer.addChild(new Spacer(1));
370
+ this.modeContainer.addChild(editor);
371
+ this.updateHelpText();
372
+ this.invalidate();
373
+ this.tui.requestRender();
374
+ }
375
+
376
+ handleInput(data: string): void {
377
+ if (this.mode === "freeform" || this.mode === "comment") {
378
+ if (matchesKey(data, Key.escape)) {
379
+ this.showSelectMode();
380
+ return;
381
+ }
382
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
383
+ this.onDone(null);
384
+ return;
385
+ }
386
+ this.ensureEditor().handleInput(data);
387
+ this.tui.requestRender();
388
+ return;
389
+ }
390
+ this.ensureList().handleInput(data);
391
+ this.tui.requestRender();
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Tool registration
397
+ // ---------------------------------------------------------------------------
398
+
399
+ export default function questionExtension(pi: ExtensionAPI) {
400
+ pi.registerTool({
401
+ name: "question",
402
+ label: "Question",
403
+ description:
404
+ "Ask the user a question in the Pi UI and return the answer. Supports multiple-choice options, multi-select, freeform answers, optional context, and an optional comment. Use when you need user input to proceed.",
405
+ promptSnippet: "Ask the user a question when required information is missing.",
406
+ promptGuidelines: [
407
+ "Use question only when you cannot proceed safely or accurately without user input.",
408
+ "Ask concise questions. Prefer options when choices are known.",
409
+ "Set allowFreeform=true when user may need to provide a custom answer.",
410
+ "Use allowMultiple=true when more than one option may apply.",
411
+ "Pass a short summary via context when the question depends on prior findings.",
412
+ "After the user answers, continue the task immediately using that answer; do not stop and ask the user to type continue unless the task is complete.",
413
+ "Do not use question for information you can infer, inspect, or compute with other tools.",
414
+ ],
415
+ // Block other tool calls in the same turn until the user answers, so the
416
+ // model can't batch question with side-effecting tools and run them first.
417
+ executionMode: "sequential",
418
+ parameters: QuestionParamsSchema,
419
+
420
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
421
+ if (signal?.aborted) {
422
+ return {
423
+ content: [{ type: "text", text: "Question cancelled" }],
424
+ details: { question: params.question, options: [], response: null, cancelled: true } satisfies QuestionDetails,
425
+ };
426
+ }
427
+
428
+ const rawQuestion = params.question as string;
429
+ // `title` is a deprecated alias for context; merge if only title is given.
430
+ const rawContext = (params.context as string | undefined)?.trim() || (params.title as string | undefined)?.trim() || undefined;
431
+ const allowMultiple = params.allowMultiple ?? false;
432
+ const allowFreeform = params.allowFreeform ?? !((params.options ?? []).length > 0);
433
+ const allowComment = params.allowComment ?? false;
434
+ const timeout = params.timeout as number | undefined;
435
+
436
+ const envMode = process.env.PI_QUESTION_DISPLAY_MODE;
437
+ const envDisplayMode: DisplayMode | undefined = envMode === "overlay" || envMode === "inline" ? envMode : undefined;
438
+ const effectiveDisplayMode: DisplayMode = (params.displayMode as DisplayMode | undefined) ?? envDisplayMode ?? "overlay";
439
+
440
+ const shortcuts: ResolvedShortcuts = resolveShortcuts(
441
+ params.overlayToggleKey as string | null | undefined,
442
+ process.env.PI_QUESTION_OVERLAY_TOGGLE_KEY,
443
+ params.commentToggleKey as string | null | undefined,
444
+ process.env.PI_QUESTION_COMMENT_TOGGLE_KEY,
445
+ );
446
+
447
+ const optionsFormatError = getOptionsFormatError(params.options as RawOption[] | undefined);
448
+ if (optionsFormatError) {
449
+ return {
450
+ content: [{ type: "text", text: optionsFormatError }],
451
+ isError: true,
452
+ details: { question: rawQuestion, context: rawContext, options: [], response: null, cancelled: true } satisfies QuestionDetails,
453
+ };
454
+ }
455
+
456
+ const options = normalizeOptions(params.options as RawOption[] | undefined);
457
+ const normalizedContext = rawContext || undefined;
458
+ const detailsBase = { question: rawQuestion, context: normalizedContext, options };
459
+
460
+ const protocol: UIProtocol = createTUIProtocol(ctx);
461
+
462
+ if (!protocol.hasUI) {
463
+ const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
464
+ const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
465
+ const commentHint = allowComment ? "\n\nAfter choosing, you may add an optional comment." : "";
466
+ const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
467
+ return {
468
+ content: [
469
+ { type: "text", text: `Question requires interactive mode. Please answer:\n\n${rawQuestion}${contextText}${optionText}${freeformHint}${commentHint}` },
470
+ ],
471
+ isError: true,
472
+ details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
473
+ };
474
+ }
475
+
476
+ protocol.setStatus(
477
+ QUESTION_STATUS_KEY,
478
+ protocol.theme.fg("accent", `waiting: ${oneLine((normalizedContext ? `${normalizedContext}: ` : "") + rawQuestion, 48)}`),
479
+ );
480
+
481
+ // No options: pure freeform via dialog input.
482
+ if (options.length === 0) {
483
+ if (!allowFreeform) {
484
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("error", "question failed"));
485
+ return {
486
+ content: [{ type: "text", text: "Question options are empty and allowFreeform is false." }],
487
+ isError: true,
488
+ details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
489
+ };
490
+ }
491
+ try {
492
+ const prompt = normalizedContext ? `${rawQuestion}\n\nContext:\n${normalizedContext}` : rawQuestion;
493
+ const answer = await protocol.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
494
+ const response = createFreeformResponse(answer);
495
+ if (!response) {
496
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("warning", "question cancelled"));
497
+ return {
498
+ content: [{ type: "text", text: "User cancelled the question" }],
499
+ details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
500
+ };
501
+ }
502
+ pi.events.emit("question:answered", { question: rawQuestion, context: normalizedContext, response });
503
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("success", "question answered"));
504
+ return {
505
+ content: [{ type: "text", text: `User answered: ${formatResponseSummary(response)}` }],
506
+ details: { ...detailsBase, response, cancelled: false } satisfies QuestionDetails,
507
+ };
508
+ } catch (error) {
509
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("error", "question failed"));
510
+ throw error;
511
+ }
512
+ }
513
+
514
+ onUpdate?.({
515
+ content: [{ type: "text", text: "Waiting for user input..." }],
516
+ details: { ...detailsBase, response: null, cancelled: false } satisfies QuestionDetails,
517
+ });
518
+
519
+ let result: QuestionResponse | null;
520
+ let overlayHandle: OverlayHandle | undefined;
521
+ let removeOverlayInputListener: (() => void) | undefined;
522
+ let hasAnnouncedHide = false;
523
+
524
+ try {
525
+ const customFactory: CustomFactory = (
526
+ tui: TUI,
527
+ theme: Theme,
528
+ keybindings: KeybindingsManager,
529
+ done: (result: QuestionResponse | null) => void,
530
+ ) => {
531
+ if (signal) {
532
+ const onAbort = () => done(null);
533
+ signal.addEventListener("abort", onAbort, { once: true });
534
+ }
535
+ if (timeout && timeout > 0) setTimeout(() => done(null), timeout);
536
+
537
+ return new QuestionComponent(
538
+ rawQuestion,
539
+ normalizedContext,
540
+ options,
541
+ allowMultiple,
542
+ allowFreeform,
543
+ allowComment,
544
+ effectiveDisplayMode,
545
+ tui,
546
+ theme,
547
+ keybindings,
548
+ shortcuts,
549
+ done,
550
+ );
551
+ };
552
+
553
+ const overlayToggle = shortcuts.overlayToggle;
554
+ if (
555
+ effectiveDisplayMode === "overlay" &&
556
+ !overlayToggle.disabled &&
557
+ typeof protocol.onTerminalInput === "function"
558
+ ) {
559
+ removeOverlayInputListener = protocol.onTerminalInput((data) => {
560
+ if (!overlayToggle.matches(data) || !overlayHandle) return undefined;
561
+ const nextHidden = !overlayHandle.isHidden();
562
+ overlayHandle.setHidden(nextHidden);
563
+ if (nextHidden && !hasAnnouncedHide) {
564
+ hasAnnouncedHide = true;
565
+ protocol.notify?.(`question hidden — press ${overlayToggle.spec} to reopen`, "info");
566
+ }
567
+ return { consume: true };
568
+ });
569
+ }
570
+
571
+ const customResult = await protocol.custom(customFactory, buildCustomUIOptions(effectiveDisplayMode, (handle) => {
572
+ overlayHandle = handle;
573
+ }));
574
+
575
+ if (customResult !== undefined) {
576
+ result = customResult;
577
+ } else {
578
+ // RPC/headless: custom() returns undefined — degrade to dialog protocol.
579
+ result = await DialogFallback.ask(
580
+ { question: rawQuestion, context: normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode: effectiveDisplayMode, shortcuts, timeout },
581
+ protocol,
582
+ );
583
+ }
584
+ } catch (error) {
585
+ const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
586
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("error", "question failed"));
587
+ return {
588
+ content: [{ type: "text", text: `Question tool failed: ${message}` }],
589
+ isError: true,
590
+ details: { error: message } as unknown as QuestionDetails,
591
+ };
592
+ } finally {
593
+ removeOverlayInputListener?.();
594
+ }
595
+
596
+ if (result === null) {
597
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("warning", "question cancelled"));
598
+ pi.events.emit("question:cancelled", { question: rawQuestion, context: normalizedContext, options });
599
+ return {
600
+ content: [{ type: "text", text: "User cancelled the question" }],
601
+ details: { ...detailsBase, response: null, cancelled: true } satisfies QuestionDetails,
602
+ };
603
+ }
604
+
605
+ protocol.setStatus(QUESTION_STATUS_KEY, protocol.theme.fg("success", "question answered"));
606
+ pi.events.emit("question:answered", { question: rawQuestion, context: normalizedContext, response: result });
607
+ return {
608
+ content: [{ type: "text", text: `User answered: ${formatResponseSummary(result)}` }],
609
+ details: { ...detailsBase, response: result, cancelled: false } satisfies QuestionDetails,
610
+ };
611
+ },
612
+
613
+ renderCall(args, theme, _context) {
614
+ const question = (args.question as string) || "";
615
+ const rawOptions = Array.isArray(args.options) ? args.options : [];
616
+ const title = args.context ? `${args.context}: ` : args.title ? `${args.title}: ` : "";
617
+ const optionCount = rawOptions.length;
618
+ const flags: string[] = [];
619
+ if (args.allowMultiple) flags.push("multi");
620
+ if (args.allowComment) flags.push("comment");
621
+ const suffix = optionCount
622
+ ? theme.fg("dim", ` · ${optionCount} option${optionCount === 1 ? "" : "s"}${flags.length ? ` · ${flags.join("/")}` : ""}`)
623
+ : theme.fg("dim", ` · freeform${flags.length ? ` · ${flags.join("/")}` : ""}`);
624
+ return new Text(
625
+ `${theme.fg("toolTitle", theme.bold("QUESTION"))} ${theme.fg("accent", theme.bold(oneLine(title + question, 120)))}${suffix}`,
626
+ 0,
627
+ 0,
628
+ );
629
+ },
630
+
631
+ renderResult(result, options, theme, _context) {
632
+ const details = result.details as (QuestionDetails & { error?: string }) | undefined;
633
+
634
+ if (details?.error) return new Text(theme.fg("error", details.error), 0, 0);
635
+
636
+ if (options.isPartial) {
637
+ const waitingText =
638
+ result.content
639
+ ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
640
+ .map((part: { text?: string }) => part.text ?? "")
641
+ .join("\n")
642
+ .trim() || "Waiting for user input...";
643
+ return new Text(theme.fg("muted", waitingText), 0, 0);
644
+ }
645
+
646
+ if (!details || details.cancelled || !details.response) {
647
+ return new Text(theme.fg("warning", "question cancelled"), 0, 0);
648
+ }
649
+
650
+ const response = details.response;
651
+ let text = theme.fg("success", theme.bold("answered: "));
652
+ text += theme.fg("accent", theme.bold(oneLine(formatResponseSummary(response), 120)));
653
+
654
+ if (options.expanded && details.question) {
655
+ text += `\n${theme.fg("dim", `Q: ${oneLine(details.question, 200)}`)}`;
656
+ if (details.context) text += `\n${theme.fg("dim", oneLine(details.context, 200))}`;
657
+ if (response.kind === "selection" && details.options.length > 0) {
658
+ const selected = new Set(response.selections);
659
+ text += `\n${theme.fg("dim", "Options:")}`;
660
+ for (const opt of details.options) {
661
+ const desc = opt.description ? ` — ${opt.description}` : "";
662
+ const marker = selected.has(opt.label) ? theme.fg("success", "●") : theme.fg("dim", "○");
663
+ text += `\n ${marker} ${theme.fg("dim", opt.label)}${theme.fg("dim", desc)}`;
664
+ }
665
+ if (response.comment) text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`;
666
+ }
667
+ }
668
+ return new Text(text, 0, 0);
669
+ },
670
+ });
671
+ }
@@ -0,0 +1,14 @@
1
+ // Vim-style navigation aliases (ctrl+j/k don't collide with fuzzy-search input).
2
+
3
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
+ import { matchesKey } from "@earendil-works/pi-tui";
5
+
6
+ import { VIM_DOWN, VIM_UP } from "./constants.ts";
7
+
8
+ export function matchesUp(data: string, keybindings: KeybindingsManager): boolean {
9
+ return keybindings.matches(data, "tui.select.up") || matchesKey(data, VIM_UP);
10
+ }
11
+
12
+ export function matchesDown(data: string, keybindings: KeybindingsManager): boolean {
13
+ return keybindings.matches(data, "tui.select.down") || matchesKey(data, VIM_DOWN);
14
+ }