@hobin/developer 0.1.4 → 0.1.6

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,13 +1,15 @@
1
- import {
2
- type ExtensionCommandContext,
3
- type KeybindingsManager,
4
- type Theme,
5
- type ThemeColor,
1
+ import type {
2
+ ExtensionCommandContext,
3
+ ExtensionContext,
4
+ KeybindingsManager,
5
+ Theme,
6
+ ThemeColor,
6
7
  } from "@earendil-works/pi-coding-agent";
7
8
  import {
8
9
  Container,
9
10
  matchesKey,
10
11
  type SelectItem,
12
+ type SizeValue,
11
13
  Text,
12
14
  truncateToWidth,
13
15
  visibleWidth,
@@ -16,13 +18,18 @@ import {
16
18
 
17
19
  import {
18
20
  protocolState,
21
+ type ChoiceResponseField,
22
+ type ChoiceResponseOption,
19
23
  type DeveloperMode,
20
24
  type DeveloperState,
25
+ type JudgmentStatus,
21
26
  type PendingQuestion,
22
27
  type ProtocolState,
23
28
  } from "./state.ts";
24
29
 
25
- export type DeveloperAction = "status" | "questions" | DeveloperMode;
30
+ export type DeveloperAction =
31
+ | { kind: "command"; value: "status" | "questions" | DeveloperMode }
32
+ | { kind: "question"; questionId: string };
26
33
 
27
34
  function modeName(mode: DeveloperMode): string {
28
35
  if (mode === "on") return "adaptive";
@@ -31,7 +38,13 @@ function modeName(mode: DeveloperMode): string {
31
38
 
32
39
  function protocolColor(value: ProtocolState): ThemeColor {
33
40
  if (value === "blocked") return "error";
34
- if (value === "needs-evidence" || value === "needs-verification") return "warning";
41
+ if (
42
+ value === "needs-evidence" ||
43
+ value === "needs-answer" ||
44
+ value === "needs-routing" ||
45
+ value === "needs-verification"
46
+ )
47
+ return "warning";
35
48
  if (value === "needs-judgment") return "accent";
36
49
  return "dim";
37
50
  }
@@ -42,6 +55,22 @@ function modeColor(mode: DeveloperMode): ThemeColor {
42
55
  return "dim";
43
56
  }
44
57
 
58
+ 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";
63
+ }
64
+
65
+ 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
+
45
74
  export function renderDeveloperFooter(state: DeveloperState, theme: Theme): string {
46
75
  const currentProtocol = protocolState(state);
47
76
  const target = state.activeRoute?.owner ?? "none";
@@ -64,13 +93,7 @@ export function developerActionItems(state: DeveloperState): SelectItem[] {
64
93
  description: `${modeName(state.mode)} · ${currentProtocol} · ${state.pendingQuestions.length} open`,
65
94
  },
66
95
  ];
67
- if (state.pendingQuestions.length > 0) {
68
- items.push({
69
- value: "questions",
70
- label: "Revisit an open question",
71
- description: `Choose from ${state.pendingQuestions.length} unresolved question(s) and prepare the editor`,
72
- });
73
- }
96
+ items.push(...pendingQuestionItems(state.pendingQuestions));
74
97
  items.push(
75
98
  {
76
99
  value: "on",
@@ -80,7 +103,7 @@ export function developerActionItems(state: DeveloperState): SelectItem[] {
80
103
  {
81
104
  value: "strict",
82
105
  label: state.mode === "strict" ? "Strict mode (active)" : "Strict mode",
83
- description: "Require a direct route before Pi built-in mutation tools become active",
106
+ description: "Allow bash on judgment routes; require direct for Pi built-in edit/write",
84
107
  },
85
108
  {
86
109
  value: "off",
@@ -91,31 +114,84 @@ export function developerActionItems(state: DeveloperState): SelectItem[] {
91
114
  return items;
92
115
  }
93
116
 
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";
122
+ }
123
+
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:";
128
+ }
129
+
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";
134
+
135
+ export type ImmediateQuestionDisposition = { kind: "answer"; request: string } | { kind: "defer" };
136
+
137
+ interface WritableTerminal {
138
+ write(data: string): void;
139
+ }
140
+
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;
146
+ }
147
+
148
+ function disableMouseScroll(terminal: WritableTerminal | undefined): void {
149
+ terminal?.write(DISABLE_MOUSE_SCROLL);
150
+ }
151
+
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;
158
+ }
159
+
94
160
  export function pendingQuestionItems(questions: PendingQuestion[]): SelectItem[] {
95
- return questions.map((question) => ({
96
- value: question.id,
97
- label: question.question,
98
- description: question.status,
99
- }));
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
+ });
100
169
  }
101
170
 
102
171
  interface SelectDialogOptions {
103
172
  title: string;
104
173
  subtitle: string;
105
174
  items: SelectItem[];
106
- width: number;
175
+ width: SizeValue;
176
+ minWidth: number;
107
177
  maxVisible: number;
108
178
  selectedLabelMaxLines: number;
109
179
  selectedDescriptionMaxLines: number;
110
180
  }
111
181
 
112
- function boundedWrappedLines(content: string, width: number, maxLines: number, ellipsis: string): string[] {
182
+ function boundedWrappedLines(
183
+ content: string,
184
+ width: number,
185
+ maxLines: number,
186
+ ellipsis: string,
187
+ ): string[] {
113
188
  const contentWidth = Math.max(1, width);
114
189
  const wrapped = wrapTextWithAnsi(content, contentWidth);
115
190
  const visible = wrapped.slice(0, Math.max(1, maxLines));
116
191
  if (wrapped.length > visible.length && visible.length > 0) {
117
192
  const last = visible.length - 1;
118
- visible[last] = truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
193
+ visible[last] =
194
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
119
195
  }
120
196
  return visible;
121
197
  }
@@ -136,6 +212,7 @@ class WrappedSelectList {
136
212
  private readonly items: SelectItem[];
137
213
  private readonly keybindings: KeybindingsManager;
138
214
  private readonly maxVisible: number;
215
+ private readonly renderHeight: number;
139
216
  private readonly selectedDescriptionMaxLines: number;
140
217
  private readonly selectedLabelMaxLines: number;
141
218
  private readonly theme: Theme;
@@ -158,6 +235,10 @@ class WrappedSelectList {
158
235
  this.keybindings = keybindings;
159
236
  this.selectedLabelMaxLines = options.selectedLabelMaxLines;
160
237
  this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
238
+ this.renderHeight = Math.max(
239
+ 1,
240
+ maxVisible - 1 + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 1,
241
+ );
161
242
  }
162
243
 
163
244
  render(width: number): string[] {
@@ -166,7 +247,10 @@ class WrappedSelectList {
166
247
  const lines: string[] = [];
167
248
  const startIndex = Math.max(
168
249
  0,
169
- Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible),
250
+ Math.min(
251
+ this.selectedIndex - Math.floor(this.maxVisible / 2),
252
+ this.items.length - this.maxVisible,
253
+ ),
170
254
  );
171
255
  const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
172
256
 
@@ -202,18 +286,37 @@ class WrappedSelectList {
202
286
  lines.push(
203
287
  this.theme.fg(
204
288
  "dim",
205
- truncateToWidth(` (${this.selectedIndex + 1}/${this.items.length})`, Math.max(1, width - 2), ""),
289
+ truncateToWidth(
290
+ ` (${this.selectedIndex + 1}/${this.items.length})`,
291
+ Math.max(1, width - 2),
292
+ "",
293
+ ),
206
294
  ),
207
295
  );
208
296
  }
209
- return lines;
297
+ const visible = lines.slice(0, this.renderHeight);
298
+ while (visible.length < this.renderHeight) visible.push("");
299
+ return visible;
210
300
  }
211
301
 
212
302
  handleInput(data: string): void {
213
- if (this.keybindings.matches(data, "tui.select.up")) {
214
- this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
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;
215
309
  } else if (this.keybindings.matches(data, "tui.select.down")) {
216
- this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
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);
217
320
  } else if (this.keybindings.matches(data, "tui.select.confirm")) {
218
321
  const selected = this.items[this.selectedIndex];
219
322
  if (selected) this.onSelect?.(selected);
@@ -222,72 +325,82 @@ class WrappedSelectList {
222
325
  }
223
326
  }
224
327
 
328
+ private moveSelection(delta: number): void {
329
+ this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, this.selectedIndex + delta));
330
+ }
331
+
225
332
  invalidate(): void {}
226
333
  }
227
334
 
228
335
  async function showSelectDialog(
229
- ctx: ExtensionCommandContext,
336
+ ctx: ExtensionContext,
230
337
  options: SelectDialogOptions,
231
338
  ): Promise<string | undefined> {
232
- const result = await ctx.ui.custom<string | null>(
233
- (tui, theme, keybindings, done) => {
234
- const container = new Container();
235
- const title = new Text("", 1, 0);
236
- const subtitle = new Text("", 1, 0);
237
- const hint = new Text("", 1, 0);
238
- const updateText = () => {
239
- title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
240
- subtitle.setText(theme.fg("muted", options.subtitle));
241
- hint.setText(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"));
242
- };
243
- updateText();
244
-
245
- const list = new WrappedSelectList(
246
- options.items,
247
- Math.min(options.items.length, options.maxVisible),
248
- theme,
249
- keybindings,
250
- {
251
- selectedLabelMaxLines: options.selectedLabelMaxLines,
252
- selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
253
- },
254
- );
255
- list.onSelect = (item) => done(item.value);
256
- list.onCancel = () => done(null);
257
-
258
- container.addChild(title);
259
- container.addChild(subtitle);
260
- container.addChild(list);
261
- container.addChild(hint);
262
-
263
- return {
264
- render(width: number) {
265
- return renderModalFrame(container, theme, width);
266
- },
267
- invalidate() {
268
- updateText();
269
- container.invalidate();
270
- },
271
- handleInput(data: string) {
272
- list.handleInput(data);
273
- tui.requestRender();
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,
274
397
  },
275
- };
276
- },
277
- {
278
- overlay: true,
279
- overlayOptions: {
280
- anchor: "center",
281
- width: options.width,
282
- maxHeight: Math.min(
283
- options.maxVisible + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 7,
284
- 24,
285
- ),
286
- margin: 1,
287
398
  },
288
- },
289
- );
290
- return result ?? undefined;
399
+ );
400
+ return result ?? undefined;
401
+ } finally {
402
+ disableMouseScroll(mouseTerminal);
403
+ }
291
404
  }
292
405
 
293
406
  export async function showDeveloperActionSelector(
@@ -298,31 +411,278 @@ export async function showDeveloperActionSelector(
298
411
  title: "Developer control",
299
412
  subtitle: `Current · ${modeName(state.mode)} · ${protocolState(state)}`,
300
413
  items: developerActionItems(state),
301
- width: 78,
414
+ width: "84%",
415
+ minWidth: 78,
302
416
  maxVisible: 6,
303
- selectedLabelMaxLines: 2,
304
- selectedDescriptionMaxLines: 2,
417
+ selectedLabelMaxLines: 3,
418
+ selectedDescriptionMaxLines: 3,
305
419
  });
306
- if (result === "status" || result === "questions" || result === "on" || result === "strict" || result === "off") {
307
- return result;
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 };
308
432
  }
309
433
  return undefined;
310
434
  }
311
435
 
312
- export async function showPendingQuestionSelector(
436
+ export function showPendingQuestionSelector(
313
437
  ctx: ExtensionCommandContext,
314
438
  questions: PendingQuestion[],
315
439
  ): Promise<string | undefined> {
316
- if (questions.length === 0) return undefined;
440
+ if (questions.length === 0) return Promise.resolve(undefined);
317
441
  return showSelectDialog(ctx, {
318
- title: "Revisit an open Developer question",
319
- subtitle: "Selection prepares an editable prompt; protocol state changes only after the route is judged",
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",
320
445
  items: pendingQuestionItems(questions),
321
- width: 96,
446
+ width: "92%",
447
+ minWidth: 88,
322
448
  maxVisible: 10,
323
449
  selectedLabelMaxLines: 5,
324
- selectedDescriptionMaxLines: 1,
450
+ selectedDescriptionMaxLines: 3,
451
+ });
452
+ }
453
+
454
+ interface ChoiceResponseAnswer {
455
+ option: ChoiceResponseOption;
456
+ detail?: string;
457
+ }
458
+
459
+ function responseWithinLimits(
460
+ ctx: ExtensionContext,
461
+ request: string,
462
+ label = "Question response",
463
+ ): 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");
485
+ }
486
+
487
+ async function editChoiceDetail(
488
+ ctx: ExtensionContext,
489
+ field: ChoiceResponseField,
490
+ option: ChoiceResponseOption,
491
+ ): 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
+ }
506
+ }
507
+
508
+ function structuredResolutionRequest(
509
+ question: PendingQuestion,
510
+ answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
511
+ ): 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");
521
+ }
522
+
523
+ type ChoiceFieldAction =
524
+ | { kind: "answer"; answer: ChoiceResponseAnswer }
525
+ | { kind: "cancel" }
526
+ | { kind: "retry" };
527
+
528
+ type ChoiceReviewAction =
529
+ | { kind: "submit" }
530
+ | { kind: "edit"; fieldIndex: number }
531
+ | { kind: "back" };
532
+
533
+ async function selectChoiceAnswer(
534
+ ctx: ExtensionContext,
535
+ field: ChoiceResponseField,
536
+ fieldIndex: number,
537
+ fieldCount: number,
538
+ ): 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,
325
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 } };
559
+ }
560
+
561
+ async function reviewChoiceAnswers(
562
+ ctx: ExtensionContext,
563
+ question: PendingQuestion,
564
+ fields: ChoiceResponseField[],
565
+ answers: ReadonlyArray<ChoiceResponseAnswer | undefined>,
566
+ ): 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 };
598
+ }
599
+
600
+ function previousChoiceField(
601
+ fieldIndex: number,
602
+ fieldCount: number,
603
+ returnToReview: boolean,
604
+ ): number | undefined {
605
+ if (returnToReview) return fieldCount;
606
+ return fieldIndex === 0 ? undefined : fieldIndex - 1;
607
+ }
608
+
609
+ async function collectChoiceResponse(
610
+ ctx: ExtensionContext,
611
+ question: PendingQuestion,
612
+ ): 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,
650
+ ): 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
+ }
326
686
  }
327
687
 
328
688
  export class DeveloperWidget {
@@ -346,19 +706,27 @@ export class DeveloperWidget {
346
706
  );
347
707
  }
348
708
  for (const question of this.state.pendingQuestions.slice(0, 3)) {
709
+ const label = question.resolutionOwner === "user" ? "? answer" : "? evidence";
349
710
  lines.push(
350
711
  truncateToWidth(
351
- `${this.theme.fg(question.status === "blocked" ? "error" : "warning", "? open")} ${this.theme.fg("dim", "·")} ${this.theme.fg("muted", question.question)}`,
712
+ `${this.theme.fg(question.status === "blocked" ? "error" : "warning", label)} ${this.theme.fg("dim", `· ${question.gate} ·`)} ${this.theme.fg("muted", question.question)}`,
352
713
  width,
353
714
  "…",
354
715
  ),
355
716
  );
356
717
  }
357
718
  if (this.state.pendingQuestions.length > 3) {
358
- lines.push(this.theme.fg("dim", ` +${this.state.pendingQuestions.length - 3} more open questions`));
719
+ lines.push(
720
+ this.theme.fg("dim", ` +${this.state.pendingQuestions.length - 3} more open questions`),
721
+ );
359
722
  }
360
723
  if (this.state.implementationFramingRequired) {
361
- lines.push(this.theme.fg("warning", "→ next · sketch feature shape or signal structural movement"));
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"));
362
730
  }
363
731
  if (this.state.verificationRequired) {
364
732
  lines.push(this.theme.fg("warning", "→ next · verify changed artifacts before completion"));
@@ -404,14 +772,24 @@ export class DeveloperStatusPanel {
404
772
  this.onClose();
405
773
  return;
406
774
  }
407
- if (matchesKey(data, "up")) this.scrollOffset = Math.max(0, this.scrollOffset - 1);
408
- else if (matchesKey(data, "down")) {
409
- this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + 1);
410
- } else return;
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;
411
785
  this.invalidate();
412
786
  this.requestRender();
413
787
  }
414
788
 
789
+ private moveScroll(delta: number): void {
790
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset + delta));
791
+ }
792
+
415
793
  render(width: number): string[] {
416
794
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
417
795
 
@@ -419,8 +797,14 @@ export class DeveloperStatusPanel {
419
797
  const innerWidth = Math.max(1, panelWidth - 2);
420
798
  const rows: string[] = [];
421
799
  const border = (text: string) => this.theme.fg("borderAccent", text);
422
- const row = (content = "") => `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("│")}`;
423
- const addWrapped = (label: string, value: string, color: ThemeColor = "muted", maxLines = 2) => {
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
+ ) => {
424
808
  const labelText = `${label} ·`;
425
809
  const plainPrefix = ` ${labelText} `;
426
810
  const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
@@ -430,13 +814,15 @@ export class DeveloperStatusPanel {
430
814
  if (wrapped.length > visible.length && visible.length > 0) {
431
815
  const last = visible.length - 1;
432
816
  visible[last] =
433
- truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + this.theme.fg("dim", "…");
817
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") +
818
+ this.theme.fg("dim", "…");
434
819
  }
435
820
  rows.push(row(styledPrefix + (visible[0] ?? "")));
436
821
  const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
437
822
  for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
438
823
  };
439
- const section = (title: string) => rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
824
+ const section = (title: string) =>
825
+ rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
440
826
 
441
827
  rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
442
828
  rows.push(row(` ${this.theme.fg("accent", this.theme.bold("◆ Developer status"))}`));
@@ -450,7 +836,8 @@ export class DeveloperStatusPanel {
450
836
  `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
451
837
  this.theme.fg("dim", " · ") +
452
838
  `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`;
453
- for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2))) rows.push(row(` ${line}`));
839
+ for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2)))
840
+ rows.push(row(` ${line}`));
454
841
  rows.push(row());
455
842
 
456
843
  section("Active route");
@@ -471,41 +858,60 @@ export class DeveloperStatusPanel {
471
858
  } else {
472
859
  for (const question of state.pendingQuestions.slice(0, 4)) {
473
860
  addWrapped(
474
- question.status === "blocked" ? "blocked" : "needs evidence",
861
+ `${question.status} · ${question.resolutionOwner} · ${question.gate}`,
475
862
  question.question,
476
863
  question.status === "blocked" ? "error" : "warning",
477
864
  2,
478
865
  );
866
+ addWrapped("resolves when", question.resolutionCriteria, "dim", 2);
479
867
  }
480
868
  if (state.pendingQuestions.length > 4) {
481
- addWrapped("more", `${state.pendingQuestions.length - 4} additional open questions`, "dim", 1);
869
+ addWrapped(
870
+ "more",
871
+ `${state.pendingQuestions.length - 4} additional open questions`,
872
+ "dim",
873
+ 1,
874
+ );
482
875
  }
483
876
  }
484
877
 
485
878
  rows.push(row());
486
- section("Last judgment");
487
- if (state.lastJudgment) {
488
- addWrapped(
489
- "status",
490
- state.lastJudgment.status,
491
- protocolColor(
492
- state.lastJudgment.status === "blocked"
493
- ? "blocked"
494
- : state.lastJudgment.status === "needs-evidence"
495
- ? "needs-evidence"
496
- : "idle",
497
- ),
498
- 1,
499
- );
500
- addWrapped("result", state.lastJudgment.result, "muted", 3);
501
- addWrapped(
502
- "evidence",
503
- `${state.lastJudgment.basis.length} basis · ${state.lastJudgment.artifacts.length} artifacts`,
504
- "dim",
505
- 1,
506
- );
507
- } else {
879
+ section(`Judgment history · ${state.judgmentHistory.length}`);
880
+ if (state.judgmentHistory.length === 0) {
508
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
+ }
509
915
  }
510
916
 
511
917
  rows.push(row());
@@ -523,9 +929,10 @@ export class DeveloperStatusPanel {
523
929
  this.maxScrollOffset = Math.max(0, body.length - bodyCapacity);
524
930
  this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset);
525
931
  const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + bodyCapacity);
932
+ while (visibleBody.length < bodyCapacity) visibleBody.push(row());
526
933
  const position =
527
934
  body.length > bodyCapacity
528
- ? `↑↓ scroll · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
935
+ ? `wheel/↑↓ · PgUp/PgDn · Home/End · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
529
936
  : "enter/esc close · /develop questions revisits open work";
530
937
  const visibleRows = [
531
938
  ...header,
@@ -545,28 +952,64 @@ export class DeveloperStatusPanel {
545
952
  }
546
953
  }
547
954
 
548
- export async function showDeveloperStatus(ctx: ExtensionCommandContext, view: DeveloperStatusView): Promise<void> {
549
- await ctx.ui.custom<void>(
550
- (tui, theme, _keybindings, done) =>
551
- new DeveloperStatusPanel(view, theme, () => done(), {
552
- viewportHeight: Math.max(6, Math.min(28, tui.terminal.rows - 2)),
553
- requestRender: () => tui.requestRender(),
554
- }),
555
- {
556
- overlay: true,
557
- overlayOptions: {
558
- anchor: "center",
559
- width: 92,
560
- minWidth: 56,
561
- maxHeight: 28,
562
- margin: 1,
955
+ export async function showDeveloperStatus(
956
+ ctx: ExtensionCommandContext,
957
+ view: DeveloperStatusView,
958
+ ): 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
+ });
563
968
  },
564
- },
565
- );
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
+ }
566
983
  }
567
984
 
568
- export function prepareQuestionPrompt(ctx: ExtensionCommandContext, question: PendingQuestion): void {
569
- const prompt = `Revisit this Developer question: ${question.question}`;
985
+ 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,
1009
+ ): Promise<string | undefined> {
1010
+ if (question.responseSpec) return collectChoiceResponse(ctx, question);
570
1011
  const current = ctx.ui.getEditorText();
571
- ctx.ui.setEditorText(current.trim() ? `${current.trimEnd()}\n\n${prompt}` : prompt);
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);
572
1015
  }