my-durable 0.0.7 → 0.0.9

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 (44) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +29 -3
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  33. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  34. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  35. package/apps/dpi-tui/src/app.ts +889 -187
  36. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  37. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  38. package/apps/dpi-tui/src/main.ts +5 -1
  39. package/apps/dpi-tui/src/theme.ts +14 -0
  40. package/apps/dpi-tui/src/views.ts +311 -0
  41. package/bin/dpi.mjs +21 -4
  42. package/package.json +2 -2
  43. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
@@ -1,13 +1,13 @@
1
- // The TUI: one device, one agent at a time. Layout, keys and streams; rendering lives in render.ts.
1
+ // The TUI, rebuilt on the herdr-0.4.0 template (apps/tui/src/app.ts of the
2
+ // owner-supplied reference): a PURE frame(width, height) renderer driven by
3
+ // handleInput/tick, four views (herd | network | agent | log), five agent
4
+ // tabs, inline message input, in-frame palette/help panels. The data layer is
5
+ // this project's real machinery — DpiClient streams, AgentModel/SessionModel,
6
+ // the pairing controller — adapted into herdr's shapes.
2
7
  import {
3
- CombinedAutocompleteProvider,
4
- Editor,
5
- matchesKey,
6
8
  ProcessTerminal,
7
- ScrollView,
8
- Text,
9
9
  TuiAltScreen,
10
- VStack,
10
+ matchesKey,
11
11
  type Component,
12
12
  type OverlayHandle,
13
13
  type TuiInputListenerResult,
@@ -24,11 +24,13 @@ import {
24
24
  type WhenBusy,
25
25
  type WireAssistantMessage,
26
26
  } from "../../dpi-sdk/src/api.ts";
27
- import type { NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
27
+ import type { DeviceRow, NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
28
28
  import { COMMANDS, runCommand } from "./commands.ts";
29
- import { pickModel, pickSession, Prompt, SecretPrompt } from "./pickers.ts";
30
- import { Line, spinner, TranscriptView } from "./render.ts";
31
- import { c, editorTheme } from "./theme.ts";
29
+ import { Prompt, SecretPrompt, pickModel } from "./pickers.ts";
30
+ import { renderHeader, renderHerd, renderHerdPanel, renderNetwork, renderStatusbar, renderViewbar, herdRows, joinPanel, networkRows } from "./views.ts";
31
+ import { fetchHubNetwork } from "./hub-feed.ts";
32
+ import { HubMux } from "./hub-mux.ts";
33
+ import { box, c, STATE_COLOR, STATE_GLYPH, type RowState } from "./theme.ts";
32
34
 
33
35
  export interface DeviceRef {
34
36
  id: string;
@@ -37,14 +39,71 @@ export interface DeviceRef {
37
39
  token: string;
38
40
  }
39
41
 
42
+ type View = "herd" | "network" | "agent" | "log";
43
+ type AgentTab = "transcript" | "tasks" | "tools" | "state" | "config";
44
+ type InputKind = "message" | "filter" | "palette" | "new" | "kill";
45
+
46
+ const AGENT_TABS: { id: AgentTab; label: string }[] = [
47
+ { id: "transcript", label: "transcript" },
48
+ { id: "tasks", label: "tasks" },
49
+ { id: "tools", label: "tools" },
50
+ { id: "state", label: "state" },
51
+ { id: "config", label: "config" },
52
+ ];
53
+
54
+ const VIEW_TABS: { id: View; label: string }[] = [
55
+ { id: "herd", label: "herd" },
56
+ { id: "network", label: "network" },
57
+ { id: "agent", label: "agent" },
58
+ { id: "log", label: "log" },
59
+ ];
60
+
61
+ /**
62
+ * Slash-command autocomplete: the commands whose name extends the typed
63
+ * prefix. Live only before the first space — after that you are typing
64
+ * arguments, not a command.
65
+ */
66
+ export function slashSuggestions(
67
+ value: string,
68
+ commands: { name: string }[],
69
+ ): { names: string[]; prefix: string } | null {
70
+ if (!value.startsWith("/") || value.includes(" ")) return null;
71
+ const prefix = value.slice(1).toLowerCase();
72
+ const names = commands.map((c) => c.name).filter((n) => n.startsWith(prefix));
73
+ return names.length ? { names, prefix } : null;
74
+ }
75
+
76
+ const PALETTE: { name: string; hint: string; run: (a: App) => void }[] = [
77
+ { name: "herd", hint: "session dashboard", run: (a) => a.setView("herd") },
78
+ { name: "network", hint: "devices and sessions", run: (a) => a.setView("network") },
79
+ { name: "agent", hint: "back to the conversation", run: (a) => a.setView("agent") },
80
+ { name: "log", hint: "device events", run: (a) => a.setView("log") },
81
+ { name: "sessions", hint: "switch or create a session", run: (a) => void a.command("sessions") },
82
+ { name: "model", hint: "choose this agent's model", run: (a) => void a.command("model") },
83
+ { name: "fork", hint: "fork at the last reply", run: (a) => void a.command("fork") },
84
+ { name: "compact", hint: "summarize older context", run: (a) => void a.command("compact") },
85
+ { name: "pair", hint: "pair this device with a hub", run: (a) => void a.command("pair") },
86
+ { name: "quit", hint: "exit (the agent keeps running)", run: (a) => a.exit() },
87
+ ];
88
+
40
89
  const newId = () => crypto.randomUUID();
41
- /** Quote a shell word only when it needs it. */
42
90
  const shellWord = (s: string) => (/^[\w.:@/~-]+$/.test(s) ? s : `'${s}'`);
43
91
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
44
- const compact = (n: number) => Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
92
+ const compactNum = (n: number) => Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
93
+ const rep = (ch: string, n: number) => ch.repeat(Math.max(0, n));
94
+
95
+ /** The whole app paints through this single component — herdr's AppView. */
96
+ class AppView implements Component {
97
+ constructor(private readonly app: App) {}
98
+ invalidate() {}
99
+ render(width: number): string[] {
100
+ return this.app.frame(width);
101
+ }
102
+ }
45
103
 
46
104
  export class App {
47
- readonly tui = new TuiAltScreen(new ProcessTerminal(), false, undefined, {
105
+ private readonly procTerminal = new ProcessTerminal();
106
+ readonly tui = new TuiAltScreen(this.procTerminal, false, undefined, {
48
107
  scrollToEndIndicator: () => c.muted(" ↓ new output "),
49
108
  });
50
109
  readonly model = new AgentModel();
@@ -52,47 +111,88 @@ export class App {
52
111
  sessionId?: string;
53
112
  agentId?: number;
54
113
  handle?: AgentHandle;
55
- private header = new Line("");
56
- private view = new TranscriptView();
57
- private status = new Text("", 1, 0);
58
- private editor = new Editor(this.tui, editorTheme, { paddingX: 1 });
59
- private footer = new Line("");
114
+ info: DeviceInfo;
115
+
116
+ // herdr app state --------------------------------------------------------
117
+ view: View = "herd";
118
+ tab: AgentTab = "transcript";
119
+ cursor = 0;
120
+ filter = "";
121
+ input: { kind: InputKind; value: string } | null = null;
122
+ helpOpen = false;
123
+ toast = "";
124
+ toastTicks = 0;
125
+ scrollOffset = 0;
126
+ /** ctrl+o: show full tool output instead of the first lines. */
127
+ expand = false;
128
+ /** Slash autocomplete: which suggestion row is selected (0 = first). */
129
+ private suggIndex = 0;
130
+ /** Hub feed (multi-host display parity with the web sidebar). */
131
+ private hub?: { url: string; token: string };
132
+ /** The hub's /api/stream mux — tunnel-device attach (same transport as the web). */
133
+ private mux?: HubMux;
134
+ /** True while the current device is reached through the hub proxy. */
135
+ private viaHub = false;
136
+ private hubRows: DeviceRow[] = [];
137
+ private hubTick = 0;
138
+
139
+ /** Sent-message history for ↑ recall (drafts preserved while browsing). */
140
+ private inputHistory: string[] = [];
141
+ private histIndex = -1;
142
+ private inputDraft = "";
143
+
144
+ device: DeviceRef;
145
+ client: DpiClient;
146
+ registry?: NetworkRegistry;
147
+
148
+ private root = new AppView(this);
60
149
  private connection: ConnectionState | "idle" = "idle";
61
150
  private streams?: AbortController;
62
151
  private overlays: Array<{ handle: OverlayHandle; onClose?: () => void }> = [];
63
- private notice?: { text: string; error: boolean; at: number };
64
152
  private ticker?: ReturnType<typeof setInterval>;
65
153
  private sessions: SessionSummary[] = [];
154
+ private devicesCache: DeviceRow[] = [];
155
+ private deviceOnline = false;
156
+ private events: { at: string; glyph: string; text: string }[] = [];
157
+ private selectedId?: string;
66
158
 
67
159
  constructor(
68
- public device: DeviceRef,
69
- public client: DpiClient,
70
- public info: DeviceInfo,
71
- public registry?: NetworkRegistry,
160
+ device: DeviceRef,
161
+ client: DpiClient,
162
+ info: DeviceInfo,
163
+ registry?: NetworkRegistry,
164
+ hub?: { url: string; token: string },
72
165
  ) {
73
- this.tui.setLayoutRoot(
74
- new VStack([
75
- { component: this.header, basis: "auto" },
76
- { component: new ScrollView(this.view, { follow: "end", primary: true }), basis: 0, grow: 1, minSize: 3 },
77
- { component: this.status, basis: "auto", visible: () => this.statusLines().length > 0 },
78
- { component: this.editor, basis: "auto", shrink: 1, minSize: 1 },
79
- { component: this.footer, basis: "auto" },
80
- ]),
81
- );
82
- this.editor.setAutocompleteProvider(new CombinedAutocompleteProvider(COMMANDS, process.cwd()));
83
- this.editor.onSubmit = (text) => void this.submit(text, this.busy() ? "steer" : "followUp");
84
- this.tui.addInputListener((data) => this.key(data));
85
- this.model.subscribe(() => this.redraw());
86
- this.session.subscribe(() => this.redraw(false));
87
- this.tui.setFocus(this.editor);
166
+ this.device = device;
167
+ this.client = client;
168
+ this.info = info;
169
+ this.registry = registry;
170
+ this.hub = hub;
171
+ this.tui.addChild(this.root);
172
+ this.tui.setFocus(this.root);
173
+ // herdr render model: every keystroke and every tick repaint, so plain
174
+ // typing (append chars, no explicit notify) is never left off-screen.
175
+ this.tui.addInputListener((data) => {
176
+ const result = this.handleInput(data);
177
+ this.requestRender();
178
+ return result;
179
+ });
180
+ this.model.subscribe(() => this.requestRender());
181
+ this.session.subscribe(() => this.requestRender());
182
+ this.ticker = setInterval(() => this.tick(), 120);
183
+ if (this.hub)
184
+ void fetchHubNetwork(this.hub.url, this.hub.token).then((rows) => {
185
+ if (rows) (this.hubRows = rows), this.requestRender();
186
+ });
88
187
  this.tui.start();
89
- this.redraw();
188
+ void this.refreshSessions();
189
+ this.requestRender();
90
190
  }
91
191
 
92
192
  // ------------------------------------------------------------------ streams
93
193
 
94
194
  async open(sessionRef?: string, agentRef?: string) {
95
- if (!sessionRef) return pickSession(this);
195
+ if (!sessionRef) return this.setView("herd");
96
196
  await this.run(async () => {
97
197
  const s = await this.client.session(sessionRef).get();
98
198
  const agentId = agentRef
@@ -100,7 +200,6 @@ export class App {
100
200
  : (s.agents.find((a) => a.kind === "root") ?? s.agents[0])?.id ?? 1;
101
201
  await this.attach(s.id, agentId);
102
202
  });
103
- if (!this.sessionId) await pickSession(this);
104
203
  }
105
204
 
106
205
  async attach(sessionId: string, agentId: number) {
@@ -108,56 +207,96 @@ export class App {
108
207
  const streams = (this.streams = new AbortController());
109
208
  const sameSession = sessionId === this.sessionId;
110
209
  this.sessionId = sessionId;
210
+ this.selectedId = sessionId;
111
211
  this.agentId = agentId;
112
212
  this.handle = this.client.agent(sessionId, agentId);
113
213
  this.model.clear();
114
- this.view.reset();
115
214
  if (!sameSession) this.session.clear();
215
+ this.scrollOffset = 0;
116
216
  void this.refreshSessions();
117
217
  const handle = this.handle;
218
+ // Hub-routed devices stream through the mux (same upstreams the web rides);
219
+ // direct devices stream straight from the device.
220
+ const agentEvents = (signal: AbortSignal) =>
221
+ this.viaHub && this.mux
222
+ ? this.mux.events(`${this.device.id}/agent/${sessionId}/${agentId}`, signal)
223
+ : handle.events(signal);
224
+ const sessionEvents = (signal: AbortSignal) =>
225
+ this.viaHub && this.mux
226
+ ? this.mux.events(`${this.device.id}/session/${sessionId}`, signal)
227
+ : this.client.session(sessionId).events(signal);
118
228
  void follow(
119
- (signal) => handle.events(signal),
229
+ agentEvents,
120
230
  (frame) => this.model.apply(frame),
121
231
  (state, error) => {
122
232
  this.connection = state;
123
- if (error && state !== "offline") this.flash(error.message, true);
124
- this.redraw(false);
233
+ if (error && state !== "offline") this.notify(error.message, true);
234
+ this.requestRender();
125
235
  },
126
236
  streams.signal,
127
237
  );
128
238
  void follow(
129
- (signal) => this.client.session(sessionId).events(signal),
239
+ sessionEvents,
130
240
  (frame) => this.session.apply(frame),
131
241
  () => {},
132
242
  streams.signal,
133
243
  );
244
+ this.setView("agent");
245
+ this.logEvent("start", `attached ${sessionId}:${agentId}`);
134
246
  }
135
247
 
136
248
  async switchDevice(id: string) {
137
- const row = this.registry?.get(id);
138
- if (!row?.token) return this.flash(`No token for ${id}. Run: just net register ${id}`, true);
139
- if (!row.url) return this.flash(`${id} is tunnel-only (no LAN URL) — use the hub web UI.`, true);
249
+ const row = this.registry?.get(id) ?? this.hubRows.find((d) => d.id === id);
250
+ if (!row) return this.notify(`Unknown device ${id}`, true);
251
+ if (!row.url) {
252
+ // Tunnel device: no LAN endpoint here — reach it through the hub proxy
253
+ // (REST) + the hub's stream mux (events), exactly like the web FE.
254
+ if (!this.hub) return this.notify(`${row.name} is tunnel-only — start the TUI with --hub-url/--hub-token`, true);
255
+ const client = new DpiClient(`${this.hub.url}/api/devices/${row.id}`, this.hub.token);
256
+ this.mux ??= new HubMux(this.hub);
257
+ return void this.run(async () => {
258
+ this.info = await client.device({ timeoutMs: 10_000 });
259
+ this.streams?.abort();
260
+ this.device = { id: row.id, name: row.name, url: client.baseUrl, token: this.hub!.token };
261
+ this.client = client;
262
+ this.viaHub = true;
263
+ this.sessionId = undefined;
264
+ this.agentId = undefined;
265
+ this.handle = undefined;
266
+ this.model.clear();
267
+ this.session.clear();
268
+ this.sessions = [];
269
+ this.notify(`attached ${row.name} through the hub`);
270
+ await this.refreshSessions();
271
+ this.setView("herd");
272
+ });
273
+ }
274
+ if (!row.token) return this.notify(`No token for ${id}. Run: just net register ${id}`, true);
140
275
  const client = new DpiClient(row.url, row.token);
141
276
  await this.run(async () => {
142
277
  this.info = await client.device({ timeoutMs: 8000 });
143
278
  this.streams?.abort();
144
279
  this.device = { id: row.id, name: row.name, url: row.url!, token: row.token! };
145
280
  this.client = client;
281
+ this.viaHub = false;
146
282
  this.sessionId = undefined;
147
283
  this.agentId = undefined;
148
284
  this.handle = undefined;
149
285
  this.model.clear();
150
286
  this.session.clear();
151
- this.view.reset();
152
- await pickSession(this);
287
+ this.sessions = [];
288
+ this.setView("herd");
153
289
  });
154
290
  }
155
291
 
156
292
  private async refreshSessions() {
157
293
  try {
158
294
  this.sessions = (await this.client.tree()).sessions;
159
- this.redraw(false);
160
- } catch {}
295
+ this.deviceOnline = true;
296
+ this.requestRender();
297
+ } catch {
298
+ this.deviceOnline = false;
299
+ }
161
300
  }
162
301
 
163
302
  sessionSummary(): SessionSummary | undefined {
@@ -165,61 +304,132 @@ export class App {
165
304
  return s && this.session.state.ready ? { ...s, agents: this.session.state.agents } : s;
166
305
  }
167
306
 
168
- // ------------------------------------------------------------------ input
169
-
170
307
  busy() {
171
308
  return this.model.state.running || (this.agentId !== undefined && this.session.foregroundBusy(this.agentId));
172
309
  }
173
310
 
174
- private key(data: string): TuiInputListenerResult {
175
- if (matchesKey(data, "ctrl+c")) {
176
- if (this.overlays.length) return this.closeOverlay(), { consume: true };
177
- if (this.editor.getText()) return this.editor.setText(""), { consume: true };
178
- this.exit();
179
- return { consume: true };
180
- }
181
- if (this.overlays.length) return undefined;
182
- if (matchesKey(data, "ctrl+d") && !this.editor.getText()) return this.exit(), { consume: true };
183
- if (matchesKey(data, "escape") && !this.editor.isShowingAutocomplete()) {
184
- if (this.busy()) void this.interrupt();
185
- return { consume: true };
186
- }
187
- if (matchesKey(data, "alt+enter")) {
188
- const text = this.editor.getText();
189
- this.editor.setText("");
190
- void this.submit(text, "followUp");
191
- return { consume: true };
192
- }
193
- if (matchesKey(data, "shift+tab")) return void this.cycleThinking(), { consume: true };
194
- if (matchesKey(data, "ctrl+l")) return void pickModel(this), { consume: true };
195
- if (matchesKey(data, "ctrl+o")) {
196
- this.view.expand = !this.view.expand;
197
- this.redraw();
198
- return { consume: true };
311
+ command(name: string) {
312
+ return runCommand(this, `/${name}`);
313
+ }
314
+
315
+ // ------------------------------------------------------------------ actions
316
+
317
+ setView(view: View) {
318
+ this.view = view;
319
+ this.cursor = 0;
320
+ this.scrollOffset = 0;
321
+ if (view === "herd") void this.refreshSessions();
322
+ if (view === "network") this.devicesCache = this.networkDevices();
323
+ this.requestRender();
324
+ }
325
+
326
+ private networkDevices(): DeviceRow[] {
327
+ // The hub feed is the same source the web sidebar renders; fall back to
328
+ // the local registry when no hub is configured or before the first poll.
329
+ let rows = this.hubRows.length ? this.hubRows : this.registry ? this.registry.list() : [];
330
+ // The current device also appears as a hub row (same deviceUid) — drop
331
+ // that one and keep the direct LAN row, but inherit the hub's tree so its
332
+ // sessions still list here.
333
+ const mine = this.info?.uid ? rows.find((r) => r.deviceUid === this.info!.uid) : undefined;
334
+ if (mine) rows = rows.filter((r) => r !== mine);
335
+ if (this.device.id !== "adhoc" || rows.some((r) => r.id === "adhoc")) return rows;
336
+ return [
337
+ ...rows,
338
+ {
339
+ id: this.device.id,
340
+ name: this.info?.name || this.device.name || this.device.id,
341
+ kind: "url",
342
+ transport: "lan",
343
+ url: this.device.url,
344
+ token: this.device.token,
345
+ source: "config",
346
+ position: 0,
347
+ deviceUid: null,
348
+ lastStatus: this.deviceOnline || this.connection === "live" ? "online" : "offline",
349
+ lastError: null,
350
+ lastSeenAt: Date.now(),
351
+ lastInfo: this.info,
352
+ lastTree:
353
+ mine?.lastTree ??
354
+ { uid: this.device.id, name: this.info?.name || this.device.id, sessions: this.sessions, at: Date.now() },
355
+ createdAt: 0,
356
+ updatedAt: 0,
357
+ },
358
+ ];
359
+ }
360
+
361
+ private rows(): Array<{ sessionId?: string; deviceId?: string; label: string }> {
362
+ if (this.view === "herd") return herdRows(this.sessions, this.filter);
363
+ if (this.view === "network") return networkRows(this.devicesCache, this.device.id, this.filter);
364
+ return [];
365
+ }
366
+
367
+ private openCursor() {
368
+ const rows = this.rows();
369
+ const row = rows[this.cursor] ?? rows.find((r) => r.sessionId);
370
+ if (row?.sessionId) return this.openSession(row.sessionId, row.deviceId);
371
+ if (row?.deviceId && this.view === "network") {
372
+ const target = this.devicesCache.find((d) => d.id === row.deviceId);
373
+ if (!target) return;
374
+ if (target.id === this.device.id) return this.setView("herd");
375
+ if (!target.url && !this.hub)
376
+ return this.notify(
377
+ `${target.name} reaches this machine only through the hub tunnel — start the TUI with --hub-url/--hub-token`,
378
+ true,
379
+ );
380
+ void this.switchDevice(target.id);
381
+ return;
199
382
  }
200
- return undefined;
383
+ const first = rows.find((r) => r.sessionId);
384
+ if (first?.sessionId) this.openSession(first.sessionId);
385
+ }
386
+
387
+ private openSession(sessionId: string, deviceId?: string) {
388
+ // A network row may name a session on ANOTHER device: switching first is
389
+ // what makes the attach land on the right host (live bug: the session was
390
+ // requested from the current device and the stream answered `gone`).
391
+ const target = deviceId && deviceId !== this.device.id ? deviceId : undefined;
392
+ void this.run(async () => {
393
+ if (target) {
394
+ await this.switchDevice(target);
395
+ if (this.device.id !== target) return; // switch refused (offline/tunnel without hub)
396
+ }
397
+ const summary = this.sessions.find((s) => s.id === sessionId);
398
+ const agentId = summary?.agents.find((a) => a.kind === "root")?.id;
399
+ await this.attach(sessionId, agentId ?? 1);
400
+ });
201
401
  }
202
402
 
203
- private async submit(text: string, whenBusy: WhenBusy) {
403
+ /** 1-9 jump: the nth session row of the current view. */
404
+ private jump(n: number) {
405
+ const rows = this.rows().filter((r) => r.sessionId);
406
+ const nth = rows[n - 1];
407
+ if (nth?.sessionId) this.openSession(nth.sessionId, nth.deviceId);
408
+ else this.notify(`no session #${n} in this view`);
409
+ }
410
+
411
+ private async submit(text: string, mode?: "followUp") {
204
412
  const content = text.trim();
205
413
  if (!content) return;
414
+ if (this.inputHistory[this.inputHistory.length - 1] !== content) this.inputHistory.push(content);
415
+ if (this.inputHistory.length > 200) this.inputHistory.shift();
416
+ this.histIndex = -1;
417
+ this.inputDraft = "";
206
418
  if (content.startsWith("/") && (await runCommand(this, content))) return;
207
- if (!this.handle) return this.flash("Pick a session first (/sessions).", true);
208
- // The same requestId on every retry: the device admits the message exactly once.
419
+ if (!this.handle) return this.notify("Pick a session first (g, then ↵).", true);
209
420
  const requestId = newId();
210
421
  for (let attempt = 1; ; attempt++) {
211
422
  try {
212
- await this.handle.send(content, whenBusy, requestId);
213
- this.editor.addToHistory(content);
214
- if (whenBusy === "steer") this.flash("Steering: joins after the current tool round");
423
+ await this.handle.send(content, this.busy() && mode !== "followUp" ? "steer" : "followUp", requestId);
424
+ this.logEvent("turn", `${this.sessionSummary()?.name ?? this.sessionId} ← message`);
425
+ if (this.busy()) this.notify("steering: joins after the current tool round");
215
426
  return;
216
427
  } catch (e) {
217
428
  if (isDpiError(e) && e.code === "offline" && attempt < 3) {
218
429
  await sleep(1000 * attempt);
219
430
  continue;
220
431
  }
221
- if (!this.editor.getText()) this.editor.setText(text);
222
- this.flash(isDpiError(e) && e.code === "busy" ? "Agent is busy (reject mode)" : (e as Error).message, true);
432
+ this.notify(isDpiError(e) && e.code === "busy" ? "Agent is busy (reject mode)" : (e as Error).message, true);
223
433
  return;
224
434
  }
225
435
  }
@@ -228,7 +438,8 @@ export class App {
228
438
  private async interrupt() {
229
439
  await this.run(async () => {
230
440
  await this.handle?.abort(false);
231
- this.flash("Interrupted. Background jobs continue.");
441
+ this.notify("Interrupted. Background jobs continue.");
442
+ this.logEvent("compact", `interrupt ${this.sessionSummary()?.name ?? ""} · checkpointed`);
232
443
  });
233
444
  }
234
445
 
@@ -237,9 +448,203 @@ export class App {
237
448
  const current = (this.model.state.agent.thinkingLevel ?? "low") as ThinkingLevel;
238
449
  const next = THINKING_LEVELS[(THINKING_LEVELS.indexOf(current) + 1) % THINKING_LEVELS.length];
239
450
  await this.run(() => this.handle!.configure({ thinkingLevel: next }));
451
+ this.notify(`thinking: ${next}`);
452
+ }
453
+
454
+ // ------------------------------------------------------------------- input
455
+
456
+ handleInput(data: string): TuiInputListenerResult {
457
+ // pi-tui overlays (welcome screen, pickers, prompts) own the keyboard while
458
+ // open; only ctrl+c still closes them from here.
459
+ if (this.overlays.length) {
460
+ if (matchesKey(data, "ctrl+c")) return this.closeOverlay(), { consume: true };
461
+ return { consume: false };
462
+ }
463
+ // pi chords that work in both modes: model picker + transcript expand.
464
+ if (matchesKey(data, "ctrl+l")) return (void pickModel(this), { consume: true });
465
+ if (matchesKey(data, "ctrl+o")) {
466
+ this.expand = !this.expand;
467
+ this.notify(`expand ${this.expand ? "on — full tool output" : "off"}`);
468
+ return { consume: true };
469
+ }
470
+ if (this.handleTyping(data)) return { consume: true };
471
+ if (this.helpOpen) {
472
+ this.helpOpen = false;
473
+ this.requestRender();
474
+ return { consume: true };
475
+ }
476
+ if (matchesKey(data, "ctrl+c")) {
477
+ if (this.overlays.length) return this.closeOverlay(), { consume: true };
478
+ this.exit();
479
+ return { consume: true };
480
+ }
481
+ if (data === "q") {
482
+ this.exit();
483
+ return { consume: true };
484
+ }
485
+ if (data === "?") (this.helpOpen = true), this.requestRender();
486
+ else if (data === ":") this.beginInput("palette", ": ");
487
+ // "/" seeds the message line (slash-commands live here, pi-style); the
488
+ // herdr filter role rides "f" instead — one keyboard, both flows.
489
+ else if (data === "/") {
490
+ this.beginInput("message", "follow-up ❯ ");
491
+ this.input!.value = "/";
492
+ } else if (data === "f") this.beginInput("filter", "/ ");
493
+ else if (data === "n") this.beginInput("new", "new session › ");
494
+ else if (data === "x") this.beginInput("kill", `reset ${this.sessionSummary()?.name ?? "context"}? y/n`);
495
+ else if (data === "g") this.setView("herd");
496
+ else if (data === "N") this.setView("network");
497
+ else if (data === "L") this.setView("log");
498
+ else if (data === "j" || matchesKey(data, "down")) this.move(1);
499
+ else if (data === "k" || matchesKey(data, "up")) this.move(-1);
500
+ else if (matchesKey(data, "enter")) this.enter();
501
+ else if (matchesKey(data, "escape")) this.escape();
502
+ else if (data === "i") void this.interrupt();
503
+ else if (data === "F") void this.command("fork");
504
+ else if (data === "c") void this.command("compact");
505
+ else if (data === "y") void this.cycleThinking();
506
+ else if (data === "a" || data === "d") this.notify("nothing is waiting for approval");
507
+ else if (data === "m") this.beginInput("message", "follow-up ❯ ");
508
+ else if (data === "]" || matchesKey(data, "tab")) this.cycleTab(1);
509
+ else if (data === "[" || matchesKey(data, "shift+tab")) this.cycleTab(-1);
510
+ else if (/^[1-9]$/.test(data)) this.jump(Number(data));
511
+ // Agent view keeps pi muscle memory: any other printable char opens the
512
+ // message line seeded with itself (herdr requires "m"; typing talks here).
513
+ else if (this.view === "agent" && /^[^\x00-\x1f]$/.test(data)) {
514
+ this.beginInput("message", "follow-up ❯ ");
515
+ this.input!.value = data;
516
+ }
517
+ return { consume: true };
518
+ }
519
+
520
+ /** True when an inline input consumed the key (herdr's handleTyping). */
521
+ private handleTyping(data: string): boolean {
522
+ const input = this.input;
523
+ if (!input) return false;
524
+ if (matchesKey(data, "escape")) {
525
+ this.input = null;
526
+ this.toast = "";
527
+ return true;
528
+ }
529
+ // ↑/↓ recall sent messages; the current draft survives the round-trip.
530
+ if (input.kind === "message" && matchesKey(data, "up")) {
531
+ if (this.inputHistory.length) {
532
+ if (this.histIndex < 0) (this.inputDraft = input.value), (this.histIndex = this.inputHistory.length);
533
+ this.histIndex = Math.max(0, this.histIndex - 1);
534
+ input.value = this.inputHistory[this.histIndex] ?? input.value;
535
+ this.suggIndex = 0;
536
+ this.requestRender();
537
+ }
538
+ return true;
539
+ }
540
+ if (input.kind === "message" && matchesKey(data, "down")) {
541
+ if (this.histIndex >= 0) {
542
+ this.histIndex += 1;
543
+ if (this.histIndex >= this.inputHistory.length) (this.histIndex = -1), (input.value = this.inputDraft);
544
+ else input.value = this.inputHistory[this.histIndex] ?? input.value;
545
+ this.requestRender();
546
+ }
547
+ return true;
548
+ }
549
+ // Tab completes the selected slash suggestion; shift+tab cycles it.
550
+ if (input.kind === "message" && matchesKey(data, "shift+tab")) {
551
+ const sugg = slashSuggestions(input.value, COMMANDS);
552
+ if (sugg) this.suggIndex = (this.suggIndex + 1) % sugg.names.length;
553
+ this.requestRender();
554
+ return true;
555
+ }
556
+ if (input.kind === "message" && matchesKey(data, "tab")) {
557
+ const sugg = slashSuggestions(input.value, COMMANDS);
558
+ if (sugg) {
559
+ const pick = sugg.names[this.suggIndex % sugg.names.length]!;
560
+ input.value = `/${pick} `;
561
+ this.suggIndex = 0;
562
+ this.requestRender();
563
+ }
564
+ return true;
565
+ }
566
+ if (matchesKey(data, "alt+enter")) {
567
+ // pi muscle memory: send NOW as a follow-up even mid-turn (enter steers).
568
+ const value = input.value;
569
+ this.input = null;
570
+ this.toast = "";
571
+ void this.submit(value, "followUp");
572
+ return true;
573
+ }
574
+ if (matchesKey(data, "enter")) {
575
+ const value = input.value;
576
+ this.input = null;
577
+ this.toast = "";
578
+ if (input.kind === "message") void this.submit(value);
579
+ else if (input.kind === "filter") {
580
+ this.filter = value.trim();
581
+ this.cursor = 0;
582
+ this.notify(this.filter ? `filter: ${this.filter}` : "filter cleared");
583
+ } else if (input.kind === "palette") {
584
+ const cmd = PALETTE.find((p) => p.name.startsWith(value.trim()));
585
+ if (cmd) cmd.run(this);
586
+ else this.notify(`no command “${value.trim()}”`);
587
+ } else if (input.kind === "kill") {
588
+ if (value.trim().toLowerCase().startsWith("y")) void this.command("reset");
589
+ else this.notify("reset cancelled");
590
+ } else if (input.kind === "new") void this.command("new");
591
+ return true;
592
+ }
593
+ if (data === "\x7f" || matchesKey(data, "backspace")) (input.value = input.value.slice(0, -1)), (this.suggIndex = 0);
594
+ // Whole chunks append (bracketed paste and multi-byte chars both arrive
595
+ // as long strings — herdr H03's lesson: never filter by data.length === 1).
596
+ // Newlines survive paste (sent through to the agent); the line shows ⏎.
597
+ else {
598
+ const chunk = data.replace(/\r\n?/g, "\n");
599
+ if (chunk >= " " || [...chunk].some((ch) => ch >= " ") || chunk.includes("\n")) (input.value += chunk), (this.suggIndex = 0);
600
+ }
601
+ return true;
602
+ }
603
+
604
+ private beginInput(kind: InputKind, prompt: string) {
605
+ this.input = { kind, value: "" };
606
+ this.toast = prompt;
607
+ this.requestRender();
240
608
  }
241
609
 
242
- // ------------------------------------------------------------------ overlays and helpers
610
+ private escape() {
611
+ if (this.filter) {
612
+ this.filter = "";
613
+ this.notify("filter cleared");
614
+ } else if (this.view === "agent") {
615
+ if (this.busy()) void this.interrupt();
616
+ } else this.setView("agent");
617
+ }
618
+
619
+ private move(delta: number) {
620
+ if (this.view === "agent" && this.tab === "transcript") {
621
+ // Scroll the transcript instead of moving a cursor (herdr H04's lesson).
622
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
623
+ this.requestRender();
624
+ return;
625
+ }
626
+ const rows = this.rows();
627
+ if (!rows.length) return;
628
+ this.cursor = Math.max(0, Math.min(this.cursor + delta, rows.length - 1));
629
+ const row = rows[this.cursor];
630
+ if (row?.sessionId) this.selectedId = row.sessionId;
631
+ this.requestRender();
632
+ }
633
+
634
+ private enter() {
635
+ if (this.view === "agent" || this.view === "log") return this.setView("herd");
636
+ this.openCursor();
637
+ }
638
+
639
+ private cycleTab(delta: number) {
640
+ if (this.view !== "agent") return;
641
+ const index = AGENT_TABS.findIndex((t) => t.id === this.tab);
642
+ this.tab = AGENT_TABS[(index + delta + AGENT_TABS.length) % AGENT_TABS.length]!.id;
643
+ this.scrollOffset = 0;
644
+ this.requestRender();
645
+ }
646
+
647
+ // ------------------------------------------------------------- overlays
243
648
 
244
649
  overlay(component: Component, onClose?: () => void) {
245
650
  this.overlays.push({
@@ -249,7 +654,6 @@ export class App {
249
654
  this.tui.requestRender();
250
655
  }
251
656
 
252
- /** How many overlays are open (the welcome screen waits for panels to close). */
253
657
  get overlayCount() {
254
658
  return this.overlays.length;
255
659
  }
@@ -258,7 +662,7 @@ export class App {
258
662
  const overlay = this.overlays.pop();
259
663
  overlay?.handle.hide();
260
664
  overlay?.onClose?.();
261
- if (!this.overlays.length) this.tui.setFocus(this.editor);
665
+ if (!this.overlays.length) this.tui.setFocus(this.root);
262
666
  if (exitIfNothing) return this.exit();
263
667
  this.tui.requestRender();
264
668
  }
@@ -282,7 +686,6 @@ export class App {
282
686
  });
283
687
  }
284
688
 
285
- /** Like prompt(), but the value is only ever rendered as bullets (tokens). */
286
689
  promptSecret(label: string) {
287
690
  return new Promise<string | undefined>((done) => {
288
691
  this.overlay(
@@ -302,21 +705,32 @@ export class App {
302
705
  }
303
706
 
304
707
  flash(text: string, error = false) {
305
- this.notice = { text, error, at: Date.now() };
306
- this.redraw(false);
307
- setTimeout(() => this.redraw(false), 6100);
708
+ this.notify(text, error);
709
+ }
710
+
711
+ notify(message: string, error = false) {
712
+ this.toast = (error ? "✗ " : "") + message;
713
+ this.toastTicks = 50;
714
+ this.requestRender();
715
+ }
716
+
717
+ private logEvent(glyph: string, text: string) {
718
+ const at = new Date().toLocaleTimeString("en-GB", { hour12: false });
719
+ this.events.push({ at, glyph, text });
720
+ if (this.events.length > 500) this.events.splice(0, this.events.length - 500);
308
721
  }
309
722
 
310
723
  async run(fn: () => Promise<unknown>) {
311
724
  try {
312
725
  await fn();
313
726
  } catch (e) {
314
- this.flash((e as Error).message, true);
727
+ this.notify((e as Error).message, true);
315
728
  }
316
729
  }
317
730
 
318
731
  exit() {
319
732
  this.streams?.abort();
733
+ this.mux?.stop();
320
734
  clearInterval(this.ticker);
321
735
  this.tui.stop();
322
736
  const ref = this.reattachRef();
@@ -332,7 +746,6 @@ export class App {
332
746
  process.exit(0);
333
747
  }
334
748
 
335
- /** Names when they resolve to exactly this session and agent, otherwise ids. */
336
749
  private reattachRef() {
337
750
  const session = this.sessionSummary();
338
751
  const agent = session?.agents.find((a) => a.id === this.agentId);
@@ -350,9 +763,373 @@ export class App {
350
763
  return { session: sessionRef, agent: agentRef };
351
764
  }
352
765
 
353
- // ------------------------------------------------------------------ chrome
766
+ // ------------------------------------------------------------------ tick
767
+
768
+ tick() {
769
+ if (this.toastTicks > 0 && --this.toastTicks === 0) this.toast = "";
770
+ if (this.hub && ++this.hubTick >= 125) {
771
+ this.hubTick = 0;
772
+ void fetchHubNetwork(this.hub.url, this.hub.token).then((rows) => {
773
+ if (rows) {
774
+ this.hubRows = rows;
775
+ if (this.view === "network") this.devicesCache = this.networkDevices();
776
+ this.requestRender();
777
+ }
778
+ });
779
+ }
780
+ this.requestRender();
781
+ }
782
+
783
+ private requestRender() {
784
+ this.tui.requestRender();
785
+ }
786
+
787
+ // ------------------------------------------------------------------ frame
788
+
789
+ frame(width: number): string[] {
790
+ const w = Math.max(20, width);
791
+ const h = Math.max(8, this.procTerminal.rows || 24);
792
+ const lines: string[] = new Array(h).fill("") as string[];
793
+ lines[0] = this.header(w);
794
+ lines[1] = this.viewbar(w);
795
+ const bodyHeight = h - 4;
796
+ const body = this.body(w, bodyHeight);
797
+ for (let i = 0; i < bodyHeight; i += 1) lines[2 + i] = body[i] ?? "";
798
+ lines[h - 2] = this.messageLine(w);
799
+ lines[h - 1] = this.statusbar(w);
800
+ const sugg = this.suggestionLines(w);
801
+ for (let i = 0; i < sugg.length; i += 1) lines[h - 3 - sugg.length + 1 + i] = sugg[i];
802
+ const overlay = this.frameOverlay(w, bodyHeight);
803
+ if (overlay) for (let i = 0; i < overlay.length; i += 1) {
804
+ const row = 2 + i;
805
+ if (row < h - 2) lines[row] = overlay[i]!;
806
+ }
807
+ return lines;
808
+ }
809
+
810
+ private header(w: number): string {
811
+ const devices = this.registry ? this.networkDevices() : [];
812
+ return (
813
+ " " +
814
+ renderHeader(w - 2, {
815
+ device: this.device.id,
816
+ sessions: this.sessions.length,
817
+ devicesOnline: devices.filter((d) => d.lastStatus === "online").length,
818
+ devicesTotal: devices.length || undefined,
819
+ conn: this.connection === "live" ? "live" : this.connection === "idle" ? "no agent" : String(this.connection),
820
+ connOk: this.connection === "live",
821
+ })
822
+ );
823
+ }
824
+
825
+ private viewbar(w: number): string {
826
+ const session = this.sessions.find((x) => x.id === this.sessionId);
827
+ const right =
828
+ this.view === "agent" && session
829
+ ? `${this.device.id}:${session.name}:${this.agentId ?? 1}`
830
+ : this.view === "agent"
831
+ ? "no workspace"
832
+ : "j/k move · ↵ open";
833
+ return " " + renderViewbar(w - 2, this.view === "log" ? "log" : this.view, right);
834
+ }
835
+
836
+ private body(w: number, h: number): string[] {
837
+ if (h <= 0) return [];
838
+ if (this.view === "agent") return this.agentBody(w, h);
839
+ if (this.view === "network") return this.networkBody(w, h);
840
+ if (this.view === "log") return this.logBody(w, h);
841
+ return this.herdBody(w, h);
842
+ }
843
+
844
+ // ------------------------------------------------------------------ herd
845
+
846
+ private herdBody(w: number, h: number): string[] {
847
+ const rows = herdRows(this.sessions, this.filter);
848
+ const wide = w >= 104;
849
+ const panelW = wide ? Math.min(44, Math.max(34, Math.round(w * 0.32))) : 0;
850
+ const tableW = wide ? w - panelW - 1 : w;
851
+ const table = renderHerd(tableW, rows, this.sessions, {
852
+ selectedId: this.selectedId ?? this.sessionId,
853
+ cursor: this.cursor,
854
+ filter: this.filter,
855
+ }).slice(0, h);
856
+ if (!wide) return table;
857
+ const selected = this.sessions.find((s) => s.id === (this.selectedId ?? this.sessionId));
858
+ const panel = renderHerdPanel(panelW, h, selected, this.contextLeft());
859
+ return joinPanel(table, panel, tableW, w, h);
860
+ }
354
861
 
355
- /** Context window left, in percent: the last reply's prompt + output against the model's window. */
862
+ // --------------------------------------------------------------- network
863
+
864
+ private networkBody(w: number, h: number): string[] {
865
+ const rows = networkRows(this.devicesCache, this.device.id, this.filter);
866
+ return renderNetwork(w, rows, this.devicesCache, {
867
+ cursor: this.cursor,
868
+ currentSessionId: this.sessionId,
869
+ filter: this.filter,
870
+ }).slice(0, h);
871
+ }
872
+
873
+ // ----------------------------------------------------------------- agent
874
+
875
+ private agentState(): RowState {
876
+ return this.busy() ? "busy" : "idle";
877
+ }
878
+
879
+ private agentBody(w: number, h: number): string[] {
880
+ const session = this.sessionSummary();
881
+ const lines: string[] = [];
882
+ const state = this.agentState();
883
+ const ctx = this.contextLeft();
884
+ const barW = Math.min(40, Math.max(12, Math.round(w * 0.3)));
885
+ const filled = ctx === undefined ? 0 : Math.round((ctx / 100) * barW);
886
+ lines.push(
887
+ `${c.muted(this.device.id)} ${c.muted("›")} ${c.text(session?.name ?? "—")} ${c.muted("›")} ${c.bold(`#${this.agentId ?? 1}`)} ` +
888
+ `${STATE_COLOR[state](STATE_GLYPH[state] + " " + (this.busy() ? "working" : "idle"))} ` +
889
+ `${c.muted("ctx")} ${ctx === undefined ? c.muted("—") : c.accent(rep("█", filled)) + c.muted(rep("░", barW - filled)) + " " + c.muted(`${ctx}%`)}`,
890
+ );
891
+ lines.push(
892
+ AGENT_TABS.map((t) => (t.id === this.tab ? c.inverse(c.bold(` ${t.label} `)) : c.muted(` ${t.label} `))).join(
893
+ c.muted("│"),
894
+ ) + c.muted(rep(" ", Math.max(0, w - 62))) + c.muted(this.model.state.agent.model?.modelId?.split("/").pop() ?? ""),
895
+ );
896
+ lines.push(c.muted(rep(box.h, w)));
897
+ const inner = h - 3;
898
+ const content = this.agentContent(w);
899
+ const from = Math.max(0, content.length - inner - this.scrollOffset);
900
+ for (const line of content.slice(from, from + inner)) lines.push(line);
901
+ return lines.slice(0, h);
902
+ }
903
+
904
+ /** The transcript/tasks/tools/state/config tabs, from the REAL model state. */
905
+ private agentContent(w: number): string[] {
906
+ const s = this.model.state;
907
+ const wrap = (text: string, indent = " ") =>
908
+ text
909
+ .split("\n")
910
+ .flatMap((l) => (l.length > w - 4 ? [l.slice(0, w - 4), l.slice(w - 4)] : [l]))
911
+ .map((l) => indent + l);
912
+ if (this.tab === "transcript") {
913
+ const lines: string[] = [];
914
+ let turn = 0;
915
+ for (const item of s.items) {
916
+ if (item.kind === "user") {
917
+ lines.push(`${c.user("YOU")}`);
918
+ lines.push(...wrap(item.text));
919
+ lines.push("");
920
+ } else if (item.kind === "assistant") {
921
+ turn += 1;
922
+ lines.push(`${c.accent(`AGENT #${turn}`)}`);
923
+ for (const b of item.blocks) {
924
+ if (b.type === "thinking") lines.push(...wrap(b.text).map((l) => c.dim(c.italic(l))));
925
+ else if (b.type === "text") lines.push(...wrap(b.text));
926
+ else if (b.type === "tool") {
927
+ const glyph = b.call.status === "error" ? c.err("✗") : b.call.status === "running" ? c.warn("◐") : c.ok("✓");
928
+ const args = JSON.stringify(b.call.args ?? {}) || "";
929
+ lines.push(` ${glyph} ${c.bold(b.call.name)} ${c.muted(this.expand ? args.slice(0, w - 8) : args.slice(0, Math.max(10, w - 20)))}`);
930
+ const out = b.call.output ?? b.call.result ?? "";
931
+ if (typeof out === "string" && out)
932
+ for (const l of (this.expand ? out.split("\n") : out.split("\n").slice(0, 6)))
933
+ lines.push(` ${c.muted(l.slice(0, w - 6))}`);
934
+ }
935
+ }
936
+ if (item.error) lines.push(` ${c.err(item.error.slice(0, w - 4))}`);
937
+ lines.push("");
938
+ }
939
+ }
940
+ if (!lines.length) lines.push(c.muted(" no entries yet — m to send the first message"));
941
+ return lines;
942
+ }
943
+ if (this.tab === "tasks") {
944
+ const lines: string[] = [];
945
+ for (const item of s.inbox)
946
+ lines.push(
947
+ ` ${c.warn("◆")} ${c.text(`${item.mode === "steer" ? "steer" : "queued"}: ${(item.content ?? "context update").replace(/\s+/g, " ").slice(0, w - 20)}`)}`,
948
+ );
949
+ const bg = this.agentId === undefined ? [] : this.session.backgroundTasks(this.agentId);
950
+ for (const t of bg) lines.push(` ${c.accent("◐")} ${c.text(String(t).slice(0, w - 6))}`);
951
+ if (!lines.length) lines.push(c.muted(" no queued or background work"));
952
+ return lines;
953
+ }
954
+ if (this.tab === "tools") {
955
+ const counts = new Map<string, number>();
956
+ for (const item of s.items) {
957
+ if (item.kind !== "assistant") continue;
958
+ for (const b of item.blocks) if (b.type === "tool") counts.set(b.call.name, (counts.get(b.call.name) ?? 0) + 1);
959
+ }
960
+ const lines = [c.muted(` tool calls across this context`), ""];
961
+ const max = Math.max(1, ...counts.values());
962
+ for (const [name, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
963
+ const barW = 16;
964
+ const filled = Math.round((n / max) * barW);
965
+ lines.push(` ${c.text(name.padEnd(14))} ${c.muted(rep("▮", filled))}${c.muted(rep("▯", barW - filled))} ${c.muted(String(n))}`);
966
+ }
967
+ if (!counts.size) lines.push(c.muted(" no tool calls yet"));
968
+ return lines;
969
+ }
970
+ if (this.tab === "state") {
971
+ const u = sumUsage(s.usage);
972
+ const state = {
973
+ device: this.device.id,
974
+ session: this.sessionId,
975
+ agent: this.agentId,
976
+ connection: this.connection,
977
+ running: s.running,
978
+ compactions: s.compactions.length,
979
+ usage: { tokens: u.tokens, cost: u.cost },
980
+ inbox: s.inbox.length,
981
+ notices: s.notices.length,
982
+ };
983
+ const lines = [];
984
+ for (const line of JSON.stringify(state, null, 2).split("\n")) lines.push(` ${c.muted(line)}`);
985
+ lines.push("", ` ${c.muted("Shared with every fork of this session on the device.")}`);
986
+ return lines;
987
+ }
988
+ // config tab
989
+ const model = this.model.state.agent.model;
990
+ const lines = [
991
+ ` ${c.muted("model")} ${c.text(model?.modelId ?? "—")}`,
992
+ ` ${c.muted("provider")} ${c.muted(model?.provider ?? "—")}`,
993
+ ` ${c.muted("thinking")} ${c.text(String(this.model.state.agent.thinkingLevel ?? "low"))} ${c.muted("(y cycles)")}`,
994
+ "",
995
+ ` ${c.muted("device")} ${c.text(this.device.id)} ${c.muted(this.device.url)}`,
996
+ ` ${c.muted("start dirs")} ${c.muted(this.info.startDirs.join(", ").slice(0, w - 18) || "—")}`,
997
+ ];
998
+ return lines;
999
+ }
1000
+
1001
+ // ------------------------------------------------------------------- log
1002
+
1003
+ private logBody(w: number, h: number): string[] {
1004
+ const rows = this.events.filter((e) => !this.filter || e.text.toLowerCase().includes(this.filter.toLowerCase()));
1005
+ const lines = [c.muted(` ${this.events.length} events · this client`)];
1006
+ const visible = h - 1;
1007
+ for (const e of rows.slice(-visible)) lines.push(` ${c.muted(e.at)} ${c.accent(e.glyph)} ${c.text(e.text.slice(0, w - 18))}`);
1008
+ return lines;
1009
+ }
1010
+
1011
+ // ---------------------------------------------------------------- chrome
1012
+
1013
+ /** In-frame slash autocomplete list, drawn just above the message line. */
1014
+ private suggestionLines(w: number): string[] {
1015
+ if (!this.input || this.input.kind !== "message") return [];
1016
+ const sugg = slashSuggestions(this.input.value, COMMANDS);
1017
+ if (!sugg) return [];
1018
+ const shown = sugg.names.slice(0, 5);
1019
+ const idx = this.suggIndex % shown.length;
1020
+ const desc = (name: string) => COMMANDS.find((c) => c.name === name)?.description ?? "";
1021
+ const width = Math.min(w - 2, 44);
1022
+ return shown.map((name, i) => {
1023
+ const row = ` /${name.padEnd(11)} ${desc(name)}`.slice(0, width);
1024
+ return i === idx ? " " + c.accentBg(row) : " " + c.muted(row);
1025
+ });
1026
+ }
1027
+
1028
+ private messageLine(w: number): string {
1029
+ if (this.input) {
1030
+ const shown = this.input.value.replace(/\n/g, "\u23ce").slice(-(w - 14));
1031
+ return " " + `${c.muted(this.toast)}${c.text(shown)}${c.accent("▌")}`.slice(0, w);
1032
+ }
1033
+ if (this.toast) return " " + `${this.toast.startsWith("✗") ? c.err(this.toast) : c.accent("◆") + " " + c.text(this.toast)}`.slice(0, w);
1034
+ if (this.view === "agent") {
1035
+ const hint =
1036
+ this.tab === "transcript"
1037
+ ? "m message · i interrupt · F fork · c compact · y thinking · esc herd"
1038
+ : "esc herd · [ ] tabs · m message";
1039
+ return " " + c.muted(hint).slice(0, w);
1040
+ }
1041
+ return " " + c.muted("j/k move · ↵ open · : palette · n new · f filter · / msg · ? help").slice(0, w);
1042
+ }
1043
+
1044
+ private statusbar(w: number): string {
1045
+ return (
1046
+ " " +
1047
+ renderStatusbar(w - 2, {
1048
+ view: this.view,
1049
+ device: this.device.id,
1050
+ ctxLeft: this.contextLeft(),
1051
+ background: this.agentId === undefined ? 0 : this.session.backgroundTasks(this.agentId).length,
1052
+ mode: this.input ? "INSERT" : "NORMAL",
1053
+ })
1054
+ );
1055
+ }
1056
+
1057
+ // -------------------------------------------------------------- overlays
1058
+
1059
+ private frameOverlay(w: number, h: number): string[] | undefined {
1060
+ if (this.helpOpen) return this.helpPanel(w, h);
1061
+ if (this.input?.kind === "palette") return this.palettePanel(w, h);
1062
+ return undefined;
1063
+ }
1064
+
1065
+ private palettePanel(w: number, h: number): string[] {
1066
+ const query = (this.input?.value ?? "").trim();
1067
+ const items = PALETTE.filter((p) => p.name.includes(query) || p.hint.includes(query));
1068
+ const width = Math.min(62, w - 4);
1069
+ const height = Math.min(items.length + 2, h);
1070
+ const x = Math.floor((w - width) / 2);
1071
+ const panel = this.panel(width, height, "command palette");
1072
+ items.slice(0, height - 2).forEach((item, index) => {
1073
+ panel[index + 1] = this.panelLine(width, `${index === 0 ? "▌" : " "} ${item.name.padEnd(10)} ${item.hint}`, index === 0);
1074
+ });
1075
+ return place(panel, w, h, x, 1);
1076
+ }
1077
+
1078
+ private helpPanel(w: number, h: number): string[] {
1079
+ const width = Math.min(76, w - 4);
1080
+ const rows = [
1081
+ `${c.accent("▲")} ${c.bold("dpi keymap (herdr template)")}`,
1082
+ "",
1083
+ `${c.accent("j k ↑ ↓")} move/scroll ${c.accent("1-9")} jump to session`,
1084
+ `${c.accent("↵")} open ${c.accent("g N L")} herd / network / log`,
1085
+ `${c.accent("[ ]")} agent tabs ${c.accent("m")} message (enter sends)`,
1086
+ `${c.accent("esc")} back/interrupt ${c.accent("i")} interrupt ≠ kill`,
1087
+ `${c.accent("F")} fork ${c.accent("c")} compact context`,
1088
+ `${c.accent("y")} thinking level ${c.accent("x")} reset (y/n confirm)`,
1089
+ `${c.muted("alt+↵")} send now, no steer ${c.muted("ctrl+l")} model picker`,
1090
+ `${c.muted("tab")} complete /command ${c.muted("↑")} recall sent`,
1091
+ `${c.muted("ctrl+o")} expand tool output`,
1092
+ `${c.accent(":")} palette ${c.accent("f")} filter rows`,
1093
+ `${c.accent("n")} new session ${c.accent("/")} message // commands`,
1094
+ "",
1095
+ c.muted("Durability"),
1096
+ `${c.muted("·")} every turn is checkpointed on the device`,
1097
+ `${c.muted("·")} interrupt leaves the run resumable; only reset starts fresh`,
1098
+ `${c.muted("·")} sends retry by requestId — the device admits each exactly once`,
1099
+ `${c.muted("·")} /commands still work: type them in the message line`,
1100
+ ];
1101
+ const height = Math.min(rows.length + 2, h);
1102
+ const panel = this.panel(width, height, "help");
1103
+ rows.slice(0, height - 2).forEach((line, index) => {
1104
+ panel[index + 1] = this.panelLine(width, ` ${line}`, false);
1105
+ });
1106
+ const x = Math.floor((w - width) / 2);
1107
+ const y = Math.max(0, Math.floor((h - height) / 2));
1108
+ return place(panel, w, h, x, y);
1109
+ }
1110
+
1111
+ private panel(width: number, height: number, title: string): string[] {
1112
+ const inner = Math.max(0, width - title.length - 5);
1113
+ const top = c.muted(box.tl + box.h) + c.muted(` ${title} `) + c.muted(rep(box.h, inner) + box.tr);
1114
+ const bottom = c.muted(box.bl + rep(box.h, width - 2) + box.br);
1115
+ return [
1116
+ top,
1117
+ ...Array.from({ length: Math.max(0, height - 2) }, () => c.muted(box.v) + rep(" ", width - 2) + c.muted(box.v)),
1118
+ bottom,
1119
+ ];
1120
+ }
1121
+
1122
+ private panelLine(width: number, content: string, active: boolean): string {
1123
+ const inner = width - 4;
1124
+ let body = content;
1125
+ while (body.length > inner) body = body.slice(0, -2); // crude but ANSI-safe enough for short rows
1126
+ const padded = body + rep(" ", Math.max(0, inner - body.length));
1127
+ return `${c.muted(box.v)} ${active ? c.inverse(padded) : padded} ${c.muted(box.v)}`;
1128
+ }
1129
+
1130
+ // ------------------------------------------------------------------ ctx
1131
+
1132
+ /** Context window left, in percent: the last reply's usage against the model's window. */
356
1133
  private contextLeft(): number | undefined {
357
1134
  const s = this.model.state;
358
1135
  const m = s.agent.model;
@@ -368,90 +1145,15 @@ export class App {
368
1145
  }
369
1146
  return Math.max(0, Math.min(100, Math.floor(100 * (1 - used / window))));
370
1147
  }
1148
+ }
371
1149
 
372
- private statusLines() {
373
- const s = this.model.state;
374
- const lines: string[] = [];
375
- if (this.busy())
376
- lines.push(
377
- `${c.warn("●")} ${c.secondary(s.retry ? `retrying: ${s.retry.error}` : s.compactions.length ? "compacting…" : "working…")} ${c.muted("esc to interrupt")}`,
378
- );
379
- for (const item of s.inbox)
380
- lines.push(c.muted(`${item.mode === "steer" ? "↪ steer" : "⏳ queued"}: ${(item.content ?? "context update").replace(/\s+/g, " ")}`));
381
- for (const n of s.notices.slice(-2)) lines.push(c.err(n));
382
- if (this.notice && Date.now() - this.notice.at < 6000)
383
- lines.push(this.notice.error ? c.err(this.notice.text) : c.ok(this.notice.text));
384
- return lines;
385
- }
386
-
387
- private redraw(items = true) {
388
- const s = this.model.state;
389
- if (items) this.view.update(s.items);
390
- const running = s.items.some(
391
- (i) => (i.kind === "tool" && i.call.status === "running") || (i.kind === "assistant" && i.blocks.some((b) => b.type === "tool" && b.call.status === "running")),
392
- );
393
- if (running && !this.ticker)
394
- this.ticker = setInterval(() => {
395
- spinner.frame++;
396
- this.tui.requestRender();
397
- }, 120);
398
- else if (!running && this.ticker) {
399
- clearInterval(this.ticker);
400
- this.ticker = undefined;
401
- }
402
- const conn =
403
- this.connection === "live"
404
- ? c.ok("● live")
405
- : this.connection === "idle"
406
- ? c.muted("○ no agent")
407
- : this.connection === "connecting"
408
- ? c.warn("◌ connecting")
409
- : c.err(`○ ${this.connection}`);
410
- const lineage = this.agentId === undefined ? [] : this.session.lineage(this.agentId);
411
- const label = (kind: string, value: string) => `${c.muted(kind)} ${value}`;
412
- const agentLabel = lineage.length
413
- ? lineage
414
- .map((a) =>
415
- a.kind === "fork"
416
- ? label("fork", `⑂ ${a.name}`)
417
- : a.kind === "subagent"
418
- ? label("subagent", `↳ ${a.name}`)
419
- : label("agent", a.name),
420
- )
421
- .join(c.muted(" › "))
422
- : this.agentId !== undefined
423
- ? label("agent", `#${this.agentId}`)
424
- : "";
425
- const session = this.sessions.find((x) => x.id === this.sessionId);
426
- this.header.text = " " + (
427
- [
428
- c.bold(c.accent("dpi")),
429
- label("device", c.secondary(this.device.id)),
430
- session ? label("workspace", c.bold(session.name)) : c.muted("no workspace"),
431
- agentLabel,
432
- conn,
433
- ]
434
- .filter(Boolean)
435
- .join(c.muted(" │ "))
436
- );
437
- this.status.setText(this.statusLines().join("\n"));
438
- const u = sumUsage(s.usage);
439
- const model = (s.agent.model?.modelId ?? "").split("/").pop();
440
- const ctx = this.contextLeft();
441
- const bg = this.agentId === undefined ? 0 : this.session.backgroundTasks(this.agentId).length;
442
- this.footer.text = " " + (
443
- c.muted(
444
- [
445
- model ? `${model}: ${s.agent.thinkingLevel ?? "low"}` : "",
446
- ctx === undefined ? "" : `${ctx}% CTX`,
447
- `${compact(u.tokens)} tok $${u.cost.toFixed(3)}`,
448
- bg ? `${bg} bg` : "",
449
- "/help",
450
- ]
451
- .filter(Boolean)
452
- .join(" │ "),
453
- )
454
- );
455
- this.tui.requestRender();
456
- }
1150
+ /** Compose full-width body rows with a panel placed at (x, y). */
1151
+ function place(panel: string[], width: number, height: number, x: number, y: number): string[] {
1152
+ const out: string[] = Array.from({ length: height }, () => rep(" ", width));
1153
+ panel.forEach((line, index) => {
1154
+ const row = y + index;
1155
+ if (row < 0 || row >= height) return;
1156
+ out[row] = rep(" ", x) + line.slice(0, Math.max(0, width - x));
1157
+ });
1158
+ return out;
457
1159
  }