my-durable 0.0.7 → 0.0.8
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/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +29 -3
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +8 -3
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// The TUI
|
|
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
|
-
|
|
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 {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
160
|
+
device: DeviceRef,
|
|
161
|
+
client: DpiClient,
|
|
162
|
+
info: DeviceInfo,
|
|
163
|
+
registry?: NetworkRegistry,
|
|
164
|
+
hub?: { url: string; token: string },
|
|
72
165
|
) {
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.
|
|
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.
|
|
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
|
|
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
|
-
|
|
229
|
+
agentEvents,
|
|
120
230
|
(frame) => this.model.apply(frame),
|
|
121
231
|
(state, error) => {
|
|
122
232
|
this.connection = state;
|
|
123
|
-
if (error && state !== "offline") this.
|
|
124
|
-
this.
|
|
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
|
-
|
|
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
|
|
139
|
-
if (!row.url)
|
|
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.
|
|
152
|
-
|
|
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.
|
|
160
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
213
|
-
this.
|
|
214
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
306
|
-
|
|
307
|
-
|
|
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.
|
|
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
|
-
// ------------------------------------------------------------------
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
}
|