hubble-cli 4.0.0__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.
hubble/banner.py ADDED
@@ -0,0 +1,484 @@
1
+ """Hubble home screen: gradient block-letter logo, status line and context-aware tips."""
2
+
3
+ import time
4
+ from typing import Any, List, Optional, Tuple
5
+
6
+ from rich.console import Console
7
+ from rich.markup import escape
8
+ from rich.text import Text
9
+
10
+ NAME = "Hubble"
11
+
12
+ # "ANSI Shadow" style letters, 6 rows each.
13
+ _LETTERS = {
14
+ "H": ["██╗ ██╗", "██║ ██║", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
15
+ "U": ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
16
+ "B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
17
+ "L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
18
+ "E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
19
+ }
20
+
21
+ GRADIENT = [(0, 215, 255), (95, 135, 255), (175, 95, 255), (255, 95, 215)] # cyan -> violet -> pink
22
+
23
+
24
+ def _logo_rows(word: str = NAME.upper()) -> List[str]:
25
+ return ["".join(_LETTERS[ch][row] for ch in word) for row in range(6)]
26
+
27
+
28
+ def _blend(stops: List[Tuple[int, int, int]], t: float) -> str:
29
+ t = min(max(t, 0.0), 1.0) * (len(stops) - 1)
30
+ i = min(int(t), len(stops) - 2)
31
+ f = t - i
32
+ a, b = stops[i], stops[i + 1]
33
+ r, g, bl = (round(a[k] + (b[k] - a[k]) * f) for k in range(3))
34
+ return f"#{r:02x}{g:02x}{bl:02x}"
35
+
36
+
37
+ def gradient_logo() -> Text:
38
+ rows = _logo_rows()
39
+ width = max(len(r) for r in rows)
40
+ out = Text()
41
+ for row in rows:
42
+ out.append(" ")
43
+ for col, ch in enumerate(row):
44
+ # Shadow characters get a dimmer tone so the letters read as raised.
45
+ style = _blend(GRADIENT, col / max(width - 1, 1))
46
+ out.append(ch, style=style if ch == "█" else f"{style} dim")
47
+ out.append("\n")
48
+ return out
49
+
50
+
51
+ def small_logo() -> Text:
52
+ out = Text("✦ ", style="bold #5f87ff")
53
+ for i, ch in enumerate(NAME):
54
+ out.append(ch, style=f"bold {_blend(GRADIENT, i / (len(NAME) - 1))}")
55
+ return out
56
+
57
+
58
+ # ----- Hubble space scene ---------------------------------------------------
59
+ #
60
+ # Everything is a pure function of time t (seconds), so the final frame is deterministic and
61
+ # the animation looks the same at any frame rate.
62
+
63
+ import math
64
+ import random as _random
65
+
66
+ class _Art:
67
+ """One telescope/planet design. The scene engine works with any size."""
68
+
69
+ def __init__(self, telescope, planet, lens_row):
70
+ self.telescope = telescope
71
+ self.planet = planet
72
+ self.width = max(len(r) for r in telescope)
73
+ self.lens_row = lens_row
74
+ self.lens_col = telescope[lens_row].index("◯")
75
+ self.height = len(telescope) + 2 # one spare row above and below for the bobbing
76
+ self.ring_row = next(i for i, r in enumerate(planet) if "(" in r)
77
+
78
+
79
+ FULL = _Art([
80
+ " ┌─────────────┐",
81
+ " │▒▒▒▒▒▒▒▒▒▒▒▒▒│",
82
+ " └──────┬──────┘",
83
+ " ╭───────────┴────────────╮",
84
+ " │ ▪ ▪ ▪ H U B B L E ├─╮",
85
+ " │════════════════════════│◯│",
86
+ " │ ▪ ▪ ▪ ├─╯",
87
+ " ╰───────────┬────────────╯",
88
+ " ┌──────┴──────┐",
89
+ " │▒▒▒▒▒▒▒▒▒▒▒▒▒│",
90
+ " └─────────────┘",
91
+ ], [
92
+ " .-~~~~-.",
93
+ " -( )-",
94
+ " `-.__.-'",
95
+ ], lens_row=5)
96
+
97
+ COMPACT = _Art([
98
+ " ┌▒▒▒▒▒▒▒▒▒▒┐",
99
+ " ╭───┴──────────┴───╮",
100
+ " │▪ ▪ H U B B L E ├◯",
101
+ " ╰───┬──────────┬───╯",
102
+ " └▒▒▒▒▒▒▒▒▒▒┘",
103
+ ], [
104
+ " .-~~-.",
105
+ "-( )-",
106
+ " `-..-'",
107
+ ], lens_row=2)
108
+
109
+ # Kept for callers/tests that refer to the full design's height.
110
+ SCENE_H = FULL.height
111
+ SURFACE = "░▒▓▒░ ░▒ ▒▓▓▒░ ░" # planet surface bands, scrolled to look like rotation
112
+ FPS = 20
113
+
114
+ # Three parallax layers: (count per 100 columns, speed in columns per second, glyphs, colors dim/bright)
115
+ STAR_LAYERS = [
116
+ (9, 1.5, ".", ("#303044", "#4a4a66")),
117
+ (5, 4.0, "·+", ("#5a5a7a", "#8a8aa8")),
118
+ (2, 9.0, "✦*", ("#9a9ab8", "#ffffff")),
119
+ ]
120
+
121
+
122
+ def pick_art(height: int) -> Optional[_Art]:
123
+ """Largest design that fits the terminal with room for the logo line; None if too short."""
124
+ if height >= FULL.height + 4:
125
+ return FULL
126
+ if height >= COMPACT.height + 3:
127
+ return COMPACT
128
+ return None
129
+
130
+
131
+ def _stars(width: int, height: int):
132
+ rng = _random.Random(7)
133
+ stars = []
134
+ for density, speed, glyphs, colors in STAR_LAYERS:
135
+ for _ in range(max(1, width * density // 100) * height // 3):
136
+ stars.append((rng.uniform(0, width), rng.randrange(height), speed, rng.choice(glyphs), colors,
137
+ rng.uniform(0, 6.28)))
138
+ return stars
139
+
140
+
141
+ def _telescope_style(ch: str, c: int, t: float, art: _Art) -> str:
142
+ if ch == "▒":
143
+ # A glint of sunlight sweeps across the solar panels.
144
+ glint = (t * 14) % (art.width + 20) - 10
145
+ d = abs(c - glint)
146
+ return "bold #d7e7ff" if d < 1 else "#87afff" if d < 3 else "#5f87ff"
147
+ if ch == "▪":
148
+ return "#ffaf00" if int(t * 2 + c) % 3 else "bold #ffd75f" # blinking status lights
149
+ if ch == "◯":
150
+ pulse = (math.sin(t * 5) + 1) / 2
151
+ return "bold #fff3b0" if pulse > 0.66 else "bold #ffd75f" if pulse > 0.33 else "#d7af00"
152
+ if ch == "═":
153
+ return "#87afd7"
154
+ if ch.isalpha():
155
+ return f"bold {_blend(GRADIENT, c / art.width)}"
156
+ return "#bcbcbc"
157
+
158
+
159
+ def _new_grid(width: int, height: int):
160
+ return [[(" ", "")] * width for _ in range(height)]
161
+
162
+
163
+ _BLEND_CACHE: dict = {}
164
+
165
+
166
+ def _mix(dim: str, bright: str, w: float) -> str:
167
+ """Color between two hex colors (w 0..1), quantized to 12 steps so styles stay cacheable."""
168
+ q = max(0, min(12, round(w * 12)))
169
+ key = (dim, bright, q)
170
+ hit = _BLEND_CACHE.get(key)
171
+ if hit:
172
+ return hit
173
+ a = [int(dim[i:i + 2], 16) for i in (1, 3, 5)]
174
+ b = [int(bright[i:i + 2], 16) for i in (1, 3, 5)]
175
+ f = q / 12
176
+ out = "#" + "".join(f"{round(a[k] + (b[k] - a[k]) * f):02x}" for k in range(3))
177
+ _BLEND_CACHE[key] = out
178
+ return out
179
+
180
+
181
+ _SPACE = "#101018" # color a star fades to as it leaves a cell
182
+
183
+
184
+ def _draw_stars(grid, t: float):
185
+ """Parallax starfield: stars drift left, nearer layers faster.
186
+
187
+ Terminal cells are coarse, so each star is anti-aliased across the two cells it straddles:
188
+ it fades out of one and into the next instead of jumping, which reads as smooth motion.
189
+ """
190
+ height, width = len(grid), len(grid[0])
191
+ for x, row, speed, glyph, colors, phase in _stars(width, height):
192
+ pos = (x - speed * t) % width
193
+ c0 = int(pos)
194
+ frac = pos - c0
195
+ twinkle = (math.sin(t * 2.2 + phase) + 1) / 2 # smooth, not on/off
196
+ color = _mix(colors[0], colors[1], twinkle)
197
+ for c, w in ((c0, 1 - frac), ((c0 + 1) % width, frac)):
198
+ if w > 0.2 and grid[row][c][0] == " ":
199
+ grid[row][c] = (glyph, _mix(_SPACE, color, w))
200
+
201
+
202
+ def _draw_comet(grid, row: int, t: float, period: float = 6.0):
203
+ width = len(grid[0])
204
+ ct = (t % period) / 1.2
205
+ if ct < 1:
206
+ head = int(width * (1 - ct))
207
+ for k, (ch, st) in enumerate((("●", "bold #ffffff"), ("━", "#d7d7ff"), ("━", "#8787af"), ("─", "#5f5f87"))):
208
+ if 0 <= head + k < width:
209
+ grid[row][head + k] = (ch, st)
210
+
211
+
212
+ def _stamp(grid, sprite, top, left, style_of):
213
+ # Opaque between first and last visible char, so stars never show through.
214
+ height, width = len(grid), len(grid[0])
215
+ for r, line in enumerate(sprite):
216
+ body = line.rstrip()
217
+ first = len(body) - len(body.lstrip())
218
+ for c in range(first, len(body)):
219
+ if 0 <= top + r < height and 0 <= left + c < width:
220
+ ch = body[c]
221
+ grid[top + r][left + c] = (ch, style_of(ch, r, c) if ch != " " else "")
222
+
223
+
224
+ def _draw_scene(grid, top: int, t: float, art: _Art):
225
+ """Telescope (bobbing), rotating planet and the light beam between them, starting at row `top`."""
226
+ width = len(grid[0])
227
+ bob = round(math.sin(t * 0.9) * 0.8) # gentle float: rests between drifts
228
+ tx, ty = 2, top + 1 + bob
229
+ _stamp(grid, art.telescope, ty, tx, lambda ch, r, c: _telescope_style(ch, c, t, art))
230
+
231
+ ring = art.planet[art.ring_row]
232
+ px = width - len(ring) - 3
233
+ planet_top = ty + art.lens_row - art.ring_row # ring level with the lens, so the beam always connects
234
+ _stamp(grid, art.planet, planet_top, px,
235
+ lambda ch, r, c: "#d7af87" if ch == "-" and r == art.ring_row else "#ff875f")
236
+ spin = int(t * 6)
237
+ ring_y = planet_top + art.ring_row
238
+ if 0 <= ring_y < len(grid):
239
+ for c in range(ring.index("(") + 1, ring.index(")")):
240
+ band = SURFACE[(c + spin) % len(SURFACE)]
241
+ grid[ring_y][px + c] = (band, "#d75f5f" if band == "▓" else "#ff875f" if band == "▒" else "#ffaf87")
242
+
243
+ beam_row = ty + art.lens_row
244
+ if 0 <= beam_row < len(grid):
245
+ start, end = tx + art.lens_col + 2, px
246
+ for c in range(start, end):
247
+ phase = (c - start - int(t * 12)) % 5
248
+ if grid[beam_row][c][0] == " " or phase == 0:
249
+ grid[beam_row][c] = ("•" if phase == 0 else "·", "bold #ffd75f" if phase == 0 else "#af8700")
250
+
251
+
252
+ def _grid_text(grid) -> Text:
253
+ out = Text()
254
+ for i, row in enumerate(grid):
255
+ for ch, style in row:
256
+ out.append(ch, style=style or None)
257
+ if i < len(grid) - 1:
258
+ out.append("\n")
259
+ return out
260
+
261
+
262
+ def space_scene(width: int, t: float = 2.4, art: _Art = FULL) -> Text:
263
+ """Just the scene: starfield, comet, telescope, planet, beam (art.height rows)."""
264
+ grid = _new_grid(max(width, art.width + 24), art.height)
265
+ _draw_stars(grid, t)
266
+ _draw_comet(grid, 0, t)
267
+ _draw_scene(grid, 0, t, art)
268
+ return _grid_text(grid).append("\n")
269
+
270
+
271
+ # ----- composed home screen -------------------------------------------------
272
+
273
+ _MEASURE = Console(width=400, color_system="truecolor", force_terminal=True, legacy_windows=False,
274
+ file=__import__("io").StringIO())
275
+
276
+
277
+ _CELL_CACHE: dict = {}
278
+
279
+
280
+ def _cells(text: Text) -> List[Tuple[str, Any]]:
281
+ """Per-character (char, style) for one line of rich Text (cached: status/tips never change)."""
282
+ key = id(text)
283
+ hit = _CELL_CACHE.get(key)
284
+ if hit is not None and hit[0] is text:
285
+ return hit[1]
286
+ cells: List[Tuple[str, Any]] = []
287
+ for seg in text.render(_MEASURE):
288
+ if seg.text == "\n":
289
+ continue
290
+ for ch in seg.text:
291
+ cells.append((ch, seg.style))
292
+ if len(_CELL_CACHE) > 256:
293
+ _CELL_CACHE.clear()
294
+ _CELL_CACHE[key] = (text, cells)
295
+ return cells
296
+
297
+
298
+ def _stamp_text(grid, row: int, col: int, text: Text):
299
+ """Opaque text line: stars stay out of the span from first to last visible character."""
300
+ cells = _cells(text)
301
+ width = len(grid[0])
302
+ last = max((i for i, (ch, _) in enumerate(cells) if ch != " "), default=-1)
303
+ for i, (ch, style) in enumerate(cells[: last + 1]):
304
+ if 0 <= col + i < width and 0 <= row < len(grid):
305
+ if ch == " " and i < len(cells) and not any(c != " " for c, _ in cells[:i]):
306
+ continue # leading spaces stay transparent
307
+ grid[row][col + i] = (ch, style)
308
+
309
+
310
+ def home_info_lines(*, version: str, provider: str, model: str, mode: str, root: str, session_id: Optional[str],
311
+ memory_files: List[str], model_count: Optional[int], provider_count: int, resumable: int,
312
+ show_provider: bool) -> Tuple[List[Text], List[Text]]:
313
+ """(status lines, tip lines) as rich Text, shared by the static and animated home screens."""
314
+ via = f"[dim]{escape(provider)}[/dim] · " if show_provider else ""
315
+ count = f" [dim]({model_count} models)[/dim]" if model_count else ""
316
+ mode_color = {"default": "white", "accept-edits": "green", "plan": "blue", "yolo": "red"}.get(mode, "white")
317
+ status = [f"[bold]model [/bold] {via}[#00d7ff]{escape(model)}[/#00d7ff]{count}",
318
+ f"[bold]mode [/bold] [{mode_color}]{mode}[/{mode_color}] [dim](Shift+Tab to change)[/dim]",
319
+ f"[bold]cwd [/bold] {escape(root)}"]
320
+ if session_id:
321
+ status.append(f"[bold]session[/bold] [dim]{session_id}[/dim]")
322
+
323
+ tips = ["Ask for a change, a fix or an explanation, e.g. [italic]\"add tests for @src/utils.py\"[/italic]",
324
+ "Type [bold]/[/bold] for commands, [bold]@[/bold] to attach a file, [bold]![/bold] to run a shell command"]
325
+ if memory_files:
326
+ tips.append(f"Project memory loaded from [bold]{escape(', '.join(memory_files))}[/bold]")
327
+ else:
328
+ tips.append("Run [bold]/init[/bold] to create HUBBLE.md so Hubble remembers this project")
329
+ if resumable:
330
+ tips.append(f"[bold]/resume[/bold] continues one of {resumable} earlier session{'s' if resumable != 1 else ''} here")
331
+ if provider_count < 2:
332
+ tips.append("[bold]/provider add[/bold] connects another OpenAI-compatible API")
333
+ tip_lines = [Text.from_markup("[bold #af87ff]Tips for getting started[/bold #af87ff]")]
334
+ tip_lines += [Text.from_markup(f"[dim]{i}.[/dim] {tip}") for i, tip in enumerate(tips, 1)]
335
+ return [Text.from_markup(s) for s in status], tip_lines
336
+
337
+
338
+ def compose_home(width: int, height: int, t: float, status: List[Text], tips: List[Text], version: str) -> Text:
339
+ return _grid_text(compose_grid(width, height, t, status, tips, version))
340
+
341
+
342
+ def compose_home_fragments(width: int, height: int, t: float, status: List[Text], tips: List[Text],
343
+ version: str) -> List[Tuple[str, str]]:
344
+ """Same frame as prompt_toolkit fragments: no ANSI round trip, same-style runs merged."""
345
+ return _grid_fragments(compose_grid(width, height, t, status, tips, version))
346
+
347
+
348
+ _PT_STYLES: dict = {}
349
+
350
+
351
+ def _pt_style(style: Any) -> str:
352
+ """rich style (string or Style) -> prompt_toolkit style string. 'dim' becomes a darker color."""
353
+ if not style:
354
+ return ""
355
+ key = style if isinstance(style, str) else str(style)
356
+ cached = _PT_STYLES.get(key)
357
+ if cached is not None:
358
+ return cached
359
+ from rich.style import Style
360
+ try:
361
+ st = style if isinstance(style, Style) else Style.parse(style)
362
+ except Exception:
363
+ _PT_STYLES[key] = ""
364
+ return ""
365
+ parts = []
366
+ if st.color is not None:
367
+ r, g, b = st.color.get_truecolor()
368
+ if st.dim:
369
+ r, g, b = int(r * 0.55), int(g * 0.55), int(b * 0.55)
370
+ parts.append(f"fg:#{r:02x}{g:02x}{b:02x}")
371
+ elif st.dim:
372
+ parts.append("fg:#808080")
373
+ if st.bold:
374
+ parts.append("bold")
375
+ if st.italic:
376
+ parts.append("italic")
377
+ if st.strike:
378
+ parts.append("strike")
379
+ out = " ".join(parts)
380
+ _PT_STYLES[key] = out
381
+ return out
382
+
383
+
384
+ def _grid_fragments(grid) -> List[Tuple[str, str]]:
385
+ frags: List[Tuple[str, str]] = []
386
+ for row in grid:
387
+ cur, buf = None, []
388
+ for ch, st in row:
389
+ s = _pt_style(st) if ch != " " else ""
390
+ if s != cur and buf:
391
+ frags.append((cur or "", "".join(buf)))
392
+ buf = []
393
+ cur = s
394
+ buf.append(ch)
395
+ if buf:
396
+ frags.append((cur or "", "".join(buf)))
397
+ frags.append(("", "\n"))
398
+ return frags
399
+
400
+
401
+ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: List[Text], version: str):
402
+ """Whole home screen on one animated starfield: logo, scene, status and tips.
403
+
404
+ Picks the richest layout that fits `height` rows, dropping tips, then shrinking the scene,
405
+ then the logo, so it also works in short terminals such as VS Code's panel.
406
+ """
407
+ width = max(40, min(width - 1, 110))
408
+ height = max(height, 1)
409
+ big_logo = width >= 58
410
+ # Richest to poorest. Below a plain small-logo layout, keep shrinking the status block itself
411
+ # (never just tips) so something animated always fits, all the way down to the logo line alone.
412
+ n_status = len(status)
413
+ layouts = [
414
+ (big_logo, FULL, True, n_status), (big_logo, COMPACT, True, n_status),
415
+ (big_logo, COMPACT, False, n_status), (False, COMPACT, False, n_status),
416
+ (False, None, True, n_status), (False, None, False, n_status),
417
+ (False, None, False, min(2, n_status)), (False, None, False, min(1, n_status)),
418
+ (False, None, False, 0),
419
+ ]
420
+ use_big, art, with_tips, n_status = layouts[-1]
421
+ for cand_big, cand_art, cand_tips, cand_n in layouts:
422
+ if cand_art is not None and width < cand_art.width + 24:
423
+ continue
424
+ need = (7 if cand_big else 1) + 1 + (cand_art.height + 1 if cand_art else 0) + cand_n + \
425
+ (1 + len(tips) if cand_tips else 0) + 1
426
+ if need <= height:
427
+ use_big, art, with_tips, n_status = cand_big, cand_art, cand_tips, cand_n
428
+ break
429
+ status = status[:n_status]
430
+ logo_h = 7 if use_big else 1
431
+
432
+ rows_total = logo_h + 1 + (art.height + 1 if art else 0) + len(status) + (1 + len(tips) if with_tips else 0) + 1
433
+ rows_total = min(rows_total, height) if rows_total > height else rows_total # never exceed the budget
434
+ grid = _new_grid(width, max(rows_total, 1))
435
+ _draw_stars(grid, t)
436
+
437
+ row = 0
438
+ if use_big:
439
+ logo = _logo_rows()
440
+ lw = max(len(r) for r in logo)
441
+ for r, line in enumerate(logo):
442
+ # Letters shimmer: the gradient slowly slides across the word.
443
+ body = line.rstrip()
444
+ for c, ch in enumerate(body): # opaque across the word so stars pass behind it
445
+ if 2 + c < width:
446
+ shade = _blend(GRADIENT, ((c / max(lw - 1, 1)) + t * 0.08) % 1.0)
447
+ grid[row + r][2 + c] = (ch, (shade if ch == "█" else f"{shade} dim") if ch != " " else "")
448
+ _stamp_text(grid, row + 6, 4, Text.assemble(("your model hub for code", "italic #8787af"),
449
+ (" v" + version, "dim")))
450
+ else:
451
+ _stamp_text(grid, row, 2, Text.assemble(small_logo(), (" v" + version, "dim")))
452
+ row += logo_h + 1
453
+
454
+ if art is not None:
455
+ _draw_comet(grid, row, t)
456
+ _draw_scene(grid, row, t, art)
457
+ row += art.height + 1
458
+
459
+ for line in status:
460
+ _stamp_text(grid, row, 2, line)
461
+ row += 1
462
+ if with_tips:
463
+ row += 1
464
+ for line in tips:
465
+ _stamp_text(grid, row, 2, line)
466
+ row += 1
467
+ return grid
468
+
469
+
470
+ def to_ansi(text: Text, width: int) -> str:
471
+ """Render rich Text to a truecolor ANSI string (for prompt_toolkit's ANSI formatted text)."""
472
+ con = Console(width=width, color_system="truecolor", force_terminal=True, legacy_windows=False,
473
+ file=__import__("io").StringIO())
474
+ con.print(text, end="", soft_wrap=True, crop=True)
475
+ return con.file.getvalue()
476
+
477
+
478
+ def render_home(console: Console, *, animate: bool = False, **info):
479
+ """Static home screen (non-interactive terminals, /home fallback)."""
480
+ status, tips = home_info_lines(**info)
481
+ height = console.height if console.is_terminal else 200
482
+ console.print()
483
+ console.print(compose_home(console.width, max(height - 3, 12), 2.4, status, tips, info["version"]))
484
+ console.print()
hubble/board.py ADDED
@@ -0,0 +1,118 @@
1
+ """Live dashboard for sub-agents: one row per agent with status, current action, tools, tokens, time."""
2
+
3
+ import threading
4
+ import time
5
+ from typing import Dict, List, Optional
6
+
7
+ from rich.console import Group
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from hubble.spinner import GLYPHS, Shimmer
12
+
13
+ STATUS_ICON = {"done": ("✔", "bold green"), "failed": ("✘", "bold red"), "stopped": ("■", "yellow")}
14
+
15
+
16
+ class _Row:
17
+ def __init__(self, label: str):
18
+ self.label = label
19
+ self.status = "running" # running | done | failed | stopped
20
+ self.action = "starting"
21
+ self.tools = 0
22
+ self.tokens = 0
23
+ self.start = time.time()
24
+ self.end: Optional[float] = None
25
+ self.detail = ""
26
+
27
+ @property
28
+ def elapsed(self) -> float:
29
+ return (self.end or time.time()) - self.start
30
+
31
+
32
+ def _fmt_tokens(n: int) -> str:
33
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
34
+
35
+
36
+ class SubagentBoard:
37
+ """Thread-safe model plus a rich renderable (Live calls __rich__ on every refresh)."""
38
+
39
+ def __init__(self):
40
+ self.rows: Dict[str, _Row] = {}
41
+ self.order: List[str] = []
42
+ self.lock = threading.Lock()
43
+ self.start = time.time()
44
+ self.header = Shimmer("Sub-agents exploring", details=False)
45
+
46
+ # ----- updates (called from worker threads) --------------------------
47
+
48
+ def add(self, key: str, label: str):
49
+ with self.lock:
50
+ if key not in self.rows:
51
+ self.rows[key] = _Row(label)
52
+ self.order.append(key)
53
+
54
+ def step(self, key: str, action: str, is_tool: bool = True):
55
+ with self.lock:
56
+ row = self.rows.get(key)
57
+ if row:
58
+ row.action = action
59
+ row.tools += int(is_tool)
60
+
61
+ def tokens(self, key: str, n: int):
62
+ with self.lock:
63
+ row = self.rows.get(key)
64
+ if row:
65
+ row.tokens += n
66
+
67
+ def finish(self, key: str, status: str, detail: str = ""):
68
+ with self.lock:
69
+ row = self.rows.get(key)
70
+ if row and row.status == "running":
71
+ row.status, row.detail, row.end = status, detail, time.time()
72
+
73
+ # ----- rendering -------------------------------------------------------
74
+
75
+ @property
76
+ def counts(self):
77
+ with self.lock:
78
+ total = len(self.rows)
79
+ done = sum(r.status != "running" for r in self.rows.values())
80
+ return done, total
81
+
82
+ def __rich__(self):
83
+ with self.lock:
84
+ rows = [self.rows[k] for k in self.order]
85
+ done = sum(r.status != "running" for r in rows)
86
+ running = len(rows) - done
87
+ elapsed = int(time.time() - self.start)
88
+
89
+ if running:
90
+ self.header.set_label(f"{len(rows)} sub-agent{'s' if len(rows) != 1 else ''} exploring")
91
+ head = self.header.__rich__()
92
+ head.append(f" {done}/{len(rows)} done · {elapsed}s · ctrl+c stops all", style="dim")
93
+ else:
94
+ failed = sum(r.status == "failed" for r in rows)
95
+ head = Text.assemble(("● ", "bold magenta"),
96
+ (f"{len(rows)} sub-agent{'s' if len(rows) != 1 else ''} finished", "bold"),
97
+ (f" {done - failed}/{len(rows)} succeeded · {elapsed}s", "dim"))
98
+
99
+ label_w = min(max((len(r.label) for r in rows), default=8), 42)
100
+ table = Table.grid(padding=(0, 1))
101
+ table.add_column(no_wrap=True) # indent + icon
102
+ table.add_column(width=label_w, no_wrap=True, overflow="ellipsis") # label
103
+ table.add_column(justify="right", no_wrap=True, style="dim") # stats
104
+ table.add_column(ratio=1, no_wrap=True, overflow="ellipsis") # current action
105
+ frame = int(time.time() * 10)
106
+ for i, r in enumerate(rows):
107
+ if r.status == "running":
108
+ icon = Text(" " + GLYPHS[(frame + i * 3) % len(GLYPHS)], style="bold #ff875f")
109
+ action = Text(r.action, style="#d7875f")
110
+ label = Text(r.label, style="bold")
111
+ else:
112
+ ch, style = STATUS_ICON.get(r.status, ("•", "dim"))
113
+ icon = Text(" " + ch, style=style)
114
+ action = Text(r.detail or r.status, style="red" if r.status == "failed" else "dim")
115
+ label = Text(r.label, style="bold" if r.status == "done" else "")
116
+ stats = f"{r.tools:>2} tool{'s' if r.tools != 1 else ' '} · {_fmt_tokens(r.tokens):>5} tok · {int(r.elapsed):>3}s "
117
+ table.add_row(icon, label, stats, action)
118
+ return Group(head, table)