zettcode-commands 0.4.2__py3-none-any.whl

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.
@@ -0,0 +1,1077 @@
1
+ """``/hub``: who is here, what the daemons are doing, and who talked to whom, on one full-screen page with three tabs.
2
+
3
+ The gateway's page shows this in a browser; this is the same picture where the work happens. What it reads is the
4
+ broker's roster and message graph through the SDK, the sessions stored for this workspace, and the daemons' records
5
+ through :mod:`zettcode.sdk.daemons`, so nothing new is listened to and nothing is stored here.
6
+
7
+ * **Graph**: the busiest sessions as boxes, coloured by state, and a line between two that exchanged messages, with
8
+ the number beside the end it is drawn from (:mod:`~zettcode.commands.hub_graph` says how). Every box has a place, so
9
+ an arrow key selects the nearest box that way, which keeps its lines and dims the rest; ``Esc`` deselects.
10
+ * **Sessions**: the sessions that are running, as the broker's roster has them, and nothing else. Only looked at here:
11
+ it is a daemon that can be started.
12
+ * **Daemons**: every daemon recorded on this machine, running or not, then the sessions of this workspace that are
13
+ only stored: a daemon is what can be started on them. Whatever has no process is ``offline`` — a daemon that was
14
+ stopped keeps its record and stays listed until it is deleted, and a stored session has none. ``s`` stops the one
15
+ under the mark and keeps its record; ``Enter`` starts an offline one, resuming its session.
16
+
17
+ ``/`` searches the table in front (Sessions or Daemons): what is typed after it keeps the rows whose name, title or id
18
+ contain every word of it, ignoring case. ``Enter`` stops typing and keeps the filter (``s`` and ``r`` act again),
19
+ ``Esc`` clears it and only then closes the page. Each tab has its own search.
20
+
21
+ ``Tab`` and ``Shift-Tab`` are the only keys that change tab, ``r`` reads everything again, and the top right of the
22
+ page says when the last reading was and how long it took. The page shows what that reading found; it does not follow
23
+ the broker by itself.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import dataclasses
30
+ import time
31
+ from collections.abc import Awaitable, Callable, Sequence
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ from zettcode.commands import hub_graph
37
+ from zettcode.commands.hub_graph import Link, Node, Picture
38
+ from zettcode.plugin_api.commands import CommandContext, CommandHost, CommandResult
39
+ from zettcode.plugin_api.plugin import TuiPlugin
40
+ from zettcode.tui import SEPARATOR, Canvas, ListItem, ListPage, PasteEvent, Rect, TextEvent, Theme, event_key
41
+ from zettcode.tui.render import Style, display_width, truncate
42
+
43
+ if TYPE_CHECKING: # pragma: no cover - annotations only
44
+ from zettcode.plugin_api import PluginContext
45
+
46
+ __all__ = ["HubCommand", "HubData", "HubPage"]
47
+
48
+ #: The three tabs, in the order Tab walks them, and the position of each.
49
+ TABS = ("Graph", "Sessions", "Daemons")
50
+ GRAPH, SESSIONS, DAEMONS = range(len(TABS))
51
+
52
+ #: What a daemon's status says when its process is gone, or when nothing says it is running.
53
+ GONE = ("dead", "unknown")
54
+
55
+ #: The status of a row that is no daemon at all yet: a session stored in this workspace that nothing is running. It is
56
+ #: on the Daemons tab because a daemon is what can be started on it, and ``Enter`` does that, resuming the session.
57
+ OFFLINE = "offline"
58
+
59
+ #: What ``Enter`` can start: a daemon whose process is gone, and a stored session with no daemon.
60
+ STARTABLE = (*GONE, OFFLINE)
61
+
62
+ #: How many of the newest stored sessions are looked at. The store reads every session's file to answer however many
63
+ #: are asked for, so the cost is the same at any limit; the limit only bounds how long the table is.
64
+ OFFLINE_LIMIT = 200
65
+
66
+ #: The smallest area the picture is drawn in: room for two rows of boxes and a number between them, and for a box and
67
+ #: its neighbour side by side. Below it the page says so rather than draw a chain of two boxes.
68
+ MIN_GRAPH_WIDTH = 40
69
+ MIN_GRAPH_HEIGHT = 10
70
+
71
+ #: The largest rectangle lines are routed in; on a bigger screen the picture is centred in it.
72
+ MAX_ROUTED_WIDTH = 150
73
+ MAX_ROUTED_HEIGHT = 44
74
+
75
+ #: Rows above the picture (blank, tabs, summary, note) and below it (legend, keys), and the page height from which
76
+ #: the legend is drawn at all.
77
+ TOP_ROWS = 4
78
+ BOTTOM_ROWS = 3
79
+ LEGEND_FROM = 9
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Peer:
84
+ """One session the broker knows about."""
85
+
86
+ handle: str
87
+ title: str
88
+ status: str
89
+ workspace: str
90
+ kind: str = "session"
91
+ session_id: str = ""
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class Daemon:
96
+ """One daemon recorded on this machine, running or not, or a stored session no daemon has (status ``offline``).
97
+
98
+ ``label`` is what the row is called; ``name`` and ``title`` are the two things it may be called after, kept apart
99
+ because a search finds either. ``effort`` and ``permission`` are what the record says the daemon ran at, so that
100
+ starting it again does not change them: a daemon that was denied a shell is not given one by pressing Enter.
101
+ """
102
+
103
+ session_id: str
104
+ label: str
105
+ status: str
106
+ model: str = ""
107
+ workspace: str = ""
108
+ name: str = ""
109
+ title: str = ""
110
+ effort: str = ""
111
+ permission: str = ""
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class HubData:
116
+ """Everything the three tabs show, read once per refresh."""
117
+
118
+ peers: tuple[Peer, ...] = ()
119
+ daemons: tuple[Daemon, ...] = ()
120
+ note: str = ""
121
+ nodes: tuple[Node, ...] = ()
122
+ links: tuple[Link, ...] = ()
123
+ #: Whether the message graph could be read; an empty picture says which of the two it is.
124
+ graph_ok: bool = True
125
+ #: The reader's own session: the name the other sessions call it by and its title, either of which may be empty.
126
+ #: The page writes them at the top left, cut to :data:`ME_NAME_WIDTH` and :data:`ME_TITLE_WIDTH` columns.
127
+ me_name: str = ""
128
+ me_title: str = ""
129
+ #: Whether stored sessions older than the :data:`OFFLINE_LIMIT` newest were left out, which the table says.
130
+ offline_cut: bool = False
131
+ #: When the reading began (``12:34:56``), how long it took in milliseconds, and each step's share of that: elapsed
132
+ #: time on the event loop, so a step is also slow when the loop was busy with something else meanwhile. The page
133
+ #: composes the fullest line of it that fits (:meth:`HubPage._stamp`): the steps need about 85 columns, by their
134
+ #: initials, and about 110 by name, so a narrow window shows only the total and widening it shows where it went.
135
+ began: str = ""
136
+ took: int = 0
137
+ steps: tuple[tuple[str, int], ...] = ()
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class Table:
142
+ """What a list tab shows: its rows, the title of each column, and how wide each column after the name is.
143
+
144
+ ``finds`` is what each row can be found by, in the order of the rows (the name, the title and the id, lower-cased);
145
+ a row without one, like the line that says older sessions were left out, is not a match for any search.
146
+ """
147
+
148
+ items: list[ListItem]
149
+ titles: tuple[str, ...] = ()
150
+ widths: tuple[int, ...] = ()
151
+ finds: tuple[str, ...] = ()
152
+
153
+ def filtered(self, query: str) -> Table:
154
+ """Return the rows that contain every word of ``query``, ignoring case; the columns keep their widths.
155
+
156
+ A blank query is every row. The widths stay those of the whole table, so the columns do not move as the
157
+ reader types. A row with no ``find`` (the line that says older sessions were left out) matches no word, so
158
+ a search leaves it out, deliberately: it describes the whole table and not the rows that are left.
159
+ """
160
+ words = query.lower().split()
161
+ if not words:
162
+ return self
163
+ kept = [
164
+ (item, find) for item, find in zip(self.items, self.finds, strict=True) if all(w in find for w in words)
165
+ ]
166
+ return Table([item for item, _ in kept], self.titles, self.widths, tuple(find for _, find in kept))
167
+
168
+
169
+ #: The space between two columns of a table.
170
+ COLUMN_GAP = 2
171
+
172
+ #: The most columns a table's TITLE cell takes, so a long title takes at most its own share of the row.
173
+ TABLE_TITLE_WIDTH = 32
174
+
175
+ #: The most columns the reader's own name and title take at the top left, so a long title never crowds the page.
176
+ ME_NAME_WIDTH = 20
177
+ ME_TITLE_WIDTH = 40
178
+
179
+ #: The steps of a reading, in the order :meth:`HubCommand._read` takes them. A new step is added here, which is a
180
+ #: decision to make where it can be seen: ``lap`` refuses a name that is not in it, and the letters below go with it.
181
+ #: ``build`` is the picture's title dictionary only; routing the lines needs the page's size, so it runs in the first
182
+ #: frame drawn after a reading (``_picture_for``) and is not a step of the reading.
183
+ STEPS = ("peers", "daemons", "sessions", "graph", "build")
184
+
185
+ #: The letters a narrow page writes the steps of a reading with:
186
+ #: ``p0 d3 s1 g19 b0`` is peers 0 ms, daemons 3, sessions 1, graph 19 and the build 0.
187
+ STEP_LETTERS = dict(zip(STEPS, ("p", "d", "s", "g", "b"), strict=True))
188
+
189
+
190
+ def _table(titles: tuple[str, ...], rows: Sequence[tuple[str, str, str, tuple[str, ...], str]]) -> Table:
191
+ """Build a table whose first column is each row's name and whose second is its state.
192
+
193
+ Args:
194
+ titles: The title of every column, the name's first.
195
+ rows: ``(value, name, state, cells, find)`` for each row: the cells are the columns after the state, and the
196
+ state column is written as the state's dot and word, in the state's colour; ``find`` is what the row can be
197
+ searched by (its name, title and id), and is kept lower-cased.
198
+ """
199
+ cells = [(f"{hub_graph.glyph(state)} {text}", *rest) for _, _, state, (text, *rest), _ in rows]
200
+ widths = tuple(
201
+ max([display_width(titles[index + 1])] + [display_width(row[index]) for row in cells])
202
+ for index in range(len(titles) - 1)
203
+ )
204
+ items = []
205
+ for (value, name, state, _, _), row in zip(rows, cells, strict=True):
206
+ padded = [
207
+ cell + " " * (width - display_width(cell) + COLUMN_GAP) for cell, width in zip(row, widths, strict=True)
208
+ ]
209
+ padded[-1] = row[-1]
210
+
211
+ def parts(theme: Theme, state: str = state, padded: list[str] = padded) -> list[tuple[str, str | None]]:
212
+ return [(padded[0], hub_graph.colours(theme)[state]), *((text, theme.muted) for text in padded[1:])]
213
+
214
+ items.append(ListItem(value, name, "".join(padded), parts=parts))
215
+ return Table(items, titles, widths, tuple(find.lower() for *_, find in rows))
216
+
217
+
218
+ def _session_table(data: HubData) -> Table:
219
+ rows = [
220
+ (
221
+ peer.handle,
222
+ peer.handle,
223
+ hub_graph.state_of(peer.status, peer.kind),
224
+ (
225
+ hub_graph.state_of(peer.status, peer.kind),
226
+ truncate(peer.title or "untitled", TABLE_TITLE_WIDTH),
227
+ peer.workspace,
228
+ ),
229
+ f"{peer.handle} {peer.title} {peer.session_id}",
230
+ )
231
+ for peer in data.peers
232
+ ]
233
+ return _table(("NAME", "STATE", "TITLE", "WORKSPACE"), rows)
234
+
235
+
236
+ def _daemon_table(data: HubData) -> Table:
237
+ rows = []
238
+ for daemon in data.daemons:
239
+ # No process is one word, whether it was a daemon that was stopped or a session that was only stored: the
240
+ # reader sees the same thing and `Enter` does the same thing, which is what STARTABLE says.
241
+ state = OFFLINE if daemon.status in STARTABLE else hub_graph.state_of(daemon.status, "")
242
+ # The state word already says working or idle or offline; the daemon's own word follows only when it says more.
243
+ said = state if daemon.status in (state, "dead") else f"{state} ({daemon.status})"
244
+ rows.append(
245
+ (
246
+ daemon.session_id,
247
+ daemon.label or daemon.session_id,
248
+ state,
249
+ (
250
+ said,
251
+ truncate(daemon.title or "untitled", TABLE_TITLE_WIDTH),
252
+ daemon.model or "no model",
253
+ daemon.workspace,
254
+ ),
255
+ f"{daemon.label} {daemon.name} {daemon.title} {daemon.session_id}",
256
+ )
257
+ )
258
+ table = _table(("NAME", "STATE", "TITLE", "MODEL", "WORKSPACE"), rows)
259
+ if data.offline_cut:
260
+ # A row of words, not a session: it cannot be marked, so no key acts on it, and it has no `find`, so no search
261
+ # keeps it. The two lists stay the same length.
262
+ notice = ListItem(None, "\u2026", f"only the {OFFLINE_LIMIT} newest stored sessions are listed", disabled=True)
263
+ table = Table([*table.items, notice], table.titles, table.widths, (*table.finds, ""))
264
+ return table
265
+
266
+
267
+ def _no_table(data: HubData) -> Table:
268
+ """The picture has no rows: it is drawn by the page."""
269
+ return Table([])
270
+
271
+
272
+ #: What each tab lists, in the order :data:`TABS` names them.
273
+ _TABLES: tuple[Callable[[HubData], Table], ...] = (_no_table, _session_table, _daemon_table)
274
+
275
+
276
+ class HubPage(ListPage):
277
+ """The three tabs, with the mark kept per tab and the keys that act on the row or node under it."""
278
+
279
+ def __init__(
280
+ self,
281
+ data: HubData,
282
+ *,
283
+ on_close: Callable[[], None],
284
+ on_stop: Callable[[str], None] | None = None,
285
+ on_start: Callable[[str], None] | None = None,
286
+ on_refresh: Callable[[], None] | None = None,
287
+ ) -> None:
288
+ """Start on the graph, with the first row of each list marked and no node selected."""
289
+ self.data = data
290
+ self.tab = GRAPH
291
+ self.marks = [0] * len(TABS)
292
+ #: What was typed after ``/`` on each tab, and whether the reader is still typing it (on the tab in front).
293
+ self.queries = [""] * len(TABS)
294
+ self.searching = False
295
+ #: The node whose lines are kept bright, by key; ``None`` shows every line as it is.
296
+ self.selected: str | None = None
297
+ #: Set when the page is closed: a task that finishes after that writes on a page nobody sees, and wakes nothing.
298
+ self.closed = False
299
+ self.on_stop = on_stop
300
+ self.on_start = on_start
301
+ self.on_refresh = on_refresh
302
+ self._picture = Picture()
303
+ self._picture_key: tuple[object, ...] | None = None
304
+ #: The table of the tab in front: its titles are drawn over the list, its widths line them up with the cells.
305
+ self.table = _TABLES[GRAPH](data)
306
+ #: How many rows the tab in front has before a search keeps some of them, for "3 of 59".
307
+ self.total = 0
308
+ super().__init__(
309
+ self.table.items,
310
+ title="Hub",
311
+ subtitle=data.note or None,
312
+ footer=self._footer(),
313
+ on_cancel=on_close,
314
+ visible_rows=None,
315
+ )
316
+
317
+ # -- what the reader sees ---------------------------------------------------------------------------
318
+ def _footer(self) -> str:
319
+ """Return the keys this tab takes: for the graph, the arrows; for a table, what it does now (typing a search,
320
+ a filter held, or neither)."""
321
+ if self.tab == GRAPH:
322
+ leave = "esc deselect" if self.selected is not None else "esc close"
323
+ return (
324
+ f"arrows move to the nearest node {SEPARATOR} tab next tab {SEPARATOR} shift-tab back {SEPARATOR} "
325
+ f"r refresh {SEPARATOR} {leave}"
326
+ )
327
+ if self.searching:
328
+ return f"type to search {SEPARATOR} enter keep the filter {SEPARATOR} esc clear it"
329
+ filtered = bool(self.queries[self.tab])
330
+ common = (
331
+ f"tab next tab {SEPARATOR} shift-tab back {SEPARATOR} / search {SEPARATOR} r refresh {SEPARATOR} "
332
+ f"{'esc clear the filter' if filtered else 'esc close'}"
333
+ )
334
+ if self.tab == DAEMONS:
335
+ return f"enter start an offline one {SEPARATOR} s stop {SEPARATOR} {common}"
336
+ return common
337
+
338
+ def layout(self, rect: Rect) -> None:
339
+ """Lay the list one row lower than a plain list page does, to make room for the titles of its columns."""
340
+ super().layout(rect)
341
+ self.list.layout(Rect(rect.x + 2, rect.y + 4, max(0, rect.width - 4), max(0, rect.height - 5)))
342
+
343
+ def render(self, canvas: Canvas) -> None:
344
+ """Fill the page, then the tabs (the open one in the accent colour), the tab's body, and the keys."""
345
+ if self.rect.empty:
346
+ return
347
+ theme = self.theme
348
+ canvas.fill(self.rect.x, self.rect.y, self.rect.width, self.rect.height, Style(background=theme.surface_alt))
349
+ heading = Style(foreground=theme.text, background=theme.surface_alt, bold=True)
350
+ open_tab = Style(foreground=theme.surface_alt, background=theme.accent, bold=True)
351
+ hint = Style(foreground=theme.muted, background=theme.surface_alt)
352
+ x, y = self.rect.x + 2, self.rect.y + 1
353
+ room = max(0, self.rect.width - 4)
354
+ # Three columns of air between the name and the tabs, and between the tabs themselves; the open one carries a
355
+ # column of its own inside the accent band so the band never touches its neighbour.
356
+ gap = 3
357
+ who = self._who(room)
358
+ if who:
359
+ canvas.draw_text(x, self.rect.y, who, hint, max_width=room)
360
+ canvas.draw_text(x, y, "Hub", heading, max_width=room)
361
+ x += len("Hub") + gap
362
+ for index, name in enumerate(TABS):
363
+ drawn = f" {name} " if index == self.tab else name
364
+ style = open_tab if index == self.tab else heading
365
+ canvas.draw_text(x, y, drawn, style, max_width=max(0, room - (x - self.rect.x)))
366
+ x += display_width(drawn) + gap
367
+ # When the page was read and how long it took, at the right end of the tab line and only where it fits.
368
+ stamp = self._stamp(room - (x - self.rect.x))
369
+ if stamp:
370
+ canvas.draw_text(self.rect.right - 2 - display_width(stamp), y, stamp, hint)
371
+ if self.tab == GRAPH:
372
+ self._draw_graph(canvas, hint, room)
373
+ else:
374
+ self._draw_search_and_note(canvas, theme, room)
375
+ self._draw_titles(canvas, theme)
376
+ self.list.render(canvas)
377
+ if self.queries[self.tab] and not self.list.items:
378
+ canvas.draw_text(
379
+ self.rect.x + 4,
380
+ self.rect.y + 5,
381
+ truncate(f"nothing matches \u201c{self.queries[self.tab]}\u201d", max(0, room - 2)),
382
+ hint,
383
+ )
384
+ canvas.draw_text(self.rect.x + 2, self.rect.bottom - 1, self.footer, hint, max_width=room)
385
+
386
+ def _draw_search_and_note(self, canvas: Canvas, theme: Theme, room: int) -> None:
387
+ """Draw the one row under the tabs of a table: the search (what is typed, and how many rows it keeps) and the
388
+ note of the last action after it, or the note alone when there is no search.
389
+
390
+ The row has to hold what the reader is typing, so that comes first and is never what gets cut: a query too
391
+ long for the row loses its head and keeps its tail and the cursor, then the count is kept, and the note gets
392
+ what is left, with an ellipsis where it is cut.
393
+ """
394
+ query, x, y = self.queries[self.tab], self.rect.x + 2, self.rect.y + 2
395
+ notice = Style(foreground=theme.accent_bright, background=theme.surface_alt, bold=True)
396
+ quiet = Style(foreground=theme.muted, background=theme.surface_alt)
397
+ used = 0
398
+ if self.searching or query:
399
+ kept = sum(1 for item in self.list.items if not item.disabled)
400
+ count = f" {kept} of {self.total}"
401
+ keep_count = room - display_width(count) >= 8 # a row too narrow for both gives the query all of it
402
+ typed = _tail(
403
+ query + ("\u258f" if self.searching else ""), room - 2 - (display_width(count) if keep_count else 0)
404
+ )
405
+ prompt = f"/ {typed}"
406
+ canvas.draw_text(x, y, prompt, notice, max_width=room)
407
+ used = display_width(prompt)
408
+ if keep_count:
409
+ canvas.draw_text(x + used, y, count, quiet, max_width=room - used)
410
+ used += display_width(count)
411
+ used += 3 # air between the search and the note
412
+ left = room - used
413
+ if self.subtitle is not None and left >= 6:
414
+ canvas.draw_text(x + used, y, truncate(self.subtitle, left), notice, max_width=left)
415
+
416
+ def _draw_titles(self, canvas: Canvas, theme: Theme) -> None:
417
+ """Draw the title of each column over the list, lined up with the cells under it.
418
+
419
+ The list puts its marker in two columns and the name after it, then every cell after the widest name; the
420
+ titles follow that, and are left out where the list itself gives up on columns for want of room.
421
+ """
422
+ table, items = self.table, self.list.items
423
+ if not table.titles or not items:
424
+ return
425
+ style = Style(foreground=theme.subtle, background=theme.surface_alt, bold=True)
426
+ x, y = self.list.rect.x, self.rect.y + 3
427
+ name = max(display_width(item.label) for item in items)
428
+ marker, gap = self.list.MARKER_WIDTH, self.list.COLUMN_GAP
429
+ canvas.draw_text(x + marker, y, truncate(table.titles[0], name + gap - 1), style, max_width=name + gap - 1)
430
+ start = marker + name + gap
431
+ room = self.list.rect.width - start
432
+ if room <= 3:
433
+ return
434
+ cells = [
435
+ title + " " * (width - display_width(title) + COLUMN_GAP)
436
+ for title, width in zip(table.titles[1:], table.widths, strict=True)
437
+ ]
438
+ cells[-1] = cells[-1].rstrip()
439
+ canvas.draw_text(x + start, y, truncate("".join(cells), room), style, max_width=room)
440
+
441
+ def _stamp(self, room: int) -> str:
442
+ """Return the fullest account of the last reading that fits in ``room`` columns, or nothing.
443
+
444
+ The page is the only side that knows the room, so the data carries the parts and the page picks, longest
445
+ first: the clock, the total and each step by name; without the clock; with the steps shortened to the
446
+ letters of :data:`STEP_LETTERS` (``p0 d3 s1 g19 b0``); the same without the clock; the clock and the total;
447
+ the total alone. The steps are the point of it, so they come before the clock in the narrower forms. Two
448
+ columns are kept clear of the last tab.
449
+ """
450
+ data = self.data
451
+ if not data.took and not data.began:
452
+ return ""
453
+ total = f"read {data.took} ms"
454
+ # A reading with no clock has no form that names one.
455
+ clock = f"read {data.began} \u00b7 {data.took} ms" if data.began else total
456
+ forms = [clock, total]
457
+ if data.steps:
458
+ named = ", ".join(f"{name} {ms}" for name, ms in data.steps)
459
+ short = " ".join(f"{STEP_LETTERS.get(name, name[:3])}{ms}" for name, ms in data.steps)
460
+ forms[:0] = [f"{clock} ({named} ms)", f"{total} ({named} ms)", f"{clock} ({short})", f"{total} ({short})"]
461
+ for text in forms:
462
+ if display_width(text) + 2 <= room:
463
+ return text
464
+ return ""
465
+
466
+ def _picture_for(self, width: int, height: int) -> Picture:
467
+ """Return the laid-out graph for this size, laid out again only when the data or the size changed."""
468
+ key = (self.data.nodes, self.data.links, width, height)
469
+ if key != self._picture_key:
470
+ self._picture = hub_graph.layout(self.data.nodes, self.data.links, width, height)
471
+ self._picture_key = key
472
+ if self.selected is not None and all(node.key != self.selected for node in self._picture.nodes):
473
+ self.selected = None
474
+ self.footer = self._footer() # it says "esc deselect" only while something is selected
475
+ return self._picture
476
+
477
+ def _forget_picture(self) -> None:
478
+ """Drop the picture and the selection when nothing is drawn, so no key moves a node that is not there.
479
+
480
+ The footer is made again with them: it says ``esc deselect`` only while something is selected, and Escape
481
+ closes the page when nothing is.
482
+ """
483
+ self._picture, self._picture_key, self.selected = Picture(), None, None
484
+ self.footer = self._footer()
485
+
486
+ def _who(self, room: int) -> str:
487
+ """Return the reader's own name and title for the top left: ``name \u00b7 title``, each cut to its own width
488
+ (the name 20 columns, the title 40) with an ellipsis, and the whole to the room there is. Whichever is empty
489
+ is left out, and an empty string means there is nothing to say."""
490
+ parts = [
491
+ truncate(text, width)
492
+ for text, width in ((self.data.me_name, ME_NAME_WIDTH), (self.data.me_title, ME_TITLE_WIDTH))
493
+ if text
494
+ ]
495
+ return truncate(" \u00b7 ".join(parts), room) if parts else ""
496
+
497
+ def _draw_graph(self, canvas: Canvas, hint: Style, room: int) -> None:
498
+ """Draw the summary, the note, the picture and the legend, or say why there is no picture."""
499
+ theme = self.theme
500
+ left = self.rect.x + 2
501
+ top = self.rect.y + TOP_ROWS
502
+ height = self.rect.bottom - BOTTOM_ROWS - top
503
+ data = self.data
504
+ if data.note:
505
+ notice = Style(foreground=theme.accent_bright, background=theme.surface_alt, bold=True)
506
+ canvas.draw_text(left, self.rect.y + 3, data.note, notice, max_width=room)
507
+ picture = Picture()
508
+ offset = (0, 0)
509
+ if not data.nodes:
510
+ said = "no session has talked to another yet" if data.graph_ok else "the message graph cannot be read"
511
+ elif height < MIN_GRAPH_HEIGHT or room < MIN_GRAPH_WIDTH:
512
+ said = "too small for the graph"
513
+ else:
514
+ said = ""
515
+ # Routing costs more the bigger the area and stops improving past a point, so a big screen is routed in
516
+ # a bounded rectangle and that is drawn in the middle of it.
517
+ wide, high = min(room, MAX_ROUTED_WIDTH), min(height, MAX_ROUTED_HEIGHT)
518
+ picture = self._picture_for(wide, high)
519
+ offset = ((room - wide) // 2, (height - high) // 2)
520
+ if not picture.nodes:
521
+ self._forget_picture()
522
+ if data.nodes:
523
+ line = hub_graph.summary(data.nodes, data.links, picture, self.selected)
524
+ canvas.draw_text(left, self.rect.y + 2, truncate(line, room), hint, max_width=room)
525
+ if said:
526
+ if height > 0:
527
+ column = self.rect.x + max(2, (self.rect.width - display_width(said)) // 2)
528
+ canvas.draw_text(column, top + height // 2, said, hint, max_width=room)
529
+ else:
530
+ hub_graph.paint(canvas, theme, left + offset[0], top + offset[1], picture, self.selected)
531
+ if self.rect.height >= LEGEND_FROM and picture.nodes:
532
+ x = left
533
+ for group in hub_graph.legend(theme):
534
+ wide = sum(display_width(text) for text, _ in group)
535
+ if x + wide > left + room:
536
+ break
537
+ for text, style in group:
538
+ canvas.draw_text(x, self.rect.bottom - 2, text, style)
539
+ x += display_width(text)
540
+
541
+ def set_data(self, data: HubData) -> None:
542
+ """Take a fresh reading, and keep the mark where it was on the tab in front.
543
+
544
+ It is called from a task, not from an input event, so it asks for a frame itself (:meth:`_repaint`).
545
+ """
546
+ self.marks[self.tab] = max(0, self.list.selected)
547
+ self.data = data
548
+ self.subtitle = self.data.note or None
549
+ self._show(self.tab)
550
+ self._repaint()
551
+
552
+ def note(self, text: str) -> None:
553
+ """Say one line under the tabs — what a key just did, or why it could not.
554
+
555
+ A task writes notes too (``started …``, an error), so it asks for a frame itself (:meth:`_repaint`).
556
+ """
557
+ self.data = dataclasses.replace(self.data, note=text)
558
+ self.subtitle = text
559
+ self._repaint()
560
+
561
+ def _repaint(self) -> None:
562
+ """Wake the loop for a frame: a change made by a task is not an input event, and without this the old screen
563
+ stays until the next key (``refreshing…`` stayed up for as long as the reader did not press anything).
564
+
565
+ Only the app's own calls are heard: a widget's ``invalidate`` and ``request_layout`` set flags nothing in the
566
+ framework reads. ``invalidate`` is enough here because the page's geometry is constants; a note that took a
567
+ row of its own would need ``self.app.request_layout()``. A page that was never mounted has no app to ask, and
568
+ a closed one has nothing on screen to bring up to date.
569
+ """
570
+ if self.app is not None and not self.closed:
571
+ self.app.invalidate()
572
+
573
+ def _show(self, tab: int) -> None:
574
+ """Put one tab in front, with the mark it had and the search it holds.
575
+
576
+ Leaving a tab ends the typing of its search; the filter itself is kept for when the reader comes back.
577
+ """
578
+ if tab != self.tab:
579
+ self.searching = False
580
+ self.tab = tab
581
+ whole = _TABLES[tab](self.data)
582
+ self.total = sum(1 for item in whole.items if not item.disabled)
583
+ self.table = whole.filtered(self.queries[tab])
584
+ rows = self.table.items
585
+ self.list.set_items(rows)
586
+ # The mark the tab had, kept off a row nothing can act on: the one that says older sessions were left out can
587
+ # take its index when a refresh leaves fewer rows, so the nearest row before it that can be marked is used,
588
+ # and none is marked when that row is all there is.
589
+ wanted = min(self.marks[tab], len(rows) - 1)
590
+ while wanted > 0 and rows[wanted].disabled:
591
+ wanted -= 1
592
+ self.list.selected = wanted if rows and not rows[wanted].disabled else -1
593
+ self.footer = self._footer()
594
+ self._repaint()
595
+
596
+ # -- the keys ---------------------------------------------------------------------------------------
597
+ def capture_event(self, event, host) -> bool:
598
+ """Take Tab and Shift-Tab (the only keys that change tab), the search, the node selection and the daemon keys."""
599
+ key = event_key(event)
600
+ if self.searching and self.tab != GRAPH and key not in ("tab", "backtab"):
601
+ # What is typed is the query, whatever it spells: `s` and `r` are letters here, not stop and refresh.
602
+ return self._type(event, key) or super().capture_event(event, host)
603
+ if key == "tab":
604
+ self.marks[self.tab] = max(0, self.list.selected)
605
+ self._show((self.tab + 1) % len(TABS))
606
+ return True
607
+ if key == "backtab":
608
+ self.marks[self.tab] = max(0, self.list.selected)
609
+ self._show((self.tab - 1) % len(TABS))
610
+ return True
611
+ if key == "/" and self.tab != GRAPH:
612
+ self.searching = True
613
+ self._show(self.tab)
614
+ return True
615
+ if key == "escape" and self.tab != GRAPH and self.queries[self.tab]:
616
+ self._set_query("") # a filter is cleared before the page is closed
617
+ return True
618
+ if key == "r" and self.on_refresh is not None:
619
+ self.note("refreshing\u2026")
620
+ self.on_refresh()
621
+ return True
622
+ if self.tab == GRAPH and key in ("up", "down", "left", "right"):
623
+ self._move(key)
624
+ return True
625
+ if self.tab == GRAPH and key == "escape" and self.selected is not None:
626
+ self.selected = None
627
+ self.footer = self._footer()
628
+ self._repaint()
629
+ return True
630
+ if self.tab == DAEMONS and key == "enter":
631
+ self._chosen()
632
+ return True
633
+ if self.tab == DAEMONS and key == "s" and self.on_stop is not None:
634
+ chosen = self._daemon()
635
+ if chosen is None:
636
+ self.note("no daemon under the mark")
637
+ elif chosen.status == OFFLINE:
638
+ # A stored session no daemon ever had: there is nothing to stop, and the SDK's sentence about a daemon
639
+ # that does not exist is not one for this page.
640
+ self.note("no daemon to stop on this one; enter starts one")
641
+ else:
642
+ self.note("stopping\u2026")
643
+ self.on_stop(chosen.session_id)
644
+ return True
645
+ return super().capture_event(event, host)
646
+
647
+ def _type(self, event: object, key: str | None) -> bool:
648
+ """Take one key while the search is being typed; return whether it was taken (``False`` leaves it to the list,
649
+ which still moves the mark with the arrow keys and pages).
650
+ """
651
+ query = self.queries[self.tab]
652
+ if key == "escape":
653
+ self.searching = False
654
+ self._set_query("")
655
+ return True
656
+ if key == "enter":
657
+ self.searching = False
658
+ self._show(self.tab)
659
+ return True
660
+ if key == "backspace":
661
+ self._set_query(query[:-1])
662
+ return True
663
+ if isinstance(event, PasteEvent):
664
+ # A pasted id is the likeliest thing to type here. A paste carries newlines and a text event does not, and
665
+ # the search splits on any whitespace anyway, so the query takes the paste as one line of words.
666
+ self._set_query(query + " ".join(event.text.split()))
667
+ return True
668
+ if isinstance(event, TextEvent):
669
+ self._set_query(query + event.text)
670
+ return True
671
+ if key == "space":
672
+ self._set_query(query + " ")
673
+ return True
674
+ if key is not None and len(key) == 1: # a printable key that arrived as a key and not as text
675
+ self._set_query(query + key)
676
+ return True
677
+ return False
678
+
679
+ def _set_query(self, text: str) -> None:
680
+ """Make ``text`` this tab's search, and show the rows that match it from the first."""
681
+ self.queries[self.tab] = text
682
+ self.marks[self.tab] = 0
683
+ self._show(self.tab)
684
+
685
+ def _move(self, key: str) -> None:
686
+ """Select the node nearest in the direction of an arrow key; the first arrow selects the reader's own."""
687
+ target = hub_graph.nearest(self._picture, self.selected, key)
688
+ if target is not None:
689
+ self.selected = target
690
+ self.footer = self._footer()
691
+ self._repaint()
692
+
693
+ def _daemon(self) -> Daemon | None:
694
+ """Return the daemon under the mark, by the id the row carries: a refresh may have reordered the list."""
695
+ items = self.list.items
696
+ if not (0 <= self.list.selected < len(items)):
697
+ return None
698
+ chosen = items[self.list.selected].value
699
+ return next((daemon for daemon in self.data.daemons if daemon.session_id == chosen), None)
700
+
701
+ def _chosen(self) -> None:
702
+ """Enter on the daemons tab starts what is under the mark when nothing is running it: a daemon whose process
703
+ is gone, or an offline session.
704
+
705
+ It says so on the page either way: that it is starting, or why nothing was started.
706
+ """
707
+ if self.on_start is None or self.tab != DAEMONS:
708
+ return None
709
+ chosen = self._daemon()
710
+ if chosen is None:
711
+ self.note("no daemon under the mark")
712
+ elif chosen.status in STARTABLE:
713
+ self.note("starting\u2026")
714
+ self.on_start(chosen.session_id)
715
+ else:
716
+ self.note(f"{chosen.label or chosen.session_id[:8]} is running; enter starts an offline one")
717
+ return None
718
+
719
+
720
+ class HubCommand(TuiPlugin):
721
+ """``/hub``: the graph, the sessions and the daemons, and the two actions a daemon row takes: stop and start."""
722
+
723
+ name = "hub-command"
724
+ description = "who is here, the daemons, and who talked to whom"
725
+
726
+ #: The page in front, so a failed action can say so on it; ``None`` when no page is open.
727
+ _page: HubPage | None = None
728
+ #: The actions started from the page, kept so their failures are read rather than logged at the interpreter.
729
+ _actions: set[asyncio.Future[None]]
730
+ #: Session ids a daemon is being started for, so a second Enter does not start a second one.
731
+ _starting: set[str]
732
+ #: Daemons that were working when ``s`` was pressed and have been told so: the next ``s`` stops them anyway.
733
+ _forced: set[str]
734
+
735
+ def __init__(self) -> None:
736
+ """Start with no page and no action running."""
737
+ self._page = None
738
+ self._actions = set()
739
+ self._starting = set()
740
+ self._forced = set()
741
+
742
+ def activate(self, ctx: PluginContext) -> None:
743
+ """Register ``/hub``; it only reads, so it opens over a running request rather than turning the reader away."""
744
+ if ctx.tui is None:
745
+ return
746
+ ctx.tui.register_command(
747
+ "hub",
748
+ "who is here, the daemons and who talked to whom",
749
+ self.hub,
750
+ when_busy="now",
751
+ )
752
+
753
+ async def hub(self, context: CommandContext) -> CommandResult:
754
+ """Open the page, and wire its keys to the SDK's daemon actions."""
755
+ host = context.require_host()
756
+ client = host.client
757
+ data = await self._read(client)
758
+ page = HubPage(
759
+ data,
760
+ on_close=self._closer(host),
761
+ on_refresh=lambda: self._start(self._refresh(page, client), page),
762
+ on_stop=lambda session_id: self._start(self._stop(page, client, session_id), page),
763
+ on_start=lambda session_id: self._start(self._resume(page, client, session_id), page),
764
+ )
765
+ self._page = page
766
+ return CommandResult(widget=page)
767
+
768
+ def _closer(self, host: CommandHost) -> Callable[[], None]:
769
+ """Return what closing the page runs: mark it closed, forget it here, then let the shell close it.
770
+
771
+ A late failure then has nothing to write on, and a task that finishes after the page was closed neither wakes
772
+ the app nor lands on the page that was opened in its place.
773
+ """
774
+
775
+ def close() -> None:
776
+ if self._page is not None:
777
+ self._page.closed = True
778
+ self._page = None
779
+ host.close_page()
780
+
781
+ return close
782
+
783
+ def _start(self, task: Awaitable[None], page: HubPage) -> None:
784
+ """Run one action on the loop the page was opened on, and keep it so a failure is not lost.
785
+
786
+ The action belongs to ``page``: a failure is written there, and only while it is the page in front.
787
+ """
788
+ running = asyncio.ensure_future(task)
789
+ self._actions.add(running)
790
+ running.add_done_callback(lambda finished: self._done(finished, page))
791
+
792
+ def _done(self, finished: asyncio.Future[None], page: HubPage) -> None:
793
+ """Forget an action, and say what it failed with, on the page it was started from."""
794
+ self._actions.discard(finished)
795
+ if finished.cancelled():
796
+ return
797
+ error = finished.exception()
798
+ if error is not None and page is self._page:
799
+ page.note(str(error))
800
+
801
+ async def _refresh(self, page: HubPage, client: object) -> None:
802
+ """Read everything again, and show it.
803
+
804
+ ``r`` runs this too: the page said "refreshing" when the key was pressed, and the new reading's own time and
805
+ duration, at the top right, say it is done (and differ from the last one's if it was pressed twice). It also
806
+ forgets which working daemons were warned, so a second ``s`` that is meant to force a stop follows the first.
807
+ """
808
+ self._forced.clear()
809
+ page.set_data(await self._read(client))
810
+
811
+ async def _stop(self, page: HubPage, client: object, session_id: str) -> None:
812
+ """Stop one daemon, keeping its record, then show what that changed.
813
+
814
+ A daemon in the middle of a turn is not stopped by the first ``s``: stopping it throws that turn away. The page
815
+ says so and remembers it; the next ``s`` on it stops it anyway. (The SDK asks for ``force`` in a flag the page
816
+ cannot pass, and its sentence names that flag, so the page words it itself.)
817
+ """
818
+ from zettcode.sdk.daemons import stop_daemon
819
+ from zettcode.sdk.errors import ConfigurationError
820
+
821
+ page.note("stopping\u2026")
822
+ try:
823
+ said = await stop_daemon(session_id, keep=True, force=session_id in self._forced)
824
+ except ConfigurationError:
825
+ self._forced.add(session_id)
826
+ chosen = next((daemon for daemon in page.data.daemons if daemon.session_id == session_id), None)
827
+ label = chosen.label if chosen is not None else session_id[:8]
828
+ page.note(f"{label} is working on a turn: press s again to stop it anyway and throw that turn away")
829
+ return
830
+ self._forced.discard(session_id)
831
+ await self._refresh(page, client)
832
+ page.note(said)
833
+
834
+ async def _resume(self, page: HubPage, client: object, session_id: str) -> None:
835
+ """Start a daemon on an offline session, resuming it: one whose daemon was stopped, or one that is only stored.
836
+
837
+ A daemon that was stopped starts on the workspace, model, reasoning level and shell level its record names, and
838
+ under the label it had: pressing Enter must not give a shell to a daemon that was denied one, or take it from
839
+ one that had it. A stored session has no record, so it starts in the reader's own workspace (the store is read
840
+ for that workspace) on the first configured model, with the SDK's defaults. A record with no workspace falls
841
+ back to the reader's own, and one with no model to the first configured. A session another process has open is
842
+ said so before anything is started: a daemon cannot write to it, and would only say so from its log after a start.
843
+ One start per row at a time, until the page shows it running: a second ``Enter`` while the first is starting
844
+ or the page is being read again is told so, since two processes would share one record and one session lock.
845
+ A running daemon is not started again.
846
+ """
847
+ from zettcode.sdk.daemons import spawn_daemon
848
+
849
+ chosen = next((daemon for daemon in page.data.daemons if daemon.session_id == session_id), None)
850
+ if chosen is None:
851
+ return
852
+ if chosen.status not in STARTABLE:
853
+ page.note(f"{chosen.label or session_id[:8]} is running")
854
+ return
855
+ if session_id in self._starting:
856
+ page.note("already starting that one")
857
+ return
858
+ self._starting.add(session_id)
859
+ page.note("starting\u2026")
860
+ try:
861
+ try:
862
+ workspace = chosen.workspace or str(client.workspace) # type: ignore[attr-defined]
863
+ if await _open_elsewhere(client, session_id, workspace):
864
+ raise RuntimeError(
865
+ "that session is open in another ZettCode process, so a daemon cannot write to it"
866
+ )
867
+ # What the record says, and nothing it does not: an offline session has none of these, and the SDK's
868
+ # own defaults are the right ones for it.
869
+ kept = {
870
+ key: value
871
+ for key, value in (
872
+ ("effort", chosen.effort),
873
+ ("name", chosen.name),
874
+ ("permission", chosen.permission),
875
+ )
876
+ if value
877
+ }
878
+ started = await spawn_daemon(workspace, model=chosen.model or None, resume=session_id, **kept)
879
+ except Exception as error: # noqa: BLE001 - the page says what happened, whatever it was
880
+ text = str(error)
881
+ # The store has no such session: a daemon that never held a conversation leaves none (an empty one is
882
+ # removed when it closes), and a record whose workspace no longer holds it reads the same. The words
883
+ # are the engine's, reached through the daemon's log, so this is a match on a message, not a type.
884
+ note = (
885
+ "nothing to resume: its session is not stored in that workspace"
886
+ if "Unknown session" in text
887
+ else text
888
+ )
889
+ else:
890
+ note = f"started {started.session_id[:8]}"
891
+ await self._refresh(page, client)
892
+ finally:
893
+ self._starting.discard(session_id)
894
+ page.note(note)
895
+
896
+ async def _read(self, client: object) -> HubData:
897
+ """Read the three tabs: the broker's roster and graph, and the daemons' records, running or not.
898
+
899
+ The stored sessions of this workspace are read only for their titles, to name a session the graph has a line
900
+ to that is no longer in the roster; the Sessions tab lists what is running and nothing else.
901
+ """
902
+ from zettcode.sdk.daemons import list_daemons
903
+
904
+ clock = time.strftime("%H:%M:%S")
905
+ began = last = time.perf_counter()
906
+ steps: list[tuple[str, int]] = []
907
+
908
+ def lap(name: str) -> None:
909
+ nonlocal last
910
+ if name not in STEPS:
911
+ raise ValueError(f"{name!r} is not a step of a reading: add it to STEPS, with its letter")
912
+ now = time.perf_counter()
913
+ steps.append((name, round((now - last) * 1000)))
914
+ last = now
915
+
916
+ peers = tuple(
917
+ Peer(
918
+ handle=peer.handle,
919
+ title=peer.title,
920
+ status=peer.status,
921
+ workspace=peer.workspace,
922
+ kind=peer.kind,
923
+ session_id=peer.session_id,
924
+ )
925
+ for peer in client.peers() # type: ignore[attr-defined]
926
+ )
927
+ lap("peers")
928
+ daemons = tuple(
929
+ Daemon(
930
+ session_id=info.session_id,
931
+ label=info.name or info.title or info.session_id[:8],
932
+ status=info.status,
933
+ model=info.model or "",
934
+ workspace=str(info.workspace or ""),
935
+ name=info.name or "",
936
+ title=info.title or "",
937
+ effort=info.effort or "",
938
+ permission=info.permission or "",
939
+ )
940
+ for info in await asyncio.to_thread(list_daemons)
941
+ )
942
+ lap("daemons")
943
+ note = ""
944
+ try:
945
+ me = client.session.id # type: ignore[attr-defined]
946
+ except Exception: # noqa: BLE001 - a client with no session still has a roster to show
947
+ me = ""
948
+ try:
949
+ # One more than is shown, so the table can say when older sessions were left out.
950
+ listed = await client.sessions.list(limit=OFFLINE_LIMIT + 1) # type: ignore[attr-defined]
951
+ except Exception: # noqa: BLE001 - a store that cannot be read costs only the names of sessions that are gone
952
+ listed = ()
953
+ cut = len(listed) > OFFLINE_LIMIT
954
+ listed = listed[:OFFLINE_LIMIT]
955
+ lap("sessions")
956
+ graph_ok = True
957
+ try:
958
+ graph = await client.traffic_graph() # type: ignore[attr-defined]
959
+ except Exception as error: # noqa: BLE001 - without a broker the picture is empty, the lists still work
960
+ graph, graph_ok = {}, False
961
+ note = note or f"the message graph cannot be read: {error}"
962
+ lap("graph")
963
+ # The reader's own name and title, for the top left: the roster knows both once the session is registered; the
964
+ # client's handle and the stored title stand in before that.
965
+ mine: dict[str, Any] = (
966
+ next((e for e in graph.get("sessions", ()) if str(e.get("session_id", "")) == me), {}) if me else {}
967
+ )
968
+ try:
969
+ handle = str(client.handle) # type: ignore[attr-defined]
970
+ except Exception: # noqa: BLE001 - a client with no handle still has a page to show
971
+ handle = ""
972
+ me_name = str(mine.get("handle", "")) or handle
973
+ me_title = str(mine.get("title", "")) or next(
974
+ (info.title or "" for info in listed if info.id == me), ""
975
+ ) # The picture is of this workspace: another project's dead daemons are on the Daemons tab, not in it, and a
976
+ # session's own title wins over its daemon's name.
977
+ try:
978
+ here = Path(client.workspace).resolve() # type: ignore[attr-defined]
979
+ except (AttributeError, OSError, RuntimeError): # a client with no workspace still has a roster to show
980
+ here = Path.cwd()
981
+ ours = [daemon for daemon in daemons if _is_in(daemon.workspace, here)]
982
+ titles = {info.id: info.title or info.id[:8] for info in listed}
983
+ for daemon in ours:
984
+ titles.setdefault(daemon.session_id, daemon.label)
985
+ nodes, links = hub_graph.build(
986
+ graph, me=me, titles=titles, offline=[daemon.session_id for daemon in ours if daemon.status in GONE]
987
+ )
988
+ if not graph_ok:
989
+ # Nothing to say about who talks to whom, so the picture is empty and says why, not a lone stopped box.
990
+ nodes, links = (), ()
991
+ # Offline: the stored sessions of this workspace that nothing accounts for — not the reader's own, not in the
992
+ # roster, and not the session of any daemon record (which has its row already, running or stopped). They follow
993
+ # the daemons, newest first, and are what a daemon can be started on.
994
+ # Who is in the roster is the live graph's word, and the Sessions tab, the picture and the offline rows all take
995
+ # it from there: a peer that has just disconnected lingers in the client's cache, and one tab would call a
996
+ # session idle while another called it offline. The cached peers stand in only when the graph could not be read.
997
+ if graph_ok:
998
+ peers = tuple(
999
+ Peer(
1000
+ handle=str(entry.get("handle", "")),
1001
+ title=str(entry.get("title", "")),
1002
+ status=str(entry.get("status", "")),
1003
+ workspace=str(entry.get("workspace", "")),
1004
+ kind=str(entry.get("kind", "")),
1005
+ session_id=str(entry.get("session_id", "")),
1006
+ )
1007
+ for entry in graph.get("sessions", ())
1008
+ if str(entry.get("session_id", "")) != me
1009
+ )
1010
+ roster = {peer.session_id for peer in peers}
1011
+ accounted = {me, *roster, *(daemon.session_id for daemon in daemons)}
1012
+ offline = tuple(
1013
+ Daemon(
1014
+ session_id=info.id,
1015
+ label=info.title or info.id[:8],
1016
+ status=OFFLINE,
1017
+ workspace=str(here),
1018
+ title=info.title or "",
1019
+ )
1020
+ for info in listed
1021
+ if info.id not in accounted
1022
+ )
1023
+ lap("build")
1024
+ return HubData(
1025
+ peers=peers,
1026
+ daemons=(*daemons, *offline),
1027
+ note=note,
1028
+ nodes=nodes,
1029
+ links=links,
1030
+ graph_ok=graph_ok,
1031
+ me_name=me_name,
1032
+ me_title=me_title,
1033
+ offline_cut=cut,
1034
+ began=clock,
1035
+ took=round((time.perf_counter() - began) * 1000),
1036
+ steps=tuple(steps),
1037
+ )
1038
+
1039
+
1040
+ async def _open_elsewhere(client: object, session_id: str, workspace: str) -> bool:
1041
+ """Return whether another process has the session open, asked of the SDK on a thread (it takes a file lock for an
1042
+ instant to find out). A client that cannot be asked is not a reason to refuse a start: a daemon that cannot write
1043
+ to its session refuses by itself, and says why."""
1044
+ try:
1045
+ return bool(
1046
+ await asyncio.to_thread(client.sessions.open_elsewhere, session_id, workspace=workspace) # type: ignore[attr-defined]
1047
+ )
1048
+ except Exception: # noqa: BLE001 - see above
1049
+ return False
1050
+
1051
+
1052
+ def _tail(text: str, width: int) -> str:
1053
+ """Return the last ``width`` columns of ``text``, with an ellipsis in front where the head was cut.
1054
+
1055
+ Columns, not characters: a wide glyph is two. What is typed ends at the cursor, so the end is what has to stay.
1056
+ """
1057
+ if width <= 0:
1058
+ return ""
1059
+ if display_width(text) <= width:
1060
+ return text
1061
+ kept: list[str] = []
1062
+ used = 1 # the ellipsis
1063
+ for character in reversed(text):
1064
+ step = display_width(character)
1065
+ if used + step > width:
1066
+ break
1067
+ kept.append(character)
1068
+ used += step
1069
+ return "\u2026" + "".join(reversed(kept))
1070
+
1071
+
1072
+ def _is_in(workspace: str, here: Path) -> bool:
1073
+ """Return whether a daemon's workspace is the reader's."""
1074
+ try:
1075
+ return bool(workspace) and Path(workspace).expanduser().resolve() == here
1076
+ except (OSError, RuntimeError):
1077
+ return False