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/ui.py ADDED
@@ -0,0 +1,469 @@
1
+ """Terminal rendering: streamed markdown, tool call lines, diffs and approval prompts."""
2
+
3
+ import sys
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from rich.console import Console
7
+ from rich.markdown import Markdown
8
+ from rich.markup import escape
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from hubble.agent import Events, describe_call
13
+ from hubble.provider import TurnResult
14
+ from hubble.board import SubagentBoard
15
+ from hubble.spinner import start_shimmer
16
+ from hubble.tools import Tool, ToolContext
17
+
18
+ if sys.platform == "win32":
19
+ for stream in (sys.stdout, sys.stderr):
20
+ try:
21
+ stream.reconfigure(encoding="utf-8")
22
+ except (AttributeError, ValueError):
23
+ pass
24
+
25
+ console = Console(highlight=False)
26
+ err_console = Console(stderr=True, highlight=False)
27
+
28
+ TOOL_LABELS = {"read_file": "Read", "write_file": "Write", "edit_file": "Edit", "shell": "Shell",
29
+ "grep": "Search", "glob": "Glob", "list_dir": "List", "todo_write": "Todos", "task": "Task",
30
+ "web_search": "Web Search", "web_fetch": "Fetch"}
31
+ MAX_DIFF_LINES = 120
32
+
33
+
34
+ def render_diff(diff: str, max_lines: int = MAX_DIFF_LINES) -> Text:
35
+ out = Text()
36
+ lines = diff.splitlines()
37
+ for line in lines[:max_lines]:
38
+ if line.startswith(("+++", "---")):
39
+ style = "bold"
40
+ elif line.startswith("+"):
41
+ style = "green"
42
+ elif line.startswith("-"):
43
+ style = "red"
44
+ elif line.startswith("@@"):
45
+ style = "cyan"
46
+ else:
47
+ style = "dim"
48
+ out.append(line + "\n", style=style)
49
+ if len(lines) > max_lines:
50
+ out.append(f"... {len(lines) - max_lines} more diff lines\n", style="dim")
51
+ return out
52
+
53
+
54
+ class StreamingMarkdown:
55
+ """Renders markdown block by block as it streams, never re-drawing earlier output."""
56
+
57
+ def __init__(self, con: Console):
58
+ self.console = con
59
+ self.buf = ""
60
+ self.printed_any = False
61
+
62
+ def feed(self, delta: str):
63
+ self.buf += delta
64
+ cut = self._boundary()
65
+ if cut:
66
+ chunk, self.buf = self.buf[:cut], self.buf[cut:]
67
+ self._render(chunk)
68
+
69
+ def flush(self):
70
+ if self.buf.strip():
71
+ self._render(self.buf)
72
+ self.buf = ""
73
+
74
+ def _boundary(self) -> Optional[int]:
75
+ in_fence, pos, last = False, 0, None
76
+ for line in self.buf.splitlines(keepends=True):
77
+ if not line.endswith("\n"):
78
+ break
79
+ pos += len(line)
80
+ stripped = line.strip()
81
+ if stripped.startswith(("```", "~~~")):
82
+ in_fence = not in_fence
83
+ if not in_fence:
84
+ last = pos
85
+ elif not stripped and not in_fence:
86
+ last = pos
87
+ return last
88
+
89
+ def _render(self, chunk: str):
90
+ if not chunk.strip():
91
+ return
92
+ if self.printed_any:
93
+ self.console.print()
94
+ self.console.print(Markdown(chunk.strip("\n"), code_theme="monokai"))
95
+ self.printed_any = True
96
+
97
+
98
+ def activity_label(tool: Tool, args: Dict[str, Any]) -> str:
99
+ """Words shown in the shimmer while a tool runs."""
100
+ target = describe_call(tool, args)
101
+ short = target if len(target) <= 48 else target[:45] + "..."
102
+ return {
103
+ "read_file": f"Reading {short}", "write_file": f"Writing {short}", "edit_file": f"Editing {short}",
104
+ "shell": f"Running {short}", "grep": f"Searching for {short}", "glob": f"Finding {short}",
105
+ "list_dir": f"Looking around {short}", "todo_write": "Updating the plan", "task": f"Researching {short}",
106
+ "web_search": f"Searching the web for {short}", "web_fetch": f"Fetching {short}",
107
+ }.get(tool.name, f"Running {tool.name}")
108
+
109
+
110
+ def summarize_output(tool: Tool, output: str, is_error: bool) -> Tuple[str, List[str]]:
111
+ """(headline, extra lines) shown under a tool call."""
112
+ lines = output.splitlines()
113
+ if is_error:
114
+ return lines[0][:200] if lines else "error", lines[1:4]
115
+ if tool.name == "read_file":
116
+ body = [l for l in lines[1:] if " | " in l]
117
+ return f"Read {len(body)} lines", []
118
+ if tool.name in ("grep", "glob"):
119
+ if output.startswith("No "):
120
+ return output.splitlines()[0], []
121
+ return f"{len(lines)} result{'s' if len(lines) != 1 else ''}", lines[:3]
122
+ if tool.name == "shell":
123
+ exit_line = lines[-1] if lines else ""
124
+ body = lines[:-1]
125
+ return exit_line.strip("[]"), body[-8:]
126
+ if tool.name == "web_search":
127
+ hits = [l for l in lines if l[:3].strip().rstrip(".").isdigit()]
128
+ return (f"{len(hits)} result{'s' if len(hits) != 1 else ''}" if hits else lines[0][:120]), \
129
+ [h.split(". ", 1)[-1] for h in hits[:3]]
130
+ if tool.name == "web_fetch":
131
+ return f"Fetched {len(output):,} chars", lines[2:3]
132
+ if tool.name == "task":
133
+ return f"Report ready ({len(output):,} chars)", lines[:2]
134
+ return lines[0][:200] if lines else "done", []
135
+
136
+
137
+ class ReplEvents(Events):
138
+ def __init__(self, ctx: ToolContext, show_reasoning: bool = False):
139
+ self.ctx = ctx
140
+ self.show_reasoning = show_reasoning
141
+ self.reasoning_chars = 0
142
+ self.answer_started = False
143
+ self.md: Optional[StreamingMarkdown] = None
144
+ self.status = None
145
+ self.in_reasoning = False
146
+ self.approved_diff_shown = False
147
+ self.batch = 0 # >0 while parallel sub-agents run: the board owns the screen
148
+ self.board: Optional[SubagentBoard] = None
149
+ self.board_live = None
150
+
151
+ # ----- sub-agent dashboard ---------------------------------------------
152
+
153
+ def batch_start(self, count: int):
154
+ self._stop_status(force=True)
155
+ self.batch = count
156
+
157
+ def batch_end(self):
158
+ self.batch = 0
159
+ self._close_board()
160
+
161
+ def subagent_start(self, key: str, label: str):
162
+ if self.board is None:
163
+ from rich.live import Live
164
+ self._stop_status(force=True)
165
+ self.board = SubagentBoard()
166
+ self.board_live = Live(self.board, console=console, refresh_per_second=12, transient=True)
167
+ self.board_live.start()
168
+ self.board.add(key, label)
169
+
170
+ def subagent_step(self, key: str, action: str, is_tool: bool = True):
171
+ if self.board:
172
+ self.board.step(key, action, is_tool)
173
+
174
+ def subagent_tokens(self, key: str, tokens: int):
175
+ if self.board:
176
+ self.board.tokens(key, tokens)
177
+
178
+ def subagent_end(self, key: str, status: str, detail: str = ""):
179
+ if self.board:
180
+ self.board.finish(key, status, detail)
181
+
182
+ def _close_board(self):
183
+ """Stop the live board and leave its final state on screen."""
184
+ if self.board_live is not None:
185
+ self.board_live.stop()
186
+ console.print(self.board)
187
+ self.board_live = None
188
+ self.board = None
189
+
190
+ def _stop_status(self, force: bool = False):
191
+ if (self.batch or self.board_live is not None) and not force:
192
+ return
193
+ if self.status is not None:
194
+ self.status.stop()
195
+ self.status = None
196
+
197
+ def _end_reasoning(self):
198
+ if self.in_reasoning:
199
+ console.print()
200
+ self.in_reasoning = False
201
+
202
+ def turn_start(self):
203
+ self.md = StreamingMarkdown(console)
204
+ self.reasoning_chars = 0
205
+ self.answer_started = False
206
+ self.status = start_shimmer(console)
207
+
208
+ def reasoning(self, delta: str):
209
+ if not self.show_reasoning:
210
+ # Keep the animation running and count the hidden thinking tokens (/thinking shows them).
211
+ self.reasoning_chars += len(delta)
212
+ if self.status is not None and hasattr(self.status, "shimmer"):
213
+ self.status.shimmer.tokens = int(self.reasoning_chars / 3.8)
214
+ return
215
+ self._stop_status()
216
+ if not self.in_reasoning:
217
+ console.print("[dim italic]* thinking[/dim italic]")
218
+ self.in_reasoning = True
219
+ console.print(Text(delta, style="dim italic"), end="")
220
+
221
+ def text(self, delta: str):
222
+ self._stop_status()
223
+ self._end_reasoning()
224
+ if not self.answer_started:
225
+ # A clear header, distinct from the cyan "you typed this" prompt line above it.
226
+ console.print("[bold #af87ff]✻ Hubble[/bold #af87ff]")
227
+ self.answer_started = True
228
+ if self.md:
229
+ self.md.feed(delta)
230
+
231
+ def turn_end(self, result: TurnResult):
232
+ self._stop_status()
233
+ self._end_reasoning()
234
+ if self.md:
235
+ self.md.flush()
236
+ if self.md.printed_any:
237
+ console.print()
238
+
239
+ def tool_start(self, tool: Tool, args: Dict[str, Any]):
240
+ label = TOOL_LABELS.get(tool.name, tool.name)
241
+ if tool.name == "task":
242
+ return # shown on the sub-agent board instead
243
+ console.print(f"[bold cyan]●[/bold cyan] [bold]{label}[/bold]([cyan]{escape(describe_call(tool, args))}[/cyan])")
244
+ if tool.kind == "edit" and not self.approved_diff_shown:
245
+ try:
246
+ diff = tool.preview(args, self.ctx)
247
+ except Exception:
248
+ diff = None
249
+ if diff and diff.startswith("---"):
250
+ console.print(render_diff(diff, max_lines=40))
251
+ self.approved_diff_shown = False
252
+ if not self.batch:
253
+ self.status = start_shimmer(console, activity_label(tool, args))
254
+
255
+ def tool_result(self, tool: Tool, args: Dict[str, Any], output: str, is_error: bool):
256
+ if tool.name == "task" and args:
257
+ if not self.batch:
258
+ self._close_board() # single sub-agent: its board ends with it
259
+ return
260
+ self._stop_status()
261
+ if not args and is_error:
262
+ console.print(f"[bold red]●[/bold red] [bold]{TOOL_LABELS.get(tool.name, tool.name)}[/bold]")
263
+ headline, extra = summarize_output(tool, output, is_error)
264
+ if tool.name == "task" and args.get("description"):
265
+ headline = f"{args['description']}: {headline}"
266
+ style = "red" if is_error else "dim"
267
+ console.print(f" [dim]⎿[/dim] [{style}]{escape(headline)}[/{style}]")
268
+ for line in extra:
269
+ console.print(f" [dim]{escape(line[:160])}[/dim]")
270
+
271
+ def notice(self, message: str, level: str = "info"):
272
+ self._stop_status()
273
+ style = {"warn": "yellow", "error": "bold red", "dim": "dim"}.get(level, "cyan")
274
+ console.print(f"[{style}]{escape(message)}[/{style}]")
275
+
276
+ def todos(self, todos: List[Dict[str, str]]):
277
+ render_todos(todos)
278
+
279
+ def ask(self, tool: Tool, args: Dict[str, Any], preview: Optional[str]) -> Tuple[str, str]:
280
+ self._stop_status()
281
+ label = TOOL_LABELS.get(tool.name, tool.name)
282
+ title = f"{label} {describe_call(tool, args)}"
283
+ if tool.kind == "exec":
284
+ body: Any = Text(preview or args.get("command", ""), style="bold")
285
+ if args.get("description"):
286
+ body = Text.assemble(body, "\n", Text(args["description"], style="dim"))
287
+ elif preview and preview.startswith("---"):
288
+ body = render_diff(preview)
289
+ else:
290
+ body = Text(preview or "", style="dim")
291
+ console.print(Panel(body, title=f"[bold yellow]{escape(title[:100])}[/bold yellow]",
292
+ title_align="left", border_style="yellow"))
293
+ always = {"edit": "edits this session", "web": f"{tool.target(args)} this session"}.get(
294
+ tool.kind, "similar commands this session")
295
+ console.print(f" [bold]y[/bold] yes [bold]a[/bold] always ({always}) "
296
+ f"[bold]n[/bold] no, tell the model why [dim]Ctrl+C stop[/dim]")
297
+ while True:
298
+ try:
299
+ answer = console.input("[bold yellow] allow? [/bold yellow]").strip().lower()
300
+ except EOFError:
301
+ raise KeyboardInterrupt
302
+ if answer in ("", "y", "yes"):
303
+ self.approved_diff_shown = True
304
+ return "yes", ""
305
+ if answer in ("a", "always"):
306
+ self.approved_diff_shown = True
307
+ return "always", ""
308
+ if answer in ("n", "no"):
309
+ try:
310
+ feedback = console.input("[dim] feedback for the model (optional): [/dim]").strip()
311
+ except EOFError:
312
+ feedback = ""
313
+ return "no", feedback
314
+ if answer.startswith("n "):
315
+ return "no", answer[2:].strip()
316
+ console.print("[dim] type y, a or n[/dim]")
317
+
318
+
319
+ def render_todos(todos: List[Dict[str, str]]):
320
+ if not todos:
321
+ return
322
+ marks = {"completed": "[green]✔[/green]", "in_progress": "[yellow]◐[/yellow]", "pending": "[dim]○[/dim]"}
323
+ for t in todos:
324
+ text = escape(t["content"])
325
+ if t["status"] == "completed":
326
+ text = f"[dim strike]{text}[/dim strike]"
327
+ elif t["status"] == "in_progress":
328
+ text = f"[bold]{text}[/bold]"
329
+ console.print(f" {marks.get(t['status'], '○')} {text}")
330
+
331
+
332
+ class HeadlessEvents(Events):
333
+ """For `-p`: answer text on stdout (text format), progress on stderr, approvals denied."""
334
+
335
+ def __init__(self, stream_text: bool, quiet: bool = False):
336
+ self.stream_text = stream_text
337
+ self.quiet = quiet
338
+
339
+ def text(self, delta: str):
340
+ if self.stream_text:
341
+ sys.stdout.write(delta)
342
+ sys.stdout.flush()
343
+
344
+ def turn_end(self, result: TurnResult):
345
+ if self.stream_text and result.text and not result.text.endswith("\n"):
346
+ sys.stdout.write("\n")
347
+ sys.stdout.flush()
348
+
349
+ def tool_start(self, tool, args):
350
+ if not self.quiet:
351
+ err_console.print(f"[dim]● {TOOL_LABELS.get(tool.name, tool.name)}({escape(describe_call(tool, args))})[/dim]")
352
+
353
+ def tool_result(self, tool, args, output, is_error):
354
+ if is_error and not self.quiet:
355
+ err_console.print(f"[red] ⎿ {escape(output.splitlines()[0][:200] if output else 'error')}[/red]")
356
+
357
+ def notice(self, message, level="info"):
358
+ if not self.quiet or level == "error":
359
+ err_console.print(f"[yellow]{escape(message)}[/yellow]")
360
+
361
+ def subagent_start(self, key, label):
362
+ if not self.quiet:
363
+ err_console.print(f"[dim]● sub-agent started: {escape(label)}[/dim]")
364
+ self._labels = {**getattr(self, "_labels", {}), key: label}
365
+
366
+ def subagent_end(self, key, status, detail=""):
367
+ if not self.quiet:
368
+ icon = {"done": "[green]✔[/green]", "failed": "[red]✘[/red]"}.get(status, "[yellow]■[/yellow]")
369
+ err_console.print(f" {icon} [dim]{escape(getattr(self, '_labels', {}).get(key, key))}: {escape(detail)}[/dim]")
370
+
371
+ def ask(self, tool, args, preview):
372
+ if not self.quiet:
373
+ err_console.print(f"[yellow] denied (needs approval): {tool.name} {escape(describe_call(tool, args))}. "
374
+ "Use --permission-mode accept-edits/yolo or allow rules.[/yellow]")
375
+ return "no", ("This action needs user approval, which is unavailable in non-interactive mode. "
376
+ "Do not retry it; finish what you can and report what remains.")
377
+
378
+
379
+ def pick(title: str, items: List[Tuple[Any, str, str]], current: Any = None, max_visible: int = 14) -> Any:
380
+ """Inline arrow-key picker. items: (value, label, meta). Returns the chosen value or None.
381
+
382
+ Up/Down (or Ctrl+P/N) move, typing filters, Enter selects, Esc or Ctrl+C cancels.
383
+ """
384
+ from prompt_toolkit.application import Application
385
+ from prompt_toolkit.buffer import Buffer
386
+ from prompt_toolkit.formatted_text import FormattedText
387
+ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
388
+ from prompt_toolkit.key_binding.defaults import load_key_bindings
389
+ from prompt_toolkit.layout import HSplit, Layout, Window
390
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
391
+ from prompt_toolkit.layout.processors import BeforeInput
392
+
393
+ if not items:
394
+ return None
395
+ state = {"idx": next((i for i, it in enumerate(items) if it[0] == current), 0)}
396
+
397
+ def matches():
398
+ q = query.text.strip().lower()
399
+ return [it for it in items if not q or q in it[1].lower() or q in it[2].lower()]
400
+
401
+ def on_change(_):
402
+ state["idx"] = 0
403
+
404
+ query = Buffer(multiline=False, on_text_changed=on_change)
405
+ label_width = min(max((len(it[1]) for it in items), default=10) + 2, 60)
406
+
407
+ def render():
408
+ rows = matches()
409
+ state["idx"] = max(0, min(state["idx"], len(rows) - 1))
410
+ idx = state["idx"]
411
+ top = max(0, min(idx - max_visible // 2, len(rows) - max_visible))
412
+ out = [("bold", f" {title}\n")]
413
+ if not rows:
414
+ out.append(("fg:ansiyellow", " no matches\n"))
415
+ for i in range(top, min(top + max_visible, len(rows))):
416
+ value, label, meta = rows[i]
417
+ mark = "●" if value == current else " "
418
+ text = f" {mark} {label:<{label_width}} {meta}"
419
+ if i == idx:
420
+ out.append(("reverse bold", f"❯{text} \n"))
421
+ else:
422
+ style = "fg:ansigray" if "unavailable" in meta else ""
423
+ out.append((style, f" {text}\n"))
424
+ more = len(rows) - max_visible
425
+ pos = f"{idx + 1}/{len(rows)}" if rows else "0/0"
426
+ out.append(("fg:ansigray", f" {pos}{' (scroll for more)' if more > 0 else ''}"
427
+ " · ↑↓ move · type to filter · Enter select · Esc cancel"))
428
+ return FormattedText(out)
429
+
430
+ kb = KeyBindings()
431
+
432
+ @kb.add("up")
433
+ @kb.add("c-p")
434
+ def _(event):
435
+ state["idx"] = max(0, state["idx"] - 1)
436
+
437
+ @kb.add("down")
438
+ @kb.add("c-n")
439
+ def _(event):
440
+ state["idx"] = min(len(matches()) - 1, state["idx"] + 1)
441
+
442
+ @kb.add("pageup")
443
+ def _(event):
444
+ state["idx"] = max(0, state["idx"] - max_visible)
445
+
446
+ @kb.add("pagedown")
447
+ def _(event):
448
+ state["idx"] = min(len(matches()) - 1, state["idx"] + max_visible)
449
+
450
+ @kb.add("enter")
451
+ def _(event):
452
+ rows = matches()
453
+ event.app.exit(result=rows[state["idx"]][0] if rows else None)
454
+
455
+ @kb.add("escape", eager=True)
456
+ @kb.add("c-c")
457
+ def _(event):
458
+ event.app.exit(result=None)
459
+
460
+ layout = Layout(HSplit([
461
+ Window(FormattedTextControl(render), dont_extend_height=True),
462
+ Window(BufferControl(query, input_processors=[BeforeInput(" filter: ", style="fg:ansicyan")]), height=1),
463
+ ]), focused_element=query)
464
+ app = Application(layout=layout, key_bindings=merge_key_bindings([load_key_bindings(), kb]),
465
+ full_screen=False, erase_when_done=True, mouse_support=False)
466
+ try:
467
+ return app.run()
468
+ except (EOFError, KeyboardInterrupt):
469
+ return None