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