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/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
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)
|