@hobin/developer 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -0
  2. package/extensions/tui.ts +183 -117
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -267,6 +267,17 @@ Load the workspace package into Pi without installing it:
267
267
  pi -e ./packages/developer
268
268
  ```
269
269
 
270
+ Launch the isolated modal fixture in a new Ghostty window for visual QA:
271
+
272
+ ```sh
273
+ ./packages/developer/scripts/ghostty-tui-qa.sh
274
+ ```
275
+
276
+ Choose **Inspect status** or **Revisit an open question**, then resize the window
277
+ while the modal is open. The fixture disables discovered extensions, skills,
278
+ tools, sessions, and network startup so it cannot mutate Developer state or call
279
+ a model.
280
+
270
281
  `check` validates package structure and deterministic behavior. `eval` launches
271
282
  the real Pi RPC surface without a model and covers package resources, commands,
272
283
  mode state, and strict tool gating. Maintainers can use `eval:json` and the live
package/extensions/tui.ts CHANGED
@@ -1,15 +1,13 @@
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,
13
11
  Text,
14
12
  truncateToWidth,
15
13
  visibleWidth,
@@ -97,7 +95,7 @@ export function pendingQuestionItems(questions: PendingQuestion[]): SelectItem[]
97
95
  return questions.map((question) => ({
98
96
  value: question.id,
99
97
  label: question.question,
100
- description: `${question.status} · ${question.id}`,
98
+ description: question.status,
101
99
  }));
102
100
  }
103
101
 
@@ -107,52 +105,124 @@ interface SelectDialogOptions {
107
105
  items: SelectItem[];
108
106
  width: number;
109
107
  maxVisible: number;
110
- maxPrimaryColumnWidth?: number;
108
+ selectedLabelMaxLines: number;
109
+ selectedDescriptionMaxLines: number;
111
110
  }
112
111
 
113
- class SelectionPreview {
114
- private item?: SelectItem;
115
- private offset = 0;
116
- private lastWidth = 40;
112
+ function boundedWrappedLines(content: string, width: number, maxLines: number, ellipsis: string): string[] {
113
+ const contentWidth = Math.max(1, width);
114
+ const wrapped = wrapTextWithAnsi(content, contentWidth);
115
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
116
+ if (wrapped.length > visible.length && visible.length > 0) {
117
+ const last = visible.length - 1;
118
+ visible[last] = truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + ellipsis;
119
+ }
120
+ return visible;
121
+ }
122
+
123
+ function renderModalFrame(container: Container, theme: Theme, width: number): string[] {
124
+ if (width < 3) return container.render(Math.max(1, width));
125
+
126
+ const innerWidth = width - 2;
127
+ const border = (text: string) => theme.fg("borderAccent", text);
128
+ const rows = container
129
+ .render(innerWidth)
130
+ .map((line) => `${border("│")}${truncateToWidth(line, innerWidth, "…", true)}${border("│")}`);
131
+ return [border(`╭${"─".repeat(innerWidth)}╮`), ...rows, border(`╰${"─".repeat(innerWidth)}╯`)];
132
+ }
133
+
134
+ class WrappedSelectList {
135
+ private selectedIndex = 0;
136
+ private readonly items: SelectItem[];
137
+ private readonly keybindings: KeybindingsManager;
138
+ private readonly maxVisible: number;
139
+ private readonly selectedDescriptionMaxLines: number;
140
+ private readonly selectedLabelMaxLines: number;
117
141
  private readonly theme: Theme;
118
- private readonly maxLines: number;
142
+ onSelect?: (item: SelectItem) => void;
143
+ onCancel?: () => void;
119
144
 
120
- constructor(theme: Theme, maxLines = 5) {
145
+ constructor(
146
+ items: SelectItem[],
147
+ maxVisible: number,
148
+ theme: Theme,
149
+ keybindings: KeybindingsManager,
150
+ options: {
151
+ selectedLabelMaxLines: number;
152
+ selectedDescriptionMaxLines: number;
153
+ },
154
+ ) {
155
+ this.items = items;
156
+ this.maxVisible = maxVisible;
121
157
  this.theme = theme;
122
- this.maxLines = maxLines;
158
+ this.keybindings = keybindings;
159
+ this.selectedLabelMaxLines = options.selectedLabelMaxLines;
160
+ this.selectedDescriptionMaxLines = options.selectedDescriptionMaxLines;
123
161
  }
124
162
 
125
- setItem(item: SelectItem): void {
126
- this.item = item;
127
- this.offset = 0;
128
- }
163
+ render(width: number): string[] {
164
+ if (this.items.length === 0) return [this.theme.fg("warning", " No items")];
165
+
166
+ const lines: string[] = [];
167
+ const startIndex = Math.max(
168
+ 0,
169
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible),
170
+ );
171
+ const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
172
+
173
+ for (let index = startIndex; index < endIndex; index += 1) {
174
+ const item = this.items[index];
175
+ if (!item) continue;
176
+ if (index !== this.selectedIndex) {
177
+ lines.push(` ${truncateToWidth(item.label, Math.max(1, width - 2), "…")}`);
178
+ continue;
179
+ }
180
+
181
+ const labels = boundedWrappedLines(
182
+ this.theme.fg("accent", item.label),
183
+ width - 2,
184
+ this.selectedLabelMaxLines,
185
+ this.theme.fg("dim", "…"),
186
+ );
187
+ lines.push(`${this.theme.fg("accent", "→ ")}${labels[0] ?? ""}`);
188
+ for (const line of labels.slice(1)) lines.push(` ${line}`);
189
+
190
+ if (item.description) {
191
+ const descriptions = boundedWrappedLines(
192
+ this.theme.fg("muted", item.description),
193
+ width - 4,
194
+ this.selectedDescriptionMaxLines,
195
+ this.theme.fg("dim", "…"),
196
+ );
197
+ for (const line of descriptions) lines.push(` ${line}`);
198
+ }
199
+ }
129
200
 
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)));
201
+ if (startIndex > 0 || endIndex < this.items.length) {
202
+ lines.push(
203
+ this.theme.fg(
204
+ "dim",
205
+ truncateToWidth(` (${this.selectedIndex + 1}/${this.items.length})`, Math.max(1, width - 2), ""),
206
+ ),
207
+ );
208
+ }
209
+ return lines;
133
210
  }
134
211
 
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`));
212
+ 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;
215
+ } else if (this.keybindings.matches(data, "tui.select.down")) {
216
+ this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
217
+ } else if (this.keybindings.matches(data, "tui.select.confirm")) {
218
+ const selected = this.items[this.selectedIndex];
219
+ if (selected) this.onSelect?.(selected);
220
+ } else if (this.keybindings.matches(data, "tui.select.cancel")) {
221
+ this.onCancel?.();
142
222
  }
143
- return visible;
144
223
  }
145
224
 
146
225
  invalidate(): void {}
147
-
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);
155
- }
156
226
  }
157
227
 
158
228
  async function showSelectDialog(
@@ -160,64 +230,46 @@ async function showSelectDialog(
160
230
  options: SelectDialogOptions,
161
231
  ): Promise<string | undefined> {
162
232
  const result = await ctx.ui.custom<string | null>(
163
- (tui, theme, _keybindings, done) => {
233
+ (tui, theme, keybindings, done) => {
164
234
  const container = new Container();
165
- const surface = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
166
- surface.addChild(container);
167
235
  const title = new Text("", 1, 0);
168
236
  const subtitle = new Text("", 1, 0);
169
237
  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
238
  const updateText = () => {
173
239
  title.setText(theme.fg("accent", theme.bold(`◆ ${options.title}`)));
174
240
  subtitle.setText(theme.fg("muted", options.subtitle));
175
- hint.setText(theme.fg("dim", "↑↓ navigate · shift+↑↓ scroll detail · enter select · esc cancel"));
241
+ hint.setText(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"));
176
242
  };
177
243
  updateText();
178
244
 
179
- const list = new SelectList(
245
+ const list = new WrappedSelectList(
180
246
  options.items,
181
247
  Math.min(options.items.length, options.maxVisible),
248
+ theme,
249
+ keybindings,
182
250
  {
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),
251
+ selectedLabelMaxLines: options.selectedLabelMaxLines,
252
+ selectedDescriptionMaxLines: options.selectedDescriptionMaxLines,
188
253
  },
189
- options.maxPrimaryColumnWidth
190
- ? { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: options.maxPrimaryColumnWidth }
191
- : undefined,
192
254
  );
193
255
  list.onSelect = (item) => done(item.value);
194
256
  list.onCancel = () => done(null);
195
- list.onSelectionChange = (item) => preview.setItem(item);
196
- const initialItem = list.getSelectedItem();
197
- if (initialItem) preview.setItem(initialItem);
198
257
 
199
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
200
258
  container.addChild(title);
201
259
  container.addChild(subtitle);
202
260
  container.addChild(list);
203
- container.addChild(new DynamicBorder((text) => theme.fg("borderMuted", text)));
204
- container.addChild(previewLabel);
205
- container.addChild(preview);
206
261
  container.addChild(hint);
207
- container.addChild(new DynamicBorder((text) => theme.fg("borderAccent", text)));
208
262
 
209
263
  return {
210
264
  render(width: number) {
211
- return surface.render(width);
265
+ return renderModalFrame(container, theme, width);
212
266
  },
213
267
  invalidate() {
214
268
  updateText();
215
- surface.invalidate();
269
+ container.invalidate();
216
270
  },
217
271
  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);
272
+ list.handleInput(data);
221
273
  tui.requestRender();
222
274
  },
223
275
  };
@@ -227,7 +279,10 @@ async function showSelectDialog(
227
279
  overlayOptions: {
228
280
  anchor: "center",
229
281
  width: options.width,
230
- maxHeight: Math.min(options.maxVisible + 15, 26),
282
+ maxHeight: Math.min(
283
+ options.maxVisible + options.selectedLabelMaxLines + options.selectedDescriptionMaxLines + 7,
284
+ 24,
285
+ ),
231
286
  margin: 1,
232
287
  },
233
288
  },
@@ -245,6 +300,8 @@ export async function showDeveloperActionSelector(
245
300
  items: developerActionItems(state),
246
301
  width: 78,
247
302
  maxVisible: 6,
303
+ selectedLabelMaxLines: 2,
304
+ selectedDescriptionMaxLines: 2,
248
305
  });
249
306
  if (result === "status" || result === "questions" || result === "on" || result === "strict" || result === "off") {
250
307
  return result;
@@ -263,7 +320,8 @@ export async function showPendingQuestionSelector(
263
320
  items: pendingQuestionItems(questions),
264
321
  width: 96,
265
322
  maxVisible: 10,
266
- maxPrimaryColumnWidth: 52,
323
+ selectedLabelMaxLines: 5,
324
+ selectedDescriptionMaxLines: 1,
267
325
  });
268
326
  }
269
327
 
@@ -357,77 +415,89 @@ export class DeveloperStatusPanel {
357
415
  render(width: number): string[] {
358
416
  if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
359
417
 
360
- const panelWidth = Math.max(20, width);
361
- const innerWidth = Math.max(18, panelWidth - 2);
418
+ const panelWidth = Math.max(1, width);
419
+ const innerWidth = Math.max(1, panelWidth - 2);
362
420
  const rows: string[] = [];
363
- const background = (text: string) => this.theme.bg("customMessageBg", text);
364
421
  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));
422
+ const row = (content = "") => `${border("│")}${truncateToWidth(content, innerWidth, "…", true)}${border("│")}`;
423
+ const addWrapped = (label: string, value: string, color: ThemeColor = "muted", maxLines = 2) => {
424
+ const labelText = `${label} ·`;
425
+ const plainPrefix = ` ${labelText} `;
426
+ const styledPrefix = ` ${this.theme.fg("dim", labelText)} `;
427
+ const contentWidth = Math.max(1, innerWidth - visibleWidth(plainPrefix));
428
+ const wrapped = wrapTextWithAnsi(this.theme.fg(color, value.trim()), contentWidth);
429
+ const visible = wrapped.slice(0, Math.max(1, maxLines));
430
+ if (wrapped.length > visible.length && visible.length > 0) {
431
+ const last = visible.length - 1;
432
+ visible[last] =
433
+ truncateToWidth(visible[last] ?? "", Math.max(1, contentWidth - 1), "") + this.theme.fg("dim", "…");
434
+ }
435
+ rows.push(row(styledPrefix + (visible[0] ?? "")));
436
+ const hangingIndent = " ".repeat(visibleWidth(plainPrefix));
437
+ for (const line of visible.slice(1)) rows.push(row(hangingIndent + line));
374
438
  };
375
439
  const section = (title: string) => rows.push(row(` ${this.theme.fg("accent", this.theme.bold(title))}`));
376
440
 
377
- rows.push(background(border(`╭${"─".repeat(innerWidth)}╮`)));
441
+ rows.push(border(`╭${"─".repeat(innerWidth)}╮`));
378
442
  rows.push(row(` ${this.theme.fg("accent", this.theme.bold("◆ Developer status"))}`));
379
443
  rows.push(row());
380
444
 
381
445
  const state = this.view.state;
382
446
  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
- );
447
+ const summary =
448
+ `mode ${this.theme.fg(modeColor(state.mode), modeName(state.mode))}` +
449
+ this.theme.fg("dim", " · ") +
450
+ `protocol ${this.theme.fg(protocolColor(currentProtocol), currentProtocol)}` +
451
+ this.theme.fg("dim", " · ") +
452
+ `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}`));
392
454
  rows.push(row());
393
455
 
394
456
  section("Active route");
395
457
  if (state.activeRoute) {
396
458
  addWrapped("id", state.activeRoute.routeId, "dim", 1);
397
- addWrapped("question", state.activeRoute.question, "text");
398
- addWrapped("reason", state.activeRoute.reason);
459
+ addWrapped("question", state.activeRoute.question, "text", 3);
460
+ addWrapped("reason", state.activeRoute.reason, "muted", 2);
399
461
  addWrapped("skill", state.activeRoute.methodLocation ?? "direct action", "dim", 1);
400
462
  addWrapped("known evidence", String(state.activeRoute.knownEvidence.length), "muted", 1);
401
463
  } else {
402
- addWrapped("state", "No route is currently waiting for judgment.", "dim", 1);
464
+ addWrapped("state", "No route is currently waiting for judgment.", "dim", 2);
403
465
  }
404
466
 
405
467
  rows.push(row());
406
468
  section(`Open questions · ${state.pendingQuestions.length}`);
407
469
  if (state.pendingQuestions.length === 0) {
408
- addWrapped("state", "No unresolved Developer questions.", "dim", 1);
470
+ addWrapped("state", "No unresolved Developer questions.", "dim", 2);
409
471
  } else {
410
- for (const question of state.pendingQuestions) {
472
+ for (const question of state.pendingQuestions.slice(0, 4)) {
411
473
  addWrapped(
412
474
  question.status === "blocked" ? "blocked" : "needs evidence",
413
475
  question.question,
414
476
  question.status === "blocked" ? "error" : "warning",
415
- 1,
477
+ 2,
416
478
  );
417
479
  }
480
+ if (state.pendingQuestions.length > 4) {
481
+ addWrapped("more", `${state.pendingQuestions.length - 4} additional open questions`, "dim", 1);
482
+ }
418
483
  }
419
484
 
420
485
  rows.push(row());
421
486
  section("Last judgment");
422
487
  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");
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);
431
501
  addWrapped(
432
502
  "evidence",
433
503
  `${state.lastJudgment.basis.length} basis · ${state.lastJudgment.artifacts.length} artifacts`,
@@ -435,7 +505,7 @@ export class DeveloperStatusPanel {
435
505
  1,
436
506
  );
437
507
  } else {
438
- addWrapped("state", "No judgment has been recorded on this branch.", "dim", 1);
508
+ addWrapped("state", "No judgment has been recorded on this branch.", "dim", 2);
439
509
  }
440
510
 
441
511
  rows.push(row());
@@ -446,23 +516,22 @@ export class DeveloperStatusPanel {
446
516
  1,
447
517
  );
448
518
  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
519
 
452
520
  const header = rows.slice(0, 2);
453
- const body = rows.slice(2, -2);
521
+ const body = rows.slice(2);
454
522
  const bodyCapacity = Math.max(1, this.viewportHeight - 4);
455
523
  this.maxScrollOffset = Math.max(0, body.length - bodyCapacity);
456
524
  this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset);
457
525
  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";
526
+ const position =
527
+ body.length > bodyCapacity
528
+ ? `↑↓ scroll · ${this.scrollOffset + 1}–${Math.min(body.length, this.scrollOffset + bodyCapacity)}/${body.length} · enter/esc close`
529
+ : "enter/esc close · /develop questions revisits open work";
461
530
  const visibleRows = [
462
531
  ...header,
463
532
  ...visibleBody,
464
533
  row(` ${this.theme.fg("dim", position)}`),
465
- background(border(`╰${"─".repeat(innerWidth)}╯`)),
534
+ border(`╰${"─".repeat(innerWidth)}╯`),
466
535
  ];
467
536
 
468
537
  this.cachedWidth = width;
@@ -476,23 +545,20 @@ export class DeveloperStatusPanel {
476
545
  }
477
546
  }
478
547
 
479
- export async function showDeveloperStatus(
480
- ctx: ExtensionCommandContext,
481
- view: DeveloperStatusView,
482
- ): Promise<void> {
548
+ export async function showDeveloperStatus(ctx: ExtensionCommandContext, view: DeveloperStatusView): Promise<void> {
483
549
  await ctx.ui.custom<void>(
484
550
  (tui, theme, _keybindings, done) =>
485
551
  new DeveloperStatusPanel(view, theme, () => done(), {
486
- viewportHeight: Math.max(12, Math.floor(tui.terminal.rows * 0.8)),
552
+ viewportHeight: Math.max(6, Math.min(28, tui.terminal.rows - 2)),
487
553
  requestRender: () => tui.requestRender(),
488
554
  }),
489
555
  {
490
556
  overlay: true,
491
557
  overlayOptions: {
492
558
  anchor: "center",
493
- width: "82%",
559
+ width: 92,
494
560
  minWidth: 56,
495
- maxHeight: "85%",
561
+ maxHeight: 28,
496
562
  margin: 1,
497
563
  },
498
564
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hobin/developer",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Adaptive product-development reasoning and evidence for Pi.",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "pi", "developer", "skills"],