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/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
|