@neosh/sidebar 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/main.ts +139 -19
  2. package/package.json +1 -1
package/main.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  type PickerItem,
79
79
  prompt,
80
80
  pulseBright,
81
+ pulseHl,
81
82
  spinnerFrame,
82
83
  } from "@neosh/api/ui";
83
84
 
@@ -1968,6 +1969,7 @@ async function showNodes(neosh: Neosh): Promise<void> {
1968
1969
  const listening = await neosh.vars
1969
1970
  .get<{ addr: string | null; error?: string }>({ scope: "global" }, "swarm.listen")
1970
1971
  .catch(() => null);
1972
+ const ascii = (await neosh.opt.get<boolean>("ui.ascii_only").catch(() => false)) ?? false;
1971
1973
 
1972
1974
  type Row =
1973
1975
  | { kind: "node"; node: SwarmNode }
@@ -1975,11 +1977,17 @@ async function showNodes(neosh: Neosh): Promise<void> {
1975
1977
  | { kind: "add" }
1976
1978
  | { kind: "self" };
1977
1979
 
1978
- const build = async (): Promise<PickerItem<Row>[]> => {
1979
- const [nodes, strangers] = await Promise.all([
1980
- neosh.swarm.nodes().catch(() => []),
1981
- neosh.swarm.strangers().catch(() => []),
1982
- ]);
1980
+ // The last answer from the host, so a spinner frame costs a redraw rather than two round trips.
1981
+ // At 80ms a tick, rebuilding these over the API would be the most talkative thing in the
1982
+ // workspace, to animate one glyph.
1983
+ let nodes: SwarmNode[] = [];
1984
+ let strangers: SwarmStranger[] = [];
1985
+
1986
+ /** Whether anything on screen is mid-dial, and therefore whether the tick has any work. */
1987
+ const spinning = () =>
1988
+ nodes.some((n) => n.link.state === "connecting" || n.link.state === "retrying");
1989
+
1990
+ const build = (): PickerItem<Row>[] => {
1983
1991
  const rows: PickerItem<Row>[] = [];
1984
1992
 
1985
1993
  for (const s of strangers) {
@@ -1989,17 +1997,30 @@ async function showNodes(neosh: Neosh): Promise<void> {
1989
1997
  ? `found at ${s.addr ?? "an address you gave"} · ${fingerprint(s.info.id)} · ↵ to add`
1990
1998
  : `wants to join · ${fingerprint(s.info.id)} · ↵ to allow`,
1991
1999
  keywords: `${s.info.id} pending join pair new`,
2000
+ // A machine asking to join is the one row here that wants answering, so it wears the
2001
+ // colour the workspace already uses for *act now* and pulses on the same 1 Hz duty cycle
2002
+ // as every other waiting indicator. Nothing else in this list moves unless it is working.
2003
+ icon: "?",
2004
+ hl: pulseHl("Status.Pending"),
1992
2005
  value: { kind: "stranger", stranger: s },
1993
2006
  });
1994
2007
  }
1995
2008
 
1996
2009
  for (const n of nodes) {
1997
2010
  const running = n.agents.filter((a) => a.state === "running").length;
1998
- // One sentence per link state, because they are different answers to "why is it not
1999
- // here": being dialled for the first time, being dialled again, and not being dialled.
2000
- const state =
2001
- n.link.state === "up"
2002
- ? `${n.agents.length} ${n.agents.length === 1 ? "conversation" : "conversations"}`
2011
+ // One sentence per link state, because they are different answers to "why is it not here" —
2012
+ // and the fourth of them is the reason this reads the way it does. `waiting` is the far half
2013
+ // of pairing: reached, proven, and not yet allowed over there. It used to arrive as
2014
+ // `connecting` with attempt 0, which printed a bare `connecting…` and threw the only useful
2015
+ // sentence away, so adding a computer looked like a network fault for as long as anybody
2016
+ // was willing to watch it.
2017
+ const state = n.link.state === "up"
2018
+ ? `${n.agents.length} ${n.agents.length === 1 ? "conversation" : "conversations"}`
2019
+ : n.link.state === "waiting"
2020
+ // Reads on from the label, which is the machine's name — naming it again here gave
2021
+ // `linux-box allow this computer on linux-box`. What the sentence has to carry is the
2022
+ // key and the fact that it is pressed somewhere else.
2023
+ ? "has not allowed this computer yet — ^J there"
2003
2024
  : n.link.state === "connecting"
2004
2025
  ? n.link.attempt > 0
2005
2026
  ? `connecting — try ${n.link.attempt}, ${n.reason ?? "no answer"}`
@@ -2009,6 +2030,16 @@ async function showNodes(neosh: Neosh): Promise<void> {
2009
2030
  ? `reconnecting — try ${n.link.attempt}`
2010
2031
  : "reconnecting…"
2011
2032
  : `disconnected — ${n.reason ?? "it dials in"} · ^R reconnects`;
2033
+ // The glyph says what the row is doing; the sentence says what to do about it. A spinner
2034
+ // only ever means "this machine is working on it" — never over `waiting`, where nothing on
2035
+ // this computer is trying and the motion would be a promise nobody is keeping.
2036
+ const [icon, hl] = n.link.state === "up"
2037
+ ? [ascii ? "*" : "●", "Diagnostic.Ok"]
2038
+ : n.link.state === "waiting"
2039
+ ? [ascii ? "!" : "◍", pulseHl("Status.Pending")]
2040
+ : n.link.state === "down"
2041
+ ? [ascii ? "-" : "○", "Sidebar.Dim"]
2042
+ : [spinnerFrame(), "Status.Streaming"];
2012
2043
  rows.push({
2013
2044
  label: n.info.name,
2014
2045
  detail: [
@@ -2019,14 +2050,20 @@ async function showNodes(neosh: Neosh): Promise<void> {
2019
2050
  fingerprint(n.info.id),
2020
2051
  ].filter(Boolean).join(" · "),
2021
2052
  keywords: `${n.info.os} ${n.info.id} ${n.link.state}`,
2053
+ icon,
2054
+ hl,
2022
2055
  value: { kind: "node", node: n },
2023
2056
  });
2024
2057
  }
2025
2058
 
2026
2059
  rows.push({
2027
- label: "+ Add a computer…",
2060
+ // The `+` is the icon now that this list has a gutter, not the first character of the label:
2061
+ // with both it read `+ + Add a computer…`.
2062
+ label: "Add a computer…",
2028
2063
  detail: "its address on your network, or through Tailscale",
2029
2064
  keywords: "pair join new machine host",
2065
+ icon: "+",
2066
+ hl: "Sidebar.Dim",
2030
2067
  value: { kind: "add" },
2031
2068
  });
2032
2069
  rows.push({
@@ -2049,24 +2086,55 @@ async function showNodes(neosh: Neosh): Promise<void> {
2049
2086
  return rows;
2050
2087
  };
2051
2088
 
2052
- const items = await build();
2089
+ /** Ask the host again. Everything else here draws from what this last brought back. */
2090
+ const fetch = async () => {
2091
+ [nodes, strangers] = await Promise.all([
2092
+ neosh.swarm.nodes().catch(() => []),
2093
+ neosh.swarm.strangers().catch(() => []),
2094
+ ]);
2095
+ };
2096
+
2097
+ const redraw = () => {
2098
+ items.splice(0, items.length, ...build());
2099
+ };
2053
2100
  const refill = async () => {
2054
- items.splice(0, items.length, ...(await build()));
2101
+ await fetch();
2102
+ redraw();
2055
2103
  };
2056
2104
 
2105
+ await fetch();
2106
+ const items: PickerItem<Row>[] = build();
2107
+
2057
2108
  const chosen = await picker(neosh, items, {
2058
2109
  title: "Computers",
2059
2110
  width: 84,
2060
2111
  height: 14,
2061
- hints: "↵ choose ^R reconnect ^D disconnect ^X remove ^Y my id esc close",
2112
+ hints: "↵ open ^E rename ^R reconnect ^D disconnect ^X remove ^Y my id",
2062
2113
  // The list keeps up while it is open: a machine connecting, dropping, or moving from
2063
2114
  // "connecting" to a row of conversations changes under the cursor rather than on reopen.
2064
- subscribe: (reload) =>
2065
- neosh.swarm.onChange(() => {
2115
+ subscribe: (reload) => {
2116
+ const changed = neosh.swarm.onChange(() => {
2066
2117
  void refill().then(reload);
2067
- }),
2118
+ });
2119
+ // And the spinner turns on the shared clock, off what the last change brought back rather
2120
+ // than off a fresh pair of API calls: a dial takes seconds, the clock ticks twelve times a
2121
+ // second, and asking the host what it knows on every frame to move one glyph would make
2122
+ // this panel the noisiest thing in the workspace. Nothing ticks while nothing is dialling,
2123
+ // so a list of connected machines costs exactly as much as it did before.
2124
+ const ticked = onTick(() => {
2125
+ if (!spinning()) return;
2126
+ redraw();
2127
+ reload();
2128
+ });
2129
+ return {
2130
+ dispose() {
2131
+ changed.dispose();
2132
+ ticked.dispose();
2133
+ },
2134
+ };
2135
+ },
2068
2136
  // Chords, because every bare letter a picker takes is a letter its filter can never contain.
2069
- ownKeys: ["<C-y>", "<C-x>", "<C-r>", "<C-d>"],
2137
+ ownKeys: ["<C-y>", "<C-x>", "<C-r>", "<C-d>", "<C-e>"],
2070
2138
  async onKey(key, ctx) {
2071
2139
  if (key.key.code.kind !== "char" || !key.key.mods.ctrl) return;
2072
2140
  switch (key.key.code.c.toLowerCase()) {
@@ -2090,6 +2158,27 @@ async function showNodes(neosh: Neosh): Promise<void> {
2090
2158
  await refill();
2091
2159
  return "reload";
2092
2160
  }
2161
+ case "e": {
2162
+ const row = ctx.item;
2163
+ if (row?.kind !== "node") return "handled";
2164
+ // Prefilled with what the row says, so this is an edit rather than a blank field you
2165
+ // have to remember the old name to fill in. Clearing it is how you go back to the
2166
+ // hostname, which is why an empty answer is not the same as `Esc`.
2167
+ const next = await prompt(neosh, `Call ${row.node.info.name}`, {
2168
+ initial: row.node.info.name,
2169
+ width: 60,
2170
+ });
2171
+ if (next === null) return "handled";
2172
+ try {
2173
+ await neosh.swarm.rename(row.node.info.id, next.trim() === "" ? null : next.trim());
2174
+ await refill();
2175
+ return "reload";
2176
+ } catch (e) {
2177
+ // Almost always "that one is in your config file", which names the field to change.
2178
+ neosh.notify(String(e), "warn");
2179
+ return "handled";
2180
+ }
2181
+ }
2093
2182
  case "x": {
2094
2183
  const row = ctx.item;
2095
2184
  if (row?.kind !== "node") return "handled";
@@ -2137,6 +2226,23 @@ async function showNodes(neosh: Neosh): Promise<void> {
2137
2226
  return;
2138
2227
  }
2139
2228
  case "node": {
2229
+ // A row you cannot open should say why in the terms the row is in. "Nothing running" is true
2230
+ // of a machine that has not allowed this one yet, and is not what is wrong with it.
2231
+ if (chosen.node.link.state === "waiting") {
2232
+ neosh.notify(
2233
+ `${chosen.node.info.name} has not allowed this computer yet — press ^J there and add `
2234
+ + `${me.name}`,
2235
+ "info",
2236
+ );
2237
+ return;
2238
+ }
2239
+ if (!chosen.node.up) {
2240
+ neosh.notify(
2241
+ `${chosen.node.info.name} is not connected — ${chosen.node.reason ?? "still dialling"}`,
2242
+ "info",
2243
+ );
2244
+ return;
2245
+ }
2140
2246
  const newest = [...chosen.node.agents].sort((a, b) => b.updated_at - a.updated_at)[0];
2141
2247
  if (!newest) {
2142
2248
  neosh.notify(`${chosen.node.info.name} has nothing running`, "info");
@@ -2384,9 +2490,16 @@ async function addComputer(neosh: Neosh): Promise<void> {
2384
2490
  const target = addr.includes(":") ? addr.trim() : `${addr.trim()}:7717`;
2385
2491
 
2386
2492
  let found;
2493
+ // A dial across a network somebody just typed the address of is the one thing here that can take
2494
+ // long enough to look broken — a wrong hostname sits on a TCP timeout — so it turns while it
2495
+ // waits. Progress is keyed and replaced in place, which is what makes this one line rather than
2496
+ // a column of `asking…`.
2497
+ const turning = onTick(() => {
2498
+ neosh.progress("swarm.probe", `${spinnerFrame()} asking ${target}…`);
2499
+ });
2387
2500
  try {
2388
2501
  // A state that stops being true the moment the far end answers or does not.
2389
- neosh.progress("swarm.probe", `asking ${target}…`);
2502
+ neosh.progress("swarm.probe", `${spinnerFrame()} asking ${target}…`);
2390
2503
  found = await neosh.swarm.probe(target);
2391
2504
  } catch (e) {
2392
2505
  // `String(e)` here would read `NeoshError: not found: …: swarm i/o: Connection refused (os
@@ -2401,6 +2514,7 @@ async function addComputer(neosh: Neosh): Promise<void> {
2401
2514
  neosh.log.warn(`swarm probe of ${target} failed: ${String(e)}`);
2402
2515
  return;
2403
2516
  } finally {
2517
+ turning.dispose();
2404
2518
  neosh.done("swarm.probe");
2405
2519
  }
2406
2520
 
@@ -2419,6 +2533,12 @@ async function addComputer(neosh: Neosh): Promise<void> {
2419
2533
  // Both halves have to happen, and only one of them is ours. Saying so now saves the ten minutes
2420
2534
  // otherwise spent wondering why the machine is listed and permanently unreachable.
2421
2535
  neosh.notify(`${found.name} added — now allow this computer over there, with ^J`);
2536
+ // And back to the panel, which is where that second half becomes visible: the new row sits on
2537
+ // `allow this computer on <name>` until somebody does it and then turns over to its
2538
+ // conversations, live, without a key being pressed here. Dropping the user back at the composer
2539
+ // instead is what made pairing feel like it had silently failed — the one thing worth watching
2540
+ // was on a panel they had just been taken out of.
2541
+ await showNodes(neosh);
2422
2542
  }
2423
2543
 
2424
2544
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neosh/sidebar",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A docked panel: where you are, what changed, and what is answering.",
5
5
  "license": "MIT",
6
6
  "type": "module",