solohq-cli 0.1.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.
- solohq_cli/__init__.py +0 -0
- solohq_cli/browser.py +360 -0
- solohq_cli/chat.py +415 -0
- solohq_cli/config.py +147 -0
- solohq_cli/debug_panel.py +182 -0
- solohq_cli/display.py +247 -0
- solohq_cli/factory.py +102 -0
- solohq_cli/main.py +89 -0
- solohq_cli/py.typed +0 -0
- solohq_cli-0.1.0.dist-info/METADATA +98 -0
- solohq_cli-0.1.0.dist-info/RECORD +14 -0
- solohq_cli-0.1.0.dist-info/WHEEL +4 -0
- solohq_cli-0.1.0.dist-info/entry_points.txt +2 -0
- solohq_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
solohq_cli/__init__.py
ADDED
|
File without changes
|
solohq_cli/browser.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import Application
|
|
9
|
+
from prompt_toolkit.formatted_text import ANSI, FormattedText, to_formatted_text
|
|
10
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
11
|
+
from prompt_toolkit.layout.containers import HSplit, Window
|
|
12
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
13
|
+
from prompt_toolkit.layout.layout import Layout
|
|
14
|
+
from prompt_toolkit.styles import Style
|
|
15
|
+
|
|
16
|
+
from .display import (
|
|
17
|
+
console,
|
|
18
|
+
print_artifact_detail,
|
|
19
|
+
print_context_detail,
|
|
20
|
+
print_episode_detail,
|
|
21
|
+
print_info,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from solohq_agno import AgnoContextMemory
|
|
26
|
+
from solohq_memory import ContextMemoryManager
|
|
27
|
+
|
|
28
|
+
MAX_VISIBLE = 15
|
|
29
|
+
|
|
30
|
+
_STYLE = Style.from_dict({
|
|
31
|
+
"title": "#888888",
|
|
32
|
+
"selected": "bold #ffffff",
|
|
33
|
+
"item": "#aaaaaa",
|
|
34
|
+
"hint": "#666666",
|
|
35
|
+
"sep": "#555555",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clear_up(n: int) -> None:
|
|
45
|
+
"""Move cursor up n lines and clear everything below."""
|
|
46
|
+
sys.stdout.write(f"\033[{n}A\033[J")
|
|
47
|
+
sys.stdout.flush()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _term_height() -> int:
|
|
51
|
+
try:
|
|
52
|
+
return os.get_terminal_size().lines
|
|
53
|
+
except (ValueError, OSError):
|
|
54
|
+
return 24
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _capture_rich(fn, *args, **kwargs) -> str: # noqa: ANN001, ANN003
|
|
58
|
+
"""Call a Rich display function and capture its ANSI output."""
|
|
59
|
+
from rich.console import Console as RichConsole
|
|
60
|
+
|
|
61
|
+
buf = StringIO()
|
|
62
|
+
capture = RichConsole(
|
|
63
|
+
file=buf,
|
|
64
|
+
force_terminal=True,
|
|
65
|
+
width=console.width or 80,
|
|
66
|
+
color_system=console.color_system or "256",
|
|
67
|
+
)
|
|
68
|
+
import solohq_cli.display as _display
|
|
69
|
+
|
|
70
|
+
old = _display.console
|
|
71
|
+
_display.console = capture
|
|
72
|
+
try:
|
|
73
|
+
fn(*args, **kwargs)
|
|
74
|
+
finally:
|
|
75
|
+
_display.console = old
|
|
76
|
+
return buf.getvalue()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ansi_lines(text: str) -> list[list[tuple[str, str]]]:
|
|
80
|
+
"""Parse ANSI text into lines of prompt_toolkit (style, text) fragments."""
|
|
81
|
+
raw = to_formatted_text(ANSI(text))
|
|
82
|
+
merged: list[tuple[str, str]] = []
|
|
83
|
+
for style, ch in raw:
|
|
84
|
+
if merged and merged[-1][0] == style:
|
|
85
|
+
merged[-1] = (style, merged[-1][1] + ch)
|
|
86
|
+
else:
|
|
87
|
+
merged.append((style, ch))
|
|
88
|
+
lines: list[list[tuple[str, str]]] = [[]]
|
|
89
|
+
for style, chunk in merged:
|
|
90
|
+
parts = chunk.split("\n")
|
|
91
|
+
for i, part in enumerate(parts):
|
|
92
|
+
if i > 0:
|
|
93
|
+
lines.append([])
|
|
94
|
+
if part:
|
|
95
|
+
lines[-1].append((style, part))
|
|
96
|
+
if lines and not lines[-1]:
|
|
97
|
+
lines.pop()
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Full-screen scrollable detail viewer
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _show_detail(fn, *args, **kwargs) -> None: # noqa: ANN001, ANN003
|
|
107
|
+
"""Show Rich output in a full-screen scrollable view."""
|
|
108
|
+
captured = _capture_rich(fn, *args, **kwargs)
|
|
109
|
+
lines = _ansi_lines(captured)
|
|
110
|
+
total = len(lines)
|
|
111
|
+
if total == 0:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
view_h = [_term_height() - 2]
|
|
115
|
+
scroll = [0]
|
|
116
|
+
|
|
117
|
+
def _max_scroll() -> int:
|
|
118
|
+
return max(0, total - view_h[0])
|
|
119
|
+
|
|
120
|
+
def get_fragments() -> FormattedText:
|
|
121
|
+
result: list[tuple[str, str]] = []
|
|
122
|
+
vis = lines[scroll[0] : scroll[0] + view_h[0]]
|
|
123
|
+
for line_frags in vis:
|
|
124
|
+
result.extend(line_frags)
|
|
125
|
+
result.append(("", "\n"))
|
|
126
|
+
ms = _max_scroll()
|
|
127
|
+
if ms > 0:
|
|
128
|
+
lo = scroll[0] + 1
|
|
129
|
+
hi = min(scroll[0] + view_h[0], total)
|
|
130
|
+
result.append(("class:hint", f" [{lo}-{hi}/{total}] \u2191\u2193 scroll Esc back"))
|
|
131
|
+
else:
|
|
132
|
+
result.append(("class:hint", " Esc to go back"))
|
|
133
|
+
return FormattedText(result)
|
|
134
|
+
|
|
135
|
+
control = FormattedTextControl(get_fragments, focusable=True)
|
|
136
|
+
window = Window(content=control, always_hide_cursor=True)
|
|
137
|
+
|
|
138
|
+
kb = KeyBindings()
|
|
139
|
+
|
|
140
|
+
@kb.add("up")
|
|
141
|
+
@kb.add("k")
|
|
142
|
+
def _up(event) -> None: # noqa: ANN001
|
|
143
|
+
if scroll[0] > 0:
|
|
144
|
+
scroll[0] -= 1
|
|
145
|
+
|
|
146
|
+
@kb.add("down")
|
|
147
|
+
@kb.add("j")
|
|
148
|
+
def _down(event) -> None: # noqa: ANN001
|
|
149
|
+
if scroll[0] < _max_scroll():
|
|
150
|
+
scroll[0] += 1
|
|
151
|
+
|
|
152
|
+
@kb.add("pageup")
|
|
153
|
+
def _pgup(event) -> None: # noqa: ANN001
|
|
154
|
+
scroll[0] = max(0, scroll[0] - view_h[0])
|
|
155
|
+
|
|
156
|
+
@kb.add("pagedown")
|
|
157
|
+
@kb.add(" ")
|
|
158
|
+
def _pgdn(event) -> None: # noqa: ANN001
|
|
159
|
+
scroll[0] = min(_max_scroll(), scroll[0] + view_h[0])
|
|
160
|
+
|
|
161
|
+
@kb.add("escape")
|
|
162
|
+
def _dismiss(event) -> None: # noqa: ANN001
|
|
163
|
+
event.app.exit()
|
|
164
|
+
|
|
165
|
+
app: Application[None] = Application(
|
|
166
|
+
layout=Layout(HSplit([window])),
|
|
167
|
+
key_bindings=kb,
|
|
168
|
+
style=_STYLE,
|
|
169
|
+
full_screen=True,
|
|
170
|
+
)
|
|
171
|
+
await app.run_async()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Inline picker — renders in-place, erases on exit
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def pick_item(items: list[tuple[str, str]], title: str = "") -> str | None:
|
|
180
|
+
"""Inline vertical picker (\u2191\u2193). Self-cleaning via save/restore."""
|
|
181
|
+
if not items:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
selected = [0]
|
|
185
|
+
offset = [0]
|
|
186
|
+
|
|
187
|
+
def get_fragments() -> FormattedText:
|
|
188
|
+
frags: list[tuple[str, str]] = []
|
|
189
|
+
if title:
|
|
190
|
+
frags.append(("class:title", f" {title}\n"))
|
|
191
|
+
vis_count = min(len(items), MAX_VISIBLE)
|
|
192
|
+
for i in range(offset[0], offset[0] + vis_count):
|
|
193
|
+
if i >= len(items):
|
|
194
|
+
break
|
|
195
|
+
_id, label = items[i]
|
|
196
|
+
if i == selected[0]:
|
|
197
|
+
frags.append(("class:selected", f" > {label}\n"))
|
|
198
|
+
else:
|
|
199
|
+
frags.append(("class:item", f" {label}\n"))
|
|
200
|
+
frags.append(("class:hint", " \u2191\u2193 navigate Enter select Esc back"))
|
|
201
|
+
return FormattedText(frags)
|
|
202
|
+
|
|
203
|
+
kb = KeyBindings()
|
|
204
|
+
|
|
205
|
+
@kb.add("up")
|
|
206
|
+
@kb.add("k")
|
|
207
|
+
def _up(event) -> None: # noqa: ANN001
|
|
208
|
+
if selected[0] > 0:
|
|
209
|
+
selected[0] -= 1
|
|
210
|
+
if selected[0] < offset[0]:
|
|
211
|
+
offset[0] = selected[0]
|
|
212
|
+
|
|
213
|
+
@kb.add("down")
|
|
214
|
+
@kb.add("j")
|
|
215
|
+
def _down(event) -> None: # noqa: ANN001
|
|
216
|
+
if selected[0] < len(items) - 1:
|
|
217
|
+
selected[0] += 1
|
|
218
|
+
if selected[0] >= offset[0] + MAX_VISIBLE:
|
|
219
|
+
offset[0] = selected[0] - MAX_VISIBLE + 1
|
|
220
|
+
|
|
221
|
+
@kb.add("enter")
|
|
222
|
+
def _select(event) -> None: # noqa: ANN001
|
|
223
|
+
event.app.exit(result=items[selected[0]][0])
|
|
224
|
+
|
|
225
|
+
@kb.add("escape")
|
|
226
|
+
def _cancel(event) -> None: # noqa: ANN001
|
|
227
|
+
event.app.exit(result=None)
|
|
228
|
+
|
|
229
|
+
control = FormattedTextControl(get_fragments, focusable=True)
|
|
230
|
+
window = Window(content=control, always_hide_cursor=True)
|
|
231
|
+
|
|
232
|
+
app: Application[str | None] = Application(
|
|
233
|
+
layout=Layout(HSplit([window])),
|
|
234
|
+
key_bindings=kb,
|
|
235
|
+
style=_STYLE,
|
|
236
|
+
full_screen=False,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
result = await app.run_async()
|
|
240
|
+
|
|
241
|
+
# Erase picker: title + visible items + hint line.
|
|
242
|
+
height = (1 if title else 0) + min(len(items), MAX_VISIBLE) + 1
|
|
243
|
+
_clear_up(height)
|
|
244
|
+
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Browse functions
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def browse_contexts(
|
|
254
|
+
memory: ContextMemoryManager, user_id: str, plugin: AgnoContextMemory
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Interactive context browser."""
|
|
257
|
+
contexts = await memory._storage.list_contexts(user_id)
|
|
258
|
+
if not contexts:
|
|
259
|
+
print_info("No contexts found.")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
items = [
|
|
263
|
+
(ctx.id, f"{ctx.title} {ctx.updated_at:%m-%d %H:%M}")
|
|
264
|
+
for ctx in contexts
|
|
265
|
+
]
|
|
266
|
+
while True:
|
|
267
|
+
chosen = await pick_item(items, title="Contexts")
|
|
268
|
+
if not chosen:
|
|
269
|
+
return
|
|
270
|
+
ctx = await memory._storage.get_context(chosen)
|
|
271
|
+
if not ctx:
|
|
272
|
+
continue
|
|
273
|
+
loaded = await memory.load_context(chosen)
|
|
274
|
+
await _ctx_menu(ctx, loaded, memory, plugin)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def browse_current(
|
|
278
|
+
ctx, loaded, memory: ContextMemoryManager, plugin: AgnoContextMemory # noqa: ANN001
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Entry point for /current."""
|
|
281
|
+
await _ctx_menu(ctx, loaded, memory, plugin)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _ctx_menu(
|
|
285
|
+
ctx, loaded, memory: ContextMemoryManager, plugin: AgnoContextMemory # noqa: ANN001
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Context sub-menu — inline picker with drill-down options."""
|
|
288
|
+
ep_count = len(loaded.episodes)
|
|
289
|
+
art_count = len(loaded.artifacts)
|
|
290
|
+
options: list[tuple[str, str]] = [
|
|
291
|
+
("detail", "View details"),
|
|
292
|
+
("episodes", f"{ep_count} episodes"),
|
|
293
|
+
("artifacts", f"{art_count} artifacts"),
|
|
294
|
+
]
|
|
295
|
+
while True:
|
|
296
|
+
choice = await pick_item(options, title=ctx.title)
|
|
297
|
+
match choice:
|
|
298
|
+
case "detail":
|
|
299
|
+
await _show_detail(print_context_detail, ctx, loaded)
|
|
300
|
+
case "episodes":
|
|
301
|
+
await _episodes_view(loaded.episodes, memory)
|
|
302
|
+
case "artifacts":
|
|
303
|
+
await _artifacts_view(loaded.artifacts, memory)
|
|
304
|
+
case _:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def browse_artifacts(memory: ContextMemoryManager, ctx_id: str) -> None:
|
|
309
|
+
"""Interactive artifact browser for /artifacts."""
|
|
310
|
+
artifacts = await memory._storage.list_artifacts_for_context(ctx_id)
|
|
311
|
+
if not artifacts:
|
|
312
|
+
print_info("No artifacts in current context.")
|
|
313
|
+
return
|
|
314
|
+
await _artifacts_view(list(artifacts), memory)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def _artifacts_view(artifacts: list, memory: ContextMemoryManager) -> None:
|
|
318
|
+
"""Artifact list -> scrollable detail."""
|
|
319
|
+
if not artifacts:
|
|
320
|
+
print_info("No artifacts.")
|
|
321
|
+
return
|
|
322
|
+
items = [
|
|
323
|
+
(art.id, f"{art.title} ({art.type}, v{art.current_version})")
|
|
324
|
+
for art in artifacts
|
|
325
|
+
]
|
|
326
|
+
while True:
|
|
327
|
+
chosen = await pick_item(items, title="Artifacts")
|
|
328
|
+
if not chosen:
|
|
329
|
+
return
|
|
330
|
+
art = await memory._storage.get_artifact(chosen)
|
|
331
|
+
if art:
|
|
332
|
+
await _show_detail(print_artifact_detail, art)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def browse_episodes(memory: ContextMemoryManager, ctx_id: str) -> None:
|
|
336
|
+
"""Interactive episode browser for /episodes."""
|
|
337
|
+
episodes = await memory._storage.list_episodes(ctx_id)
|
|
338
|
+
if not episodes:
|
|
339
|
+
print_info("No episodes in current context.")
|
|
340
|
+
return
|
|
341
|
+
await _episodes_view(list(episodes), memory)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def _episodes_view(episodes: list, memory: ContextMemoryManager) -> None:
|
|
345
|
+
"""Episode list -> scrollable detail."""
|
|
346
|
+
if not episodes:
|
|
347
|
+
print_info("No episodes.")
|
|
348
|
+
return
|
|
349
|
+
items = [
|
|
350
|
+
(ep.id, f"{ep.summary[:60]} {ep.time_start:%m-%d %H:%M}")
|
|
351
|
+
for ep in episodes
|
|
352
|
+
]
|
|
353
|
+
while True:
|
|
354
|
+
chosen = await pick_item(items, title="Episodes")
|
|
355
|
+
if not chosen:
|
|
356
|
+
return
|
|
357
|
+
ep = await memory._storage.get_episode(chosen)
|
|
358
|
+
if ep:
|
|
359
|
+
messages = await memory._storage.list_messages(ep.id)
|
|
360
|
+
await _show_detail(print_episode_detail, ep, messages)
|