my-durable 0.0.9 → 0.0.10

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.
@@ -35,6 +35,9 @@ const store = await Store.open({
35
35
  const app = express();
36
36
  app.disable("x-powered-by");
37
37
  app.get("/healthz", (_req, res) => res.json({ ok: true }));
38
+ // The base URL is what people type first; a JSON 404 reads like a broken
39
+ // daemon. Land them on the one real page (unpaired: enroll; paired: status).
40
+ app.get("/", (_req, res) => res.redirect("/pair"));
38
41
  const pairing = new DevicePairing(store, config.home, config.name, process.env.DPI_HUB_URL, {
39
42
  // Forced hub change (P-5): once the new pairing is persisted the OLD
40
43
  // connector is stopped — never two live tunnels for one device, and the old
@@ -26,7 +26,7 @@ import {
26
26
  } from "../../dpi-sdk/src/api.ts";
27
27
  import type { DeviceRow, NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
28
28
  import { COMMANDS, runCommand } from "./commands.ts";
29
- import { Prompt, SecretPrompt, pickModel } from "./pickers.ts";
29
+ import { Prompt, SecretPrompt, pickModel, newSession } from "./pickers.ts";
30
30
  import { renderHeader, renderHerd, renderHerdPanel, renderNetwork, renderStatusbar, renderViewbar, herdRows, joinPanel, networkRows } from "./views.ts";
31
31
  import { fetchHubNetwork } from "./hub-feed.ts";
32
32
  import { HubMux } from "./hub-mux.ts";
@@ -587,7 +587,7 @@ export class App {
587
587
  } else if (input.kind === "kill") {
588
588
  if (value.trim().toLowerCase().startsWith("y")) void this.command("reset");
589
589
  else this.notify("reset cancelled");
590
- } else if (input.kind === "new") void this.command("new");
590
+ } else if (input.kind === "new") void newSession(this, value);
591
591
  return true;
592
592
  }
593
593
  if (data === "\x7f" || matchesKey(data, "backspace")) (input.value = input.value.slice(0, -1)), (this.suggIndex = 0);
@@ -855,7 +855,7 @@ export class App {
855
855
  }).slice(0, h);
856
856
  if (!wide) return table;
857
857
  const selected = this.sessions.find((s) => s.id === (this.selectedId ?? this.sessionId));
858
- const panel = renderHerdPanel(panelW, h, selected, this.contextLeft());
858
+ const panel = renderHerdPanel(panelW, h, selected, this.contextLeft(), this.device.name);
859
859
  return joinPanel(table, panel, tableW, w, h);
860
860
  }
861
861
 
@@ -61,6 +61,11 @@ export async function runCommand(app: App, text: string): Promise<boolean> {
61
61
  pickDevice(app);
62
62
  break;
63
63
  case "new":
64
+ // "new agent" needs an open session; the n KEY (new session) is the other door.
65
+ if (!app.sessionId) {
66
+ app.notify("no session open — press n to create one");
67
+ break;
68
+ }
64
69
  await app.run(async () => {
65
70
  const created = await app.client.session(app.sessionId!).createAgent(arg ? { name: arg } : {});
66
71
  await app.attach(app.sessionId!, created.id);
@@ -25,35 +25,91 @@ class Title implements Component {
25
25
  invalidate() {}
26
26
  }
27
27
 
28
- /** A titled SelectList; typing filters it. */
28
+ /**
29
+ * A titled list with local filtering. Two deliberate departures from pi-tui's
30
+ * SelectList defaults, both found by driving the real picker:
31
+ * - it filters on `value.startsWith`, so typing "pro" found nothing in a
32
+ * directory named "project/"; we filter on the LABEL (substring), which is
33
+ * what the user reads;
34
+ * - its empty message is "No matching commands" — the wrong vocabulary for a
35
+ * directory or model list, so the theme names the filter instead.
36
+ * j/k move (as the app advertises) instead of being swallowed as filter text.
37
+ */
29
38
  export class Picker extends Box {
30
- readonly list: SelectList;
39
+ list: SelectList;
31
40
  private filter = "";
32
41
  private title: Title;
42
+ private readonly hintText: string;
33
43
  constructor(
34
44
  private label: string,
35
- items: SelectItem[],
36
- onPick: (item: SelectItem) => void,
37
- onCancel: () => void,
38
- hint = "↑↓ move │ enter select │ type to filter │ esc close",
39
- subtitle?: string,
45
+ private readonly allItems: SelectItem[],
46
+ private onPick: (item: SelectItem) => void,
47
+ private readonly onCancel: () => void,
48
+ hint = "↑↓/j/k move │ enter select │ type to filter │ esc close",
49
+ private readonly subtitle?: string,
40
50
  ) {
41
51
  super(1, 0, panelBg);
52
+ this.hintText = hint;
42
53
  this.title = new Title(c.bold(c.accent(label)));
43
- this.list = new SelectList(items, Math.max(1, Math.min(14, items.length)), selectTheme);
44
- this.list.onSelect = onPick;
45
- this.list.onCancel = onCancel;
54
+ this.list = this.makeList(this.allItems);
55
+ this.rebuild();
56
+ }
57
+ private makeList(items: SelectItem[]): SelectList {
58
+ const theme = {
59
+ ...selectTheme,
60
+ // Name the filter: "no match for “pro”" beats a command-palette phrase.
61
+ noMatch: (s: string) => c.muted(this.filter ? ` no match for “${this.filter}”` : s),
62
+ };
63
+ const list = new SelectList(items, Math.max(1, Math.min(14, items.length)), theme);
64
+ list.onSelect = this.onPick;
65
+ list.onCancel = this.onCancel;
66
+ return list;
67
+ }
68
+ /** Rebuild typing the children in order — the filter changes the item set. */
69
+ private rebuild() {
70
+ this.clear();
46
71
  this.addChild(this.title);
47
- if (subtitle) this.addChild(new Title(subtitle));
72
+ if (this.subtitle) this.addChild(new Title(this.subtitle));
48
73
  this.addChild(this.list);
49
- this.addChild(new Title(c.muted(hint)));
74
+ this.addChild(new Title(c.muted(this.hintText)));
75
+ this.invalidate();
76
+ }
77
+ /** Every item this picker was built with (shortcut lookups need the whole set). */
78
+ items(): SelectItem[] {
79
+ return this.allItems;
80
+ }
81
+ /** Run the same path a click/enter takes. */
82
+ pick(item: SelectItem) {
83
+ this.onPick(item);
84
+ }
85
+ private applyFilter() {
86
+ const needle = this.filter.toLowerCase();
87
+ const items = needle
88
+ ? this.allItems.filter((i) => `${i.label} ${i.description ?? ""}`.toLowerCase().includes(needle))
89
+ : this.allItems;
90
+ this.list = this.makeList(items);
91
+ this.title.text =
92
+ c.bold(c.accent(this.label)) + (this.filter ? c.muted(` /${this.filter}`) : "");
93
+ this.rebuild();
50
94
  }
51
95
  handleInput(data: string) {
52
- if (matchesKey(data, "backspace")) this.filter = this.filter.slice(0, -1);
53
- else if (data.length === 1 && data >= " " && data !== "\x7f") this.filter += data;
54
- else return this.list.handleInput(data);
55
- this.list.setFilter(this.filter);
56
- this.title.text = c.bold(c.accent(this.label)) + (this.filter ? c.muted(` /${this.filter}`) : "");
96
+ // Navigation first: j/k are movement everywhere else in the app, so a list
97
+ // must not swallow them as filter characters.
98
+ // SelectList only knows arrows/enter/esc, so vim keys are translated to the
99
+ // arrow sequences it already understands.
100
+ if (data === "j") return this.list.handleInput("\x1b[B");
101
+ if (data === "k") return this.list.handleInput("\x1b[A");
102
+ if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter") || matchesKey(data, "escape"))
103
+ return this.list.handleInput(data);
104
+ if (matchesKey(data, "backspace")) {
105
+ this.filter = this.filter.slice(0, -1);
106
+ return this.applyFilter();
107
+ }
108
+ if (data.length === 1 && data >= " " && data !== "\x7f") {
109
+ this.filter += data;
110
+ return this.applyFilter();
111
+ }
112
+ this.list.handleInput(data);
57
113
  }
58
114
  }
59
115
 
@@ -216,17 +272,25 @@ export async function pickSession(app: App) {
216
272
  );
217
273
  }
218
274
 
219
- export async function newSession(app: App) {
220
- const name = await app.prompt("Session name", "");
275
+ export async function newSession(app: App, presetName?: string) {
276
+ // The `n` input line already carries the typed name — never re-ask for it.
277
+ const name = presetName !== undefined ? presetName : await app.prompt("Session name", "");
221
278
  if (name === undefined) return app.sessionId ? undefined : pickSession(app);
222
279
  const cwd = await walkDirs(app, "");
223
280
  if (!cwd) return app.sessionId ? undefined : pickSession(app);
224
- const model = await chooseModel(app, app.info.defaultModel);
281
+ // A device without a provider key (the Windows default) lists ZERO models —
282
+ // the picker would be empty and creation dead-ends. Fall back to the device's
283
+ // own default exactly like the web dialog's "(device default)" option.
284
+ const models = app.info.models;
285
+ const model = models.length
286
+ ? await chooseModel(app, app.info.defaultModel)
287
+ : { provider: (app.info as { provider?: string }).provider ?? "openrouter", id: app.info.defaultModel, name: "(device default)" };
225
288
  if (!model) return;
226
289
  await app.run(async () => {
227
290
  const s = await app.client.createSession({ name: name || "New session", cwd, model: { provider: model.provider, modelId: model.id } });
228
291
  await app.attach(s.id, s.agents.find((a) => a.kind === "root")?.id ?? 1);
229
292
  app.flash(`Session created in ${cwd}`);
293
+ if (!models.length) app.flash(`no model catalog on the device — using its default (${app.info.defaultModel})`);
230
294
  });
231
295
  }
232
296
 
@@ -241,7 +305,7 @@ function walkDirs(app: App, path: string): Promise<string | undefined> {
241
305
  ...listing.dirs.map((d) => ({ value: d.path, label: `${d.name}/` })),
242
306
  ];
243
307
  app.overlay(
244
- new Picker(`Working directory on ${app.device.id}`, items, (item) => {
308
+ new Picker(`Working directory on ${app.device.name}`, items, (item) => {
245
309
  app.closeOverlay();
246
310
  if (item.value.startsWith("use:")) done(item.value.slice(4));
247
311
  else void step(item.value);
@@ -142,6 +142,16 @@ export function renderHerd(
142
142
  if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
143
143
  lines.push(atCursor && !selected ? c.inverse(fit(line, w)) : fit(line, w));
144
144
  }
145
+ if (!rows.length && !opts.filter) {
146
+ // herdr never shows a bare header over nothing: the empty state points at
147
+ // the next move instead of a wall of blank rows.
148
+ lines.push(
149
+ "",
150
+ ` ${c.muted("no sessions yet on this device")}`,
151
+ "",
152
+ ` ${c.accent("n")} ${c.muted("new session")} ${c.accent(":")} ${c.muted("palette")} ${c.accent("N")} ${c.muted("network view")}`,
153
+ );
154
+ }
145
155
  if (opts.filter) lines.push(c.warn(`filter: ${opts.filter}`));
146
156
  // herdr frames pad EVERY line to the exact width — the caller paints rows as-is.
147
157
  return lines.map((l) => fit(l, w));
@@ -273,8 +283,23 @@ export function renderHerdPanel(
273
283
  h: number,
274
284
  s: SessionSummary | undefined,
275
285
  ctxLeft?: number,
286
+ device?: string,
276
287
  ): string[] {
277
- if (!s) return Array.from({ length: Math.max(0, h) }, () => rep(" ", w));
288
+ // No session selected: a blank panel next to the divider is what made the
289
+ // wide layout look broken — herdr's panel always carries a device header.
290
+ if (!s) {
291
+ const lines = [
292
+ c.muted(" OVERVIEW"),
293
+ ` ${c.bold(device ?? "device")}`,
294
+ ` ${c.muted("no session selected")}`,
295
+ "",
296
+ ` ${c.muted("n")} ${c.text("new session")}`,
297
+ ` ${c.muted("↵")} ${c.text("open the cursor row")}`,
298
+ ` ${c.muted("j/k")} ${c.text("move")}`,
299
+ ` ${c.muted(":")} ${c.text("palette")}`,
300
+ ];
301
+ return lines.slice(0, h).map((l) => fit(l, w));
302
+ }
278
303
  const agents = s.agents.map((a) =>
279
304
  a.kind === "fork" ? `⑂ ${a.name}` : a.kind === "subagent" ? `↳ ${a.name}` : `● ${a.name}`,
280
305
  );
@@ -194,8 +194,8 @@ export function validateDeviceInput(urlAnswer: string, tokenAnswer: string): { u
194
194
  // ------------------------------------------------------------------ the welcome screen
195
195
 
196
196
  export function welcomeItems(deviceCount: number, port: number): SelectItem[] {
197
- // Values lead with the bracket letter, so typing it jumps to the action (the
198
- // Picker filters on the value); enter selects it.
197
+ // The bracket letter is a real shortcut (see WelcomePicker) — typing it runs
198
+ // the action instead of relying on filter order.
199
199
  const items: SelectItem[] = [
200
200
  { value: "pair", label: "[p] Pair this machine", description: "code or pair URL from the hub wizard" },
201
201
  { value: "add-url", label: "[a] Add a device by URL", description: "http://host:port + its DPI_TOKEN" },
@@ -219,6 +219,12 @@ export class WelcomePicker extends Picker {
219
219
  }
220
220
  handleInput(data: string) {
221
221
  if (data === "q" || matchesKey(data, "escape")) return this.cancel();
222
+ // Letters in the labels are shortcuts, not filter text: "[d] Detect" must
223
+ // DETECT regardless of which other label happens to contain a "d".
224
+ if (data.length === 1) {
225
+ const item = this.items().find((i) => i.label.startsWith(`[${data}]`));
226
+ if (item) return this.pick(item);
227
+ }
222
228
  return super.handleInput(data);
223
229
  }
224
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -136,6 +136,16 @@ const SUITES = {
136
136
  cmd: ["node", "tests/pairing-ui.mjs"],
137
137
  title: "real PTY pairing lifecycle and 40/80/200-column resize",
138
138
  },
139
+ "ui:tui-session": {
140
+ cwd: "apps/dpi-tui",
141
+ cmd: ["node", "tests/newsession-ui.mjs"],
142
+ title: "real PTY session creation on a keyless device + empty-state layout",
143
+ },
144
+ "ui:tui-welcome": {
145
+ cwd: "apps/dpi-tui",
146
+ cmd: ["node", "tests/welcome-ui.mjs"],
147
+ title: "real PTY welcome screen, detect-local and quit",
148
+ },
139
149
  };
140
150
 
141
151
  const PHASE_GATES = ["test:verification", "check:sdk", "check:backend", "contract:backend", "build:playground", "check:tui"];
@@ -143,7 +153,20 @@ const PHASE_SUITES = {
143
153
  P0: ["sdk"],
144
154
  P1: ["sdk", "playground"],
145
155
  P2: ["sdk", "backend", "playground", "tui"],
146
- ALL: ["sdk", "backend", "timing:backend", "playground", "tui", "lint:playground", "lint:core", "ui:network", "ui:pairing", "ui:tui"],
156
+ ALL: [
157
+ "sdk",
158
+ "backend",
159
+ "timing:backend",
160
+ "playground",
161
+ "tui",
162
+ "lint:playground",
163
+ "lint:core",
164
+ "ui:network",
165
+ "ui:pairing",
166
+ "ui:tui",
167
+ "ui:tui-session",
168
+ "ui:tui-welcome",
169
+ ],
147
170
  };
148
171
 
149
172
  function run(name, suite) {