@hobin/developer 0.1.6 → 0.1.8

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/extensions/tui.ts CHANGED
@@ -1,1015 +1,1771 @@
1
1
  import type {
2
- ExtensionCommandContext,
3
- ExtensionContext,
4
- KeybindingsManager,
5
- Theme,
6
- ThemeColor,
2
+ ExtensionCommandContext,
3
+ ExtensionContext,
4
+ KeybindingsManager,
5
+ Theme,
6
+ ThemeColor,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import {
9
- Container,
10
- matchesKey,
11
- type SelectItem,
12
- type SizeValue,
13
- Text,
14
- truncateToWidth,
15
- visibleWidth,
16
- wrapTextWithAnsi,
9
+ Container,
10
+ Markdown,
11
+ type MarkdownTheme,
12
+ matchesKey,
13
+ type SelectItem,
14
+ type SettingItem,
15
+ SettingsList,
16
+ type SettingsListTheme,
17
+ type SizeValue,
18
+ Text,
19
+ truncateToWidth,
20
+ visibleWidth,
21
+ wrapTextWithAnsi,
17
22
  } from "@earendil-works/pi-tui";
18
23
 
19
24
  import {
20
- protocolState,
21
- type ChoiceResponseField,
22
- type ChoiceResponseOption,
23
- type DeveloperMode,
24
- type DeveloperState,
25
- type JudgmentStatus,
26
- type PendingQuestion,
27
- type ProtocolState,
25
+ protocolState,
26
+ type ChoiceResponseField,
27
+ type ChoiceResponseOption,
28
+ type DeveloperState,
29
+ type JudgmentEvent,
30
+ type JudgmentStatus,
31
+ type PendingQuestion,
32
+ type ProtocolState,
33
+ type RouteEvent,
28
34
  } from "./state.ts";
29
35
 
30
- export type DeveloperAction =
31
- | { kind: "command"; value: "status" | "questions" | DeveloperMode }
32
- | { kind: "question"; questionId: string };
36
+ export type DeveloperSettingsNavigation =
37
+ | { kind: "status" }
38
+ | { kind: "questions" }
39
+ | { kind: "history" };
33
40
 
34
- function modeName(mode: DeveloperMode): string {
35
- if (mode === "on") return "adaptive";
36
- return mode;
41
+ export interface DeveloperSettingsBinding {
42
+ read(): DeveloperState;
43
+ commitActivation(enabled: boolean): DeveloperState;
37
44
  }
38
45
 
39
- function protocolColor(value: ProtocolState): ThemeColor {
40
- if (value === "blocked") return "error";
41
- if (
42
- value === "needs-evidence" ||
43
- value === "needs-answer" ||
44
- value === "needs-routing" ||
45
- value === "needs-verification"
46
- )
47
- return "warning";
48
- if (value === "needs-judgment") return "accent";
49
- return "dim";
46
+ function developerMarkdownTheme(theme: Theme): MarkdownTheme {
47
+ return {
48
+ heading: (text) => theme.fg("mdHeading", text),
49
+ link: (text) => theme.fg("mdLink", text),
50
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
51
+ code: (text) => theme.fg("mdCode", text),
52
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
53
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
54
+ quote: (text) => theme.fg("mdQuote", text),
55
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
56
+ hr: (text) => theme.fg("mdHr", text),
57
+ listBullet: (text) => theme.fg("mdListBullet", text),
58
+ bold: (text) => theme.bold(text),
59
+ italic: (text) => theme.italic(text),
60
+ strikethrough: (text) => theme.strikethrough(text),
61
+ underline: (text) => theme.underline(text),
62
+ };
50
63
  }
51
64
 
52
- function modeColor(mode: DeveloperMode): ThemeColor {
53
- if (mode === "strict") return "warning";
54
- if (mode === "on") return "accent";
55
- return "dim";
65
+ function protocolColor(value: ProtocolState): ThemeColor {
66
+ if (value === "blocked") return "error";
67
+ if (
68
+ value === "needs-evidence" ||
69
+ value === "needs-answer" ||
70
+ value === "needs-routing" ||
71
+ value === "needs-verification"
72
+ )
73
+ return "warning";
74
+ if (value === "needs-judgment") return "accent";
75
+ return "dim";
56
76
  }
57
77
 
58
78
  function judgmentColor(status: JudgmentStatus): ThemeColor {
59
- if (status === "blocked") return "error";
60
- if (status === "needs-evidence") return "warning";
61
- if (status === "resolved") return "success";
62
- return "muted";
79
+ if (status === "blocked") return "error";
80
+ if (status === "needs-evidence") return "warning";
81
+ if (status === "resolved") return "success";
82
+ return "muted";
63
83
  }
64
84
 
65
85
  function judgmentSummary(result: string): string {
66
- return (
67
- result
68
- .split(/\r?\n/)
69
- .map((line) => line.trim())
70
- .find((line) => line && !line.startsWith("```")) ?? result
71
- );
72
- }
73
-
74
- export function renderDeveloperFooter(state: DeveloperState, theme: Theme): string {
75
- const currentProtocol = protocolState(state);
76
- const target = state.activeRoute?.owner ?? "none";
77
- return (
78
- theme.fg("accent", "developer") +
79
- theme.fg("dim", " · ") +
80
- theme.fg(modeColor(state.mode), modeName(state.mode)) +
81
- theme.fg("dim", " · ") +
82
- theme.fg(protocolColor(currentProtocol), currentProtocol) +
83
- theme.fg("dim", ` · ${target}`)
84
- );
85
- }
86
-
87
- export function developerActionItems(state: DeveloperState): SelectItem[] {
88
- const currentProtocol = protocolState(state);
89
- const items: SelectItem[] = [
90
- {
91
- value: "status",
92
- label: "Inspect status",
93
- description: `${modeName(state.mode)} · ${currentProtocol} · ${state.pendingQuestions.length} open`,
94
- },
95
- ];
96
- items.push(...pendingQuestionItems(state.pendingQuestions));
97
- items.push(
98
- {
99
- value: "on",
100
- label: state.mode === "on" ? "Adaptive mode (active)" : "Adaptive mode",
101
- description: "Route judgments adaptively while preserving the current active tool set",
102
- },
103
- {
104
- value: "strict",
105
- label: state.mode === "strict" ? "Strict mode (active)" : "Strict mode",
106
- description: "Allow bash on judgment routes; require direct for Pi built-in edit/write",
107
- },
108
- {
109
- value: "off",
110
- label: state.mode === "off" ? "Off (active)" : "Turn off",
111
- description: "Clear Developer protocol state and remove its persistent UI",
112
- },
113
- );
114
- return items;
86
+ return (
87
+ result
88
+ .split(/\r?\n/)
89
+ .map((line) => line.trim())
90
+ .find((line) => line && !line.startsWith("```")) ?? result
91
+ );
115
92
  }
116
93
 
117
- function questionAction(owner: PendingQuestion["resolutionOwner"]): string {
118
- if (owner === "user") return "enter to provide the required answer";
119
- if (owner === "environment") return "enter to provide access or external evidence";
120
- if (owner === "agent") return "enter to ask Pi to investigate";
121
- return "enter to classify and resolve this legacy question";
94
+ export function renderDeveloperFooter(
95
+ state: DeveloperState,
96
+ theme: Theme,
97
+ ): string {
98
+ const currentProtocol = protocolState(state);
99
+ const target = state.activeRoute?.target ?? "none";
100
+ return (
101
+ theme.fg("accent", "developer") +
102
+ theme.fg("dim", " · ") +
103
+ theme.fg(state.enabled ? "success" : "dim", state.enabled ? "on" : "off") +
104
+ theme.fg("dim", " · ") +
105
+ theme.fg(protocolColor(currentProtocol), currentProtocol) +
106
+ theme.fg("dim", ` · ${target}`)
107
+ );
122
108
  }
123
109
 
124
- function resolutionRequestLabel(owner: PendingQuestion["resolutionOwner"]): string {
125
- if (owner === "user") return "Required answer or product decision:";
126
- if (owner === "environment") return "External access, observation, or environment evidence:";
127
- return "Evidence or investigation request for Pi:";
110
+ export function hasDiscardableDeveloperWork(state: DeveloperState): boolean {
111
+ return Boolean(state.activeRoute) || state.pendingQuestions.length > 0;
128
112
  }
129
113
 
130
- const MAX_IMMEDIATE_REQUEST_BYTES = 16_000;
131
- const MAX_IMMEDIATE_REQUEST_LINES = 1_000;
132
- const ENABLE_MOUSE_SCROLL = "\u001b[?1000h\u001b[?1006h";
133
- const DISABLE_MOUSE_SCROLL = "\u001b[?1006l\u001b[?1000l";
114
+ export function developerSettingItems(state: DeveloperState): SettingItem[] {
115
+ const currentProtocol = protocolState(state);
116
+ const items: SettingItem[] = [
117
+ {
118
+ id: "activation",
119
+ label: "Developer",
120
+ currentValue: state.enabled ? "On" : "Off",
121
+ values: ["On", "Off"],
122
+ description:
123
+ "Route judgments and control Pi built-in bash, edit, and write",
124
+ },
125
+ {
126
+ id: "status",
127
+ label: "Status",
128
+ currentValue: currentProtocol,
129
+ values: [currentProtocol],
130
+ description:
131
+ "Inspect the current route, evidence, debt, and active tools",
132
+ },
133
+ ];
134
+ if (state.judgmentHistory.length > 0) {
135
+ const count = String(state.judgmentHistory.length);
136
+ items.push({
137
+ id: "history",
138
+ label: "History",
139
+ currentValue: count,
140
+ values: [count],
141
+ description: "Inspect complete route and judgment records",
142
+ });
143
+ }
144
+ if (state.pendingQuestions.length > 0) {
145
+ const count = String(state.pendingQuestions.length);
146
+ items.push({
147
+ id: "questions",
148
+ label: "Open questions",
149
+ currentValue: count,
150
+ values: [count],
151
+ description: "Review and answer unresolved Developer questions",
152
+ });
153
+ }
154
+ return items;
155
+ }
134
156
 
135
- export type ImmediateQuestionDisposition = { kind: "answer"; request: string } | { kind: "defer" };
157
+ function settingsListTheme(theme: Theme): SettingsListTheme {
158
+ return {
159
+ label: (text, selected) =>
160
+ selected ? theme.fg("accent", theme.bold(text)) : theme.fg("text", text),
161
+ value: (text, selected) => theme.fg(selected ? "accent" : "muted", text),
162
+ description: (text) => theme.fg("muted", text),
163
+ cursor: theme.fg("accent", "→ "),
164
+ hint: (text) => theme.fg("dim", text),
165
+ };
166
+ }
136
167
 
137
- interface WritableTerminal {
138
- write(data: string): void;
168
+ function questionAction(owner: PendingQuestion["resolutionOwner"]): string {
169
+ if (owner === "user") return "enter to provide the required answer";
170
+ if (owner === "environment")
171
+ return "enter to provide access or external evidence";
172
+ if (owner === "agent") return "enter to ask Pi to investigate";
173
+ return "enter to classify and resolve this legacy question";
174
+ }
175
+
176
+ function resolutionRequestLabel(
177
+ owner: PendingQuestion["resolutionOwner"],
178
+ ): string {
179
+ if (owner === "user") return "Required answer or product decision:";
180
+ if (owner === "environment")
181
+ return "External access, observation, or environment evidence:";
182
+ return "Evidence or investigation request for Pi:";
139
183
  }
140
184
 
141
- function enableMouseScroll(tui: { terminal?: WritableTerminal }): WritableTerminal | undefined {
142
- const terminal = tui.terminal;
143
- if (!terminal || typeof terminal.write !== "function") return undefined;
144
- terminal.write(ENABLE_MOUSE_SCROLL);
145
- return terminal;
185
+ const MAX_IMMEDIATE_REQUEST_BYTES = 16_000;
186
+ const MAX_IMMEDIATE_REQUEST_LINES = 1_000;
187
+
188
+ export type ImmediateQuestionDisposition =
189
+ | { kind: "answer"; request: string }
190
+ | { kind: "defer" };
191
+
192
+ export function pendingQuestionItems(
193
+ questions: PendingQuestion[],
194
+ ): SelectItem[] {
195
+ return questions.map((question) => {
196
+ const action = questionAction(question.resolutionOwner);
197
+ return {
198
+ value: question.id,
199
+ label: question.question,
200
+ description: `${question.status} · ${question.resolutionOwner} · ${question.gate} · ${action}`,
201
+ };
202
+ });
146
203
  }
147
204
 
148
- function disableMouseScroll(terminal: WritableTerminal | undefined): void {
149
- terminal?.write(DISABLE_MOUSE_SCROLL);
205
+ export interface DeveloperHistoryEntry {
206
+ id: string;
207
+ judgment: JudgmentEvent;
208
+ route?: RouteEvent;
150
209
  }
151
210
 
152
- function mouseWheelDirection(data: string): -1 | 1 | undefined {
153
- const match = /^\u001b\[<(\d+);\d+;\d+M$/.exec(data);
154
- if (!match?.[1]) return undefined;
155
- const button = Number(match[1]);
156
- if ((button & 64) === 0 || (button & 3) > 1) return undefined;
157
- return (button & 1) === 0 ? -1 : 1;
211
+ export function developerHistoryEntries(
212
+ state: DeveloperState,
213
+ ): DeveloperHistoryEntry[] {
214
+ return state.judgmentHistory.toReversed().map((judgment) => ({
215
+ id: judgment.routeId,
216
+ judgment,
217
+ route: state.routeHistory.find(
218
+ (candidate) => candidate.routeId === judgment.routeId,
219
+ ),
220
+ }));
158
221
  }
159
222
 
160
- export function pendingQuestionItems(questions: PendingQuestion[]): SelectItem[] {
161
- return questions.map((question) => {
162
- const action = questionAction(question.resolutionOwner);
163
- return {
164
- value: question.id,
165
- label: question.question,
166
- description: `${question.status} · ${question.resolutionOwner} · ${question.gate} · ${action}`,
167
- };
168
- });
223
+ export function historySelectItems(
224
+ entries: DeveloperHistoryEntry[],
225
+ ): SelectItem[] {
226
+ return entries.map(({ id, judgment }) => ({
227
+ value: id,
228
+ label: `${judgment.target} · ${judgment.status} · ${judgment.question}`,
229
+ description: judgmentSummary(judgment.result),
230
+ }));
169
231
  }
170
232
 
171
233
  interface SelectDialogOptions {
172
- title: string;
173
- subtitle: string;
174
- items: SelectItem[];
175
- width: SizeValue;
176
- minWidth: number;
177
- maxVisible: number;
178
- selectedLabelMaxLines: number;
179
- selectedDescriptionMaxLines: number;
234
+ title: string;
235
+ subtitle: string;
236
+ items: SelectItem[];
237
+ width: SizeValue;
238
+ minWidth: number;
239
+ maxVisible: number;
240
+ selectedLabelMaxLines: number;
241
+ selectedDescriptionMaxLines: number;
242
+ initialValue?: string;
180
243
  }
181
244
 
182
245
  function boundedWrappedLines(
183
- content: string,
184
- width: number,
185
- maxLines: number,
186
- ellipsis: string,
246
+ content: string,
247
+ width: number,
248
+ maxLines: number,
249
+ ellipsis: string,
187
250
  ): string[] {
188
- const contentWidth = Math.max(1, width);
189
- const wrapped = wrapTextWithAnsi(content, contentWidth);
190
- const visible = wrapped.slice(0, Math.max(1, maxLines));
191
- if (wrapped.length > visible.length && visible.length > 0) {
192
- const last = visible.length - 1;
193
- visible[last] =
194
- truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
195
- }
196
- return visible;
251
+ const contentWidth = Math.max(1, width);
252
+ const wrapped = wrapTextWithAnsi(content, contentWidth);
253
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
254
+ if (wrapped.length > visible.length && visible.length > 0) {
255
+ const last = visible.length - 1;
256
+ visible[last] =
257
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") +
258
+ ellipsis;
259
+ }
260
+ return visible;
197
261
  }
198
262
 
199
- function renderModalFrame(container: Container, theme: Theme, width: number): string[] {
200
- if (width < 3) return container.render(Math.max(1, width));
201
-
202
- const innerWidth = width - 2;
203
- const border = (text: string) => theme.fg("borderAccent", text);
204
- const rows = container
205
- .render(innerWidth)
206
- .map((line) => `${border("│")}${truncateToWidth(line, innerWidth, "…", true)}${border("│")}`);
207
- return [border(`╭${"─".repeat(innerWidth)}╮`), ...rows, border(`╰${"".repeat(innerWidth)}╯`)];
263
+ function renderModalFrame(
264
+ container: Container,
265
+ theme: Theme,
266
+ width: number,
267
+ ): string[] {
268
+ if (width < 3) return container.render(Math.max(1, width));
269
+
270
+ const innerWidth = width - 2;
271
+ const border = (text: string) => theme.fg("borderAccent", text);
272
+ const rows = container
273
+ .render(innerWidth)
274
+ .map(
275
+ (line) =>
276
+ `${border("│")}${truncateToWidth(line, innerWidth, "…", true)}${border("│")}`,
277
+ );
278
+ return [
279
+ border(`╭${"─".repeat(innerWidth)}╮`),
280
+ ...rows,
281
+ border(`╰${"─".repeat(innerWidth)}╯`),
282
+ ];
208
283
  }
209
284
 
210
285
  class WrappedSelectList {
211
- private selectedIndex = 0;
212
- private readonly items: SelectItem[];
213
- private readonly keybindings: KeybindingsManager;
214
- private readonly maxVisible: number;
215
- private readonly renderHeight: number;
216
- private readonly selectedDescriptionMaxLines: number;
217
- private readonly selectedLabelMaxLines: number;
218
- private readonly theme: Theme;
219
- onSelect?: (item: SelectItem) => void;
220
- onCancel?: () => void;
221
-
222
- constructor(
223
- items: SelectItem[],
224
- maxVisible: number,
225
- theme: Theme,
226
- keybindings: KeybindingsManager,
227
- options: {
228
- selectedLabelMaxLines: number;
229
- selectedDescriptionMaxLines: number;
230
- },
231
- ) {
232
- this.items = items;
233
- this.maxVisible = maxVisible;
234
- this.theme = theme;
235
- this.keybindings = keybindings;
236
- this.selectedLabelMaxLines = options.selectedLabelMaxLines;
237
- this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
238
- this.renderHeight = Math.max(
239
- 1,
240
- maxVisible - 1 + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 1,
241
- );
242
- }
243
-
244
- render(width: number): string[] {
245
- if (this.items.length === 0) return [this.theme.fg("warning", " No items")];
246
-
247
- const lines: string[] = [];
248
- const startIndex = Math.max(
249
- 0,
250
- Math.min(
251
- this.selectedIndex - Math.floor(this.maxVisible / 2),
252
- this.items.length - this.maxVisible,
253
- ),
254
- );
255
- const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
256
-
257
- for (let index = startIndex; index < endIndex; index += 1) {
258
- const item = this.items[index];
259
- if (!item) continue;
260
- if (index !== this.selectedIndex) {
261
- lines.push(` ${truncateToWidth(item.label, Math.max(1, width - 2), "…")}`);
262
- continue;
263
- }
264
-
265
- const labels = boundedWrappedLines(
266
- this.theme.fg("accent", item.label),
267
- width - 2,
268
- this.selectedLabelMaxLines,
269
- this.theme.fg("dim", "…"),
270
- );
271
- lines.push(`${this.theme.fg("accent", "→ ")}${labels[0] ?? ""}`);
272
- for (const line of labels.slice(1)) lines.push(` ${line}`);
273
-
274
- if (item.description) {
275
- const descriptions = boundedWrappedLines(
276
- this.theme.fg("muted", item.description),
277
- width - 4,
278
- this.selectedDescriptionMaxLines,
279
- this.theme.fg("dim", "…"),
280
- );
281
- for (const line of descriptions) lines.push(` ${line}`);
282
- }
283
- }
284
-
285
- if (startIndex > 0 || endIndex < this.items.length) {
286
- lines.push(
287
- this.theme.fg(
288
- "dim",
289
- truncateToWidth(
290
- ` (${this.selectedIndex + 1}/${this.items.length})`,
291
- Math.max(1, width - 2),
292
- "",
293
- ),
294
- ),
295
- );
296
- }
297
- const visible = lines.slice(0, this.renderHeight);
298
- while (visible.length < this.renderHeight) visible.push("");
299
- return visible;
300
- }
301
-
302
- handleInput(data: string): void {
303
- const wheelDirection = mouseWheelDirection(data);
304
- if (wheelDirection !== undefined) {
305
- this.moveSelection(wheelDirection * 3);
306
- } else if (this.keybindings.matches(data, "tui.select.up")) {
307
- this.selectedIndex =
308
- this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
309
- } else if (this.keybindings.matches(data, "tui.select.down")) {
310
- this.selectedIndex =
311
- this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
312
- } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
313
- this.moveSelection(-Math.max(1, this.maxVisible - 1));
314
- } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
315
- this.moveSelection(Math.max(1, this.maxVisible - 1));
316
- } else if (matchesKey(data, "home")) {
317
- this.selectedIndex = 0;
318
- } else if (matchesKey(data, "end")) {
319
- this.selectedIndex = Math.max(0, this.items.length - 1);
320
- } else if (this.keybindings.matches(data, "tui.select.confirm")) {
321
- const selected = this.items[this.selectedIndex];
322
- if (selected) this.onSelect?.(selected);
323
- } else if (this.keybindings.matches(data, "tui.select.cancel")) {
324
- this.onCancel?.();
325
- }
326
- }
327
-
328
- private moveSelection(delta: number): void {
329
- this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, this.selectedIndex + delta));
330
- }
331
-
332
- invalidate(): void {}
286
+ private selectedIndex: number;
287
+ private readonly items: SelectItem[];
288
+ private readonly keybindings: KeybindingsManager;
289
+ private readonly maxVisible: number;
290
+ private readonly renderHeight: number;
291
+ private readonly selectedDescriptionMaxLines: number;
292
+ private readonly selectedLabelMaxLines: number;
293
+ private readonly theme: Theme;
294
+ onSelect?: (item: SelectItem) => void;
295
+ onCancel?: () => void;
296
+
297
+ constructor(
298
+ items: SelectItem[],
299
+ maxVisible: number,
300
+ theme: Theme,
301
+ keybindings: KeybindingsManager,
302
+ options: {
303
+ selectedLabelMaxLines: number;
304
+ selectedDescriptionMaxLines: number;
305
+ initialValue?: string;
306
+ },
307
+ ) {
308
+ this.items = items;
309
+ this.maxVisible = maxVisible;
310
+ this.theme = theme;
311
+ this.keybindings = keybindings;
312
+ this.selectedLabelMaxLines = options.selectedLabelMaxLines;
313
+ this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
314
+ const initialIndex = options.initialValue
315
+ ? items.findIndex((item) => item.value === options.initialValue)
316
+ : -1;
317
+ this.selectedIndex = initialIndex >= 0 ? initialIndex : 0;
318
+ this.renderHeight = Math.max(
319
+ 1,
320
+ maxVisible -
321
+ 1 +
322
+ options.selectedLabelMaxLines +
323
+ options.selectedDescriptionMaxLines +
324
+ 1,
325
+ );
326
+ }
327
+
328
+ render(width: number): string[] {
329
+ if (this.items.length === 0)
330
+ return [this.theme.fg("warning", " No items")];
331
+
332
+ const lines: string[] = [];
333
+ const startIndex = Math.max(
334
+ 0,
335
+ Math.min(
336
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
337
+ this.items.length - this.maxVisible,
338
+ ),
339
+ );
340
+ const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
341
+
342
+ for (let index = startIndex; index < endIndex; index += 1) {
343
+ const item = this.items[index];
344
+ if (!item) continue;
345
+ if (index !== this.selectedIndex) {
346
+ lines.push(
347
+ ` ${truncateToWidth(item.label, Math.max(1, width - 2), "…")}`,
348
+ );
349
+ continue;
350
+ }
351
+
352
+ const labels = boundedWrappedLines(
353
+ this.theme.fg("accent", item.label),
354
+ width - 2,
355
+ this.selectedLabelMaxLines,
356
+ this.theme.fg("dim", "…"),
357
+ );
358
+ lines.push(`${this.theme.fg("accent", "→ ")}${labels[0] ?? ""}`);
359
+ for (const line of labels.slice(1)) lines.push(` ${line}`);
360
+
361
+ if (item.description) {
362
+ const descriptions = boundedWrappedLines(
363
+ this.theme.fg("muted", item.description),
364
+ width - 4,
365
+ this.selectedDescriptionMaxLines,
366
+ this.theme.fg("dim", "…"),
367
+ );
368
+ for (const line of descriptions) lines.push(` ${line}`);
369
+ }
370
+ }
371
+
372
+ if (startIndex > 0 || endIndex < this.items.length) {
373
+ lines.push(
374
+ this.theme.fg(
375
+ "dim",
376
+ truncateToWidth(
377
+ ` (${this.selectedIndex + 1}/${this.items.length})`,
378
+ Math.max(1, width - 2),
379
+ "",
380
+ ),
381
+ ),
382
+ );
383
+ }
384
+ const visible = lines.slice(0, this.renderHeight);
385
+ while (visible.length < this.renderHeight) visible.push("");
386
+ return visible;
387
+ }
388
+
389
+ handleInput(data: string): void {
390
+ if (this.keybindings.matches(data, "tui.select.up")) {
391
+ this.selectedIndex =
392
+ this.selectedIndex === 0
393
+ ? this.items.length - 1
394
+ : this.selectedIndex - 1;
395
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
396
+ this.selectedIndex =
397
+ this.selectedIndex === this.items.length - 1
398
+ ? 0
399
+ : this.selectedIndex + 1;
400
+ } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
401
+ this.moveSelection(-Math.max(1, this.maxVisible - 1));
402
+ } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
403
+ this.moveSelection(Math.max(1, this.maxVisible - 1));
404
+ } else if (matchesKey(data, "home")) {
405
+ this.selectedIndex = 0;
406
+ } else if (matchesKey(data, "end")) {
407
+ this.selectedIndex = Math.max(0, this.items.length - 1);
408
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
409
+ const selected = this.items[this.selectedIndex];
410
+ if (selected) this.onSelect?.(selected);
411
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
412
+ this.onCancel?.();
413
+ }
414
+ }
415
+
416
+ private moveSelection(delta: number): void {
417
+ this.selectedIndex = Math.max(
418
+ 0,
419
+ Math.min(this.items.length - 1, this.selectedIndex + delta),
420
+ );
421
+ }
422
+
423
+ invalidate(): void {}
333
424
  }
334
425
 
335
426
  async function showSelectDialog(
336
- ctx: ExtensionContext,
337
- options: SelectDialogOptions,
427
+ ctx: ExtensionContext,
428
+ options: SelectDialogOptions,
429
+ presentation: "overlay" | "surface" = "overlay",
338
430
  ): Promise<string | undefined> {
339
- let mouseTerminal: WritableTerminal | undefined;
340
- try {
341
- const result = await ctx.ui.custom<string | null>(
342
- (tui, theme, keybindings, done) => {
343
- mouseTerminal = enableMouseScroll(tui);
344
- const container = new Container();
345
- const title = new Text("", 1, 0);
346
- const subtitle = new Text("", 1, 0);
347
- const hint = new Text("", 1, 0);
348
- const updateText = () => {
349
- title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
350
- subtitle.setText(theme.fg("muted", options.subtitle));
351
- hint.setText(
352
- theme.fg("dim", "wheel/↑↓ · PgUp/PgDn · Home/End · enter select · esc cancel"),
353
- );
354
- };
355
- updateText();
356
-
357
- const list = new WrappedSelectList(
358
- options.items,
359
- Math.min(options.items.length, options.maxVisible),
360
- theme,
361
- keybindings,
362
- {
363
- selectedLabelMaxLines: options.selectedLabelMaxLines,
364
- selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
365
- },
366
- );
367
- list.onSelect = (item) => done(item.value);
368
- list.onCancel = () => done(null);
369
-
370
- container.addChild(title);
371
- container.addChild(subtitle);
372
- container.addChild(list);
373
- container.addChild(hint);
374
-
375
- return {
376
- render(width: number) {
377
- return renderModalFrame(container, theme, width);
378
- },
379
- invalidate() {
380
- updateText();
381
- container.invalidate();
382
- },
383
- handleInput(data: string) {
384
- list.handleInput(data);
385
- tui.requestRender();
386
- },
387
- };
388
- },
389
- {
390
- overlay: true,
391
- overlayOptions: {
392
- anchor: "center",
393
- width: options.width,
394
- minWidth: options.minWidth,
395
- maxHeight: "88%",
396
- margin: 1,
397
- },
398
- },
399
- );
400
- return result ?? undefined;
401
- } finally {
402
- disableMouseScroll(mouseTerminal);
403
- }
404
- }
405
-
406
- export async function showDeveloperActionSelector(
407
- ctx: ExtensionCommandContext,
408
- state: DeveloperState,
409
- ): Promise<DeveloperAction | undefined> {
410
- const result = await showSelectDialog(ctx, {
411
- title: "Developer control",
412
- subtitle: `Current · ${modeName(state.mode)} · ${protocolState(state)}`,
413
- items: developerActionItems(state),
414
- width: "84%",
415
- minWidth: 78,
416
- maxVisible: 6,
417
- selectedLabelMaxLines: 3,
418
- selectedDescriptionMaxLines: 3,
419
- });
420
- if (!result) return undefined;
421
- if (
422
- result === "status" ||
423
- result === "questions" ||
424
- result === "on" ||
425
- result === "strict" ||
426
- result === "off"
427
- ) {
428
- return { kind: "command", value: result };
429
- }
430
- if (state.pendingQuestions.some((question) => question.id === result)) {
431
- return { kind: "question", questionId: result };
432
- }
433
- return undefined;
431
+ const result = await ctx.ui.custom<string | null>(
432
+ (tui, theme, keybindings, done) => {
433
+ const container = new Container();
434
+ const title = new Text("", 1, 0);
435
+ const subtitle = new Text("", 1, 0);
436
+ const hint = new Text("", 1, 0);
437
+ const updateText = () => {
438
+ title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
439
+ subtitle.setText(theme.fg("muted", options.subtitle));
440
+ hint.setText(
441
+ theme.fg(
442
+ "dim",
443
+ "mouse wheel scroll · drag select · Cmd+C copy · ↑↓ select · enter open · esc back",
444
+ ),
445
+ );
446
+ };
447
+ updateText();
448
+
449
+ const list = new WrappedSelectList(
450
+ options.items,
451
+ Math.min(options.items.length, options.maxVisible),
452
+ theme,
453
+ keybindings,
454
+ {
455
+ selectedLabelMaxLines: options.selectedLabelMaxLines,
456
+ selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
457
+ initialValue: options.initialValue,
458
+ },
459
+ );
460
+ list.onSelect = (item) => done(item.value);
461
+ list.onCancel = () => done(null);
462
+
463
+ container.addChild(title);
464
+ container.addChild(subtitle);
465
+ container.addChild(list);
466
+ container.addChild(hint);
467
+
468
+ return {
469
+ render(width: number) {
470
+ return presentation === "overlay"
471
+ ? renderModalFrame(container, theme, width)
472
+ : container.render(width);
473
+ },
474
+ invalidate() {
475
+ updateText();
476
+ container.invalidate();
477
+ },
478
+ handleInput(data: string) {
479
+ list.handleInput(data);
480
+ tui.requestRender();
481
+ },
482
+ };
483
+ },
484
+ presentation === "overlay"
485
+ ? {
486
+ overlay: true,
487
+ overlayOptions: {
488
+ anchor: "center",
489
+ width: options.width,
490
+ minWidth: options.minWidth,
491
+ maxHeight: "88%",
492
+ margin: 1,
493
+ },
494
+ }
495
+ : undefined,
496
+ );
497
+ return result ?? undefined;
498
+ }
499
+
500
+ function discardableDeveloperWorkLabel(state: DeveloperState): string {
501
+ return [
502
+ ...(state.activeRoute ? ["the active route"] : []),
503
+ ...(state.pendingQuestions.length > 0
504
+ ? [`${state.pendingQuestions.length} open question(s)`]
505
+ : []),
506
+ ].join(" and ");
507
+ }
508
+
509
+ export async function confirmDisableDeveloper(
510
+ ctx: ExtensionContext,
511
+ state: DeveloperState,
512
+ ): Promise<boolean> {
513
+ const work = discardableDeveloperWorkLabel(state);
514
+ const selected = await showSelectDialog(ctx, {
515
+ title: "Turn off Developer?",
516
+ subtitle: `Turning off clears ${work} from current protocol state. Session history remains.`,
517
+ items: [
518
+ {
519
+ value: "keep-on",
520
+ label: "Keep Developer On",
521
+ description: "Return without changing protocol state",
522
+ },
523
+ {
524
+ value: "turn-off",
525
+ label: "Turn Off and Clear",
526
+ description: `Clear ${work}`,
527
+ },
528
+ ],
529
+ width: 64,
530
+ minWidth: 42,
531
+ maxVisible: 2,
532
+ selectedLabelMaxLines: 2,
533
+ selectedDescriptionMaxLines: 2,
534
+ });
535
+ return selected === "turn-off";
536
+ }
537
+
538
+ export class DeveloperSettingsSurface extends Container {
539
+ private activationPending = false;
540
+ private readonly binding: DeveloperSettingsBinding;
541
+ private readonly ctx: ExtensionCommandContext;
542
+ private readonly done: (result: DeveloperSettingsNavigation | null) => void;
543
+ private readonly requestRender: () => void;
544
+ private settings!: SettingsList;
545
+ private state: DeveloperState;
546
+ private readonly theme: Theme;
547
+
548
+ constructor(
549
+ ctx: ExtensionCommandContext,
550
+ binding: DeveloperSettingsBinding,
551
+ theme: Theme,
552
+ done: (result: DeveloperSettingsNavigation | null) => void,
553
+ requestRender: () => void,
554
+ ) {
555
+ super();
556
+ this.ctx = ctx;
557
+ this.binding = binding;
558
+ this.theme = theme;
559
+ this.done = done;
560
+ this.requestRender = requestRender;
561
+ this.state = binding.read();
562
+ this.rebuild();
563
+ }
564
+
565
+ private rebuild(): void {
566
+ this.clear();
567
+ this.addChild(
568
+ new Text(this.theme.fg("accent", this.theme.bold("◆ Developer")), 0, 0),
569
+ );
570
+ this.addChild(
571
+ new Text(
572
+ this.theme.fg("muted", "Current branch settings and protocol details"),
573
+ 0,
574
+ 0,
575
+ ),
576
+ );
577
+ this.addChild(new Text("", 0, 0));
578
+
579
+ const items = developerSettingItems(this.state);
580
+ this.settings = new SettingsList(
581
+ items,
582
+ items.length,
583
+ settingsListTheme(this.theme),
584
+ (id, value) => {
585
+ if (id === "activation") {
586
+ this.sync(this.binding.read());
587
+ this.requestRender();
588
+ void this.requestActivation(value === "On");
589
+ return;
590
+ }
591
+ if (id === "status") {
592
+ this.done({ kind: "status" });
593
+ return;
594
+ }
595
+ if (id === "history") {
596
+ this.done({ kind: "history" });
597
+ return;
598
+ }
599
+ if (id === "questions") this.done({ kind: "questions" });
600
+ },
601
+ () => this.done(null),
602
+ );
603
+ this.addChild(this.settings);
604
+ }
605
+
606
+ private sync(next: DeveloperState): void {
607
+ const questionsShapeChanged =
608
+ this.state.pendingQuestions.length > 0 !==
609
+ next.pendingQuestions.length > 0;
610
+ this.state = next;
611
+ if (questionsShapeChanged) {
612
+ this.rebuild();
613
+ return;
614
+ }
615
+ this.settings.updateValue("activation", next.enabled ? "On" : "Off");
616
+ this.settings.updateValue("status", protocolState(next));
617
+ if (next.pendingQuestions.length > 0) {
618
+ this.settings.updateValue(
619
+ "questions",
620
+ String(next.pendingQuestions.length),
621
+ );
622
+ }
623
+ this.invalidate();
624
+ }
625
+
626
+ private async requestActivation(enabled: boolean): Promise<void> {
627
+ if (this.activationPending) return;
628
+ this.activationPending = true;
629
+ try {
630
+ const current = this.binding.read();
631
+ const accepted =
632
+ enabled || !hasDiscardableDeveloperWork(current)
633
+ ? true
634
+ : await confirmDisableDeveloper(this.ctx, current);
635
+ this.sync(
636
+ accepted ? this.binding.commitActivation(enabled) : this.binding.read(),
637
+ );
638
+ } catch (error) {
639
+ this.sync(this.binding.read());
640
+ this.ctx.ui.notify(
641
+ `Developer activation failed: ${error instanceof Error ? error.message : String(error)}`,
642
+ "error",
643
+ );
644
+ } finally {
645
+ this.activationPending = false;
646
+ this.requestRender();
647
+ }
648
+ }
649
+
650
+ handleInput(data: string): void {
651
+ if (this.activationPending) return;
652
+ this.settings.handleInput(data);
653
+ this.requestRender();
654
+ }
655
+ }
656
+
657
+ export async function showDeveloperSettings(
658
+ ctx: ExtensionCommandContext,
659
+ binding: DeveloperSettingsBinding,
660
+ ): Promise<DeveloperSettingsNavigation | undefined> {
661
+ const result = await ctx.ui.custom<DeveloperSettingsNavigation | null>(
662
+ (tui, theme, _keybindings, done) =>
663
+ new DeveloperSettingsSurface(ctx, binding, theme, done, () =>
664
+ tui.requestRender(),
665
+ ),
666
+ );
667
+ return result ?? undefined;
434
668
  }
435
669
 
436
670
  export function showPendingQuestionSelector(
437
- ctx: ExtensionCommandContext,
438
- questions: PendingQuestion[],
671
+ ctx: ExtensionCommandContext,
672
+ questions: PendingQuestion[],
439
673
  ): Promise<string | undefined> {
440
- if (questions.length === 0) return Promise.resolve(undefined);
441
- return showSelectDialog(ctx, {
442
- title: "Resolve an open Developer question",
443
- subtitle:
444
- "Enter opens an answer/evidence editor; the question closes after a resolved or not-applicable judgment",
445
- items: pendingQuestionItems(questions),
446
- width: "92%",
447
- minWidth: 88,
448
- maxVisible: 10,
449
- selectedLabelMaxLines: 5,
450
- selectedDescriptionMaxLines: 3,
451
- });
674
+ if (questions.length === 0) return Promise.resolve(undefined);
675
+ return showSelectDialog(
676
+ ctx,
677
+ {
678
+ title: "Resolve an open Developer question",
679
+ subtitle:
680
+ "Enter opens an answer/evidence editor; the question closes after a resolved or not-applicable judgment",
681
+ items: pendingQuestionItems(questions),
682
+ width: "92%",
683
+ minWidth: 88,
684
+ maxVisible: questions.length,
685
+ selectedLabelMaxLines: 5,
686
+ selectedDescriptionMaxLines: 3,
687
+ },
688
+ "surface",
689
+ );
452
690
  }
453
691
 
454
- interface ChoiceResponseAnswer {
455
- option: ChoiceResponseOption;
456
- detail?: string;
692
+ export function showDeveloperHistorySelector(
693
+ ctx: ExtensionCommandContext,
694
+ state: DeveloperState,
695
+ initialRouteId?: string,
696
+ ): Promise<string | undefined> {
697
+ const entries = developerHistoryEntries(state);
698
+ if (entries.length === 0) return Promise.resolve(undefined);
699
+ return showSelectDialog(
700
+ ctx,
701
+ {
702
+ title: `Developer history · ${entries.length}`,
703
+ subtitle:
704
+ "Enter opens complete route and judgment evidence; drag selects terminal text",
705
+ items: historySelectItems(entries),
706
+ width: "92%",
707
+ minWidth: 72,
708
+ maxVisible: entries.length,
709
+ selectedLabelMaxLines: 4,
710
+ selectedDescriptionMaxLines: 3,
711
+ initialValue: initialRouteId,
712
+ },
713
+ "surface",
714
+ );
715
+ }
716
+
717
+ export type QuestionBriefAction = "continue" | "defer";
718
+
719
+ function questionGateExplanation(question: PendingQuestion): string {
720
+ if (question.gate === "before-implementation")
721
+ return "Implementation remains blocked until this question is resolved.";
722
+ if (question.gate === "before-completion")
723
+ return "Completion remains blocked until this question is resolved.";
724
+ return "This question does not currently block implementation or completion.";
457
725
  }
458
726
 
727
+ export class DeveloperQuestionBriefPanel {
728
+ private cachedLines?: string[];
729
+ private cachedWidth?: number;
730
+ private readonly done: (action: QuestionBriefAction) => void;
731
+ private readonly keybindings: KeybindingsManager;
732
+ private readonly question: PendingQuestion;
733
+ private readonly requestRender: () => void;
734
+ private selected: QuestionBriefAction = "continue";
735
+ private readonly theme: Theme;
736
+
737
+ constructor(
738
+ question: PendingQuestion,
739
+ theme: Theme,
740
+ keybindings: KeybindingsManager,
741
+ done: (action: QuestionBriefAction) => void,
742
+ requestRender: () => void = () => {},
743
+ ) {
744
+ this.question = question;
745
+ this.theme = theme;
746
+ this.keybindings = keybindings;
747
+ this.done = done;
748
+ this.requestRender = requestRender;
749
+ }
750
+
751
+ handleInput(data: string): void {
752
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
753
+ this.done("defer");
754
+ return;
755
+ }
756
+ if (
757
+ this.keybindings.matches(data, "tui.select.up") ||
758
+ this.keybindings.matches(data, "tui.select.down")
759
+ ) {
760
+ this.selected = this.selected === "continue" ? "defer" : "continue";
761
+ this.invalidate();
762
+ this.requestRender();
763
+ return;
764
+ }
765
+ if (this.keybindings.matches(data, "tui.select.confirm"))
766
+ this.done(this.selected);
767
+ }
768
+
769
+ render(width: number): string[] {
770
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
771
+
772
+ const panelWidth = Math.max(1, width);
773
+ const innerWidth = Math.max(1, panelWidth - 2);
774
+ const rows: string[] = [];
775
+ const border = (text: string) => this.theme.fg("borderAccent", text);
776
+ const row = (content = "") =>
777
+ `${border("│")}${truncateToWidth(content, innerWidth, "", true)}${border("│")}`;
778
+ const section = (title: string) =>
779
+ rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
780
+ const addField = (
781
+ label: string,
782
+ value: string,
783
+ color: ThemeColor = "text",
784
+ ) => {
785
+ const labelText = `${label} ·`;
786
+ const plainPrefix = ` ${labelText} `;
787
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
788
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
789
+ const wrapped = wrapTextWithAnsi(
790
+ this.theme.fg(color, value.length > 0 ? value : "(none)"),
791
+ contentWidth,
792
+ );
793
+ rows.push(row(styledPrefix + (wrapped[0] ?? "")));
794
+ const indent = " ".repeat(visibleWidth(plainPrefix));
795
+ for (const line of wrapped.slice(1)) rows.push(row(indent + line));
796
+ };
797
+ const addMarkdownField = (
798
+ label: string,
799
+ value: string,
800
+ color: ThemeColor = "text",
801
+ ) => {
802
+ rows.push(row(` ${this.theme.fg("dim", `${label} ·`)}`));
803
+ const contentWidth = Math.max(1, innerWidth - 4);
804
+ const markdown = new Markdown(
805
+ value,
806
+ 0,
807
+ 0,
808
+ developerMarkdownTheme(this.theme),
809
+ { color: (text) => this.theme.fg(color, text) },
810
+ );
811
+ for (const line of markdown.render(contentWidth))
812
+ rows.push(row(` ${line}`));
813
+ };
814
+ const action = (
815
+ value: QuestionBriefAction,
816
+ label: string,
817
+ description: string,
818
+ ) => {
819
+ const selected = this.selected === value;
820
+ const cursor = selected ? this.theme.fg("accent", "→ ") : " ";
821
+ const labelColor: ThemeColor = selected ? "accent" : "text";
822
+ for (const [index, line] of wrapTextWithAnsi(
823
+ this.theme.fg(labelColor, selected ? this.theme.bold(label) : label),
824
+ Math.max(1, innerWidth - 2),
825
+ ).entries())
826
+ rows.push(row(`${index === 0 ? cursor : " "}${line}`));
827
+ for (const line of wrapTextWithAnsi(
828
+ this.theme.fg("muted", description),
829
+ Math.max(1, innerWidth - 4),
830
+ ))
831
+ rows.push(row(` ${line}`));
832
+ };
833
+
834
+ rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
835
+ rows.push(
836
+ row(
837
+ ` ${this.theme.fg("accent", this.theme.bold("◆ Developer decision brief"))}`,
838
+ ),
839
+ );
840
+ rows.push(row());
841
+
842
+ section("Why this decision is required");
843
+ if (this.question.context)
844
+ addMarkdownField("context", this.question.context);
845
+ else
846
+ addField(
847
+ "context",
848
+ "No additional context was recorded for this legacy question.",
849
+ "warning",
850
+ );
851
+ rows.push(row());
852
+
853
+ section("Decision contract");
854
+ addField("question", this.question.question);
855
+ addField(
856
+ "owner / gate",
857
+ `${this.question.resolutionOwner} / ${this.question.gate}`,
858
+ "muted",
859
+ );
860
+ addField("blocked work", questionGateExplanation(this.question), "warning");
861
+ addField("resolves when", this.question.resolutionCriteria, "muted");
862
+
863
+ const fields = this.question.responseSpec?.fields ?? [];
864
+ if (fields.length > 0) {
865
+ rows.push(row());
866
+ section(`Choice preview · ${fields.length}`);
867
+ for (const [fieldIndex, field] of fields.entries()) {
868
+ addField(
869
+ `field ${fieldIndex + 1}/${fields.length} · ${field.id}`,
870
+ field.prompt,
871
+ "accent",
872
+ );
873
+ if (field.description)
874
+ addField("description", field.description, "muted");
875
+ for (const [optionIndex, option] of field.options.entries()) {
876
+ addField(
877
+ `option ${optionIndex + 1}`,
878
+ `${option.value} · ${option.label}`,
879
+ );
880
+ if (option.description)
881
+ addField("meaning", option.description, "muted");
882
+ if (option.detailPrompt)
883
+ addField("required detail", option.detailPrompt, "warning");
884
+ }
885
+ addField(
886
+ "custom answer",
887
+ "Available after continuing if none of the listed options fit.",
888
+ "dim",
889
+ );
890
+ }
891
+ }
892
+
893
+ rows.push(row());
894
+ section("Choose after reviewing the explanation above");
895
+ action(
896
+ "continue",
897
+ "Continue to answer",
898
+ "Open the response fields or editor now.",
899
+ );
900
+ action(
901
+ "defer",
902
+ "Leave open",
903
+ "Keep the question and its gate in Developer for later.",
904
+ );
905
+ rows.push(row());
906
+ for (const line of wrapTextWithAnsi(
907
+ this.theme.fg(
908
+ "dim",
909
+ "mouse wheel scroll · drag select · Cmd+C copy · ↑↓ choose · enter continue · esc leave open",
910
+ ),
911
+ Math.max(1, innerWidth - 2),
912
+ ))
913
+ rows.push(row(` ${line}`));
914
+ rows.push(border(`╰${"─".repeat(innerWidth)}╯`));
915
+
916
+ this.cachedWidth = width;
917
+ this.cachedLines = rows;
918
+ return rows;
919
+ }
920
+
921
+ invalidate(): void {
922
+ this.cachedLines = undefined;
923
+ this.cachedWidth = undefined;
924
+ }
925
+ }
926
+
927
+ export async function showDeveloperQuestionBrief(
928
+ ctx: ExtensionContext,
929
+ question: PendingQuestion,
930
+ ): Promise<QuestionBriefAction> {
931
+ const action = await ctx.ui.custom<QuestionBriefAction | null>(
932
+ (tui, theme, keybindings, done) =>
933
+ new DeveloperQuestionBriefPanel(
934
+ question,
935
+ theme,
936
+ keybindings,
937
+ (value) => done(value),
938
+ () => tui.requestRender(),
939
+ ),
940
+ );
941
+ return action ?? "defer";
942
+ }
943
+
944
+ const CUSTOM_CHOICE_VALUE = "__custom__";
945
+
946
+ type ChoiceResponseAnswer =
947
+ | { kind: "preset"; option: ChoiceResponseOption; detail?: string }
948
+ | { kind: "custom"; text: string };
949
+
459
950
  function responseWithinLimits(
460
- ctx: ExtensionContext,
461
- request: string,
462
- label = "Question response",
951
+ ctx: ExtensionContext,
952
+ request: string,
953
+ label = "Question response",
463
954
  ): boolean {
464
- const requestBytes = new TextEncoder().encode(request).byteLength;
465
- const requestLines = request.split(/\r?\n/).length;
466
- if (requestBytes <= MAX_IMMEDIATE_REQUEST_BYTES && requestLines <= MAX_IMMEDIATE_REQUEST_LINES) {
467
- return true;
468
- }
469
- ctx.ui.notify(
470
- `${label} is too large (${requestBytes} bytes, ${requestLines} lines); shorten it before submitting.`,
471
- "warning",
472
- );
473
- return false;
474
- }
475
-
476
- function choiceDetailInitial(field: ChoiceResponseField, option: ChoiceResponseOption): string {
477
- return [
478
- `Decision: ${field.prompt}`,
479
- `Selected: ${option.value} — ${option.label}`,
480
- "",
481
- option.detailPrompt,
482
- "",
483
- "Required detail:",
484
- ].join("\n");
955
+ const requestBytes = new TextEncoder().encode(request).byteLength;
956
+ const requestLines = request.split(/\r?\n/).length;
957
+ if (
958
+ requestBytes <= MAX_IMMEDIATE_REQUEST_BYTES &&
959
+ requestLines <= MAX_IMMEDIATE_REQUEST_LINES
960
+ ) {
961
+ return true;
962
+ }
963
+ ctx.ui.notify(
964
+ `${label} is too large (${requestBytes} bytes, ${requestLines} lines); shorten it before submitting.`,
965
+ "warning",
966
+ );
967
+ return false;
968
+ }
969
+
970
+ function choiceDetailInitial(
971
+ field: ChoiceResponseField,
972
+ option: ChoiceResponseOption,
973
+ ): string {
974
+ return [
975
+ `Decision: ${field.prompt}`,
976
+ `Selected: ${option.value} — ${option.label}`,
977
+ "",
978
+ option.detailPrompt,
979
+ "",
980
+ "Required detail:",
981
+ ].join("\n");
485
982
  }
486
983
 
487
984
  async function editChoiceDetail(
488
- ctx: ExtensionContext,
489
- field: ChoiceResponseField,
490
- option: ChoiceResponseOption,
985
+ ctx: ExtensionContext,
986
+ field: ChoiceResponseField,
987
+ option: ChoiceResponseOption,
491
988
  ): Promise<string | undefined> {
492
- const initial = choiceDetailInitial(field, option);
493
- while (true) {
494
- const response = await ctx.ui.editor(`Add detail for ${field.id} · ${option.value}`, initial);
495
- if (response === undefined) return undefined;
496
- const detail = (
497
- response.startsWith(initial) ? response.slice(initial.length) : response
498
- ).trim();
499
- if (!detail) {
500
- ctx.ui.notify("This choice requires a non-empty detail.", "warning");
501
- continue;
502
- }
503
- if (!responseWithinLimits(ctx, detail, "Choice detail")) continue;
504
- return detail;
505
- }
989
+ const initial = choiceDetailInitial(field, option);
990
+ while (true) {
991
+ const response = await ctx.ui.editor(
992
+ `Add detail for ${field.id} · ${option.value}`,
993
+ initial,
994
+ );
995
+ if (response === undefined) return undefined;
996
+ const detail = (
997
+ response.startsWith(initial) ? response.slice(initial.length) : response
998
+ ).trim();
999
+ if (!detail) {
1000
+ ctx.ui.notify("This choice requires a non-empty detail.", "warning");
1001
+ continue;
1002
+ }
1003
+ if (!responseWithinLimits(ctx, detail, "Choice detail")) continue;
1004
+ return detail;
1005
+ }
1006
+ }
1007
+
1008
+ async function editCustomChoiceAnswer(
1009
+ ctx: ExtensionContext,
1010
+ field: ChoiceResponseField,
1011
+ ): Promise<string | undefined> {
1012
+ const context = field.description
1013
+ ? `${field.prompt}\n\n${field.description}`
1014
+ : field.prompt;
1015
+ const prefix = `${context}\n\nYour answer:\n`;
1016
+ let initial = prefix;
1017
+ while (true) {
1018
+ const response = await ctx.ui.editor(
1019
+ `Write another answer · ${field.id}`,
1020
+ initial,
1021
+ );
1022
+ if (response === undefined) return undefined;
1023
+ const text = (
1024
+ response.startsWith(prefix) ? response.slice(prefix.length) : response
1025
+ ).trim();
1026
+ if (!text) {
1027
+ ctx.ui.notify(
1028
+ "Write a non-empty answer or press Esc to go back.",
1029
+ "warning",
1030
+ );
1031
+ continue;
1032
+ }
1033
+ if (!responseWithinLimits(ctx, text, "Custom answer")) {
1034
+ initial = `${prefix}${text}`;
1035
+ continue;
1036
+ }
1037
+ return text;
1038
+ }
506
1039
  }
507
1040
 
508
1041
  function structuredResolutionRequest(
509
- question: PendingQuestion,
510
- answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
1042
+ question: PendingQuestion,
1043
+ answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
511
1044
  ): string {
512
- const fields = question.responseSpec?.fields ?? [];
513
- const lines = [questionResolutionPrompt(question), "", "Structured answer:"];
514
- for (const [index, field] of fields.entries()) {
515
- const answer = answers[index];
516
- if (!answer) continue;
517
- lines.push(`- ${field.id}: ${answer.option.value} ${answer.option.label}`);
518
- if (answer.detail) lines.push(` Detail: ${answer.detail}`);
519
- }
520
- return lines.join("\n");
1045
+ const fields = question.responseSpec?.fields ?? [];
1046
+ const lines = [questionResolutionPrompt(question), "", "Structured answer:"];
1047
+ for (const [index, field] of fields.entries()) {
1048
+ const answer = answers[index];
1049
+ if (!answer) continue;
1050
+ if (answer.kind === "custom") {
1051
+ const [firstLine = "", ...remainingLines] = answer.text.split(/\r?\n/);
1052
+ lines.push(`- ${field.id}: custom — user wrote: ${firstLine}`);
1053
+ lines.push(...remainingLines.map((line) => ` ${line}`));
1054
+ continue;
1055
+ }
1056
+ lines.push(
1057
+ `- ${field.id}: ${answer.option.value} — ${answer.option.label}`,
1058
+ );
1059
+ if (answer.detail) lines.push(` Detail: ${answer.detail}`);
1060
+ }
1061
+ return lines.join("\n");
521
1062
  }
522
1063
 
523
1064
  type ChoiceFieldAction =
524
- | { kind: "answer"; answer: ChoiceResponseAnswer }
525
- | { kind: "cancel" }
526
- | { kind: "retry" };
1065
+ | { kind: "answer"; answer: ChoiceResponseAnswer }
1066
+ | { kind: "cancel" }
1067
+ | { kind: "retry" };
527
1068
 
528
1069
  type ChoiceReviewAction =
529
- | { kind: "submit" }
530
- | { kind: "edit"; fieldIndex: number }
531
- | { kind: "back" };
1070
+ | { kind: "submit" }
1071
+ | { kind: "edit"; fieldIndex: number }
1072
+ | { kind: "back" };
532
1073
 
533
1074
  async function selectChoiceAnswer(
534
- ctx: ExtensionContext,
535
- field: ChoiceResponseField,
536
- fieldIndex: number,
537
- fieldCount: number,
1075
+ ctx: ExtensionContext,
1076
+ field: ChoiceResponseField,
1077
+ fieldIndex: number,
1078
+ fieldCount: number,
538
1079
  ): Promise<ChoiceFieldAction> {
539
- const selectedValue = await showSelectDialog(ctx, {
540
- title: `Decision ${fieldIndex + 1}/${fieldCount} · ${field.id}`,
541
- subtitle: field.description ?? field.prompt,
542
- items: field.options.map((option) => ({
543
- value: option.value,
544
- label: `${option.value} · ${option.label}`,
545
- description: option.description ?? field.prompt,
546
- })),
547
- width: "92%",
548
- minWidth: 88,
549
- maxVisible: Math.min(field.options.length, 12),
550
- selectedLabelMaxLines: 3,
551
- selectedDescriptionMaxLines: 4,
552
- });
553
- if (!selectedValue) return { kind: "cancel" };
554
- const option = field.options.find((candidate) => candidate.value === selectedValue);
555
- if (!option) return { kind: "retry" };
556
- const detail = option.detailPrompt ? await editChoiceDetail(ctx, field, option) : undefined;
557
- if (option.detailPrompt && detail === undefined) return { kind: "retry" };
558
- return { kind: "answer", answer: { option, detail } };
1080
+ const selectedValue = await showSelectDialog(
1081
+ ctx,
1082
+ {
1083
+ title: `Decision ${fieldIndex + 1}/${fieldCount} · ${field.id}`,
1084
+ subtitle: field.description ?? field.prompt,
1085
+ items: [
1086
+ ...field.options.map((option) => ({
1087
+ value: option.value,
1088
+ label: `${option.value} · ${option.label}`,
1089
+ description: option.description ?? field.prompt,
1090
+ })),
1091
+ {
1092
+ value: CUSTOM_CHOICE_VALUE,
1093
+ label: "Write another answer…",
1094
+ description: "Enter an answer that is not listed above",
1095
+ },
1096
+ ],
1097
+ width: "92%",
1098
+ minWidth: 88,
1099
+ maxVisible: field.options.length + 1,
1100
+ selectedLabelMaxLines: 3,
1101
+ selectedDescriptionMaxLines: 4,
1102
+ },
1103
+ "surface",
1104
+ );
1105
+ if (!selectedValue) return { kind: "cancel" };
1106
+ if (selectedValue === CUSTOM_CHOICE_VALUE) {
1107
+ const text = await editCustomChoiceAnswer(ctx, field);
1108
+ return text === undefined
1109
+ ? { kind: "retry" }
1110
+ : { kind: "answer", answer: { kind: "custom", text } };
1111
+ }
1112
+ const option = field.options.find(
1113
+ (candidate) => candidate.value === selectedValue,
1114
+ );
1115
+ if (!option) return { kind: "retry" };
1116
+ const detail = option.detailPrompt
1117
+ ? await editChoiceDetail(ctx, field, option)
1118
+ : undefined;
1119
+ if (option.detailPrompt && detail === undefined) return { kind: "retry" };
1120
+ return { kind: "answer", answer: { kind: "preset", option, detail } };
559
1121
  }
560
1122
 
561
1123
  async function reviewChoiceAnswers(
562
- ctx: ExtensionContext,
563
- question: PendingQuestion,
564
- fields: ChoiceResponseField[],
565
- answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
1124
+ ctx: ExtensionContext,
1125
+ question: PendingQuestion,
1126
+ fields: ChoiceResponseField[],
1127
+ answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
566
1128
  ): Promise<ChoiceReviewAction> {
567
- const action = await showSelectDialog(ctx, {
568
- title: "Review structured answer",
569
- subtitle: question.question,
570
- items: [
571
- {
572
- value: "submit",
573
- label: "Submit answers",
574
- description: "Send these decisions to Pi; the question remains open until judgment",
575
- },
576
- ...fields.map((field, index) => {
577
- const answer = answers[index];
578
- return {
579
- value: `edit:${index}`,
580
- label: `${field.id} · ${answer?.option.value ?? "missing"} — ${answer?.option.label ?? "Missing answer"}`,
581
- description: answer?.detail ? `Detail: ${answer.detail}` : "Enter to change this answer",
582
- };
583
- }),
584
- ],
585
- width: "92%",
586
- minWidth: 88,
587
- maxVisible: Math.min(fields.length + 1, 12),
588
- selectedLabelMaxLines: 3,
589
- selectedDescriptionMaxLines: 4,
590
- });
591
- if (action === "submit") return { kind: "submit" };
592
- if (!action?.startsWith("edit:")) return { kind: "back" };
593
- const fieldIndex = Number(action.slice("edit:".length));
594
- if (!Number.isInteger(fieldIndex) || fieldIndex < 0 || fieldIndex >= fields.length) {
595
- return { kind: "back" };
596
- }
597
- return { kind: "edit", fieldIndex };
1129
+ const action = await showSelectDialog(
1130
+ ctx,
1131
+ {
1132
+ title: "Review structured answer",
1133
+ subtitle: question.question,
1134
+ items: [
1135
+ {
1136
+ value: "submit",
1137
+ label: "Submit answers",
1138
+ description:
1139
+ "Send these decisions to Pi; the question remains open until judgment",
1140
+ },
1141
+ ...fields.map((field, index) => {
1142
+ const answer = answers[index];
1143
+ if (!answer) {
1144
+ return {
1145
+ value: `edit:${index}`,
1146
+ label: `${field.id} · missing — Missing answer`,
1147
+ description: "Enter to answer this decision",
1148
+ };
1149
+ }
1150
+ if (answer.kind === "custom") {
1151
+ return {
1152
+ value: `edit:${index}`,
1153
+ label: `${field.id} · custom ${answer.text.replace(/\s+/g, " ").trim()}`,
1154
+ description: "User-entered answer · Enter to change this answer",
1155
+ };
1156
+ }
1157
+ return {
1158
+ value: `edit:${index}`,
1159
+ label: `${field.id} · ${answer.option.value} ${answer.option.label}`,
1160
+ description: answer.detail
1161
+ ? `Detail: ${answer.detail}`
1162
+ : "Enter to change this answer",
1163
+ };
1164
+ }),
1165
+ ],
1166
+ width: "92%",
1167
+ minWidth: 88,
1168
+ maxVisible: fields.length + 1,
1169
+ selectedLabelMaxLines: 3,
1170
+ selectedDescriptionMaxLines: 4,
1171
+ },
1172
+ "surface",
1173
+ );
1174
+ if (action === "submit") return { kind: "submit" };
1175
+ if (!action?.startsWith("edit:")) return { kind: "back" };
1176
+ const fieldIndex = Number(action.slice("edit:".length));
1177
+ if (
1178
+ !Number.isInteger(fieldIndex) ||
1179
+ fieldIndex < 0 ||
1180
+ fieldIndex >= fields.length
1181
+ ) {
1182
+ return { kind: "back" };
1183
+ }
1184
+ return { kind: "edit", fieldIndex };
598
1185
  }
599
1186
 
600
1187
  function previousChoiceField(
601
- fieldIndex: number,
602
- fieldCount: number,
603
- returnToReview: boolean,
1188
+ fieldIndex: number,
1189
+ fieldCount: number,
1190
+ returnToReview: boolean,
604
1191
  ): number | undefined {
605
- if (returnToReview) return fieldCount;
606
- return fieldIndex === 0 ? undefined : fieldIndex - 1;
1192
+ if (returnToReview) return fieldCount;
1193
+ return fieldIndex === 0 ? undefined : fieldIndex - 1;
607
1194
  }
608
1195
 
609
1196
  async function collectChoiceResponse(
610
- ctx: ExtensionContext,
611
- question: PendingQuestion,
1197
+ ctx: ExtensionContext,
1198
+ question: PendingQuestion,
612
1199
  ): Promise<string | undefined> {
613
- const fields = question.responseSpec?.fields;
614
- if (!fields || fields.length === 0) return undefined;
615
- const answers: Array<ChoiceResponseAnswer | undefined> = [];
616
- let fieldIndex = 0;
617
- let returnToReview = false;
618
-
619
- while (true) {
620
- if (fieldIndex < fields.length) {
621
- const field = fields[fieldIndex];
622
- if (!field) return undefined;
623
- const action = await selectChoiceAnswer(ctx, field, fieldIndex, fields.length);
624
- if (action.kind === "retry") continue;
625
- if (action.kind === "cancel") {
626
- fieldIndex = previousChoiceField(fieldIndex, fields.length, returnToReview) ?? -1;
627
- }
628
- if (fieldIndex < 0) return undefined;
629
- if (action.kind === "cancel") continue;
630
- answers[fieldIndex] = action.answer;
631
- fieldIndex = returnToReview ? fields.length : fieldIndex + 1;
632
- continue;
633
- }
634
-
635
- const action = await reviewChoiceAnswers(ctx, question, fields, answers);
636
- if (action.kind === "submit") return structuredResolutionRequest(question, answers);
637
- if (action.kind === "edit") {
638
- fieldIndex = action.fieldIndex;
639
- returnToReview = true;
640
- continue;
641
- }
642
- returnToReview = false;
643
- fieldIndex = fields.length - 1;
644
- }
645
- }
646
-
647
- export async function promptImmediateUserQuestion(
648
- ctx: ExtensionContext,
649
- question: PendingQuestion,
1200
+ const fields = question.responseSpec?.fields;
1201
+ if (!fields || fields.length === 0) return undefined;
1202
+ const answers: Array<ChoiceResponseAnswer | undefined> = [];
1203
+ let fieldIndex = 0;
1204
+ let returnToReview = false;
1205
+
1206
+ while (true) {
1207
+ if (fieldIndex < fields.length) {
1208
+ const field = fields[fieldIndex];
1209
+ if (!field) return undefined;
1210
+ const action = await selectChoiceAnswer(
1211
+ ctx,
1212
+ field,
1213
+ fieldIndex,
1214
+ fields.length,
1215
+ );
1216
+ if (action.kind === "retry") continue;
1217
+ if (action.kind === "cancel") {
1218
+ fieldIndex =
1219
+ previousChoiceField(fieldIndex, fields.length, returnToReview) ?? -1;
1220
+ }
1221
+ if (fieldIndex < 0) return undefined;
1222
+ if (action.kind === "cancel") continue;
1223
+ answers[fieldIndex] = action.answer;
1224
+ fieldIndex = returnToReview ? fields.length : fieldIndex + 1;
1225
+ continue;
1226
+ }
1227
+
1228
+ const action = await reviewChoiceAnswers(ctx, question, fields, answers);
1229
+ if (action.kind === "submit")
1230
+ return structuredResolutionRequest(question, answers);
1231
+ if (action.kind === "edit") {
1232
+ fieldIndex = action.fieldIndex;
1233
+ returnToReview = true;
1234
+ continue;
1235
+ }
1236
+ returnToReview = false;
1237
+ fieldIndex = fields.length - 1;
1238
+ }
1239
+ }
1240
+
1241
+ interface QuestionEditorSpec {
1242
+ title: string;
1243
+ initial: string;
1244
+ }
1245
+
1246
+ async function prepareQuestionResolution(
1247
+ ctx: ExtensionContext,
1248
+ question: PendingQuestion,
1249
+ editor: QuestionEditorSpec,
650
1250
  ): Promise<ImmediateQuestionDisposition> {
651
- while (true) {
652
- const action = await showSelectDialog(ctx, {
653
- title: "Developer needs a decision",
654
- subtitle: question.question,
655
- items: [
656
- {
657
- value: "answer",
658
- label: "Answer now",
659
- description:
660
- "Review the full context and provide the decision required before direct work",
661
- },
662
- {
663
- value: "defer",
664
- label: "Leave open",
665
- description: "Keep this question in Developer and answer it later",
666
- },
667
- ],
668
- width: "92%",
669
- minWidth: 88,
670
- maxVisible: 2,
671
- selectedLabelMaxLines: 3,
672
- selectedDescriptionMaxLines: 3,
673
- });
674
- if (action !== "answer") return { kind: "defer" };
675
-
676
- const request = question.responseSpec
677
- ? await collectChoiceResponse(ctx, question)
678
- : await ctx.ui.editor(
679
- "Answer the new Developer question",
680
- questionResolutionPrompt(question),
681
- );
682
- if (request === undefined) continue;
683
- if (!responseWithinLimits(ctx, request)) continue;
684
- return { kind: "answer", request };
685
- }
1251
+ while (true) {
1252
+ const action = await showDeveloperQuestionBrief(ctx, question);
1253
+ if (action === "defer") return { kind: "defer" };
1254
+
1255
+ const request = question.responseSpec
1256
+ ? await collectChoiceResponse(ctx, question)
1257
+ : await ctx.ui.editor(editor.title, editor.initial);
1258
+ if (request === undefined) continue;
1259
+ if (!responseWithinLimits(ctx, request)) continue;
1260
+ return { kind: "answer", request };
1261
+ }
1262
+ }
1263
+
1264
+ export function promptImmediateUserQuestion(
1265
+ ctx: ExtensionContext,
1266
+ question: PendingQuestion,
1267
+ ): Promise<ImmediateQuestionDisposition> {
1268
+ return prepareQuestionResolution(ctx, question, {
1269
+ title: "Answer the new Developer question",
1270
+ initial: questionResolutionPrompt(question),
1271
+ });
686
1272
  }
687
1273
 
688
1274
  export class DeveloperWidget {
689
- private readonly state: DeveloperState;
690
- private readonly theme: Theme;
691
-
692
- constructor(state: DeveloperState, theme: Theme) {
693
- this.state = state;
694
- this.theme = theme;
695
- }
696
-
697
- render(width: number): string[] {
698
- const lines: string[] = [];
699
- if (this.state.activeRoute) {
700
- lines.push(
701
- truncateToWidth(
702
- `${this.theme.fg("accent", "◆ route")} ${this.theme.fg("muted", "·")} ${this.theme.fg("accent", this.state.activeRoute.owner)} ${this.theme.fg("muted", this.state.activeRoute.question)}`,
703
- width,
704
- "…",
705
- ),
706
- );
707
- }
708
- for (const question of this.state.pendingQuestions.slice(0, 3)) {
709
- const label = question.resolutionOwner === "user" ? "? answer" : "? evidence";
710
- lines.push(
711
- truncateToWidth(
712
- `${this.theme.fg(question.status === "blocked" ? "error" : "warning", label)} ${this.theme.fg("dim", `· ${question.gate} ·`)} ${this.theme.fg("muted", question.question)}`,
713
- width,
714
- "…",
715
- ),
716
- );
717
- }
718
- if (this.state.pendingQuestions.length > 3) {
719
- lines.push(
720
- this.theme.fg("dim", ` +${this.state.pendingQuestions.length - 3} more open questions`),
721
- );
722
- }
723
- if (this.state.implementationFramingRequired) {
724
- lines.push(
725
- this.theme.fg("warning", "◇ gate · frame implementation before direct (sketch or signal)"),
726
- );
727
- }
728
- if (this.state.rerouteRequired) {
729
- lines.push(this.theme.fg("warning", "→ next · reroute from the latest direct landing"));
730
- }
731
- if (this.state.verificationRequired) {
732
- lines.push(this.theme.fg("warning", "→ next · verify changed artifacts before completion"));
733
- }
734
- return lines;
735
- }
736
-
737
- invalidate(): void {}
738
- }
739
-
740
- export interface DeveloperStatusView {
741
- state: DeveloperState;
742
- activeTools: string[];
743
- availableSkills: string[];
1275
+ private readonly state: DeveloperState;
1276
+ private readonly theme: Theme;
1277
+
1278
+ constructor(state: DeveloperState, theme: Theme) {
1279
+ this.state = state;
1280
+ this.theme = theme;
1281
+ }
1282
+
1283
+ render(width: number): string[] {
1284
+ const lines: string[] = [];
1285
+ if (this.state.activeRoute) {
1286
+ lines.push(
1287
+ truncateToWidth(
1288
+ `${this.theme.fg("accent", "◆ route")} ${this.theme.fg("muted", "·")} ${this.theme.fg("accent", this.state.activeRoute.target)} ${this.theme.fg("muted", this.state.activeRoute.question)}`,
1289
+ width,
1290
+ "…",
1291
+ ),
1292
+ );
1293
+ }
1294
+ for (const question of this.state.pendingQuestions.slice(0, 3)) {
1295
+ const label =
1296
+ question.resolutionOwner === "user" ? "? answer" : "? evidence";
1297
+ lines.push(
1298
+ truncateToWidth(
1299
+ `${this.theme.fg(question.status === "blocked" ? "error" : "warning", label)} ${this.theme.fg("dim", `· ${question.gate} ·`)} ${this.theme.fg("muted", question.question)}`,
1300
+ width,
1301
+ "…",
1302
+ ),
1303
+ );
1304
+ }
1305
+ if (this.state.pendingQuestions.length > 3) {
1306
+ lines.push(
1307
+ this.theme.fg(
1308
+ "dim",
1309
+ ` +${this.state.pendingQuestions.length - 3} more open questions`,
1310
+ ),
1311
+ );
1312
+ }
1313
+ if (this.state.implementationFramingRequired) {
1314
+ lines.push(
1315
+ this.theme.fg(
1316
+ "warning",
1317
+ "◇ gate · frame implementation before mutation (sketch or signal)",
1318
+ ),
1319
+ );
1320
+ }
1321
+ if (this.state.rerouteRequired) {
1322
+ lines.push(
1323
+ this.theme.fg(
1324
+ "warning",
1325
+ "→ next · reroute from the latest implementation landing",
1326
+ ),
1327
+ );
1328
+ }
1329
+ if (this.state.verificationRequired) {
1330
+ lines.push(
1331
+ this.theme.fg(
1332
+ "warning",
1333
+ "→ next · verify changed artifacts before completion",
1334
+ ),
1335
+ );
1336
+ }
1337
+ return lines;
1338
+ }
1339
+
1340
+ invalidate(): void {}
1341
+ }
1342
+
1343
+ export class DeveloperHistoryDetailPanel {
1344
+ private cachedLines?: string[];
1345
+ private cachedWidth?: number;
1346
+ private readonly entry: DeveloperHistoryEntry;
1347
+ private readonly onClose: () => void;
1348
+ private readonly theme: Theme;
1349
+
1350
+ constructor(entry: DeveloperHistoryEntry, theme: Theme, onClose: () => void) {
1351
+ this.entry = entry;
1352
+ this.theme = theme;
1353
+ this.onClose = onClose;
1354
+ }
1355
+
1356
+ handleInput(data: string): void {
1357
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
1358
+ this.onClose();
1359
+ }
1360
+
1361
+ render(width: number): string[] {
1362
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
1363
+
1364
+ const panelWidth = Math.max(1, width);
1365
+ const innerWidth = Math.max(1, panelWidth - 2);
1366
+ const body: string[] = [];
1367
+ const border = (text: string) => this.theme.fg("borderAccent", text);
1368
+ const row = (content = "") =>
1369
+ `${border("│")}${truncateToWidth(content, innerWidth, "", true)}${border("│")}`;
1370
+ const section = (title: string) =>
1371
+ body.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
1372
+ const addField = (
1373
+ label: string,
1374
+ value: string,
1375
+ color: ThemeColor = "text",
1376
+ ) => {
1377
+ const labelText = `${label} ·`;
1378
+ const plainPrefix = ` ${labelText} `;
1379
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
1380
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
1381
+ const wrapped = wrapTextWithAnsi(
1382
+ this.theme.fg(color, value.length > 0 ? value : "(none)"),
1383
+ contentWidth,
1384
+ );
1385
+ body.push(row(styledPrefix + (wrapped[0] ?? "")));
1386
+ const indent = " ".repeat(visibleWidth(plainPrefix));
1387
+ for (const line of wrapped.slice(1)) body.push(row(indent + line));
1388
+ };
1389
+ const addValues = (label: string, values: string[]) => {
1390
+ if (values.length === 0) {
1391
+ addField(label, "(none)", "dim");
1392
+ return;
1393
+ }
1394
+ values.forEach((value, index) => {
1395
+ addField(`${label} ${index + 1}`, value, "muted");
1396
+ });
1397
+ };
1398
+
1399
+ const { judgment, route } = this.entry;
1400
+ section("Route");
1401
+ addField("id", judgment.routeId, "dim");
1402
+ if (route) {
1403
+ addField("target", route.target, "accent");
1404
+ addField("question", route.question);
1405
+ addField("reason", route.reason, "muted");
1406
+ addField(
1407
+ "method",
1408
+ route.methodLocation ?? "implementation action",
1409
+ "dim",
1410
+ );
1411
+ if (route.executionProfile)
1412
+ addField("execution profile", route.executionProfile, "dim");
1413
+ if (route.targetQuestionId)
1414
+ addField("target question", route.targetQuestionId, "dim");
1415
+ if (route.implementationStep) {
1416
+ addField("movement", route.implementationStep.movement);
1417
+ addField("stop condition", route.implementationStep.stopCondition);
1418
+ addField("verification", route.implementationStep.verification);
1419
+ }
1420
+ addValues("known evidence", route.knownEvidence);
1421
+ addValues(
1422
+ "considered alternative",
1423
+ route.consideredAlternatives.map(
1424
+ (alternative) => `${alternative.target} · ${alternative.reason}`,
1425
+ ),
1426
+ );
1427
+ } else {
1428
+ addField(
1429
+ "details",
1430
+ "Route details unavailable for this recorded judgment.",
1431
+ "warning",
1432
+ );
1433
+ }
1434
+
1435
+ body.push(row());
1436
+ section("Judgment");
1437
+ addField("target", judgment.target, "accent");
1438
+ addField("status", judgment.status, judgmentColor(judgment.status));
1439
+ addField("question", judgment.question);
1440
+ addField("result", judgment.result);
1441
+ addField(
1442
+ "changed artifacts",
1443
+ judgment.changedArtifacts ? "yes" : "no",
1444
+ judgment.changedArtifacts ? "warning" : "dim",
1445
+ );
1446
+ addValues("basis", judgment.basis);
1447
+ for (const [index, question] of judgment.openedQuestions.entries()) {
1448
+ addField(
1449
+ `opened question ${index + 1}`,
1450
+ `${question.id} · ${question.status} · ${question.resolutionOwner} · ${question.gate} · ${question.question}`,
1451
+ "warning",
1452
+ );
1453
+ addField("source route", question.sourceRouteId, "dim");
1454
+ if (question.context) addField("context", question.context, "muted");
1455
+ addField("resolves when", question.resolutionCriteria, "muted");
1456
+ if (question.responseSpec)
1457
+ addField("response spec", JSON.stringify(question.responseSpec), "dim");
1458
+ }
1459
+ for (const [index, update] of judgment.questionUpdates.entries()) {
1460
+ addField(
1461
+ `question update ${index + 1}`,
1462
+ `${update.questionId} · ${update.status} · ${update.result}`,
1463
+ "accent",
1464
+ );
1465
+ addValues("update basis", update.basis);
1466
+ }
1467
+ addValues("artifact", judgment.artifacts);
1468
+
1469
+ const header = [
1470
+ border(`╭${"─".repeat(innerWidth)}╮`),
1471
+ row(
1472
+ ` ${this.theme.fg("accent", this.theme.bold("◆ Developer history detail"))}`,
1473
+ ),
1474
+ ];
1475
+ const hint = wrapTextWithAnsi(
1476
+ this.theme.fg(
1477
+ "dim",
1478
+ "mouse wheel scroll · drag select · Cmd+C copy · esc back",
1479
+ ),
1480
+ Math.max(1, innerWidth - 2),
1481
+ ).map((line) => row(` ${line}`));
1482
+ const lines = [
1483
+ ...header,
1484
+ ...body,
1485
+ ...hint,
1486
+ border(`╰${"─".repeat(innerWidth)}╯`),
1487
+ ];
1488
+ this.cachedWidth = width;
1489
+ this.cachedLines = lines;
1490
+ return lines;
1491
+ }
1492
+
1493
+ invalidate(): void {
1494
+ this.cachedLines = undefined;
1495
+ this.cachedWidth = undefined;
1496
+ }
1497
+ }
1498
+
1499
+ export async function showDeveloperHistoryDetail(
1500
+ ctx: ExtensionCommandContext,
1501
+ entry: DeveloperHistoryEntry,
1502
+ ): Promise<void> {
1503
+ await ctx.ui.custom<void>(
1504
+ (_tui, theme, _keybindings, done) =>
1505
+ new DeveloperHistoryDetailPanel(entry, theme, () => done()),
1506
+ );
1507
+ }
1508
+
1509
+ interface DeveloperStatusView {
1510
+ state: DeveloperState;
1511
+ activeTools: string[];
1512
+ availableSkills: string[];
744
1513
  }
745
1514
 
746
1515
  export class DeveloperStatusPanel {
747
- private cachedWidth?: number;
748
- private cachedLines?: string[];
749
- private readonly view: DeveloperStatusView;
750
- private readonly theme: Theme;
751
- private readonly onClose: () => void;
752
- private readonly requestRender: () => void;
753
- private readonly viewportHeight: number;
754
- private scrollOffset = 0;
755
- private maxScrollOffset = 0;
756
-
757
- constructor(
758
- view: DeveloperStatusView,
759
- theme: Theme,
760
- onClose: () => void,
761
- options: { viewportHeight?: number; requestRender?: () => void } = {},
762
- ) {
763
- this.view = view;
764
- this.theme = theme;
765
- this.onClose = onClose;
766
- this.viewportHeight = options.viewportHeight ?? 24;
767
- this.requestRender = options.requestRender ?? (() => {});
768
- }
769
-
770
- handleInput(data: string): void {
771
- if (matchesKey(data, "escape") || matchesKey(data, "enter") || matchesKey(data, "ctrl+c")) {
772
- this.onClose();
773
- return;
774
- }
775
- const wheelDirection = mouseWheelDirection(data);
776
- const pageSize = Math.max(1, this.viewportHeight - 6);
777
- if (wheelDirection !== undefined) this.moveScroll(wheelDirection * 3);
778
- else if (matchesKey(data, "up")) this.moveScroll(-1);
779
- else if (matchesKey(data, "down")) this.moveScroll(1);
780
- else if (matchesKey(data, "pageUp")) this.moveScroll(-pageSize);
781
- else if (matchesKey(data, "pageDown")) this.moveScroll(pageSize);
782
- else if (matchesKey(data, "home")) this.scrollOffset = 0;
783
- else if (matchesKey(data, "end")) this.scrollOffset = this.maxScrollOffset;
784
- else return;
785
- this.invalidate();
786
- this.requestRender();
787
- }
788
-
789
- private moveScroll(delta: number): void {
790
- this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset + delta));
791
- }
792
-
793
- render(width: number): string[] {
794
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
795
-
796
- const panelWidth = Math.max(1, width);
797
- const innerWidth = Math.max(1, panelWidth - 2);
798
- const rows: string[] = [];
799
- const border = (text: string) => this.theme.fg("borderAccent", text);
800
- const row = (content = "") =>
801
- `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("")}`;
802
- const addWrapped = (
803
- label: string,
804
- value: string,
805
- color: ThemeColor = "muted",
806
- maxLines = 2,
807
- ) => {
808
- const labelText = `${label} ·`;
809
- const plainPrefix = ` ${labelText} `;
810
- const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
811
- const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
812
- const wrapped = wrapTextWithAnsi(this.theme.fg(color, value.trim()), contentWidth);
813
- const visible = wrapped.slice(0, Math.max(1, maxLines));
814
- if (wrapped.length > visible.length && visible.length > 0) {
815
- const last = visible.length - 1;
816
- visible[last] =
817
- truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") +
818
- this.theme.fg("dim", "…");
819
- }
820
- rows.push(row(styledPrefix + (visible[0] ?? "")));
821
- const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
822
- for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
823
- };
824
- const section = (title: string) =>
825
- rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
826
-
827
- rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
828
- rows.push(row(` ${this.theme.fg("accent", this.theme.bold("◆ Developer status"))}`));
829
- rows.push(row());
830
-
831
- const state = this.view.state;
832
- const currentProtocol = protocolState(state);
833
- const summary =
834
- `mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
835
- this.theme.fg("dim", " · ") +
836
- `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
837
- this.theme.fg("dim", " · ") +
838
- `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`;
839
- for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2)))
840
- rows.push(row(` ${line}`));
841
- rows.push(row());
842
-
843
- section("Active route");
844
- if (state.activeRoute) {
845
- addWrapped("id", state.activeRoute.routeId, "dim", 1);
846
- addWrapped("question", state.activeRoute.question, "text", 3);
847
- addWrapped("reason", state.activeRoute.reason, "muted", 2);
848
- addWrapped("skill", state.activeRoute.methodLocation ?? "direct action", "dim", 1);
849
- addWrapped("known evidence", String(state.activeRoute.knownEvidence.length), "muted", 1);
850
- } else {
851
- addWrapped("state", "No route is currently waiting for judgment.", "dim", 2);
852
- }
853
-
854
- rows.push(row());
855
- section(`Open questions · ${state.pendingQuestions.length}`);
856
- if (state.pendingQuestions.length === 0) {
857
- addWrapped("state", "No unresolved Developer questions.", "dim", 2);
858
- } else {
859
- for (const question of state.pendingQuestions.slice(0, 4)) {
860
- addWrapped(
861
- `${question.status} · ${question.resolutionOwner} · ${question.gate}`,
862
- question.question,
863
- question.status === "blocked" ? "error" : "warning",
864
- 2,
865
- );
866
- addWrapped("resolves when", question.resolutionCriteria, "dim", 2);
867
- }
868
- if (state.pendingQuestions.length > 4) {
869
- addWrapped(
870
- "more",
871
- `${state.pendingQuestions.length - 4} additional open questions`,
872
- "dim",
873
- 1,
874
- );
875
- }
876
- }
877
-
878
- rows.push(row());
879
- section(`Judgment history · ${state.judgmentHistory.length}`);
880
- if (state.judgmentHistory.length === 0) {
881
- addWrapped("state", "No judgment has been recorded on this branch.", "dim", 2);
882
- } else {
883
- const recentJudgments = state.judgmentHistory.slice(-10).toReversed();
884
- for (const judgment of recentJudgments) {
885
- const route = state.routeHistory.find(
886
- (candidate) => candidate.routeId === judgment.routeId,
887
- );
888
- addWrapped(
889
- `${judgment.owner} ${judgment.status}`,
890
- `${judgment.question} ${judgmentSummary(judgment.result)}`,
891
- judgmentColor(judgment.status),
892
- 3,
893
- );
894
- if (route) addWrapped("route reason", route.reason, "dim", 2);
895
- for (const alternative of route?.consideredAlternatives ?? []) {
896
- addWrapped(`considered ${alternative.owner}`, alternative.reason, "dim", 2);
897
- }
898
- for (const update of judgment.questionUpdates ?? []) {
899
- addWrapped(
900
- `question ${update.status}`,
901
- `${update.questionId} → ${update.result}`,
902
- "accent",
903
- 2,
904
- );
905
- }
906
- }
907
- if (state.judgmentHistory.length > recentJudgments.length) {
908
- addWrapped(
909
- "earlier",
910
- `${state.judgmentHistory.length - recentJudgments.length} earlier judgments`,
911
- "dim",
912
- 1,
913
- );
914
- }
915
- }
916
-
917
- rows.push(row());
918
- addWrapped(
919
- "resources",
920
- `${this.view.availableSkills.length} skills · ${this.view.activeTools.length} active tools`,
921
- "dim",
922
- 1,
923
- );
924
- rows.push(row());
925
-
926
- const header = rows.slice(0, 2);
927
- const body = rows.slice(2);
928
- const bodyCapacity = Math.max(1, this.viewportHeight - 4);
929
- this.maxScrollOffset = Math.max(0, body.length - bodyCapacity);
930
- this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset);
931
- const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + bodyCapacity);
932
- while (visibleBody.length < bodyCapacity) visibleBody.push(row());
933
- const position =
934
- body.length > bodyCapacity
935
- ? `wheel/↑↓ · PgUp/PgDn · Home/End · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
936
- : "enter/esc close · /develop questions revisits open work";
937
- const visibleRows = [
938
- ...header,
939
- ...visibleBody,
940
- row(` ${this.theme.fg("dim", position)}`),
941
- border(`╰${"─".repeat(innerWidth)}╯`),
942
- ];
943
-
944
- this.cachedWidth = width;
945
- this.cachedLines = visibleRows;
946
- return visibleRows;
947
- }
948
-
949
- invalidate(): void {
950
- this.cachedWidth = undefined;
951
- this.cachedLines = undefined;
952
- }
1516
+ private cachedWidth?: number;
1517
+ private cachedLines?: string[];
1518
+ private readonly view: DeveloperStatusView;
1519
+ private readonly theme: Theme;
1520
+ private readonly onClose: () => void;
1521
+
1522
+ constructor(view: DeveloperStatusView, theme: Theme, onClose: () => void) {
1523
+ this.view = view;
1524
+ this.theme = theme;
1525
+ this.onClose = onClose;
1526
+ }
1527
+
1528
+ handleInput(data: string): void {
1529
+ if (
1530
+ matchesKey(data, "escape") ||
1531
+ matchesKey(data, "enter") ||
1532
+ matchesKey(data, "ctrl+c")
1533
+ )
1534
+ this.onClose();
1535
+ }
1536
+
1537
+ render(width: number): string[] {
1538
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
1539
+
1540
+ const panelWidth = Math.max(1, width);
1541
+ const innerWidth = Math.max(1, panelWidth - 2);
1542
+ const rows: string[] = [];
1543
+ const border = (text: string) => this.theme.fg("borderAccent", text);
1544
+ const row = (content = "") =>
1545
+ `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("│")}`;
1546
+ const addWrapped = (
1547
+ label: string,
1548
+ value: string,
1549
+ color: ThemeColor = "muted",
1550
+ maxLines = 2,
1551
+ ) => {
1552
+ const labelText = `${label} ·`;
1553
+ const plainPrefix = ` ${labelText} `;
1554
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
1555
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
1556
+ const wrapped = wrapTextWithAnsi(
1557
+ this.theme.fg(color, value.trim()),
1558
+ contentWidth,
1559
+ );
1560
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
1561
+ if (wrapped.length > visible.length && visible.length > 0) {
1562
+ const last = visible.length - 1;
1563
+ visible[last] =
1564
+ truncateToWidth(
1565
+ visible[last] ?? "",
1566
+ Math.max(1, contentWidth - 1),
1567
+ "",
1568
+ ) + this.theme.fg("dim", "…");
1569
+ }
1570
+ rows.push(row(styledPrefix + (visible[0] ?? "")));
1571
+ const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
1572
+ for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
1573
+ };
1574
+ const section = (title: string) =>
1575
+ rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
1576
+
1577
+ rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
1578
+ rows.push(
1579
+ row(
1580
+ ` ${this.theme.fg("accent", this.theme.bold("◆ Developer status"))}`,
1581
+ ),
1582
+ );
1583
+ rows.push(row());
1584
+
1585
+ const state = this.view.state;
1586
+ const currentProtocol = protocolState(state);
1587
+ const summary =
1588
+ `Developer ${this.theme.fg(state.enabled ? "success" : "dim", state.enabled ? "on" : "off")}` +
1589
+ this.theme.fg("dim", " · ") +
1590
+ `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
1591
+ this.theme.fg("dim", " · ") +
1592
+ `target ${this.theme.fg("muted", state.activeRoute?.target ?? "none")}`;
1593
+ for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2)))
1594
+ rows.push(row(` ${line}`));
1595
+ rows.push(row());
1596
+
1597
+ section("Active route");
1598
+ if (state.activeRoute) {
1599
+ addWrapped("id", state.activeRoute.routeId, "dim", 1);
1600
+ addWrapped("question", state.activeRoute.question, "text", 3);
1601
+ addWrapped("reason", state.activeRoute.reason, "muted", 2);
1602
+ addWrapped(
1603
+ "skill",
1604
+ state.activeRoute.methodLocation ?? "implementation action",
1605
+ "dim",
1606
+ 1,
1607
+ );
1608
+ addWrapped(
1609
+ "known evidence",
1610
+ String(state.activeRoute.knownEvidence.length),
1611
+ "muted",
1612
+ 1,
1613
+ );
1614
+ } else {
1615
+ addWrapped(
1616
+ "state",
1617
+ "No route is currently waiting for judgment.",
1618
+ "dim",
1619
+ 2,
1620
+ );
1621
+ }
1622
+
1623
+ rows.push(row());
1624
+ section(`Open questions · ${state.pendingQuestions.length}`);
1625
+ if (state.pendingQuestions.length === 0) {
1626
+ addWrapped("state", "No unresolved Developer questions.", "dim", 2);
1627
+ } else {
1628
+ for (const question of state.pendingQuestions.slice(0, 4)) {
1629
+ addWrapped(
1630
+ `${question.status} · ${question.resolutionOwner} · ${question.gate}`,
1631
+ question.question,
1632
+ question.status === "blocked" ? "error" : "warning",
1633
+ 2,
1634
+ );
1635
+ addWrapped("resolves when", question.resolutionCriteria, "dim", 2);
1636
+ }
1637
+ if (state.pendingQuestions.length > 4) {
1638
+ addWrapped(
1639
+ "more",
1640
+ `${state.pendingQuestions.length - 4} additional open questions`,
1641
+ "dim",
1642
+ 1,
1643
+ );
1644
+ }
1645
+ }
1646
+
1647
+ rows.push(row());
1648
+ section(`Judgment history · ${state.judgmentHistory.length}`);
1649
+ if (state.judgmentHistory.length === 0) {
1650
+ addWrapped(
1651
+ "state",
1652
+ "No judgment has been recorded on this branch.",
1653
+ "dim",
1654
+ 2,
1655
+ );
1656
+ } else {
1657
+ const recentJudgments = state.judgmentHistory.slice(-10).toReversed();
1658
+ for (const judgment of recentJudgments) {
1659
+ const route = state.routeHistory.find(
1660
+ (candidate) => candidate.routeId === judgment.routeId,
1661
+ );
1662
+ addWrapped(
1663
+ `${judgment.target} ${judgment.status}`,
1664
+ `${judgment.question} → ${judgmentSummary(judgment.result)}`,
1665
+ judgmentColor(judgment.status),
1666
+ 3,
1667
+ );
1668
+ if (route) addWrapped("route reason", route.reason, "dim", 2);
1669
+ for (const alternative of route?.consideredAlternatives ?? []) {
1670
+ addWrapped(
1671
+ `considered ${alternative.target}`,
1672
+ alternative.reason,
1673
+ "dim",
1674
+ 2,
1675
+ );
1676
+ }
1677
+ for (const update of judgment.questionUpdates ?? []) {
1678
+ addWrapped(
1679
+ `question ${update.status}`,
1680
+ `${update.questionId} → ${update.result}`,
1681
+ "accent",
1682
+ 2,
1683
+ );
1684
+ }
1685
+ }
1686
+ if (state.judgmentHistory.length > recentJudgments.length) {
1687
+ addWrapped(
1688
+ "earlier",
1689
+ `${state.judgmentHistory.length - recentJudgments.length} earlier judgments`,
1690
+ "dim",
1691
+ 1,
1692
+ );
1693
+ }
1694
+ }
1695
+
1696
+ rows.push(row());
1697
+ addWrapped(
1698
+ "resources",
1699
+ `${this.view.availableSkills.length} skills · ${this.view.activeTools.length} active tools`,
1700
+ "dim",
1701
+ 1,
1702
+ );
1703
+ rows.push(row());
1704
+
1705
+ for (const line of wrapTextWithAnsi(
1706
+ this.theme.fg(
1707
+ "dim",
1708
+ "mouse wheel scroll · drag select · Cmd+C copy · enter/esc close",
1709
+ ),
1710
+ Math.max(1, innerWidth - 2),
1711
+ ))
1712
+ rows.push(row(` ${line}`));
1713
+ rows.push(border(`╰${"─".repeat(innerWidth)}╯`));
1714
+
1715
+ this.cachedWidth = width;
1716
+ this.cachedLines = rows;
1717
+ return rows;
1718
+ }
1719
+
1720
+ invalidate(): void {
1721
+ this.cachedWidth = undefined;
1722
+ this.cachedLines = undefined;
1723
+ }
953
1724
  }
954
1725
 
955
1726
  export async function showDeveloperStatus(
956
- ctx: ExtensionCommandContext,
957
- view: DeveloperStatusView,
1727
+ ctx: ExtensionCommandContext,
1728
+ view: DeveloperStatusView,
958
1729
  ): Promise<void> {
959
- let mouseTerminal: WritableTerminal | undefined;
960
- try {
961
- await ctx.ui.custom<void>(
962
- (tui, theme, _keybindings, done) => {
963
- mouseTerminal = enableMouseScroll(tui);
964
- return new DeveloperStatusPanel(view, theme, () => done(), {
965
- viewportHeight: Math.max(10, Math.min(36, Math.floor(tui.terminal.rows * 0.88))),
966
- requestRender: () => tui.requestRender(),
967
- });
968
- },
969
- {
970
- overlay: true,
971
- overlayOptions: {
972
- anchor: "center",
973
- width: "90%",
974
- minWidth: 72,
975
- maxHeight: "88%",
976
- margin: 1,
977
- },
978
- },
979
- );
980
- } finally {
981
- disableMouseScroll(mouseTerminal);
982
- }
1730
+ await ctx.ui.custom<void>(
1731
+ (_tui, theme, _keybindings, done) =>
1732
+ new DeveloperStatusPanel(view, theme, () => done()),
1733
+ );
983
1734
  }
984
1735
 
985
1736
  export function questionResolutionPrompt(question: PendingQuestion): string {
986
- const requestLabel = resolutionRequestLabel(question.resolutionOwner);
987
- const context = question.context
988
- ? ["", "Decision or evidence context:", question.context, ""]
989
- : [];
990
- return [
991
- "Resolve this open Developer question.",
992
- "",
993
- `Question: ${question.question}`,
994
- ...context,
995
- `Resolution owner: ${question.resolutionOwner}`,
996
- `Gate: ${question.gate}`,
997
- `Resolution criteria: ${question.resolutionCriteria}`,
998
- "",
999
- requestLabel,
1000
- "",
1001
- "Use the supplied answer as new evidence, or investigate with available tools when the owner is agent.",
1002
- "Route the focused question, then record resolved/not-applicable with question_updates when it is settled; otherwise retain it with the specific remaining evidence gap.",
1003
- ].join("\n");
1004
- }
1005
-
1006
- export function editQuestionResolutionRequest(
1007
- ctx: ExtensionCommandContext,
1008
- question: PendingQuestion,
1737
+ const requestLabel = resolutionRequestLabel(question.resolutionOwner);
1738
+ const context = question.context
1739
+ ? ["", "Decision or evidence context:", question.context, ""]
1740
+ : [];
1741
+ return [
1742
+ "Resolve this open Developer question.",
1743
+ "",
1744
+ `Question: ${question.question}`,
1745
+ ...context,
1746
+ `Resolution owner: ${question.resolutionOwner}`,
1747
+ `Gate: ${question.gate}`,
1748
+ `Resolution criteria: ${question.resolutionCriteria}`,
1749
+ "",
1750
+ requestLabel,
1751
+ "",
1752
+ "Use the supplied answer as new evidence, or investigate with available tools when the owner is agent.",
1753
+ "Route the focused question, then record resolved/not-applicable with question_updates when it is settled; otherwise retain it with the specific remaining evidence gap.",
1754
+ ].join("\n");
1755
+ }
1756
+
1757
+ export async function editQuestionResolutionRequest(
1758
+ ctx: ExtensionCommandContext,
1759
+ question: PendingQuestion,
1009
1760
  ): Promise<string | undefined> {
1010
- if (question.responseSpec) return collectChoiceResponse(ctx, question);
1011
- const current = ctx.ui.getEditorText();
1012
- const request = questionResolutionPrompt(question);
1013
- const initial = current.trim() ? `${current.trimEnd()}\n\n${request}` : request;
1014
- return ctx.ui.editor("Answer or investigate the selected Developer question", initial);
1761
+ const request = questionResolutionPrompt(question);
1762
+ const current = question.responseSpec ? "" : ctx.ui.getEditorText();
1763
+ const initial = current.trim()
1764
+ ? `${current.trimEnd()}\n\n${request}`
1765
+ : request;
1766
+ const disposition = await prepareQuestionResolution(ctx, question, {
1767
+ title: "Answer or investigate the selected Developer question",
1768
+ initial,
1769
+ });
1770
+ return disposition.kind === "answer" ? disposition.request : undefined;
1015
1771
  }