solohq-cli 0.1.0__tar.gz
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-0.1.0/.env.example +10 -0
- solohq_cli-0.1.0/.gitignore +13 -0
- solohq_cli-0.1.0/LICENSE +21 -0
- solohq_cli-0.1.0/PKG-INFO +98 -0
- solohq_cli-0.1.0/README.md +67 -0
- solohq_cli-0.1.0/pyproject.toml +47 -0
- solohq_cli-0.1.0/src/solohq_cli/__init__.py +0 -0
- solohq_cli-0.1.0/src/solohq_cli/browser.py +360 -0
- solohq_cli-0.1.0/src/solohq_cli/chat.py +415 -0
- solohq_cli-0.1.0/src/solohq_cli/config.py +147 -0
- solohq_cli-0.1.0/src/solohq_cli/debug_panel.py +182 -0
- solohq_cli-0.1.0/src/solohq_cli/display.py +247 -0
- solohq_cli-0.1.0/src/solohq_cli/factory.py +102 -0
- solohq_cli-0.1.0/src/solohq_cli/main.py +89 -0
- solohq_cli-0.1.0/src/solohq_cli/py.typed +0 -0
- solohq_cli-0.1.0/tests/__init__.py +0 -0
- solohq_cli-0.1.0/tests/test_config.py +145 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Provide API keys for the providers you want to use (not all required)
|
|
2
|
+
ANTHROPIC_API_KEY=sk-ant-...
|
|
3
|
+
OPENAI_API_KEY=sk-...
|
|
4
|
+
GOOGLE_API_KEY=...
|
|
5
|
+
|
|
6
|
+
# SOLOHQ_MODEL=claude-sonnet-4-6
|
|
7
|
+
# SOLOHQ_EMBEDDING_PROVIDER=openai
|
|
8
|
+
# SOLOHQ_DB_PATH=~/.solohq/memory.db
|
|
9
|
+
# SOLOHQ_USER_ID=default
|
|
10
|
+
# SOLOHQ_DEBUG=false
|
solohq_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 SoloHQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solohq-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive CLI agent for SoloHQ Context Memory — terminal chat with persistent context memory
|
|
5
|
+
Project-URL: Repository, https://github.com/whaleventure13/solohq-agent
|
|
6
|
+
Author: SoloHQ
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: agno>=2.0
|
|
20
|
+
Requires-Dist: duckduckgo-search>=7.0
|
|
21
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
22
|
+
Requires-Dist: python-dotenv>=1.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: solohq-agno>=0.1.0
|
|
25
|
+
Requires-Dist: solohq-memory[all]>=0.1.0
|
|
26
|
+
Requires-Dist: typer>=0.15
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# solohq-cli
|
|
33
|
+
|
|
34
|
+
Interactive terminal chat agent with persistent context memory powered by [SoloHQ](https://github.com/whaleventure13/solohq-agent).
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install solohq-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Set your API key
|
|
46
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
47
|
+
|
|
48
|
+
# Start chatting
|
|
49
|
+
solohq chat
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Create a `.env` file or set environment variables:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# LLM provider (pick one)
|
|
58
|
+
ANTHROPIC_API_KEY=sk-ant-...
|
|
59
|
+
OPENAI_API_KEY=sk-...
|
|
60
|
+
GOOGLE_API_KEY=...
|
|
61
|
+
|
|
62
|
+
# Chat model (optional, auto-detected from API key)
|
|
63
|
+
SOLOHQ_MODEL=claude-sonnet-4-6
|
|
64
|
+
|
|
65
|
+
# Embedding provider: "openai" (default) or "google"
|
|
66
|
+
SOLOHQ_EMBEDDING_PROVIDER=openai
|
|
67
|
+
|
|
68
|
+
# Database path (optional)
|
|
69
|
+
SOLOHQ_DB_PATH=~/.solohq/memory.db
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## CLI Options
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
solohq chat --help
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
-m, --model TEXT Chat model ID (e.g. claude-sonnet-4-6, gpt-4o)
|
|
79
|
+
--anthropic-api-key TEXT Anthropic API key
|
|
80
|
+
--openai-api-key TEXT OpenAI API key
|
|
81
|
+
--google-api-key TEXT Google API key
|
|
82
|
+
--embedding-provider TEXT Embedding provider: openai or google
|
|
83
|
+
--db-path TEXT SQLite database path
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Features
|
|
87
|
+
|
|
88
|
+
- Multi-provider support: Anthropic, OpenAI, Google
|
|
89
|
+
- Persistent context memory across conversations
|
|
90
|
+
- Automatic context classification and switching
|
|
91
|
+
- Artifact management with versioning
|
|
92
|
+
- File indexing and search
|
|
93
|
+
- Conversation boundary detection
|
|
94
|
+
- Context relationship graph
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# solohq-cli
|
|
2
|
+
|
|
3
|
+
Interactive terminal chat agent with persistent context memory powered by [SoloHQ](https://github.com/whaleventure13/solohq-agent).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install solohq-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Set your API key
|
|
15
|
+
export ANTHROPIC_API_KEY=sk-ant-...
|
|
16
|
+
|
|
17
|
+
# Start chatting
|
|
18
|
+
solohq chat
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Create a `.env` file or set environment variables:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# LLM provider (pick one)
|
|
27
|
+
ANTHROPIC_API_KEY=sk-ant-...
|
|
28
|
+
OPENAI_API_KEY=sk-...
|
|
29
|
+
GOOGLE_API_KEY=...
|
|
30
|
+
|
|
31
|
+
# Chat model (optional, auto-detected from API key)
|
|
32
|
+
SOLOHQ_MODEL=claude-sonnet-4-6
|
|
33
|
+
|
|
34
|
+
# Embedding provider: "openai" (default) or "google"
|
|
35
|
+
SOLOHQ_EMBEDDING_PROVIDER=openai
|
|
36
|
+
|
|
37
|
+
# Database path (optional)
|
|
38
|
+
SOLOHQ_DB_PATH=~/.solohq/memory.db
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## CLI Options
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
solohq chat --help
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-m, --model TEXT Chat model ID (e.g. claude-sonnet-4-6, gpt-4o)
|
|
48
|
+
--anthropic-api-key TEXT Anthropic API key
|
|
49
|
+
--openai-api-key TEXT OpenAI API key
|
|
50
|
+
--google-api-key TEXT Google API key
|
|
51
|
+
--embedding-provider TEXT Embedding provider: openai or google
|
|
52
|
+
--db-path TEXT SQLite database path
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- Multi-provider support: Anthropic, OpenAI, Google
|
|
58
|
+
- Persistent context memory across conversations
|
|
59
|
+
- Automatic context classification and switching
|
|
60
|
+
- Artifact management with versioning
|
|
61
|
+
- File indexing and search
|
|
62
|
+
- Conversation boundary detection
|
|
63
|
+
- Context relationship graph
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solohq-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Interactive CLI agent for SoloHQ Context Memory — terminal chat with persistent context memory"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "SoloHQ"},
|
|
13
|
+
]
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"solohq-memory[all]>=0.1.0",
|
|
28
|
+
"solohq-agno>=0.1.0",
|
|
29
|
+
"agno>=2.0",
|
|
30
|
+
"typer>=0.15",
|
|
31
|
+
"python-dotenv>=1.0",
|
|
32
|
+
"rich>=13.0",
|
|
33
|
+
"prompt_toolkit>=3.0",
|
|
34
|
+
"duckduckgo-search>=7.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
solohq = "solohq_cli.main:app"
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Repository = "https://github.com/whaleventure13/solohq-agent"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "strict"
|
|
File without changes
|
|
@@ -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)
|