@shanepadgett/tau-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, type Focusable, Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { editorTheme } from "@shanepadgett/tau-tui";
|
|
4
|
+
import { bindingHint, bindingsHint, rawHint, type ToolKeyHint } from "@shanepadgett/tau-tui";
|
|
5
|
+
import { renderQuestionPrompt } from "./body-render.ts";
|
|
6
|
+
import { pushSavedNote, renderInlineEditor, renderNoteEditor, wrapWithPrefix } from "./inline-editor-row.ts";
|
|
7
|
+
import { renderRecommendation } from "./input-question-body.ts";
|
|
8
|
+
import {
|
|
9
|
+
getAnswer,
|
|
10
|
+
hasCustom,
|
|
11
|
+
moveOption,
|
|
12
|
+
type NormalizedQuestion,
|
|
13
|
+
optionCount,
|
|
14
|
+
type QnaState,
|
|
15
|
+
saveCustomAnswer,
|
|
16
|
+
saveOptionNote,
|
|
17
|
+
toggleOrSelectOption,
|
|
18
|
+
} from "./model.ts";
|
|
19
|
+
|
|
20
|
+
type Mode = "navigate" | "custom" | "note";
|
|
21
|
+
|
|
22
|
+
export class ChoiceQuestionBody implements Focusable {
|
|
23
|
+
private readonly theme: Theme;
|
|
24
|
+
private readonly getState: () => QnaState;
|
|
25
|
+
private readonly setState: (state: QnaState) => void;
|
|
26
|
+
private readonly advance: () => void;
|
|
27
|
+
private readonly noteEditor: Editor;
|
|
28
|
+
private readonly customEditor: Editor;
|
|
29
|
+
private mode: Mode = "navigate";
|
|
30
|
+
private noteTarget?: { questionId: string; value: string };
|
|
31
|
+
private customQuestionId?: string;
|
|
32
|
+
private activeQuestionId: string | undefined;
|
|
33
|
+
private _focused = false;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
tui: TUI,
|
|
37
|
+
theme: Theme,
|
|
38
|
+
getState: () => QnaState,
|
|
39
|
+
setState: (state: QnaState) => void,
|
|
40
|
+
advance: () => void,
|
|
41
|
+
) {
|
|
42
|
+
this.theme = theme;
|
|
43
|
+
this.getState = getState;
|
|
44
|
+
this.setState = setState;
|
|
45
|
+
this.advance = advance;
|
|
46
|
+
this.noteEditor = new Editor(tui, editorTheme(theme));
|
|
47
|
+
this.customEditor = new Editor(tui, editorTheme(theme));
|
|
48
|
+
this.noteEditor.onSubmit = (value) => this.saveNote(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get focused(): boolean {
|
|
52
|
+
return this._focused;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
set focused(value: boolean) {
|
|
56
|
+
this._focused = value;
|
|
57
|
+
this.noteEditor.focused = value && this.mode === "note";
|
|
58
|
+
this.customEditor.focused = value && this.mode === "custom";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
forQuestion(question: NormalizedQuestion): this {
|
|
62
|
+
this.activeQuestionId = question.id;
|
|
63
|
+
this.focused = this._focused;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isEditing(): boolean {
|
|
68
|
+
return this.mode !== "navigate";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
keyHints(): readonly ToolKeyHint[] {
|
|
72
|
+
if (this.mode === "custom") {
|
|
73
|
+
return [bindingHint("tui.input.submit", "save"), bindingHint("tui.select.cancel", "cancel edit")];
|
|
74
|
+
}
|
|
75
|
+
if (this.mode === "note") {
|
|
76
|
+
return [bindingHint("tui.input.submit", "save"), bindingHint("tui.select.cancel", "cancel note")];
|
|
77
|
+
}
|
|
78
|
+
const question = this.currentQuestion();
|
|
79
|
+
return question?.kind === "multi"
|
|
80
|
+
? [
|
|
81
|
+
bindingsHint(["tui.select.up", "tui.select.down"], "move"),
|
|
82
|
+
rawHint("Space", "toggle"),
|
|
83
|
+
bindingHint("tui.select.confirm", "next"),
|
|
84
|
+
rawHint("n", "note"),
|
|
85
|
+
]
|
|
86
|
+
: [
|
|
87
|
+
bindingsHint(["tui.select.up", "tui.select.down"], "move"),
|
|
88
|
+
bindingHint("tui.select.confirm", "choose"),
|
|
89
|
+
rawHint("n", "note"),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
handleInput(data: string): void {
|
|
94
|
+
const question = this.currentQuestion();
|
|
95
|
+
if (!question) return;
|
|
96
|
+
this.activeQuestionId = question.id;
|
|
97
|
+
if (this.mode === "note") {
|
|
98
|
+
this.handleNoteInput(data);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (this.mode === "custom") {
|
|
102
|
+
this.handleCustomInput(data);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (matchesKey(data, Key.up)) {
|
|
106
|
+
this.setState(moveOption(this.getState(), -1));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (matchesKey(data, Key.down)) {
|
|
110
|
+
this.setState(moveOption(this.getState(), 1));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (this.isCustomActive(question) && isPrintable(data)) {
|
|
114
|
+
this.openCustom(question, "");
|
|
115
|
+
this.customEditor.handleInput(data);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (data === "n") {
|
|
119
|
+
this.openNote(question);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (matchesKey(data, Key.enter) && question.kind === "multi") {
|
|
123
|
+
this.advance();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (data === " " || matchesKey(data, Key.enter)) this.chooseActive(question);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
render(question: NormalizedQuestion, width: number): string[] {
|
|
130
|
+
this.activeQuestionId = question.id;
|
|
131
|
+
this.focused = this._focused;
|
|
132
|
+
const lines = renderQuestionPrompt(question, width);
|
|
133
|
+
for (let index = 0; index < optionCount(question); index++) {
|
|
134
|
+
if (question.kind === "multi") lines.push(...this.renderMultiOption(question, index, width));
|
|
135
|
+
else lines.push(...this.renderSingleOption(question, index, width));
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
lines.push(...renderRecommendation(question, width, this.theme));
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
closeInactiveEditors(): void {
|
|
143
|
+
if (this.mode === "navigate") return;
|
|
144
|
+
if (this.activeQuestionId === this.getState().questions[this.getState().activeTab]?.id) return;
|
|
145
|
+
this.closeNote();
|
|
146
|
+
this.closeCustom();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private renderSingleOption(question: NormalizedQuestion, index: number, width: number): string[] {
|
|
150
|
+
if (this.isCustomIndex(question, index)) return this.renderCustomOption(question, false, width);
|
|
151
|
+
const ctx = this.optionContext(question, index);
|
|
152
|
+
if (!ctx) return [];
|
|
153
|
+
const { option, active, selected } = ctx;
|
|
154
|
+
const prefix = active ? this.theme.fg("accent", "→ ") : " ";
|
|
155
|
+
const label = `${this.theme.fg(active ? "accent" : "text", option.label)}${
|
|
156
|
+
selected ? this.theme.fg("success", " ✓") : ""
|
|
157
|
+
}`;
|
|
158
|
+
const lines = wrapWithPrefix(prefix, label, width);
|
|
159
|
+
this.renderOptionNote(lines, question, option.value, width, " ");
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private renderMultiOption(question: NormalizedQuestion, index: number, width: number): string[] {
|
|
164
|
+
if (this.isCustomIndex(question, index)) return this.renderCustomOption(question, true, width);
|
|
165
|
+
const ctx = this.optionContext(question, index);
|
|
166
|
+
if (!ctx) return [];
|
|
167
|
+
const { option, active, selected } = ctx;
|
|
168
|
+
const pointer = active ? this.theme.fg("accent", "→ ") : " ";
|
|
169
|
+
const box = selected ? "[x]" : this.theme.fg("dim", "[ ]");
|
|
170
|
+
const label = this.theme.fg(active ? "accent" : "text", option.label);
|
|
171
|
+
const lines = wrapWithPrefix(`${pointer}${box} `, label, width);
|
|
172
|
+
this.renderOptionNote(lines, question, option.value, width, " ");
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private renderCustomOption(question: NormalizedQuestion, multi: boolean, width: number): string[] {
|
|
177
|
+
const answer = getAnswer(this.getState(), question.id);
|
|
178
|
+
const active = this.isCustomActive(question);
|
|
179
|
+
const editing = active && this.mode === "custom" && this.customQuestionId === question.id;
|
|
180
|
+
const draftSelected = editing ? Boolean(clean(this.customEditor.getText())) : false;
|
|
181
|
+
const selected = Boolean(answer.custom) || draftSelected;
|
|
182
|
+
const pointer = active ? this.theme.fg("accent", "→ ") : " ";
|
|
183
|
+
const box = multi ? `${selected ? "[x]" : this.theme.fg("dim", "[ ]")} ` : "";
|
|
184
|
+
const prefix = `${pointer}${box}`;
|
|
185
|
+
if (editing) return renderInlineEditor(prefix, this.customEditor, width);
|
|
186
|
+
const label = answer.custom || "Type your own answer...";
|
|
187
|
+
return wrapWithPrefix(
|
|
188
|
+
prefix,
|
|
189
|
+
`${this.theme.fg(active ? "accent" : "text", label)}${
|
|
190
|
+
!multi && selected ? this.theme.fg("success", " ✓") : ""
|
|
191
|
+
}`,
|
|
192
|
+
width,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private optionContext(question: NormalizedQuestion, index: number) {
|
|
197
|
+
const option = question.options[index];
|
|
198
|
+
if (!option) return null;
|
|
199
|
+
const answer = getAnswer(this.getState(), question.id);
|
|
200
|
+
const active = index === this.getState().activeOption;
|
|
201
|
+
const selected = answer.selected.includes(option.value);
|
|
202
|
+
return { option, active, selected };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private renderOptionNote(
|
|
206
|
+
lines: string[],
|
|
207
|
+
question: NormalizedQuestion,
|
|
208
|
+
value: string,
|
|
209
|
+
width: number,
|
|
210
|
+
indent: string,
|
|
211
|
+
): void {
|
|
212
|
+
if (this.mode === "note" && this.noteTarget?.questionId === question.id && this.noteTarget.value === value) {
|
|
213
|
+
renderNoteEditor(lines, this.noteEditor, width, this.theme, indent);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const note = getAnswer(this.getState(), question.id).notes[value];
|
|
217
|
+
if (note) pushSavedNote(lines, note, width, this.theme, indent);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private chooseActive(question: NormalizedQuestion): void {
|
|
221
|
+
if (this.isCustomActive(question)) {
|
|
222
|
+
this.openCustom(question, getAnswer(this.getState(), question.id).custom || "");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const option = question.options[this.getState().activeOption];
|
|
226
|
+
if (!option) return;
|
|
227
|
+
this.setState(toggleOrSelectOption(this.getState(), question, option.value));
|
|
228
|
+
if (question.kind !== "multi") this.advance();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private openNote(question: NormalizedQuestion): void {
|
|
232
|
+
const option = question.options[this.getState().activeOption];
|
|
233
|
+
if (!option) return;
|
|
234
|
+
this.noteTarget = { questionId: question.id, value: option.value };
|
|
235
|
+
this.mode = "note";
|
|
236
|
+
this.noteEditor.setText(getAnswer(this.getState(), question.id).notes[option.value] || "");
|
|
237
|
+
this.focused = this._focused;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private openCustom(question: NormalizedQuestion, value: string): void {
|
|
241
|
+
this.mode = "custom";
|
|
242
|
+
this.customQuestionId = question.id;
|
|
243
|
+
this.customEditor.setText(value);
|
|
244
|
+
this.focused = this._focused;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private handleNoteInput(data: string): void {
|
|
248
|
+
if (matchesKey(data, Key.escape)) {
|
|
249
|
+
this.closeNote();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.noteEditor.handleInput(data);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private handleCustomInput(data: string): void {
|
|
256
|
+
if (matchesKey(data, Key.escape)) {
|
|
257
|
+
this.closeCustom();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (matchesKey(data, Key.enter)) {
|
|
261
|
+
this.saveCustom();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.customEditor.handleInput(data);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private saveNote(value: string): void {
|
|
268
|
+
const question = this.currentQuestion();
|
|
269
|
+
if (question && this.noteTarget) {
|
|
270
|
+
this.setState(saveOptionNote(this.getState(), question, this.noteTarget.value, value));
|
|
271
|
+
}
|
|
272
|
+
this.closeNote();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private saveCustom(): void {
|
|
276
|
+
const question = this.currentQuestion();
|
|
277
|
+
if (!question) return;
|
|
278
|
+
this.setState(saveCustomAnswer(this.getState(), question, this.customEditor.getText()));
|
|
279
|
+
this.closeCustom();
|
|
280
|
+
this.advance();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private closeNote(): void {
|
|
284
|
+
this.mode = "navigate";
|
|
285
|
+
this.noteTarget = undefined;
|
|
286
|
+
this.noteEditor.setText("");
|
|
287
|
+
this.focused = this._focused;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private closeCustom(): void {
|
|
291
|
+
this.mode = "navigate";
|
|
292
|
+
this.customQuestionId = undefined;
|
|
293
|
+
this.customEditor.setText("");
|
|
294
|
+
this.focused = this._focused;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private currentQuestion(): NormalizedQuestion | undefined {
|
|
298
|
+
return this.getState().questions.find((question) => question.id === this.activeQuestionId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private isCustomActive(question: NormalizedQuestion): boolean {
|
|
302
|
+
return this.isCustomIndex(question, this.getState().activeOption);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private isCustomIndex(question: NormalizedQuestion, index: number): boolean {
|
|
306
|
+
return hasCustom(question) && index === question.options.length;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isPrintable(data: string): boolean {
|
|
311
|
+
return (
|
|
312
|
+
data.length > 0 &&
|
|
313
|
+
![...data].some((char) => {
|
|
314
|
+
const code = char.charCodeAt(0);
|
|
315
|
+
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function clean(value: string): string {
|
|
321
|
+
return value.trim();
|
|
322
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
|
|
5
|
+
import { normalizeParams, type QnaParams, type QnaResult } from "./model.ts";
|
|
6
|
+
import { runQnaUi } from "./ui.ts";
|
|
7
|
+
|
|
8
|
+
const TOOL_ASK_QUESTION = "ask_question";
|
|
9
|
+
|
|
10
|
+
const optionSchema = Type.Object({
|
|
11
|
+
value: Type.String({ description: "Stable option value returned in result" }),
|
|
12
|
+
label: Type.String({ description: "Short user-facing option label" }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const recommendationSchema = Type.Object({
|
|
16
|
+
values: Type.Array(Type.String(), { description: "Recommended option values, chosen after options are written" }),
|
|
17
|
+
reason: Type.String({ description: "Honest tradeoff-based justification for recommendation" }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const askQuestionParamsSchema = Type.Object({
|
|
21
|
+
title: Type.Optional(Type.String({ description: "Short title for the question panel" })),
|
|
22
|
+
questions: Type.Array(
|
|
23
|
+
Type.Object({
|
|
24
|
+
id: Type.String({ description: "Unique question id" }),
|
|
25
|
+
prompt: Type.String({ description: "Question shown to the user" }),
|
|
26
|
+
kind: StringEnum(["select", "multi", "input", "confirm"] as const, {
|
|
27
|
+
description: "select: one choice; multi: several choices; input: typed answer; confirm: yes/no",
|
|
28
|
+
}),
|
|
29
|
+
options: Type.Optional(
|
|
30
|
+
Type.Array(optionSchema, { description: "Required for select/multi, forbidden for input/confirm" }),
|
|
31
|
+
),
|
|
32
|
+
recommendation: Type.Optional(recommendationSchema),
|
|
33
|
+
}),
|
|
34
|
+
{ description: "Focused questions. Prefer 1-3." },
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const QNA_PROMPT = `Use ask_question to re-ask the question you just asked the user.
|
|
39
|
+
|
|
40
|
+
Do not blindly copy your previous wording or options. Reframe the question if needed so it follows ask_question quality rules:
|
|
41
|
+
- options must be real, valid, defensible choices
|
|
42
|
+
- do not include filler, strawmen, joke options, bad decoys, or preferred answer plus trash
|
|
43
|
+
- use as many options as the real decision space needs: 2 is fine, 10 is fine, 3 is not special
|
|
44
|
+
- if your previous options were weak, replace them
|
|
45
|
+
- ground the reframed question in existing context; inspect repo or docs first only when current context is not enough
|
|
46
|
+
- use select for one choice, multi for combinable choices, confirm for yes/no, input when choices would be fake
|
|
47
|
+
- include recommendation only when you have a real one, with honest tradeoff reason
|
|
48
|
+
- do not include a catch-all additional-context question; the UI always provides a final optional Additional Context tab
|
|
49
|
+
- do not answer the question yourself
|
|
50
|
+
- if there is no question to re-ask, say so without using tools`;
|
|
51
|
+
|
|
52
|
+
function buildQnaPrompt(context: string): string {
|
|
53
|
+
const trimmed = context.trim();
|
|
54
|
+
if (!trimmed) return QNA_PROMPT;
|
|
55
|
+
return `${QNA_PROMPT}
|
|
56
|
+
|
|
57
|
+
Additional user context for framing the question:
|
|
58
|
+
${trimmed}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createAskQuestionTool(pi: ExtensionAPI) {
|
|
62
|
+
return defineTool<typeof askQuestionParamsSchema, QnaResult>({
|
|
63
|
+
name: "ask_question",
|
|
64
|
+
label: "Ask Question",
|
|
65
|
+
description:
|
|
66
|
+
"Ask user structured question only when missing intent, preference, or constraint blocks progress. Supports select, multi-select, yes/no, and free-form input. Choices must be real, valid, non-filler. Selectable questions require recommendation values plus honest reason after options. Do not use for routine chat, obvious choices, or avoidable analysis.",
|
|
67
|
+
promptSnippet:
|
|
68
|
+
"Ask structured questions only when a real user decision blocks progress; every choice must be valid, defensible, non-filler.",
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
"Use ask_question only when missing user intent, preference, or constraint would materially change next action.",
|
|
71
|
+
"Do not use ask_question for routine chat, status updates, obvious decisions, or questions answerable from files/instructions.",
|
|
72
|
+
"If one path is clearly correct, do not use ask_question. Proceed and state assumption briefly.",
|
|
73
|
+
"When using ask_question choices, every option must be real, valid, and defensible. No filler, strawmen, joke options, bad decoys, or preferred answer plus junk.",
|
|
74
|
+
"When using ask_question choices, cover realistic decision space. Custom answer is safety valve, not excuse for weak options.",
|
|
75
|
+
"For ask_question select, multi-select, confirm, and recommended input: write options or suggested answer first, then recommendation values, then recommendation reason.",
|
|
76
|
+
"ask_question recommendation reason must explain tradeoff honestly. Do not manipulate user toward fake-obvious answer.",
|
|
77
|
+
"Use ask_question multi-select only when combining options is valid. Use select for one path. Use confirm for yes/no. Use input when choices would be fake; include an input recommendation only when you have a real suggested answer.",
|
|
78
|
+
"Ask fewest questions that unblock work. Prefer 1-3 focused questions. No surveys.",
|
|
79
|
+
"Do not add catch-all or additional-context questions; the UI always provides a final optional Additional Context tab.",
|
|
80
|
+
],
|
|
81
|
+
parameters: askQuestionParamsSchema,
|
|
82
|
+
executionMode: "sequential",
|
|
83
|
+
|
|
84
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
85
|
+
const castParams = params as QnaParams;
|
|
86
|
+
const questions = normalizeParams(castParams);
|
|
87
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
88
|
+
ctx.abort();
|
|
89
|
+
throw new Error("ask_question aborted: interactive UI unavailable");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ctx.ui.setWorkingVisible(false);
|
|
93
|
+
try {
|
|
94
|
+
emitAgentBlocked(pi, {
|
|
95
|
+
title: castParams.title || "Tau",
|
|
96
|
+
body: "Waiting for your answer",
|
|
97
|
+
source: "qna.ask_question",
|
|
98
|
+
});
|
|
99
|
+
const result = await ctx.ui.custom<QnaResult | undefined>((tui, theme, _keybindings, done) =>
|
|
100
|
+
runQnaUi(tui, theme, castParams.title, questions, done),
|
|
101
|
+
);
|
|
102
|
+
if (!result) {
|
|
103
|
+
ctx.abort();
|
|
104
|
+
throw new Error("ask_question aborted by user");
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: formatResult(result) }],
|
|
108
|
+
details: result,
|
|
109
|
+
};
|
|
110
|
+
} finally {
|
|
111
|
+
ctx.ui.setWorkingVisible(true);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
renderCall(args, theme) {
|
|
116
|
+
const params = args as QnaParams;
|
|
117
|
+
const count = Array.isArray(params.questions) ? params.questions.length : 0;
|
|
118
|
+
return new Text(
|
|
119
|
+
`${theme.fg("toolTitle", theme.bold("ask_question"))} ${theme.fg("muted", params.title || `${count} question${count === 1 ? "" : "s"}`)}`,
|
|
120
|
+
0,
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
renderResult(result, _options, theme) {
|
|
126
|
+
const details = result.details;
|
|
127
|
+
if (!details) return new Text(theme.fg("warning", "ask_question returned no details"), 0, 0);
|
|
128
|
+
return new Text(
|
|
129
|
+
formatResult(details, (text) => theme.bold(text)),
|
|
130
|
+
0,
|
|
131
|
+
0,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default function qnaExtension(pi: ExtensionAPI): void {
|
|
138
|
+
let qnaActive = false;
|
|
139
|
+
|
|
140
|
+
function syncQnaTools(): void {
|
|
141
|
+
const active = new Set(pi.getActiveTools());
|
|
142
|
+
if (qnaActive) active.add(TOOL_ASK_QUESTION);
|
|
143
|
+
else active.delete(TOOL_ASK_QUESTION);
|
|
144
|
+
pi.setActiveTools([...active]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pi.registerTool(createAskQuestionTool(pi));
|
|
148
|
+
|
|
149
|
+
pi.on("session_start", () => {
|
|
150
|
+
qnaActive = false;
|
|
151
|
+
syncQnaTools();
|
|
152
|
+
});
|
|
153
|
+
pi.on("tool_result", (event) => {
|
|
154
|
+
if (event.toolName !== TOOL_ASK_QUESTION) return;
|
|
155
|
+
qnaActive = false;
|
|
156
|
+
syncQnaTools();
|
|
157
|
+
});
|
|
158
|
+
pi.on("agent_end", () => {
|
|
159
|
+
qnaActive = false;
|
|
160
|
+
syncQnaTools();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerCommand("qna", {
|
|
164
|
+
description:
|
|
165
|
+
"Ask the agent to re-ask its last question with structured choices. Optional text is framing context.",
|
|
166
|
+
handler: async (args, ctx) => {
|
|
167
|
+
if (!ctx.isIdle()) {
|
|
168
|
+
ctx.ui.notify("Agent is busy", "warning");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
qnaActive = true;
|
|
173
|
+
syncQnaTools();
|
|
174
|
+
pi.sendMessage(
|
|
175
|
+
{ customType: "tau.qna", content: buildQnaPrompt(args), display: false },
|
|
176
|
+
{ triggerTurn: true },
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatResult(result: QnaResult, formatLabel: (text: string) => string = (text) => text): string {
|
|
183
|
+
const sections = [
|
|
184
|
+
Object.values(result.answers)
|
|
185
|
+
.map((answer, index) => formatAnswer(answer, index, formatLabel))
|
|
186
|
+
.join("\n\n"),
|
|
187
|
+
];
|
|
188
|
+
const additionalContext = result.additionalContext?.trim();
|
|
189
|
+
if (additionalContext) sections.push(`${formatLabel("Additional context:")}\n${additionalContext}`);
|
|
190
|
+
return `\n${sections.join("\n\n")}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatAnswer(
|
|
194
|
+
answer: QnaResult["answers"][string],
|
|
195
|
+
index: number,
|
|
196
|
+
formatLabel: (text: string) => string,
|
|
197
|
+
): string {
|
|
198
|
+
if (answer.kind !== "multi") {
|
|
199
|
+
return [
|
|
200
|
+
`${index + 1}. ${formatLabel(answer.prompt)}`,
|
|
201
|
+
...formatRecommendation(answer, formatLabel),
|
|
202
|
+
formatSingleAnswer(answer, formatLabel),
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return [
|
|
207
|
+
`${index + 1}. ${formatLabel(answer.prompt)}`,
|
|
208
|
+
...formatRecommendation(answer, formatLabel),
|
|
209
|
+
` ${formatLabel("Answer:")}`,
|
|
210
|
+
formatMultiAnswer(answer),
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatRecommendation(answer: QnaResult["answers"][string], formatLabel: (text: string) => string): string[] {
|
|
215
|
+
if (!answer.recommendation) return [];
|
|
216
|
+
const recommendation = answer.recommendation.labels;
|
|
217
|
+
return [
|
|
218
|
+
...(recommendation.length === 1
|
|
219
|
+
? [` ${formatLabel("Recommendation:")} ${recommendation[0]}`]
|
|
220
|
+
: [` ${formatLabel("Recommendation:")}`, ...recommendation.map((label) => ` - ${label}`)]),
|
|
221
|
+
` ${formatLabel("Reason:")} ${answer.recommendation.reason}`,
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatSingleAnswer(answer: QnaResult["answers"][string], formatLabel: (text: string) => string): string {
|
|
226
|
+
const value = answer.kind === "input" ? answer.input : answer.labels[0];
|
|
227
|
+
const answerLine = ` ${formatLabel("Answer:")} ${value || "_Skipped_"}`;
|
|
228
|
+
return [answerLine, ...formatNotes(answer, " ", answer.values[0])].join("\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function formatMultiAnswer(answer: QnaResult["answers"][string]): string {
|
|
232
|
+
if (answer.labels.length === 0) return " _Skipped_";
|
|
233
|
+
return answer.labels
|
|
234
|
+
.map((label, index) => [` - ${label}`, ...formatNotes(answer, " ", answer.values[index])].join("\n"))
|
|
235
|
+
.join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatNotes(answer: QnaResult["answers"][string], indent: string, value?: string): string[] {
|
|
239
|
+
const notes = value
|
|
240
|
+
? answer.optionNotes?.[value]
|
|
241
|
+
? [answer.optionNotes[value]]
|
|
242
|
+
: []
|
|
243
|
+
: Object.values(answer.optionNotes ?? {});
|
|
244
|
+
return notes.flatMap((note) => note.split("\n").map((line) => `${indent}└─ ${line}`));
|
|
245
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Editor, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export function renderInlineEditor(prefix: string, editor: Editor, width: number): string[] {
|
|
5
|
+
const available = Math.max(1, width - visibleWidth(prefix));
|
|
6
|
+
const lines = editorContentLines(editor.render(available));
|
|
7
|
+
const contentLines = lines.length ? lines : [""];
|
|
8
|
+
const continuation = " ".repeat(visibleWidth(prefix));
|
|
9
|
+
return contentLines.map((line, index) =>
|
|
10
|
+
truncateToWidth(`${index === 0 ? prefix : continuation}${line}`, width, ""),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderNoteEditor(lines: string[], editor: Editor, width: number, theme: Theme, indent: string): void {
|
|
15
|
+
const prefix = `${indent}${theme.fg("muted", "└─ ")}`;
|
|
16
|
+
const available = Math.max(1, width - visibleWidth(prefix));
|
|
17
|
+
const editorLines = editorContentLines(editor.render(available));
|
|
18
|
+
const contentLines = editorLines.length ? editorLines : [""];
|
|
19
|
+
lines.push(truncateToWidth(`${prefix}${contentLines[0] ?? ""}`, width, ""));
|
|
20
|
+
const continuation = " ".repeat(visibleWidth(prefix));
|
|
21
|
+
for (const line of contentLines.slice(1)) lines.push(truncateToWidth(`${continuation}${line}`, width, ""));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pushSavedNote(lines: string[], note: string, width: number, theme: Theme, indent: string): void {
|
|
25
|
+
for (const line of wrapWithPrefix(`${indent}${theme.fg("muted", "└─ ")}`, theme.fg("muted", note), width)) {
|
|
26
|
+
lines.push(line);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function wrapWithPrefix(prefix: string, text: string, width: number): string[] {
|
|
31
|
+
const restWidth = Math.max(1, width - visibleWidth(prefix));
|
|
32
|
+
return wrapTextWithAnsi(text, restWidth).map(
|
|
33
|
+
(line, index) => `${index === 0 ? prefix : " ".repeat(visibleWidth(prefix))}${line}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function editorContentLines(lines: string[]): string[] {
|
|
38
|
+
if (lines.length <= 2) return lines;
|
|
39
|
+
return lines.slice(1).filter((line) => !isEditorBorderLine(line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isEditorBorderLine(line: string): boolean {
|
|
43
|
+
const text = stripAnsi(line).trim();
|
|
44
|
+
return /^[┌┐└┘─]+$/.test(text) || /^─── [↑↓] \d+ more ─*$/.test(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripAnsi(text: string): string {
|
|
48
|
+
let result = text;
|
|
49
|
+
while (true) {
|
|
50
|
+
const start = result.indexOf("\u001b[");
|
|
51
|
+
if (start === -1) return result;
|
|
52
|
+
const end = result.indexOf("m", start + 2);
|
|
53
|
+
if (end === -1) return result;
|
|
54
|
+
result = result.slice(0, start) + result.slice(end + 1);
|
|
55
|
+
}
|
|
56
|
+
}
|