@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.
Files changed (138) hide show
  1. package/README.md +30 -0
  2. package/docs/extending-tau-agent.md +119 -0
  3. package/docs/subagents.md +57 -0
  4. package/docs/tui.md +107 -0
  5. package/extensions/appshot/README.md +11 -0
  6. package/extensions/appshot/capture.swift +191 -0
  7. package/extensions/appshot/index.ts +245 -0
  8. package/extensions/appshot/native-helper.ts +102 -0
  9. package/extensions/attention/README.md +23 -0
  10. package/extensions/attention/index.ts +71 -0
  11. package/extensions/auto-name/README.md +26 -0
  12. package/extensions/auto-name/index.ts +163 -0
  13. package/extensions/branch/README.md +22 -0
  14. package/extensions/branch/index.ts +145 -0
  15. package/extensions/branch/panel.ts +148 -0
  16. package/extensions/clear-screen/README.md +29 -0
  17. package/extensions/clear-screen/index.ts +75 -0
  18. package/extensions/commit/README.md +15 -0
  19. package/extensions/commit/commit-plan.ts +328 -0
  20. package/extensions/commit/git-change-set.ts +268 -0
  21. package/extensions/commit/index.ts +138 -0
  22. package/extensions/commit/review-ui.ts +745 -0
  23. package/extensions/explore/README.md +7 -0
  24. package/extensions/explore/autoread.ts +140 -0
  25. package/extensions/explore/find.ts +147 -0
  26. package/extensions/explore/grep.ts +749 -0
  27. package/extensions/explore/index.ts +17 -0
  28. package/extensions/explore/limits.ts +22 -0
  29. package/extensions/explore/ls.ts +113 -0
  30. package/extensions/explore/path-display.ts +41 -0
  31. package/extensions/explore/path-tree.ts +150 -0
  32. package/extensions/explore/read.ts +160 -0
  33. package/extensions/explore/result.ts +29 -0
  34. package/extensions/explore/traverse.ts +226 -0
  35. package/extensions/footer/README.md +23 -0
  36. package/extensions/footer/index.ts +534 -0
  37. package/extensions/footer/settings.ts +13 -0
  38. package/extensions/ideas/README.md +10 -0
  39. package/extensions/ideas/browser.ts +66 -0
  40. package/extensions/ideas/index.ts +31 -0
  41. package/extensions/ideas/store.ts +32 -0
  42. package/extensions/image-gen/README.md +11 -0
  43. package/extensions/image-gen/client.ts +186 -0
  44. package/extensions/image-gen/index.ts +152 -0
  45. package/extensions/manage-sessions/README.md +12 -0
  46. package/extensions/manage-sessions/index.ts +58 -0
  47. package/extensions/manage-sessions/manager-ui.ts +323 -0
  48. package/extensions/manage-sessions/sessions.ts +126 -0
  49. package/extensions/patch/README.md +48 -0
  50. package/extensions/patch/executor.ts +355 -0
  51. package/extensions/patch/index.ts +170 -0
  52. package/extensions/patch/matcher.ts +236 -0
  53. package/extensions/patch/parser.ts +347 -0
  54. package/extensions/patch/render.ts +247 -0
  55. package/extensions/patch/summary.ts +35 -0
  56. package/extensions/publish/README.md +11 -0
  57. package/extensions/publish/index.ts +225 -0
  58. package/extensions/qna/README.md +45 -0
  59. package/extensions/qna/additional-context-body.ts +51 -0
  60. package/extensions/qna/body-render.ts +6 -0
  61. package/extensions/qna/choice-question-body.ts +322 -0
  62. package/extensions/qna/index.ts +245 -0
  63. package/extensions/qna/inline-editor-row.ts +56 -0
  64. package/extensions/qna/input-question-body.ts +89 -0
  65. package/extensions/qna/model.ts +317 -0
  66. package/extensions/qna/panel.ts +240 -0
  67. package/extensions/qna/ui.ts +16 -0
  68. package/extensions/reference/README.md +47 -0
  69. package/extensions/reference/index.ts +76 -0
  70. package/extensions/reference/panel.ts +874 -0
  71. package/extensions/reference/settings.ts +29 -0
  72. package/extensions/run-summary/README.md +5 -0
  73. package/extensions/run-summary/index.ts +98 -0
  74. package/extensions/silent-command-runner/README.md +25 -0
  75. package/extensions/silent-command-runner/index.ts +468 -0
  76. package/extensions/silent-command-runner/settings.ts +65 -0
  77. package/extensions/soul/README.md +19 -0
  78. package/extensions/soul/index.ts +20 -0
  79. package/extensions/soul/prompt.ts +254 -0
  80. package/extensions/soul/settings.ts +13 -0
  81. package/extensions/stash/README.md +48 -0
  82. package/extensions/stash/browser.ts +51 -0
  83. package/extensions/stash/index.ts +55 -0
  84. package/extensions/stash/store.ts +36 -0
  85. package/extensions/subagent/README.md +34 -0
  86. package/extensions/subagent/agents/scout.md +85 -0
  87. package/extensions/subagent/agents/web-research.md +95 -0
  88. package/extensions/subagent/agents.ts +196 -0
  89. package/extensions/subagent/index.ts +214 -0
  90. package/extensions/subagent/render.ts +89 -0
  91. package/extensions/subagent/run.ts +325 -0
  92. package/extensions/tau/README.md +17 -0
  93. package/extensions/tau/index.ts +122 -0
  94. package/extensions/tau-help/README.md +3 -0
  95. package/extensions/tau-help/help.md +125 -0
  96. package/extensions/tau-help/index.ts +68 -0
  97. package/extensions/turn-budget/README.md +14 -0
  98. package/extensions/turn-budget/index.ts +150 -0
  99. package/extensions/turn-budget/settings.ts +35 -0
  100. package/extensions/web/README.md +13 -0
  101. package/extensions/web/codesearch.ts +79 -0
  102. package/extensions/web/exa.ts +101 -0
  103. package/extensions/web/html.ts +67 -0
  104. package/extensions/web/index.ts +13 -0
  105. package/extensions/web/limits.ts +11 -0
  106. package/extensions/web/tool-output.ts +54 -0
  107. package/extensions/web/webfetch.ts +177 -0
  108. package/extensions/web/websearch.ts +100 -0
  109. package/package.json +57 -0
  110. package/prompts/.gitkeep +1 -0
  111. package/prompts/cavemanify.md +25 -0
  112. package/prompts/implement.md +25 -0
  113. package/prompts/interview.md +48 -0
  114. package/prompts/plan-feature.md +30 -0
  115. package/prompts/plan-implementation.md +50 -0
  116. package/schemas/tau.schema.json +205 -0
  117. package/shared/agent-blocked.ts +12 -0
  118. package/shared/description.ts +28 -0
  119. package/shared/events.ts +135 -0
  120. package/shared/git.ts +41 -0
  121. package/shared/injected-context.ts +69 -0
  122. package/shared/jsonl-store.ts +108 -0
  123. package/shared/model-fallback/index.ts +278 -0
  124. package/shared/model-fallback/settings.ts +18 -0
  125. package/shared/model-fallback/types.ts +8 -0
  126. package/shared/ranges.ts +21 -0
  127. package/shared/settings/define.ts +15 -0
  128. package/shared/settings/files.ts +20 -0
  129. package/shared/settings/json.ts +41 -0
  130. package/shared/settings/load.ts +59 -0
  131. package/shared/settings/merge.ts +21 -0
  132. package/shared/settings/paths.ts +41 -0
  133. package/shared/settings/schema.ts +25 -0
  134. package/shared/settings/specs.ts +23 -0
  135. package/shared/text.ts +11 -0
  136. package/shared/tool-row-state.ts +43 -0
  137. package/skills/writing-preferences/SKILL.md +136 -0
  138. package/themes/.gitkeep +1 -0
@@ -0,0 +1,89 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Focusable, Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { renderQuestionPrompt } from "./body-render.ts";
4
+ import { getAnswer, type NormalizedQuestion, type QnaState, saveInputAnswer } from "./model.ts";
5
+
6
+ export class InputQuestionBody implements Focusable {
7
+ private readonly theme: Theme;
8
+ private readonly getState: () => QnaState;
9
+ private readonly setState: (state: QnaState) => void;
10
+ private readonly advance: () => void;
11
+ private readonly inputs = new Map<string, Input>();
12
+ private activeQuestionId: string | undefined;
13
+ private _focused = false;
14
+
15
+ constructor(theme: Theme, getState: () => QnaState, setState: (state: QnaState) => void, advance: () => void) {
16
+ this.theme = theme;
17
+ this.getState = getState;
18
+ this.setState = setState;
19
+ this.advance = advance;
20
+ }
21
+
22
+ get focused(): boolean {
23
+ return this._focused;
24
+ }
25
+
26
+ set focused(value: boolean) {
27
+ this._focused = value;
28
+ for (const [id, input] of this.inputs) input.focused = value && id === this.activeQuestionId;
29
+ }
30
+
31
+ forQuestion(question: NormalizedQuestion): this {
32
+ this.activeQuestionId = question.id;
33
+ this.focused = this._focused;
34
+ return this;
35
+ }
36
+
37
+ handleInput(data: string): void {
38
+ const question = this.currentQuestion();
39
+ if (!question) return;
40
+ const input = this.getQuestionInput(question);
41
+ if (matchesKey(data, Key.enter)) {
42
+ this.setState(saveInputAnswer(this.getState(), question, input.getValue()));
43
+ this.advance();
44
+ return;
45
+ }
46
+ input.handleInput(data);
47
+ this.setState(saveInputAnswer(this.getState(), question, input.getValue()));
48
+ }
49
+
50
+ render(question: NormalizedQuestion, width: number): string[] {
51
+ this.activeQuestionId = question.id;
52
+ this.focused = this._focused;
53
+ const lines = renderQuestionPrompt(question, width);
54
+ lines.push(...renderRecommendation(question, width, this.theme));
55
+ if (question.recommendation) lines.push("");
56
+ lines.push(
57
+ ...this.getQuestionInput(question)
58
+ .render(width)
59
+ .map((line) => truncateToWidth(line, width, "")),
60
+ );
61
+ return lines;
62
+ }
63
+
64
+ private currentQuestion(): NormalizedQuestion | undefined {
65
+ return this.getState().questions.find((question) => question.id === this.activeQuestionId);
66
+ }
67
+
68
+ private getQuestionInput(question: NormalizedQuestion): Input {
69
+ const existing = this.inputs.get(question.id);
70
+ if (existing) return existing;
71
+ const input = new Input();
72
+ const value = getAnswer(this.getState(), question.id).input;
73
+ if (value) input.handleInput(value);
74
+ input.focused = this._focused && this.activeQuestionId === question.id;
75
+ this.inputs.set(question.id, input);
76
+ return input;
77
+ }
78
+ }
79
+
80
+ export function renderRecommendation(question: NormalizedQuestion, width: number, theme: Theme): string[] {
81
+ if (!question.recommendation) return [];
82
+ const labels = question.recommendation.values.map(
83
+ (value) => question.options.find((option) => option.value === value)?.label ?? value,
84
+ );
85
+ return [
86
+ ...wrapTextWithAnsi(`${theme.fg("muted", theme.bold("Recommendation: "))}${labels.join(", ")}`, width),
87
+ ...wrapTextWithAnsi(theme.fg("muted", `${theme.bold("Reason: ")}${question.recommendation.reason}`), width),
88
+ ];
89
+ }
@@ -0,0 +1,317 @@
1
+ export type QuestionKind = "select" | "multi" | "input" | "confirm";
2
+
3
+ export interface QnaParams {
4
+ title?: string;
5
+ questions: QnaQuestionParam[];
6
+ }
7
+
8
+ export interface QnaQuestionParam {
9
+ id: string;
10
+ prompt: string;
11
+ kind: QuestionKind;
12
+ options?: QnaOption[];
13
+ recommendation?: QnaRecommendation;
14
+ }
15
+
16
+ export interface QnaOption {
17
+ value: string;
18
+ label: string;
19
+ }
20
+
21
+ export interface QnaRecommendation {
22
+ values: string[];
23
+ reason: string;
24
+ }
25
+
26
+ export interface NormalizedQuestion {
27
+ id: string;
28
+ label: string;
29
+ prompt: string;
30
+ kind: QuestionKind;
31
+ options: QnaOption[];
32
+ recommendation?: QnaRecommendation;
33
+ }
34
+
35
+ export interface AnswerState {
36
+ selected: string[];
37
+ custom?: string;
38
+ input?: string;
39
+ notes: Record<string, string>;
40
+ }
41
+
42
+ export interface QnaState {
43
+ title?: string;
44
+ questions: NormalizedQuestion[];
45
+ activeTab: number;
46
+ activeOption: number;
47
+ answers: Record<string, AnswerState>;
48
+ additionalContext: string;
49
+ }
50
+
51
+ export interface QnaAnswerResult {
52
+ kind: QuestionKind;
53
+ prompt: string;
54
+ values: string[];
55
+ labels: string[];
56
+ custom?: string;
57
+ input?: string;
58
+ optionNotes?: Record<string, string>;
59
+ recommendation?: QnaRecommendation & { accepted: boolean; labels: string[] };
60
+ }
61
+
62
+ export interface QnaResult {
63
+ answers: Record<string, QnaAnswerResult>;
64
+ additionalContext?: string;
65
+ }
66
+
67
+ const confirmOptions: QnaOption[] = [
68
+ { value: "yes", label: "Yes" },
69
+ { value: "no", label: "No" },
70
+ ];
71
+
72
+ export function normalizeParams(params: QnaParams): NormalizedQuestion[] {
73
+ if (!Array.isArray(params.questions) || params.questions.length === 0)
74
+ throw new Error("ask_question requires questions");
75
+
76
+ const ids = new Set<string>();
77
+ return params.questions.map((question, index) => {
78
+ const id = clean(question.id);
79
+ if (!id) throw new Error(`ask_question question ${index + 1} needs id`);
80
+ if (ids.has(id)) throw new Error(`ask_question duplicate question id: ${id}`);
81
+ ids.add(id);
82
+
83
+ const prompt = clean(question.prompt);
84
+ if (!prompt) throw new Error(`ask_question question ${id} needs prompt`);
85
+
86
+ const kind = question.kind;
87
+ if (!isQuestionKind(kind)) throw new Error(`ask_question question ${id} has invalid kind`);
88
+
89
+ const options = normalizeOptions(id, kind, question.options);
90
+ const recommendation = normalizeRecommendation(id, kind, options, question.recommendation);
91
+
92
+ return {
93
+ id,
94
+ label: labelFromId(id, index),
95
+ prompt,
96
+ kind,
97
+ options,
98
+ recommendation,
99
+ };
100
+ });
101
+ }
102
+
103
+ export function createState(title: string | undefined, questions: NormalizedQuestion[]): QnaState {
104
+ return {
105
+ title: clean(title),
106
+ questions,
107
+ activeTab: 0,
108
+ activeOption: 0,
109
+ answers: Object.fromEntries(questions.map((question) => [question.id, { selected: [], notes: {} }])),
110
+ additionalContext: "",
111
+ };
112
+ }
113
+
114
+ export function activeQuestion(state: QnaState): NormalizedQuestion | undefined {
115
+ return state.questions[state.activeTab];
116
+ }
117
+
118
+ export function optionCount(question: NormalizedQuestion): number {
119
+ return question.options.length + (hasCustom(question) ? 1 : 0);
120
+ }
121
+
122
+ export function hasCustom(question: NormalizedQuestion): boolean {
123
+ return question.kind === "select" || question.kind === "multi";
124
+ }
125
+
126
+ export function moveTab(state: QnaState, direction: 1 | -1): QnaState {
127
+ const tabCount = state.questions.length + 1;
128
+ return { ...state, activeTab: (state.activeTab + direction + tabCount) % tabCount, activeOption: 0 };
129
+ }
130
+
131
+ export function moveOption(state: QnaState, direction: 1 | -1): QnaState {
132
+ const question = activeQuestion(state);
133
+ if (!question) return state;
134
+ const count = optionCount(question);
135
+ if (count === 0) return state;
136
+ return { ...state, activeOption: (state.activeOption + direction + count) % count };
137
+ }
138
+
139
+ export function toggleOrSelectOption(state: QnaState, question: NormalizedQuestion, value: string): QnaState {
140
+ const answer = getAnswer(state, question.id);
141
+ const selected = new Set(answer.selected);
142
+ if (question.kind === "multi") {
143
+ if (selected.has(value)) selected.delete(value);
144
+ else selected.add(value);
145
+ return putAnswer(state, question.id, { ...answer, selected: [...selected] });
146
+ }
147
+ return putAnswer(state, question.id, { ...answer, selected: [value], custom: undefined });
148
+ }
149
+
150
+ export function saveCustomAnswer(state: QnaState, question: NormalizedQuestion, value: string): QnaState {
151
+ const trimmed = clean(value);
152
+ const answer = getAnswer(state, question.id);
153
+ if (!trimmed) return putAnswer(state, question.id, { ...answer, custom: undefined });
154
+ const selected = question.kind === "select" ? [] : answer.selected;
155
+ return putAnswer(state, question.id, { ...answer, selected, custom: trimmed });
156
+ }
157
+
158
+ export function saveInputAnswer(state: QnaState, question: NormalizedQuestion, value: string): QnaState {
159
+ return putAnswer(state, question.id, { ...getAnswer(state, question.id), input: clean(value) });
160
+ }
161
+
162
+ export function saveAdditionalContext(state: QnaState, value: string): QnaState {
163
+ return { ...state, additionalContext: clean(value) };
164
+ }
165
+
166
+ export function saveOptionNote(state: QnaState, question: NormalizedQuestion, value: string, note: string): QnaState {
167
+ const answer = getAnswer(state, question.id);
168
+ const notes = { ...answer.notes };
169
+ const trimmed = clean(note);
170
+ if (trimmed) notes[value] = trimmed;
171
+ else delete notes[value];
172
+ return putAnswer(state, question.id, { ...answer, notes });
173
+ }
174
+
175
+ export function isAnswered(state: QnaState, question: NormalizedQuestion): boolean {
176
+ const answer = getAnswer(state, question.id);
177
+ if (question.kind === "input") return Boolean(clean(answer.input));
178
+ if (question.kind === "select" || question.kind === "confirm")
179
+ return answer.selected.length === 1 || Boolean(answer.custom);
180
+ return answer.selected.length > 0 || Boolean(answer.custom);
181
+ }
182
+
183
+ export function buildResult(state: QnaState): QnaResult {
184
+ const answers = Object.fromEntries(state.questions.map((question) => [question.id, buildAnswer(state, question)]));
185
+ const additionalContext = clean(state.additionalContext);
186
+ return additionalContext ? { answers, additionalContext } : { answers };
187
+ }
188
+
189
+ export function getAnswer(state: QnaState, questionId: string): AnswerState {
190
+ return state.answers[questionId] ?? { selected: [], notes: {} };
191
+ }
192
+
193
+ function buildAnswer(state: QnaState, question: NormalizedQuestion): QnaAnswerResult {
194
+ const answer = getAnswer(state, question.id);
195
+ const selectedOptions = answer.selected.map((value) => optionByValue(question, value)).filter(isOption);
196
+ const optionNotes = Object.fromEntries(
197
+ selectedOptions.flatMap((option) => {
198
+ const note = answer.notes[option.value];
199
+ return note ? [[option.value, note]] : [];
200
+ }),
201
+ );
202
+ const values = selectedOptions.map((option) => option.value);
203
+ const labels = selectedOptions.map((option) => option.label);
204
+ if (answer.custom) {
205
+ values.push(answer.custom);
206
+ labels.push(answer.custom);
207
+ }
208
+ if (question.kind === "input" && answer.input) {
209
+ values.push(answer.input);
210
+ labels.push(answer.input);
211
+ }
212
+
213
+ return dropUndefined({
214
+ kind: question.kind,
215
+ prompt: question.prompt,
216
+ values,
217
+ labels,
218
+ custom: answer.custom,
219
+ input: question.kind === "input" ? answer.input : undefined,
220
+ optionNotes: Object.keys(optionNotes).length ? optionNotes : undefined,
221
+ recommendation: question.recommendation
222
+ ? {
223
+ ...question.recommendation,
224
+ accepted: sameValues(values, question.recommendation.values),
225
+ labels: question.recommendation.values.map((value) => optionByValue(question, value)?.label ?? value),
226
+ }
227
+ : undefined,
228
+ });
229
+ }
230
+
231
+ function putAnswer(state: QnaState, questionId: string, answer: AnswerState): QnaState {
232
+ return { ...state, answers: { ...state.answers, [questionId]: answer } };
233
+ }
234
+
235
+ function normalizeOptions(id: string, kind: QuestionKind, options: QnaOption[] | undefined): QnaOption[] {
236
+ if (kind === "confirm") {
237
+ if (options && options.length > 0) throw new Error(`ask_question confirm question ${id} cannot provide options`);
238
+ return confirmOptions;
239
+ }
240
+ if (kind === "input") {
241
+ if (options && options.length > 0) throw new Error(`ask_question input question ${id} cannot provide options`);
242
+ return [];
243
+ }
244
+ if (!options || options.length === 0) throw new Error(`ask_question ${kind} question ${id} needs options`);
245
+
246
+ const values = new Set<string>();
247
+ return options.map((option, index) => {
248
+ const value = clean(option.value);
249
+ if (!value) throw new Error(`ask_question question ${id} option ${index + 1} needs value`);
250
+ if (values.has(value)) throw new Error(`ask_question question ${id} duplicate option value: ${value}`);
251
+ values.add(value);
252
+ const label = clean(option.label);
253
+ if (!label) throw new Error(`ask_question question ${id} option ${value} needs label`);
254
+ return { value, label };
255
+ });
256
+ }
257
+
258
+ function normalizeRecommendation(
259
+ id: string,
260
+ kind: QuestionKind,
261
+ options: QnaOption[],
262
+ recommendation: QnaRecommendation | undefined,
263
+ ): QnaRecommendation | undefined {
264
+ if (!recommendation) {
265
+ if (kind === "input") return undefined;
266
+ throw new Error(`ask_question ${kind} question ${id} needs recommendation`);
267
+ }
268
+ const reason = clean(recommendation.reason);
269
+ if (!reason) throw new Error(`ask_question question ${id} recommendation needs reason`);
270
+ const values = recommendation.values.map(clean).filter(Boolean);
271
+ if (values.length === 0) throw new Error(`ask_question question ${id} recommendation needs values`);
272
+ if ((kind === "select" || kind === "confirm" || kind === "input") && values.length !== 1) {
273
+ throw new Error(`ask_question ${kind} question ${id} recommendation needs exactly one value`);
274
+ }
275
+ if (kind === "input") return { values, reason };
276
+ const valid = new Set(options.map((option) => option.value));
277
+ const unique = new Set<string>();
278
+ for (const value of values) {
279
+ if (!valid.has(value))
280
+ throw new Error(`ask_question question ${id} recommendation value not in options: ${value}`);
281
+ if (unique.has(value)) throw new Error(`ask_question question ${id} duplicate recommendation value: ${value}`);
282
+ unique.add(value);
283
+ }
284
+ return { values, reason };
285
+ }
286
+
287
+ function optionByValue(question: NormalizedQuestion, value: string): QnaOption | undefined {
288
+ return question.options.find((option) => option.value === value);
289
+ }
290
+
291
+ function isQuestionKind(value: string): value is QuestionKind {
292
+ return value === "select" || value === "multi" || value === "input" || value === "confirm";
293
+ }
294
+
295
+ function isOption(value: QnaOption | undefined): value is QnaOption {
296
+ return value !== undefined;
297
+ }
298
+
299
+ function sameValues(left: string[], right: string[]): boolean {
300
+ return left.length === right.length && left.every((value) => right.includes(value));
301
+ }
302
+
303
+ function labelFromId(id: string, index: number): string {
304
+ const label = id
305
+ .replace(/[-_]+/g, " ")
306
+ .replace(/\b\w/g, (letter) => letter.toUpperCase())
307
+ .trim();
308
+ return label || `Q${index + 1}`;
309
+ }
310
+
311
+ function clean(value: string | undefined): string {
312
+ return value?.trim() ?? "";
313
+ }
314
+
315
+ function dropUndefined<T extends Record<string, unknown>>(value: T): T {
316
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
317
+ }
@@ -0,0 +1,240 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type Focusable, Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
3
+ import { bindingHint, rawHint, type ToolKeyHint } from "@shanepadgett/tau-tui";
4
+ import { type TabItem, Tabs } from "@shanepadgett/tau-tui";
5
+ import { ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
6
+ import { AdditionalContextBody } from "./additional-context-body.ts";
7
+ import { ChoiceQuestionBody } from "./choice-question-body.ts";
8
+ import { InputQuestionBody } from "./input-question-body.ts";
9
+ import {
10
+ activeQuestion,
11
+ buildResult,
12
+ isAnswered,
13
+ moveTab,
14
+ type NormalizedQuestion,
15
+ type QnaResult,
16
+ type QnaState,
17
+ } from "./model.ts";
18
+
19
+ type QnaUiResult = QnaResult | undefined;
20
+
21
+ export class QnaPanel implements Component, Focusable {
22
+ private readonly tui: TUI;
23
+ private readonly done: (result: QnaUiResult) => void;
24
+ private readonly choiceBody: ChoiceQuestionBody;
25
+ private readonly inputBody: InputQuestionBody;
26
+ private readonly additionalContextBody: AdditionalContextBody;
27
+ private readonly tabs: Tabs;
28
+ private readonly panelConfig: ToolPanelConfig;
29
+ private readonly panel: ToolPanel;
30
+ private state: QnaState;
31
+ private _focused = false;
32
+
33
+ constructor(tui: TUI, theme: Theme, state: QnaState, done: (result: QnaUiResult) => void) {
34
+ this.tui = tui;
35
+ this.state = state;
36
+ this.done = done;
37
+ this.choiceBody = new ChoiceQuestionBody(
38
+ tui,
39
+ theme,
40
+ () => this.state,
41
+ (next) => this.setState(next),
42
+ () => this.advance(),
43
+ );
44
+ this.inputBody = new InputQuestionBody(
45
+ theme,
46
+ () => this.state,
47
+ (next) => this.setState(next),
48
+ () => this.advance(),
49
+ );
50
+ this.additionalContextBody = new AdditionalContextBody(
51
+ () => this.state,
52
+ (next) => this.setState(next),
53
+ () => this.submit(),
54
+ );
55
+ this.tabs = new Tabs(theme, this.tabItems(), this.activeTabId());
56
+ this.panelConfig = {
57
+ title: `qna: ${this.state.title || "Question"}`,
58
+ body: this.tabs,
59
+ footer: { kind: "hints", hints: this.footerHints() },
60
+ };
61
+ this.panel = new ToolPanel(theme, this.panelConfig);
62
+ this.syncFocus();
63
+ }
64
+
65
+ get focused(): boolean {
66
+ return this._focused;
67
+ }
68
+
69
+ set focused(value: boolean) {
70
+ this._focused = value;
71
+ this.syncFocus();
72
+ }
73
+
74
+ handleInput(data: string): void {
75
+ if (matchesKey(data, Key.ctrl("c"))) {
76
+ this.done(undefined);
77
+ return;
78
+ }
79
+
80
+ const active = this.activeBody();
81
+ if (this.choiceBody.isEditing()) {
82
+ this.choiceBody.handleInput(data);
83
+ this.refresh();
84
+ return;
85
+ }
86
+
87
+ if (matchesKey(data, Key.escape)) {
88
+ this.done(undefined);
89
+ return;
90
+ }
91
+ if (matchesKey(data, Key.alt("enter"))) {
92
+ this.submit();
93
+ return;
94
+ }
95
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
96
+ this.setState(moveTab(this.state, 1));
97
+ return;
98
+ }
99
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
100
+ this.setState(moveTab(this.state, -1));
101
+ return;
102
+ }
103
+
104
+ active.handleInput(data);
105
+ this.refresh();
106
+ }
107
+
108
+ render(width: number): string[] {
109
+ return this.panel.render(width);
110
+ }
111
+
112
+ invalidate(): void {
113
+ this.panel.invalidate();
114
+ }
115
+
116
+ private tabItems(): TabItem[] {
117
+ return [
118
+ ...this.state.questions.map((question, index) => ({
119
+ id: question.id,
120
+ label: `${question.label}${isAnswered(this.state, question) ? "•" : ""}`,
121
+ body: this.questionComponent(question, index),
122
+ })),
123
+ {
124
+ id: "additional-context",
125
+ label: `Additional Context${this.state.additionalContext ? "•" : ""}`,
126
+ body: this.additionalContextBody,
127
+ },
128
+ ];
129
+ }
130
+
131
+ private questionComponent(question: NormalizedQuestion, index: number): Component {
132
+ return {
133
+ render: (width) =>
134
+ question.kind === "input"
135
+ ? this.inputBody.render(question, width)
136
+ : this.choiceBody.render(question, width),
137
+ invalidate: () => {},
138
+ handleInput: (data) => {
139
+ this.state = { ...this.state, activeTab: index };
140
+ if (question.kind === "input") this.inputBody.forQuestion(question).handleInput(data);
141
+ else this.choiceBody.forQuestion(question).handleInput(data);
142
+ },
143
+ };
144
+ }
145
+
146
+ private activeBody(): { handleInput(data: string): void; getKeyHints: () => readonly ToolKeyHint[] } {
147
+ if (this.isAdditionalContextActive()) {
148
+ return {
149
+ handleInput: (data) => this.additionalContextBody.handleInput(data),
150
+ getKeyHints: () => this.additionalContextHints(),
151
+ };
152
+ }
153
+ const question = activeQuestion(this.state);
154
+ if (question?.kind === "input") {
155
+ this.inputBody.forQuestion(question);
156
+ return {
157
+ handleInput: (data) => this.inputBody.handleInput(data),
158
+ getKeyHints: () => this.inputHints(),
159
+ };
160
+ }
161
+ if (question) {
162
+ this.choiceBody.forQuestion(question);
163
+ return {
164
+ handleInput: (data) => this.choiceBody.handleInput(data),
165
+ getKeyHints: () => this.choiceHints(),
166
+ };
167
+ }
168
+ return {
169
+ handleInput: (data) => this.additionalContextBody.handleInput(data),
170
+ getKeyHints: () => this.additionalContextHints(),
171
+ };
172
+ }
173
+
174
+ private setState(state: QnaState): void {
175
+ this.state = state;
176
+ this.choiceBody.closeInactiveEditors();
177
+ this.tabs.setTabs(this.tabItems());
178
+ this.tabs.setActiveId(this.activeTabId());
179
+ this.syncFocus();
180
+ this.refresh();
181
+ }
182
+
183
+ private advance(): void {
184
+ if (this.state.activeTab < this.state.questions.length) {
185
+ this.setState(moveTab(this.state, 1));
186
+ return;
187
+ }
188
+ this.submit();
189
+ }
190
+
191
+ private submit(): void {
192
+ this.done(buildResult(this.state));
193
+ }
194
+
195
+ private syncFocus(): void {
196
+ this.choiceBody.focused = false;
197
+ this.inputBody.focused = false;
198
+ this.additionalContextBody.focused = false;
199
+ if (!this._focused) return;
200
+ if (this.isAdditionalContextActive()) this.additionalContextBody.focused = true;
201
+ else if (activeQuestion(this.state)?.kind === "input") this.inputBody.focused = true;
202
+ else this.choiceBody.focused = true;
203
+ }
204
+
205
+ private footerHints(): readonly ToolKeyHint[] {
206
+ return [
207
+ ...this.tabs.getKeyHints(),
208
+ ...this.activeBody().getKeyHints(),
209
+ rawHint("Alt+Enter", "submit"),
210
+ bindingHint("tui.select.cancel", "abort"),
211
+ ];
212
+ }
213
+
214
+ private inputHints(): readonly ToolKeyHint[] {
215
+ return [bindingHint("tui.input.submit", "next")];
216
+ }
217
+
218
+ private additionalContextHints(): readonly ToolKeyHint[] {
219
+ return [bindingHint("tui.input.submit", "submit")];
220
+ }
221
+
222
+ private choiceHints(): readonly ToolKeyHint[] {
223
+ return this.choiceBody.keyHints();
224
+ }
225
+
226
+ private refresh(): void {
227
+ this.panelConfig.footer = { kind: "hints", hints: this.footerHints() };
228
+ this.tui.requestRender();
229
+ }
230
+
231
+ private activeTabId(): string {
232
+ return this.isAdditionalContextActive()
233
+ ? "additional-context"
234
+ : (activeQuestion(this.state)?.id ?? "additional-context");
235
+ }
236
+
237
+ private isAdditionalContextActive(): boolean {
238
+ return this.state.activeTab === this.state.questions.length;
239
+ }
240
+ }
@@ -0,0 +1,16 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import { createState, type NormalizedQuestion, type QnaResult } from "./model.ts";
4
+ import { QnaPanel } from "./panel.ts";
5
+
6
+ type QnaUiResult = QnaResult | undefined;
7
+
8
+ export function runQnaUi(
9
+ tui: TUI,
10
+ theme: Theme,
11
+ title: string | undefined,
12
+ questions: NormalizedQuestion[],
13
+ done: (result: QnaUiResult) => void,
14
+ ): Component {
15
+ return new QnaPanel(tui, theme, createState(title, questions), done);
16
+ }
@@ -0,0 +1,47 @@
1
+ # Reference
2
+
3
+ Select local reference repositories, add new ones, and send a prompt that points Tau at them.
4
+
5
+ ## Usage
6
+
7
+ ```text
8
+ /reference
9
+ /reference new <git-url>
10
+ ```
11
+
12
+ ## Behavior
13
+
14
+ - Stores references in `~/.local/share/tau-agent/references/`.
15
+ - `/reference` opens a TUI picker.
16
+ - `n` adds a new reference from a git URL, showing clone progress in the list as `↓ 37%`.
17
+ - New references clone the remote default branch.
18
+ - `b` switches the highlighted reference to another branch.
19
+ - `u` runs `git pull --ff-only --quiet` in each reference without closing the picker, showing `…`, `✓`, or `!` next to each name.
20
+ - `o` opens the highlighted reference in an editor. Defaults to `$VISUAL`, `$EDITOR`, then `code`; settings can force `code` or `zed`.
21
+ - `d` deletes the selected references from disk (with confirm). Disabled with nothing selected.
22
+ - `f` filters references by name, branch, or path. Enter closes the filter; Esc clears/closes it.
23
+ - Space selects references; Enter opens an editor for the request.
24
+ - Sends the request with selected reference paths in the chat.
25
+
26
+ ## Settings
27
+
28
+ ```json
29
+ {
30
+ "extensions": {
31
+ "reference": {
32
+ "editor": "default",
33
+ "branchChoices": 5
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ `editor` can be `default`, `code`, or `zed`.
40
+ `branchChoices` controls how many branches are shown when switching branches.
41
+
42
+ ## Limits
43
+
44
+ - Requires TUI mode for the picker.
45
+ - `/reference new <git-url>` works without the reference picker.
46
+ - Uses the git URL basename as the folder name.
47
+ - References are treated as read-only examples by the generated prompt.