@getpipher/armory-fleet 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -0,0 +1,76 @@
1
+ // src/panel/conversation-rows.ts
2
+ // SPEC-5b-3 — pure renderers for the full-message overlay (second level over the 5b-1 timeline).
3
+ // Plain strings, no theme (codebase convention: theming is applied at the SelectList callback layer).
4
+ //
5
+ // Journal fidelity (Q2=C): MessageEvent.text is already the FULL assistant text (5b-1 Q1). Tool
6
+ // args/result are the journaled excerpt (5b-1: args≤200ch, result≤500ch, errors in-full). The
7
+ // toolHeader notes "args/result excerpted" so the asymmetry is honest.
8
+ import type { MessageEvent, ToolEvent } from "../runtime/run-log.ts";
9
+
10
+ /** Word-wrap `text` to `width` columns. Long tokens (no break opportunity) hard-split at `width`.
11
+ * Explicit `\n` is preserved as a row break (multi-line tool results). Empty → [""]; width≤0 → [""].
12
+ * Operates on the string by `.length` (UTF-16 code units) — sufficient for the overlay's purposes;
13
+ * CJK chars count as 1 col each in most terminals, matching the test expectations. */
14
+ export function wrapToLines(text: string, width: number): string[] {
15
+ if (width <= 0) return [""];
16
+ if (text.length === 0) return [""];
17
+ const out: string[] = [];
18
+ for (const para of text.split("\n")) {
19
+ if (para.length === 0) { out.push(""); continue; }
20
+ const words = para.split(" ");
21
+ let line = "";
22
+ for (const w of words) {
23
+ if (w.length === 0) continue; // collapse runs of spaces
24
+ if (line.length === 0) {
25
+ // long token with no break opportunity: hard-split at width
26
+ let rest = w;
27
+ while (rest.length > width) {
28
+ out.push(rest.slice(0, width));
29
+ rest = rest.slice(width);
30
+ }
31
+ line = rest;
32
+ } else if (line.length + 1 + w.length <= width) {
33
+ line += " " + w;
34
+ } else {
35
+ out.push(line);
36
+ let rest = w;
37
+ while (rest.length > width) {
38
+ out.push(rest.slice(0, width));
39
+ rest = rest.slice(width);
40
+ }
41
+ line = rest;
42
+ }
43
+ }
44
+ out.push(line);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Body for an assistant message: the full text, wrapped. */
50
+ export function messageBody(e: MessageEvent, width: number): string[] {
51
+ return wrapToLines(e.text, width);
52
+ }
53
+
54
+ /** Body for a tool event: `args:` + indented args, `result:` + indented result. */
55
+ export function toolBody(e: ToolEvent, width: number): string[] {
56
+ const inner = Math.max(2, width - 2);
57
+ const lines: string[] = ["args:"];
58
+ for (const l of wrapToLines(e.args, inner)) lines.push(" " + l);
59
+ lines.push("result:");
60
+ for (const l of wrapToLines(e.result, inner)) lines.push(" " + l);
61
+ return lines;
62
+ }
63
+
64
+ /** Header for an assistant message event. Omits the token segment when usage.total is absent. */
65
+ export function messageHeader(e: MessageEvent): string {
66
+ const turn = Math.max(0, e.turnIndex);
67
+ const tok = e.usage?.total != null ? ` · ${e.usage.total} tok` : "";
68
+ return `── assistant · turn ${turn}${tok} ──`;
69
+ }
70
+
71
+ /** Header for a tool event. Notes "args/result excerpted" (Q2=C honest asymmetry). */
72
+ export function toolHeader(e: ToolEvent): string {
73
+ const turn = Math.max(0, e.turnIndex);
74
+ const glyph = e.isError ? "✗" : "✓";
75
+ return `── tool: ${e.toolName} · turn ${turn} · ${glyph} · args/result excerpted ──`;
76
+ }
@@ -13,8 +13,9 @@ import type { AgentDef } from "../registry/frontmatter.ts";
13
13
  import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
14
14
  import { buildFleetItems } from "./fleet-items.ts";
15
15
  import { runsRow, runTimelineRow } from "./runs-rows.ts";
16
+ import { messageBody, toolBody, messageHeader, toolHeader } from "./conversation-rows.ts";
16
17
  import { buildRunsIndex } from "./runs-index.ts";
17
- import type { RunLog, RunMeta, RunLogEvent } from "../runtime/run-log.ts";
18
+ import type { RunLog, RunMeta, RunLogEvent, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
18
19
  import type { Scheduler, Schedule } from "../scheduling/scheduler.ts";
19
20
  import type { BgRunsStore } from "./bg-runs-store.ts";
20
21
  import { spawnSubagent, type SpawnResult } from "../engine/spawnSubagent.ts";
@@ -90,6 +91,13 @@ export class FleetPanel extends Container {
90
91
  private runTimeline: RunLogEvent[] | null = null;
91
92
  private resumeInput: Input | null = null;
92
93
  private resumeMode = false;
94
+ // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
95
+ // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
96
+ // focused component = this panel; children receive keys only if we forward them).
97
+ private selectedEventIndex: number | null = null;
98
+ private fullMessageEvent: MessageEvent | ToolEvent | null = null;
99
+ private timelineList: SelectList | null = null;
100
+ private messageBodyList: SelectList | null = null;
93
101
  /** SPEC-5a proper-fix: store-change subscriptions — fired by RunRegistry + BgRunsStore
94
102
  * so the panel re-renders the moment a (fore- or back-ground) run mutates, without a keypress. */
95
103
  private readonly unsubs: (() => void)[] = [];
@@ -208,15 +216,50 @@ export class FleetPanel extends Container {
208
216
  `nextFire: ${s.nextFire?.toLocaleString() ?? "(none)"}`,
209
217
  ]) this.addChild(new Text(this.theme.fg("text", line), 0, 0));
210
218
  this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
219
+ } else if (this.fullMessageEvent) {
220
+ // SPEC-5b-3: full-message overlay (top of the stack). Header + scrollable wrapped body.
221
+ const e = this.fullMessageEvent;
222
+ const isMsg = e.type === "message";
223
+ const header = isMsg ? messageHeader(e) : toolHeader(e);
224
+ this.addChild(new Text(this.theme.fg("dim", ` ${header}`), 0, 0));
225
+ // Width: the panel renders at the terminal width pi gives ctx.ui.custom. Rows are pre-baked
226
+ // into SelectItem.label, so wrap now. Fall back to 80 if the live width isn't reachable here
227
+ // — the list still scrolls; a resize re-wraps on the next renderShell().
228
+ const width = 80;
229
+ const bodyLines = isMsg ? messageBody(e, width) : toolBody(e, width);
230
+ const body = new SelectList(
231
+ bodyLines.map((line) => ({ value: "", label: line })),
232
+ Math.min(bodyLines.length, 12),
233
+ {
234
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
235
+ selectedText: (s: string) => this.theme.fg("accent", s),
236
+ description: (s: string) => this.theme.fg("muted", s),
237
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
238
+ noMatch: (s: string) => this.theme.fg("warning", s),
239
+ },
240
+ );
241
+ // esc → back to timeline. Leave onSelect unset so Enter (tui.select.confirm) is swallowed
242
+ // silently by SelectList.handleInput — a text line has nothing to drill into.
243
+ // Do NOT clear selectedEventIndex here: it survives to drive the timeline cursor restore.
244
+ body.onCancel = () => {
245
+ this.fullMessageEvent = null;
246
+ this.messageBodyList = null;
247
+ this.renderShell();
248
+ };
249
+ this.messageBodyList = body;
250
+ this.addChild(body);
251
+ this.addChild(new Text(this.theme.fg("dim", " esc:Back"), 0, 0));
211
252
  } else if (this.selectedRun) {
212
- // SPEC-5b-1: Runs tab — per-turn timeline replay (read-only); enter reserved for 5b-3 overlay.
253
+ // SPEC-5b-1/5b-3: Runs tab — per-turn timeline replay. SPEC-5b-3 makes the list interactive
254
+ // (arrows scroll, enter opens full-message overlay, esc back to Runs list) by forwarding input
255
+ // to the stored SelectList (see handleInput) — v0.6.0 swallowed all non-escape keys.
213
256
  this.addChild(new Text(this.theme.fg("dim", ` ── run ${this.selectedRun.runId} — timeline ──`), 0, 0));
214
- const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<RunLogEvent>;
257
+ const events = (this.runTimeline ?? []).filter((e) => e.type === "message" || e.type === "tool") as Array<MessageEvent | ToolEvent>;
215
258
  if (events.length === 0) {
216
259
  this.addChild(new Text(this.theme.fg("dim", " (no conversation events)"), 0, 0));
217
260
  } else {
218
261
  const tl = new SelectList(
219
- events.map((e) => ({ value: "", label: runTimelineRow(e as never) })),
262
+ events.map((e, idx) => ({ value: String(idx), label: runTimelineRow(e as never) })),
220
263
  Math.min(events.length, 12),
221
264
  {
222
265
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
@@ -226,10 +269,26 @@ export class FleetPanel extends Container {
226
269
  noMatch: (s: string) => this.theme.fg("warning", s),
227
270
  },
228
271
  );
229
- tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.renderShell(); };
272
+ // SPEC-5b-3: enter on a timeline row open the full-message overlay for that event.
273
+ tl.onSelect = (item) => {
274
+ const idx = Number(item.value);
275
+ const ev = events[idx];
276
+ if (!ev) { this.onNotify("event no longer available", "warning"); return; }
277
+ this.selectedEventIndex = idx;
278
+ this.fullMessageEvent = ev;
279
+ this.renderShell();
280
+ };
281
+ // SPEC-5b-3: restore the cursor to the row we were viewing (one-shot, then clear the token).
282
+ if (this.selectedEventIndex != null) {
283
+ tl.setSelectedIndex(this.selectedEventIndex);
284
+ this.selectedEventIndex = null;
285
+ }
286
+ // esc → back to Runs list (replaces the v0.6.0 panel-level escape catch).
287
+ tl.onCancel = () => { this.selectedRun = null; this.runTimeline = null; this.timelineList = null; this.renderShell(); };
288
+ this.timelineList = tl;
230
289
  this.addChild(tl);
231
290
  }
232
- this.addChild(new Text(this.theme.fg("dim", " enter: (5b-3 full message) esc:Back"), 0, 0));
291
+ this.addChild(new Text(this.theme.fg("dim", " enter:Full-message esc:Back"), 0, 0));
233
292
  } else if (this.resumeMode && this.resumeInput) {
234
293
  // SPEC-5b-1: Runs tab — resume follow-up input.
235
294
  this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
@@ -260,8 +319,10 @@ export class FleetPanel extends Container {
260
319
 
261
320
  this.addChild(new Spacer(1));
262
321
  const hint =
263
- this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
264
- ? (this.selectedRun ? " enter:(5b-3) esc:Back" : " esc:Back")
322
+ this.fullMessageEvent
323
+ ? " esc:Back"
324
+ : this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
325
+ ? (this.selectedRun ? " enter:Full-message esc:Back" : " esc:Back")
265
326
  : this.pendingCheckpoint
266
327
  ? " c:Continue v:Revise a:Abort"
267
328
  : this.lcRevising
@@ -353,6 +414,10 @@ export class FleetPanel extends Container {
353
414
  this.selectedSchedule = null;
354
415
  this.selectedRun = null;
355
416
  this.runTimeline = null;
417
+ this.selectedEventIndex = null;
418
+ this.fullMessageEvent = null;
419
+ this.timelineList = null;
420
+ this.messageBodyList = null;
356
421
  this.list = this.buildList();
357
422
  this.renderShell();
358
423
  }
@@ -374,9 +439,18 @@ export class FleetPanel extends Container {
374
439
  if (matchesKey(data, "escape")) { this.selectedSchedule = null; this.renderShell(); }
375
440
  return;
376
441
  }
442
+ if (this.fullMessageEvent) {
443
+ // SPEC-5b-3: full-message overlay — forward to the body SelectList (arrows scroll; esc via
444
+ // onCancel back to timeline; enter is a no-op). Replaces the v0.6.0 swallow-all pattern.
445
+ this.messageBodyList?.handleInput(data);
446
+ this.invalidate();
447
+ return;
448
+ }
377
449
  if (this.selectedRun) {
378
- // SPEC-5b-1: Runs timeline replay overlay esc back; enter is a 5b-3 placeholder.
379
- if (matchesKey(data, "escape")) { this.selectedRun = null; this.runTimeline = null; this.renderShell(); }
450
+ // SPEC-5b-3: forward to the timeline SelectList (arrows scroll; enter via onSelect opens the
451
+ // overlay; esc via onCancel returns to the Runs list). v0.6.0 swallowed all non-escape keys.
452
+ this.timelineList?.handleInput(data);
453
+ this.invalidate();
380
454
  return;
381
455
  }
382
456
  if (this.resumeMode && this.resumeInput) {