@hobin/developer 0.1.3 → 0.1.5

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,15 +1,14 @@
1
1
  import {
2
- DynamicBorder,
3
2
  type ExtensionCommandContext,
3
+ type KeybindingsManager,
4
4
  type Theme,
5
5
  type ThemeColor,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import {
8
- Box,
9
8
  Container,
10
9
  matchesKey,
11
10
  type SelectItem,
12
- SelectList,
11
+ type SizeValue,
13
12
  Text,
14
13
  truncateToWidth,
15
14
  visibleWidth,
@@ -20,6 +19,7 @@ import {
20
19
  protocolState,
21
20
  type DeveloperMode,
22
21
  type DeveloperState,
22
+ type JudgmentStatus,
23
23
  type PendingQuestion,
24
24
  type ProtocolState,
25
25
  } from "./state.ts";
@@ -33,7 +33,12 @@ function modeName(mode: DeveloperMode): string {
33
33
 
34
34
  function protocolColor(value: ProtocolState): ThemeColor {
35
35
  if (value === "blocked") return "error";
36
- if (value === "needs-evidence" || value === "needs-verification") return "warning";
36
+ if (
37
+ value === "needs-evidence" ||
38
+ value === "needs-answer" ||
39
+ value === "needs-routing" ||
40
+ value === "needs-verification"
41
+ ) return "warning";
37
42
  if (value === "needs-judgment") return "accent";
38
43
  return "dim";
39
44
  }
@@ -44,6 +49,22 @@ function modeColor(mode: DeveloperMode): ThemeColor {
44
49
  return "dim";
45
50
  }
46
51
 
52
+ function judgmentColor(status: JudgmentStatus): ThemeColor {
53
+ if (status === "blocked") return "error";
54
+ if (status === "needs-evidence") return "warning";
55
+ if (status === "resolved") return "success";
56
+ return "muted";
57
+ }
58
+
59
+ function judgmentSummary(result: string): string {
60
+ return (
61
+ result
62
+ .split(/\r?\n/)
63
+ .map((line) => line.trim())
64
+ .find((line) => line && !line.startsWith("```")) ?? result
65
+ );
66
+ }
67
+
47
68
  export function renderDeveloperFooter(state: DeveloperState, theme: Theme): string {
48
69
  const currentProtocol = protocolState(state);
49
70
  const target = state.activeRoute?.owner ?? "none";
@@ -82,7 +103,7 @@ export function developerActionItems(state: DeveloperState): SelectItem[] {
82
103
  {
83
104
  value: "strict",
84
105
  label: state.mode === "strict" ? "Strict mode (active)" : "Strict mode",
85
- 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",
86
107
  },
87
108
  {
88
109
  value: "off",
@@ -93,146 +114,272 @@ export function developerActionItems(state: DeveloperState): SelectItem[] {
93
114
  return items;
94
115
  }
95
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 ENABLE_MOUSE_SCROLL = "\u001b[?1000h\u001b[?1006h";
131
+ const DISABLE_MOUSE_SCROLL = "\u001b[?1006l\u001b[?1000l";
132
+
133
+ interface WritableTerminal {
134
+ write(data: string): void;
135
+ }
136
+
137
+ function enableMouseScroll(tui: { terminal?: WritableTerminal }): WritableTerminal | undefined {
138
+ const terminal = tui.terminal;
139
+ if (!terminal || typeof terminal.write !== "function") return undefined;
140
+ terminal.write(ENABLE_MOUSE_SCROLL);
141
+ return terminal;
142
+ }
143
+
144
+ function disableMouseScroll(terminal: WritableTerminal | undefined): void {
145
+ terminal?.write(DISABLE_MOUSE_SCROLL);
146
+ }
147
+
148
+ function mouseWheelDirection(data: string): -1 | 1 | undefined {
149
+ const match = /^\u001b\[<(\d+);\d+;\d+M$/.exec(data);
150
+ if (!match?.[1]) return undefined;
151
+ const button = Number(match[1]);
152
+ if ((button & 64) === 0 || (button & 3) > 1) return undefined;
153
+ return (button & 1) === 0 ? -1 : 1;
154
+ }
155
+
96
156
  export function pendingQuestionItems(questions: PendingQuestion[]): SelectItem[] {
97
- return questions.map((question) => ({
98
- value: question.id,
99
- label: question.question,
100
- description: `${question.status} · ${question.id}`,
101
- }));
157
+ return questions.map((question) => {
158
+ const action = questionAction(question.resolutionOwner);
159
+ return {
160
+ value: question.id,
161
+ label: question.question,
162
+ description: `${question.status} · ${question.resolutionOwner} · ${question.gate} · ${action}`,
163
+ };
164
+ });
102
165
  }
103
166
 
104
167
  interface SelectDialogOptions {
105
168
  title: string;
106
169
  subtitle: string;
107
170
  items: SelectItem[];
108
- width: number;
171
+ width: SizeValue;
172
+ minWidth: number;
109
173
  maxVisible: number;
110
- maxPrimaryColumnWidth?: number;
174
+ selectedLabelMaxLines: number;
175
+ selectedDescriptionMaxLines: number;
111
176
  }
112
177
 
113
- class SelectionPreview {
114
- private item?: SelectItem;
115
- private offset = 0;
116
- private lastWidth = 40;
178
+ function boundedWrappedLines(content: string, width: number, maxLines: number, ellipsis: string): string[] {
179
+ const contentWidth = Math.max(1, width);
180
+ const wrapped = wrapTextWithAnsi(content, contentWidth);
181
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
182
+ if (wrapped.length > visible.length && visible.length > 0) {
183
+ const last = visible.length - 1;
184
+ visible[last] = truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
185
+ }
186
+ return visible;
187
+ }
188
+
189
+ function renderModalFrame(container: Container, theme: Theme, width: number): string[] {
190
+ if (width < 3) return container.render(Math.max(1, width));
191
+
192
+ const innerWidth = width - 2;
193
+ const border = (text: string) => theme.fg("borderAccent", text);
194
+ const rows = container
195
+ .render(innerWidth)
196
+ .map((line) => `${border("│")}${truncateToWidth(line, innerWidth, "…", true)}${border("│")}`);
197
+ return [border(`╭${"─".repeat(innerWidth)}╮`), ...rows, border(`╰${"─".repeat(innerWidth)}╯`)];
198
+ }
199
+
200
+ class WrappedSelectList {
201
+ private selectedIndex = 0;
202
+ private readonly items: SelectItem[];
203
+ private readonly keybindings: KeybindingsManager;
204
+ private readonly maxVisible: number;
205
+ private readonly renderHeight: number;
206
+ private readonly selectedDescriptionMaxLines: number;
207
+ private readonly selectedLabelMaxLines: number;
117
208
  private readonly theme: Theme;
118
- private readonly maxLines: number;
209
+ onSelect?: (item: SelectItem) => void;
210
+ onCancel?: () => void;
119
211
 
120
- constructor(theme: Theme, maxLines = 5) {
212
+ constructor(
213
+ items: SelectItem[],
214
+ maxVisible: number,
215
+ theme: Theme,
216
+ keybindings: KeybindingsManager,
217
+ options: {
218
+ selectedLabelMaxLines: number;
219
+ selectedDescriptionMaxLines: number;
220
+ },
221
+ ) {
222
+ this.items = items;
223
+ this.maxVisible = maxVisible;
121
224
  this.theme = theme;
122
- this.maxLines = maxLines;
225
+ this.keybindings = keybindings;
226
+ this.selectedLabelMaxLines = options.selectedLabelMaxLines;
227
+ this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
228
+ this.renderHeight = Math.max(
229
+ 1,
230
+ maxVisible - 1 + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 1,
231
+ );
123
232
  }
124
233
 
125
- setItem(item: SelectItem): void {
126
- this.item = item;
127
- this.offset = 0;
128
- }
234
+ render(width: number): string[] {
235
+ if (this.items.length === 0) return [this.theme.fg("warning", " No items")];
129
236
 
130
- scroll(delta: number): void {
131
- const total = this.contentLines(this.lastWidth).length;
132
- this.offset = Math.max(0, Math.min(this.offset + delta, Math.max(0, total - this.maxLines)));
133
- }
237
+ const lines: string[] = [];
238
+ const startIndex = Math.max(
239
+ 0,
240
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible),
241
+ );
242
+ const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
243
+
244
+ for (let index = startIndex; index < endIndex; index += 1) {
245
+ const item = this.items[index];
246
+ if (!item) continue;
247
+ if (index !== this.selectedIndex) {
248
+ lines.push(` ${truncateToWidth(item.label, Math.max(1, width - 2), "…")}`);
249
+ continue;
250
+ }
134
251
 
135
- render(width: number): string[] {
136
- this.lastWidth = width;
137
- const lines = this.contentLines(width);
138
- const visible = lines.slice(this.offset, this.offset + this.maxLines);
139
- if (lines.length > this.maxLines) {
140
- const end = Math.min(lines.length, this.offset + this.maxLines);
141
- visible.push(this.theme.fg("dim", ` detail ${this.offset + 1}–${end}/${lines.length} · shift+↑↓ scroll`));
252
+ const labels = boundedWrappedLines(
253
+ this.theme.fg("accent", item.label),
254
+ width - 2,
255
+ this.selectedLabelMaxLines,
256
+ this.theme.fg("dim", "…"),
257
+ );
258
+ lines.push(`${this.theme.fg("accent", "→ ")}${labels[0] ?? ""}`);
259
+ for (const line of labels.slice(1)) lines.push(` ${line}`);
260
+
261
+ if (item.description) {
262
+ const descriptions = boundedWrappedLines(
263
+ this.theme.fg("muted", item.description),
264
+ width - 4,
265
+ this.selectedDescriptionMaxLines,
266
+ this.theme.fg("dim", "…"),
267
+ );
268
+ for (const line of descriptions) lines.push(` ${line}`);
269
+ }
270
+ }
271
+
272
+ if (startIndex > 0 || endIndex < this.items.length) {
273
+ lines.push(
274
+ this.theme.fg(
275
+ "dim",
276
+ truncateToWidth(` (${this.selectedIndex + 1}/${this.items.length})`, Math.max(1, width - 2), ""),
277
+ ),
278
+ );
142
279
  }
280
+ const visible = lines.slice(0, this.renderHeight);
281
+ while (visible.length < this.renderHeight) visible.push("");
143
282
  return visible;
144
283
  }
145
284
 
146
- invalidate(): void {}
285
+ handleInput(data: string): void {
286
+ const wheelDirection = mouseWheelDirection(data);
287
+ if (wheelDirection !== undefined) {
288
+ this.moveSelection(wheelDirection * 3);
289
+ } else if (this.keybindings.matches(data, "tui.select.up")) {
290
+ this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
291
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
292
+ this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
293
+ } else if (this.keybindings.matches(data, "tui.select.pageUp")) {
294
+ this.moveSelection(-Math.max(1, this.maxVisible - 1));
295
+ } else if (this.keybindings.matches(data, "tui.select.pageDown")) {
296
+ this.moveSelection(Math.max(1, this.maxVisible - 1));
297
+ } else if (matchesKey(data, "home")) {
298
+ this.selectedIndex = 0;
299
+ } else if (matchesKey(data, "end")) {
300
+ this.selectedIndex = Math.max(0, this.items.length - 1);
301
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
302
+ const selected = this.items[this.selectedIndex];
303
+ if (selected) this.onSelect?.(selected);
304
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
305
+ this.onCancel?.();
306
+ }
307
+ }
147
308
 
148
- private contentLines(width: number): string[] {
149
- if (!this.item) return [];
150
- const content = [
151
- this.theme.fg("accent", this.theme.bold(this.item.label)),
152
- ...(this.item.description ? [this.theme.fg("muted", this.item.description)] : []),
153
- ].join("\n");
154
- return new Text(content, 1, 0).render(width);
309
+ private moveSelection(delta: number): void {
310
+ this.selectedIndex = Math.max(0, Math.min(this.items.length - 1, this.selectedIndex + delta));
155
311
  }
312
+
313
+ invalidate(): void {}
156
314
  }
157
315
 
158
316
  async function showSelectDialog(
159
317
  ctx: ExtensionCommandContext,
160
318
  options: SelectDialogOptions,
161
319
  ): Promise<string | undefined> {
162
- const result = await ctx.ui.custom<string | null>(
163
- (tui, theme, _keybindings, done) => {
164
- const container = new Container();
165
- const surface = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
166
- surface.addChild(container);
167
- const title = new Text("", 1, 0);
168
- const subtitle = new Text("", 1, 0);
169
- const hint = new Text("", 1, 0);
170
- const previewLabel = new Text(theme.fg("dim", "Selected detail"), 1, 0);
171
- const preview = new SelectionPreview(theme);
172
- const updateText = () => {
173
- title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
174
- subtitle.setText(theme.fg("muted", options.subtitle));
175
- hint.setText(theme.fg("dim", "↑↓ navigate · shift+↑↓ scroll detail · enter select · esc cancel"));
176
- };
177
- updateText();
178
-
179
- const list = new SelectList(
180
- options.items,
181
- Math.min(options.items.length, options.maxVisible),
182
- {
183
- selectedPrefix: (text) => theme.fg("accent", text),
184
- selectedText: (text) => theme.fg("accent", text),
185
- description: (text) => theme.fg("muted", text),
186
- scrollInfo: (text) => theme.fg("dim", text),
187
- noMatch: (text) => theme.fg("warning", text),
188
- },
189
- options.maxPrimaryColumnWidth
190
- ? { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: options.maxPrimaryColumnWidth }
191
- : undefined,
192
- );
193
- list.onSelect = (item) => done(item.value);
194
- list.onCancel = () => done(null);
195
- list.onSelectionChange = (item) => preview.setItem(item);
196
- const initialItem = list.getSelectedItem();
197
- if (initialItem) preview.setItem(initialItem);
198
-
199
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
200
- container.addChild(title);
201
- container.addChild(subtitle);
202
- container.addChild(list);
203
- container.addChild(new DynamicBorder((text) => theme.fg("borderMuted", text)));
204
- container.addChild(previewLabel);
205
- container.addChild(preview);
206
- container.addChild(hint);
207
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
208
-
209
- return {
210
- render(width: number) {
211
- return surface.render(width);
212
- },
213
- invalidate() {
214
- updateText();
215
- surface.invalidate();
216
- },
217
- handleInput(data: string) {
218
- if (matchesKey(data, "shift+up")) preview.scroll(-1);
219
- else if (matchesKey(data, "shift+down")) preview.scroll(1);
220
- else list.handleInput(data);
221
- tui.requestRender();
320
+ let mouseTerminal: WritableTerminal | undefined;
321
+ try {
322
+ const result = await ctx.ui.custom<string | null>(
323
+ (tui, theme, keybindings, done) => {
324
+ mouseTerminal = enableMouseScroll(tui);
325
+ const container = new Container();
326
+ const title = new Text("", 1, 0);
327
+ const subtitle = new Text("", 1, 0);
328
+ const hint = new Text("", 1, 0);
329
+ const updateText = () => {
330
+ title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
331
+ subtitle.setText(theme.fg("muted", options.subtitle));
332
+ hint.setText(theme.fg("dim", "wheel/↑↓ · PgUp/PgDn · Home/End · enter select · esc cancel"));
333
+ };
334
+ updateText();
335
+
336
+ const list = new WrappedSelectList(
337
+ options.items,
338
+ Math.min(options.items.length, options.maxVisible),
339
+ theme,
340
+ keybindings,
341
+ {
342
+ selectedLabelMaxLines: options.selectedLabelMaxLines,
343
+ selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
344
+ },
345
+ );
346
+ list.onSelect = (item) => done(item.value);
347
+ list.onCancel = () => done(null);
348
+
349
+ container.addChild(title);
350
+ container.addChild(subtitle);
351
+ container.addChild(list);
352
+ container.addChild(hint);
353
+
354
+ return {
355
+ render(width: number) {
356
+ return renderModalFrame(container, theme, width);
357
+ },
358
+ invalidate() {
359
+ updateText();
360
+ container.invalidate();
361
+ },
362
+ handleInput(data: string) {
363
+ list.handleInput(data);
364
+ tui.requestRender();
365
+ },
366
+ };
367
+ },
368
+ {
369
+ overlay: true,
370
+ overlayOptions: {
371
+ anchor: "center",
372
+ width: options.width,
373
+ minWidth: options.minWidth,
374
+ maxHeight: "88%",
375
+ margin: 1,
222
376
  },
223
- };
224
- },
225
- {
226
- overlay: true,
227
- overlayOptions: {
228
- anchor: "center",
229
- width: options.width,
230
- maxHeight: Math.min(options.maxVisible + 15, 26),
231
- margin: 1,
232
377
  },
233
- },
234
- );
235
- return result ?? undefined;
378
+ );
379
+ return result ?? undefined;
380
+ } finally {
381
+ disableMouseScroll(mouseTerminal);
382
+ }
236
383
  }
237
384
 
238
385
  export async function showDeveloperActionSelector(
@@ -243,8 +390,11 @@ export async function showDeveloperActionSelector(
243
390
  title: "Developer control",
244
391
  subtitle: `Current · ${modeName(state.mode)} · ${protocolState(state)}`,
245
392
  items: developerActionItems(state),
246
- width: 78,
393
+ width: "84%",
394
+ minWidth: 78,
247
395
  maxVisible: 6,
396
+ selectedLabelMaxLines: 3,
397
+ selectedDescriptionMaxLines: 3,
248
398
  });
249
399
  if (result === "status" || result === "questions" || result === "on" || result === "strict" || result === "off") {
250
400
  return result;
@@ -252,18 +402,20 @@ export async function showDeveloperActionSelector(
252
402
  return undefined;
253
403
  }
254
404
 
255
- export async function showPendingQuestionSelector(
405
+ export function showPendingQuestionSelector(
256
406
  ctx: ExtensionCommandContext,
257
407
  questions: PendingQuestion[],
258
408
  ): Promise<string | undefined> {
259
- if (questions.length === 0) return undefined;
409
+ if (questions.length === 0) return Promise.resolve(undefined);
260
410
  return showSelectDialog(ctx, {
261
- title: "Revisit an open Developer question",
262
- subtitle: "Selection prepares an editable prompt; protocol state changes only after the route is judged",
411
+ title: "Resolve an open Developer question",
412
+ subtitle: "Enter opens an answer/evidence editor; the question closes after a resolved or not-applicable judgment",
263
413
  items: pendingQuestionItems(questions),
264
- width: 96,
414
+ width: "92%",
415
+ minWidth: 88,
265
416
  maxVisible: 10,
266
- maxPrimaryColumnWidth: 52,
417
+ selectedLabelMaxLines: 5,
418
+ selectedDescriptionMaxLines: 3,
267
419
  });
268
420
  }
269
421
 
@@ -288,9 +440,10 @@ export class DeveloperWidget {
288
440
  );
289
441
  }
290
442
  for (const question of this.state.pendingQuestions.slice(0, 3)) {
443
+ const label = question.resolutionOwner === "user" ? "? answer" : "? evidence";
291
444
  lines.push(
292
445
  truncateToWidth(
293
- `${this.theme.fg(question.status === "blocked" ? "error" : "warning", "? open")} ${this.theme.fg("dim", "·")} ${this.theme.fg("muted", question.question)}`,
446
+ `${this.theme.fg(question.status === "blocked" ? "error" : "warning", label)} ${this.theme.fg("dim", `· ${question.gate} ·`)} ${this.theme.fg("muted", question.question)}`,
294
447
  width,
295
448
  "…",
296
449
  ),
@@ -302,6 +455,9 @@ export class DeveloperWidget {
302
455
  if (this.state.implementationFramingRequired) {
303
456
  lines.push(this.theme.fg("warning", "→ next · sketch feature shape or signal structural movement"));
304
457
  }
458
+ if (this.state.rerouteRequired) {
459
+ lines.push(this.theme.fg("warning", "→ next · reroute from the latest direct landing"));
460
+ }
305
461
  if (this.state.verificationRequired) {
306
462
  lines.push(this.theme.fg("warning", "→ next · verify changed artifacts before completion"));
307
463
  }
@@ -346,96 +502,120 @@ export class DeveloperStatusPanel {
346
502
  this.onClose();
347
503
  return;
348
504
  }
349
- if (matchesKey(data, "up")) this.scrollOffset = Math.max(0, this.scrollOffset - 1);
350
- else if (matchesKey(data, "down")) {
351
- this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + 1);
352
- } else return;
505
+ const wheelDirection = mouseWheelDirection(data);
506
+ const pageSize = Math.max(1, this.viewportHeight - 6);
507
+ if (wheelDirection !== undefined) this.moveScroll(wheelDirection * 3);
508
+ else if (matchesKey(data, "up")) this.moveScroll(-1);
509
+ else if (matchesKey(data, "down")) this.moveScroll(1);
510
+ else if (matchesKey(data, "pageUp")) this.moveScroll(-pageSize);
511
+ else if (matchesKey(data, "pageDown")) this.moveScroll(pageSize);
512
+ else if (matchesKey(data, "home")) this.scrollOffset = 0;
513
+ else if (matchesKey(data, "end")) this.scrollOffset = this.maxScrollOffset;
514
+ else return;
353
515
  this.invalidate();
354
516
  this.requestRender();
355
517
  }
356
518
 
519
+ private moveScroll(delta: number): void {
520
+ this.scrollOffset = Math.max(0, Math.min(this.maxScrollOffset, this.scrollOffset + delta));
521
+ }
522
+
357
523
  render(width: number): string[] {
358
524
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
359
525
 
360
- const panelWidth = Math.max(20, width);
361
- const innerWidth = Math.max(18, panelWidth - 2);
526
+ const panelWidth = Math.max(1, width);
527
+ const innerWidth = Math.max(1, panelWidth - 2);
362
528
  const rows: string[] = [];
363
- const background = (text: string) => this.theme.bg("customMessageBg", text);
364
529
  const border = (text: string) => this.theme.fg("borderAccent", text);
365
- const row = (content = "") => {
366
- const clipped = truncateToWidth(content, innerWidth, "", true);
367
- const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
368
- return background(`${border("│")}${clipped}${padding}${border("│")}`);
369
- };
370
- const addWrapped = (label: string, value: string, color: ThemeColor = "muted", _maxLines?: number) => {
371
- const prefix = ` ${this.theme.fg("dim", `${label} ·`)} `;
372
- const wrapped = wrapTextWithAnsi(prefix + this.theme.fg(color, value), innerWidth);
373
- for (const line of wrapped) rows.push(row(line));
530
+ const row = (content = "") => `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("│")}`;
531
+ const addWrapped = (label: string, value: string, color: ThemeColor = "muted", maxLines = 2) => {
532
+ const labelText = `${label} ·`;
533
+ const plainPrefix = ` ${labelText} `;
534
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
535
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
536
+ const wrapped = wrapTextWithAnsi(this.theme.fg(color, value.trim()), contentWidth);
537
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
538
+ if (wrapped.length > visible.length && visible.length > 0) {
539
+ const last = visible.length - 1;
540
+ visible[last] =
541
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + this.theme.fg("dim", "…");
542
+ }
543
+ rows.push(row(styledPrefix + (visible[0] ?? "")));
544
+ const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
545
+ for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
374
546
  };
375
547
  const section = (title: string) => rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
376
548
 
377
- rows.push(background(border(`╭${"─".repeat(innerWidth)}╮`)));
549
+ rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
378
550
  rows.push(row(` ${this.theme.fg("accent", this.theme.bold("◆ Developer status"))}`));
379
551
  rows.push(row());
380
552
 
381
553
  const state = this.view.state;
382
554
  const currentProtocol = protocolState(state);
383
- rows.push(
384
- row(
385
- ` mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
386
- this.theme.fg("dim", " · ") +
387
- `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
388
- this.theme.fg("dim", " · ") +
389
- `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`,
390
- ),
391
- );
555
+ const summary =
556
+ `mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
557
+ this.theme.fg("dim", " · ") +
558
+ `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
559
+ this.theme.fg("dim", " · ") +
560
+ `target ${this.theme.fg("muted", state.activeRoute?.owner ?? "none")}`;
561
+ for (const line of wrapTextWithAnsi(summary, Math.max(1, innerWidth - 2))) rows.push(row(` ${line}`));
392
562
  rows.push(row());
393
563
 
394
564
  section("Active route");
395
565
  if (state.activeRoute) {
396
566
  addWrapped("id", state.activeRoute.routeId, "dim", 1);
397
- addWrapped("question", state.activeRoute.question, "text");
398
- addWrapped("reason", state.activeRoute.reason);
567
+ addWrapped("question", state.activeRoute.question, "text", 3);
568
+ addWrapped("reason", state.activeRoute.reason, "muted", 2);
399
569
  addWrapped("skill", state.activeRoute.methodLocation ?? "direct action", "dim", 1);
400
570
  addWrapped("known evidence", String(state.activeRoute.knownEvidence.length), "muted", 1);
401
571
  } else {
402
- addWrapped("state", "No route is currently waiting for judgment.", "dim", 1);
572
+ addWrapped("state", "No route is currently waiting for judgment.", "dim", 2);
403
573
  }
404
574
 
405
575
  rows.push(row());
406
576
  section(`Open questions · ${state.pendingQuestions.length}`);
407
577
  if (state.pendingQuestions.length === 0) {
408
- addWrapped("state", "No unresolved Developer questions.", "dim", 1);
578
+ addWrapped("state", "No unresolved Developer questions.", "dim", 2);
409
579
  } else {
410
- for (const question of state.pendingQuestions) {
580
+ for (const question of state.pendingQuestions.slice(0, 4)) {
411
581
  addWrapped(
412
- question.status === "blocked" ? "blocked" : "needs evidence",
582
+ `${question.status} · ${question.resolutionOwner} · ${question.gate}`,
413
583
  question.question,
414
584
  question.status === "blocked" ? "error" : "warning",
415
- 1,
585
+ 2,
416
586
  );
587
+ addWrapped("resolves when", question.resolutionCriteria, "dim", 2);
588
+ }
589
+ if (state.pendingQuestions.length > 4) {
590
+ addWrapped("more", `${state.pendingQuestions.length - 4} additional open questions`, "dim", 1);
417
591
  }
418
592
  }
419
593
 
420
594
  rows.push(row());
421
- section("Last judgment");
422
- if (state.lastJudgment) {
423
- addWrapped("status", state.lastJudgment.status, protocolColor(
424
- state.lastJudgment.status === "blocked"
425
- ? "blocked"
426
- : state.lastJudgment.status === "needs-evidence"
427
- ? "needs-evidence"
428
- : "idle",
429
- ), 1);
430
- addWrapped("result", state.lastJudgment.result, "muted");
431
- addWrapped(
432
- "evidence",
433
- `${state.lastJudgment.basis.length} basis · ${state.lastJudgment.artifacts.length} artifacts`,
434
- "dim",
435
- 1,
436
- );
595
+ section(`Judgment history · ${state.judgmentHistory.length}`);
596
+ if (state.judgmentHistory.length === 0) {
597
+ addWrapped("state", "No judgment has been recorded on this branch.", "dim", 2);
437
598
  } else {
438
- addWrapped("state", "No judgment has been recorded on this branch.", "dim", 1);
599
+ const recentJudgments = state.judgmentHistory.slice(-10).toReversed();
600
+ for (const judgment of recentJudgments) {
601
+ const route = state.routeHistory.find((candidate) => candidate.routeId === judgment.routeId);
602
+ addWrapped(
603
+ `${judgment.owner} ${judgment.status}`,
604
+ `${judgment.question} → ${judgmentSummary(judgment.result)}`,
605
+ judgmentColor(judgment.status),
606
+ 3,
607
+ );
608
+ if (route) addWrapped("route reason", route.reason, "dim", 2);
609
+ for (const alternative of route?.consideredAlternatives ?? []) {
610
+ addWrapped(`considered ${alternative.owner}`, alternative.reason, "dim", 2);
611
+ }
612
+ for (const update of judgment.questionUpdates ?? []) {
613
+ addWrapped(`question ${update.status}`, `${update.questionId} → ${update.result}`, "accent", 2);
614
+ }
615
+ }
616
+ if (state.judgmentHistory.length > recentJudgments.length) {
617
+ addWrapped("earlier", `${state.judgmentHistory.length - recentJudgments.length} earlier judgments`, "dim", 1);
618
+ }
439
619
  }
440
620
 
441
621
  rows.push(row());
@@ -446,23 +626,23 @@ export class DeveloperStatusPanel {
446
626
  1,
447
627
  );
448
628
  rows.push(row());
449
- rows.push(row(` ${this.theme.fg("dim", "enter/esc close · /develop questions revisits open work")}`));
450
- rows.push(background(border(`╰${"─".repeat(innerWidth)}╯`)));
451
629
 
452
630
  const header = rows.slice(0, 2);
453
- const body = rows.slice(2, -2);
631
+ const body = rows.slice(2);
454
632
  const bodyCapacity = Math.max(1, this.viewportHeight - 4);
455
633
  this.maxScrollOffset = Math.max(0, body.length - bodyCapacity);
456
634
  this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset);
457
635
  const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + bodyCapacity);
458
- const position = body.length > bodyCapacity
459
- ? `↑↓ scroll · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
460
- : "enter/esc close · /develop questions revisits open work";
636
+ while (visibleBody.length < bodyCapacity) visibleBody.push(row());
637
+ const position =
638
+ body.length > bodyCapacity
639
+ ? `wheel/↑↓ · PgUp/PgDn · Home/End · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
640
+ : "enter/esc close · /develop questions revisits open work";
461
641
  const visibleRows = [
462
642
  ...header,
463
643
  ...visibleBody,
464
644
  row(` ${this.theme.fg("dim", position)}`),
465
- background(border(`╰${"─".repeat(innerWidth)}╯`)),
645
+ border(`╰${"─".repeat(innerWidth)}╯`),
466
646
  ];
467
647
 
468
648
  this.cachedWidth = width;
@@ -476,31 +656,56 @@ export class DeveloperStatusPanel {
476
656
  }
477
657
  }
478
658
 
479
- export async function showDeveloperStatus(
480
- ctx: ExtensionCommandContext,
481
- view: DeveloperStatusView,
482
- ): Promise<void> {
483
- await ctx.ui.custom<void>(
484
- (tui, theme, _keybindings, done) =>
485
- new DeveloperStatusPanel(view, theme, () => done(), {
486
- viewportHeight: Math.max(12, Math.floor(tui.terminal.rows * 0.8)),
487
- requestRender: () => tui.requestRender(),
488
- }),
489
- {
490
- overlay: true,
491
- overlayOptions: {
492
- anchor: "center",
493
- width: "82%",
494
- minWidth: 56,
495
- maxHeight: "85%",
496
- margin: 1,
659
+ export async function showDeveloperStatus(ctx: ExtensionCommandContext, view: DeveloperStatusView): Promise<void> {
660
+ let mouseTerminal: WritableTerminal | undefined;
661
+ try {
662
+ await ctx.ui.custom<void>(
663
+ (tui, theme, _keybindings, done) => {
664
+ mouseTerminal = enableMouseScroll(tui);
665
+ return new DeveloperStatusPanel(view, theme, () => done(), {
666
+ viewportHeight: Math.max(10, Math.min(36, Math.floor(tui.terminal.rows * 0.88))),
667
+ requestRender: () => tui.requestRender(),
668
+ });
497
669
  },
498
- },
499
- );
670
+ {
671
+ overlay: true,
672
+ overlayOptions: {
673
+ anchor: "center",
674
+ width: "90%",
675
+ minWidth: 72,
676
+ maxHeight: "88%",
677
+ margin: 1,
678
+ },
679
+ },
680
+ );
681
+ } finally {
682
+ disableMouseScroll(mouseTerminal);
683
+ }
684
+ }
685
+
686
+ export function questionResolutionPrompt(question: PendingQuestion): string {
687
+ const requestLabel = resolutionRequestLabel(question.resolutionOwner);
688
+ return [
689
+ "Resolve this open Developer question.",
690
+ "",
691
+ `Question: ${question.question}`,
692
+ `Resolution owner: ${question.resolutionOwner}`,
693
+ `Gate: ${question.gate}`,
694
+ `Resolution criteria: ${question.resolutionCriteria}`,
695
+ "",
696
+ requestLabel,
697
+ "",
698
+ "Use the supplied answer as new evidence, or investigate with available tools when the owner is agent.",
699
+ "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.",
700
+ ].join("\n");
500
701
  }
501
702
 
502
- export function prepareQuestionPrompt(ctx: ExtensionCommandContext, question: PendingQuestion): void {
503
- const prompt = `Revisit this Developer question: ${question.question}`;
703
+ export function editQuestionResolutionRequest(
704
+ ctx: ExtensionCommandContext,
705
+ question: PendingQuestion,
706
+ ): Promise<string | undefined> {
504
707
  const current = ctx.ui.getEditorText();
505
- ctx.ui.setEditorText(current.trim() ? `${current.trimEnd()}\n\n${prompt}` : prompt);
708
+ const request = questionResolutionPrompt(question);
709
+ const initial = current.trim() ? `${current.trimEnd()}\n\n${request}` : request;
710
+ return ctx.ui.editor("Answer or investigate the selected Developer question", initial);
506
711
  }