opencode-db 0.6.2__tar.gz → 0.7.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.
- {opencode_db-0.6.2 → opencode_db-0.7.0}/PKG-INFO +22 -1
- opencode_db-0.7.0/README_PYPI.md +43 -0
- opencode_db-0.7.0/cmd_view.py +363 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/commands/__init__.py +2 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/formatters.py +44 -11
- {opencode_db-0.6.2 → opencode_db-0.7.0}/i18n.py +80 -0
- opencode_db-0.7.0/markdown_to_ansi.py +221 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/PKG-INFO +22 -1
- {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/SOURCES.txt +3 -0
- opencode_db-0.7.0/opencode_db.egg-info/requires.txt +6 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/top_level.txt +2 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/pyproject.toml +6 -1
- {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_commands.py +192 -0
- opencode_db-0.6.2/README_PYPI.md +0 -26
- {opencode_db-0.6.2 → opencode_db-0.7.0}/LICENSE +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/README.md +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cli.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_costs.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_delete.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_export.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_help.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_info.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_list.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_projects.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_prune.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_search.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_stats.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_todos.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_tree.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_vacuum.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/config.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/db.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/setup.cfg +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_db.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_formatters.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_i18n.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_utils.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.0}/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -8,6 +8,10 @@ Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
|
|
|
8
8
|
Requires-Python: >=3.12
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
+
Provides-Extra: rich
|
|
12
|
+
Requires-Dist: rich>=13.0; extra == "rich"
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: opencode-db[rich]; extra == "all"
|
|
11
15
|
Dynamic: license-file
|
|
12
16
|
|
|
13
17
|
# opencode-db
|
|
@@ -25,12 +29,29 @@ pip install opencode-db
|
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
31
|
opencode-db list # recent sessions
|
|
32
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
28
33
|
opencode-db stats # database summary
|
|
29
34
|
opencode-db costs --total # total token costs
|
|
30
35
|
opencode-db export # export dialog to .md (interactive)
|
|
31
36
|
opencode-db help # full reference
|
|
32
37
|
```
|
|
33
38
|
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
42
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
43
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
44
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
45
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
46
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
47
|
+
|
|
48
|
+
## Optional extras
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
52
|
+
pip install opencode-db # zero external dependencies
|
|
53
|
+
```
|
|
54
|
+
|
|
34
55
|
## Requirements
|
|
35
56
|
|
|
36
57
|
- Python 3.12+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# opencode-db
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install opencode-db
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
opencode-db list # recent sessions
|
|
16
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
17
|
+
opencode-db stats # database summary
|
|
18
|
+
opencode-db costs --total # total token costs
|
|
19
|
+
opencode-db export # export dialog to .md (interactive)
|
|
20
|
+
opencode-db help # full reference
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
26
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
27
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
28
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
29
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
30
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
31
|
+
|
|
32
|
+
## Optional extras
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
36
|
+
pip install opencode-db # zero external dependencies
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Python 3.12+
|
|
42
|
+
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
43
|
+
- macOS / Linux
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Command view: просмотр сессии в терминале с прокруткой.
|
|
2
|
+
|
|
3
|
+
Выводит отформатированный диалог с ANSI-цветами,
|
|
4
|
+
автоматически использует $PAGER (less -R) для прокрутки.
|
|
5
|
+
|
|
6
|
+
Режимы выбора сессии:
|
|
7
|
+
- view <session_id> — по ID (полный или префикс)
|
|
8
|
+
- view --latest — самая свежая сессия
|
|
9
|
+
- view (без аргументов) — интерактивный выбор из списка
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from db import (
|
|
19
|
+
SessionError,
|
|
20
|
+
get_latest_session,
|
|
21
|
+
get_messages,
|
|
22
|
+
get_project_name,
|
|
23
|
+
get_recent_sessions,
|
|
24
|
+
get_session_info,
|
|
25
|
+
get_session_title,
|
|
26
|
+
parse_model,
|
|
27
|
+
)
|
|
28
|
+
from i18n import _
|
|
29
|
+
from markdown_to_ansi import render as md_render
|
|
30
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
31
|
+
|
|
32
|
+
_VIEW_EXAMPLES = [
|
|
33
|
+
("<session_id>", "help.view.e0"),
|
|
34
|
+
("--latest", "help.view.e1"),
|
|
35
|
+
("", "help.view.e2"),
|
|
36
|
+
("--no-pager", "help.view.e3"),
|
|
37
|
+
("--raw", "help.view.e4"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register(subparsers) -> None:
|
|
42
|
+
p = subparsers.add_parser(
|
|
43
|
+
"view",
|
|
44
|
+
help=_("help.cmd.view"),
|
|
45
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
|
+
epilog=build_help_epilog("view", _VIEW_EXAMPLES),
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"session_id",
|
|
50
|
+
nargs="?",
|
|
51
|
+
help="Session ID (optional — interactive mode if omitted)",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--latest", "-l", action="store_true", help=_("view.flag.latest"))
|
|
54
|
+
p.add_argument("--no-pager", "-P", action="store_true", help=_("view.flag.no_pager"))
|
|
55
|
+
p.add_argument("--raw", "-R", action="store_true", help=_("view.flag.raw"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(args, db) -> int:
|
|
59
|
+
if args.session_id:
|
|
60
|
+
return _view_by_id(db, args.session_id, args.no_pager, args.raw)
|
|
61
|
+
if args.latest:
|
|
62
|
+
return _view_latest(db, args.no_pager, args.raw)
|
|
63
|
+
return _view_interactive(db, args.no_pager, args.raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ======================================================================
|
|
67
|
+
# Selection modes
|
|
68
|
+
# ======================================================================
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _view_by_id(db, session_id, no_pager, raw) -> int:
|
|
72
|
+
try:
|
|
73
|
+
info = get_session_info(db, session_id)
|
|
74
|
+
except SessionError as e:
|
|
75
|
+
print(e.message)
|
|
76
|
+
return 1
|
|
77
|
+
return _do_view(db, info, no_pager, raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _view_latest(db, no_pager, raw) -> int:
|
|
81
|
+
try:
|
|
82
|
+
session_id = get_latest_session(db)
|
|
83
|
+
info = get_session_info(db, session_id)
|
|
84
|
+
except SessionError as e:
|
|
85
|
+
print(e.message)
|
|
86
|
+
return 1
|
|
87
|
+
title = get_session_title(info)
|
|
88
|
+
print(_("session.latest", title=title))
|
|
89
|
+
return _do_view(db, info, no_pager, raw)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _view_interactive(db, no_pager, raw) -> int:
|
|
93
|
+
sessions = get_recent_sessions(db)
|
|
94
|
+
if not sessions:
|
|
95
|
+
print(_("view.no_sessions"))
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
print()
|
|
99
|
+
print(f" {'─' * 60}")
|
|
100
|
+
print(f" {_('view.interactive_header')}")
|
|
101
|
+
print(f" {'─' * 60}")
|
|
102
|
+
|
|
103
|
+
for i, s in enumerate(sessions, 1):
|
|
104
|
+
title = get_session_title(s)
|
|
105
|
+
created = format_ts(s["time_created"], "%Y-%m-%d %H:%M")
|
|
106
|
+
cost = format_cost(s["cost"])
|
|
107
|
+
print(f" {i:2d}. {s['id'][:24]} {created} {title[:40]:40s} {cost}")
|
|
108
|
+
|
|
109
|
+
print(f" {'─' * 60}")
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
choice = input(" " + _("view.interactive_prompt")).strip()
|
|
114
|
+
except (EOFError, KeyboardInterrupt):
|
|
115
|
+
print()
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
if not choice:
|
|
119
|
+
print(_("view.interactive_abort"))
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
idx = int(choice) - 1
|
|
124
|
+
if 0 <= idx < len(sessions):
|
|
125
|
+
break
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
print(f" {_('view.interactive_error', n=len(sessions))}")
|
|
130
|
+
|
|
131
|
+
session_id = sessions[idx]["id"]
|
|
132
|
+
title = get_session_title(sessions[idx])
|
|
133
|
+
print(_("view.interactive_viewing", title=title))
|
|
134
|
+
try:
|
|
135
|
+
info = get_session_info(db, session_id)
|
|
136
|
+
except SessionError as e:
|
|
137
|
+
print(e.message)
|
|
138
|
+
return 1
|
|
139
|
+
return _do_view(db, info, no_pager, raw)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ======================================================================
|
|
143
|
+
# Display engine
|
|
144
|
+
# ======================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _init_styles(raw=False):
|
|
148
|
+
"""Return style dict. Empty dict = no ANSI codes."""
|
|
149
|
+
if raw or not sys.stdout.isatty():
|
|
150
|
+
return {}
|
|
151
|
+
return {
|
|
152
|
+
"R": "\033[0m",
|
|
153
|
+
"B": "\033[1m",
|
|
154
|
+
"D": "\033[2m",
|
|
155
|
+
"G": "\033[32m",
|
|
156
|
+
"C": "\033[36m",
|
|
157
|
+
"Y": "\033[33m",
|
|
158
|
+
"M": "\033[90m",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _do_view(db, info, no_pager, raw) -> int:
|
|
163
|
+
sty = _init_styles(raw)
|
|
164
|
+
messages = get_messages(db, info["id"])
|
|
165
|
+
text = _format_session(db, info, messages, sty)
|
|
166
|
+
|
|
167
|
+
use_pager = not no_pager and sys.stdout.isatty() and len(text) > 1000
|
|
168
|
+
if use_pager:
|
|
169
|
+
_page_text(text)
|
|
170
|
+
else:
|
|
171
|
+
sys.stdout.write(text)
|
|
172
|
+
if not text.endswith("\n"):
|
|
173
|
+
sys.stdout.write("\n")
|
|
174
|
+
sys.stdout.flush()
|
|
175
|
+
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _format_session(db, info, messages, sty) -> str:
|
|
180
|
+
"""Build the full formatted session view string."""
|
|
181
|
+
title = get_session_title(info)
|
|
182
|
+
model = parse_model(info)
|
|
183
|
+
created = format_ts(info["time_created"])
|
|
184
|
+
msg_count = len(messages)
|
|
185
|
+
|
|
186
|
+
duration = "—"
|
|
187
|
+
if info["time_updated"] and info["time_created"]:
|
|
188
|
+
duration_sec = (info["time_updated"] - info["time_created"]) / 1000
|
|
189
|
+
if duration_sec < 60:
|
|
190
|
+
duration = _("info.dur_sec", n=int(duration_sec))
|
|
191
|
+
elif duration_sec < 3600:
|
|
192
|
+
duration = _("info.dur_min", n=int(duration_sec / 60))
|
|
193
|
+
else:
|
|
194
|
+
hours = int(duration_sec / 3600)
|
|
195
|
+
mins = int((duration_sec % 3600) / 60)
|
|
196
|
+
duration = _("info.dur_hours", h=hours, m=mins)
|
|
197
|
+
|
|
198
|
+
ti = info["tokens_input"]
|
|
199
|
+
to = info["tokens_output"]
|
|
200
|
+
tr = info["tokens_reasoning"]
|
|
201
|
+
tokens_str = f"{format_tokens(ti)} in · {format_tokens(to)} out"
|
|
202
|
+
if tr:
|
|
203
|
+
tokens_str += f" · {format_tokens(tr)} reason"
|
|
204
|
+
|
|
205
|
+
cost = format_cost(info["cost"])
|
|
206
|
+
|
|
207
|
+
project_name = None
|
|
208
|
+
if info["project_id"]:
|
|
209
|
+
project_name = get_project_name(db, info["project_id"])
|
|
210
|
+
|
|
211
|
+
sep = f"{sty.get('M', '')}{'─' * 60}{sty.get('R', '')}"
|
|
212
|
+
lr = sty.get("R", "")
|
|
213
|
+
|
|
214
|
+
lines = []
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append(f"{sty.get('B', '')}{sty.get('C', '')}🔍 {_('view.header')}{lr}")
|
|
217
|
+
lines.append(sep)
|
|
218
|
+
lines.append(f" {_('info.id')}: {info['id']}")
|
|
219
|
+
lines.append(f" {_('list.header.title')}: {title}")
|
|
220
|
+
lines.append(f" {_('info.model')}: {model}")
|
|
221
|
+
lines.append(f" {_('info.agent')}: {info['agent'] or '—'}")
|
|
222
|
+
lines.append(f" {_('info.created')}: {created}")
|
|
223
|
+
lines.append(f" {_('info.duration')}: {duration}")
|
|
224
|
+
lines.append(f" {_('info.tokens_total')}: {tokens_str}")
|
|
225
|
+
lines.append(f" {_('info.cost')}: {cost}")
|
|
226
|
+
if project_name:
|
|
227
|
+
lines.append(f" {_('info.project')}: {project_name}")
|
|
228
|
+
lines.append(f" {_('info.messages')}: {msg_count}")
|
|
229
|
+
lines.append(sep)
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
if not messages:
|
|
233
|
+
lines.append(f" {sty.get('M', '')}{_('view.no_messages')}{lr}")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append(sep)
|
|
236
|
+
lines.append("")
|
|
237
|
+
else:
|
|
238
|
+
for msg in messages:
|
|
239
|
+
role = msg.get("role", "unknown")
|
|
240
|
+
ts = format_ts(msg.get("time"), "%Y-%m-%d %H:%M:%S") if msg.get("time") else ""
|
|
241
|
+
agent = msg.get("agent", "")
|
|
242
|
+
|
|
243
|
+
if role == "user":
|
|
244
|
+
role_str = f"{sty.get('G', '')}👤 {_('view.role_user')} [{ts}]{lr}"
|
|
245
|
+
elif role == "assistant":
|
|
246
|
+
agent_tag = f" ({agent})" if agent else ""
|
|
247
|
+
role_str = f"{sty.get('C', '')}🤖 {_('view.role_assistant')}{agent_tag} [{ts}]{lr}"
|
|
248
|
+
else:
|
|
249
|
+
role_str = f"{sty.get('Y', '')}⚙️ {role} [{ts}]{lr}"
|
|
250
|
+
|
|
251
|
+
lines.append(sep)
|
|
252
|
+
lines.append(role_str)
|
|
253
|
+
lines.append(sep)
|
|
254
|
+
|
|
255
|
+
for i, part in enumerate(msg.get("parts", [])):
|
|
256
|
+
formatted = _format_part_ansi(part, sty)
|
|
257
|
+
if formatted:
|
|
258
|
+
if i > 0:
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append(formatted)
|
|
261
|
+
|
|
262
|
+
lines.append(f"{sty.get('M', '')}─── {_('view.footer')} ───{lr}")
|
|
263
|
+
lines.append("")
|
|
264
|
+
|
|
265
|
+
return "\n".join(lines)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ======================================================================
|
|
269
|
+
# Part formatting (ANSI version of format_part_to_md)
|
|
270
|
+
# ======================================================================
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _format_part_ansi(part, sty) -> str:
|
|
274
|
+
"""Форматирует часть сообщения с ANSI-цветами."""
|
|
275
|
+
t = part.get("type", "")
|
|
276
|
+
|
|
277
|
+
if t == "text":
|
|
278
|
+
text = part.get("text", "").strip()
|
|
279
|
+
if not text:
|
|
280
|
+
return ""
|
|
281
|
+
return md_render(text, sty, plain=not sty)
|
|
282
|
+
|
|
283
|
+
if t == "reasoning":
|
|
284
|
+
text = part.get("text", "").strip()
|
|
285
|
+
if not text:
|
|
286
|
+
return ""
|
|
287
|
+
title = f"{sty.get('Y', '')}{sty.get('D', '')}💭 {_('md.reasoning')}:{sty.get('R', '')}"
|
|
288
|
+
body = f"{sty.get('D', '')}{text}{sty.get('R', '')}"
|
|
289
|
+
return f"{title}\n{body}"
|
|
290
|
+
|
|
291
|
+
if t == "sound":
|
|
292
|
+
return f"{sty.get('Y', '')}*🔊 {_('md.sound')}*{sty.get('R', '')}"
|
|
293
|
+
|
|
294
|
+
if t == "tool":
|
|
295
|
+
tool_name = part.get("tool", "?")
|
|
296
|
+
t_input = part.get("input", {})
|
|
297
|
+
t_output = part.get("output", "")
|
|
298
|
+
t_status = part.get("status", "")
|
|
299
|
+
|
|
300
|
+
icon = {"completed": "✅", "error": "❌", "running": "⏳"}.get(t_status, "🔧")
|
|
301
|
+
y = sty.get("Y", "")
|
|
302
|
+
r = sty.get("R", "")
|
|
303
|
+
|
|
304
|
+
if tool_name == "read":
|
|
305
|
+
fp = t_input.get("filePath", "")
|
|
306
|
+
return f"{y}{icon} read:{r} `{fp}`"
|
|
307
|
+
if tool_name == "write":
|
|
308
|
+
fp = t_input.get("filePath", "")
|
|
309
|
+
return f"{y}{icon} write:{r} `{fp}`"
|
|
310
|
+
if tool_name == "edit":
|
|
311
|
+
fp = t_input.get("filePath", "")
|
|
312
|
+
return f"{y}{icon} edit:{r} `{fp}`"
|
|
313
|
+
if tool_name == "glob":
|
|
314
|
+
pat = t_input.get("pattern", "")
|
|
315
|
+
pth = t_input.get("path", "")
|
|
316
|
+
if pth:
|
|
317
|
+
return f"{y}{icon} glob:{r} `{pat}` in `{pth}`"
|
|
318
|
+
return f"{y}{icon} glob:{r} `{pat}`"
|
|
319
|
+
if tool_name == "grep":
|
|
320
|
+
pat = t_input.get("pattern", "")
|
|
321
|
+
return f"{y}{icon} grep:{r} `{pat}`"
|
|
322
|
+
|
|
323
|
+
lines = [f"{y}{icon} {tool_name}{r}"]
|
|
324
|
+
if t_input:
|
|
325
|
+
for k, v in t_input.items():
|
|
326
|
+
s = str(v)
|
|
327
|
+
if len(s) > 200:
|
|
328
|
+
s = s[:200] + "..."
|
|
329
|
+
lines.append(f" {k}: {s}")
|
|
330
|
+
if t_output:
|
|
331
|
+
out = str(t_output).strip()
|
|
332
|
+
if out:
|
|
333
|
+
if len(out) > 300:
|
|
334
|
+
out = out[:300] + "..."
|
|
335
|
+
lines.append(f" → {out}")
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
|
|
338
|
+
return ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ======================================================================
|
|
342
|
+
# Pager
|
|
343
|
+
# ======================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _page_text(text: str) -> None:
|
|
347
|
+
"""Pipe text through $PAGER or less -R."""
|
|
348
|
+
pager_cmd = os.environ.get("PAGER", "")
|
|
349
|
+
try:
|
|
350
|
+
if pager_cmd:
|
|
351
|
+
subprocess.run(pager_cmd, input=text, text=True, shell=True)
|
|
352
|
+
elif shutil.which("less"):
|
|
353
|
+
subprocess.run(["less", "-R"], input=text, text=True)
|
|
354
|
+
else:
|
|
355
|
+
sys.stdout.write(text)
|
|
356
|
+
if not text.endswith("\n"):
|
|
357
|
+
sys.stdout.write("\n")
|
|
358
|
+
sys.stdout.flush()
|
|
359
|
+
except OSError:
|
|
360
|
+
sys.stdout.write(text)
|
|
361
|
+
if not text.endswith("\n"):
|
|
362
|
+
sys.stdout.write("\n")
|
|
363
|
+
sys.stdout.flush()
|
|
@@ -23,6 +23,7 @@ import cmd_stats
|
|
|
23
23
|
import cmd_todos
|
|
24
24
|
import cmd_tree
|
|
25
25
|
import cmd_vacuum
|
|
26
|
+
import cmd_view
|
|
26
27
|
|
|
27
28
|
# Словарь {имя_команды: модуль}
|
|
28
29
|
# Модуль должен иметь register(subparsers) и run(args, db)
|
|
@@ -39,6 +40,7 @@ COMMANDS = {
|
|
|
39
40
|
"stats": cmd_stats,
|
|
40
41
|
"todos": cmd_todos,
|
|
41
42
|
"vacuum": cmd_vacuum,
|
|
43
|
+
"view": cmd_view,
|
|
42
44
|
"help": cmd_help,
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -15,28 +15,61 @@ def print_json(data: Any) -> None:
|
|
|
15
15
|
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def print_table_simple(
|
|
19
|
-
|
|
18
|
+
def print_table_simple(
|
|
19
|
+
headers: list[str],
|
|
20
|
+
rows: list[list],
|
|
21
|
+
sep: str = " ",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Выводит таблицу с рамкой и колонками.
|
|
24
|
+
|
|
25
|
+
Формат:
|
|
26
|
+
┌──────┬───────────┐
|
|
27
|
+
│ ID │ Title │
|
|
28
|
+
├──────┼───────────┤
|
|
29
|
+
│ abc │ Session 1 │
|
|
30
|
+
└──────┴───────────┘
|
|
31
|
+
"""
|
|
20
32
|
if not rows:
|
|
21
33
|
print(_("empty"))
|
|
22
34
|
return
|
|
23
35
|
|
|
36
|
+
ncols = len(headers)
|
|
37
|
+
pad = 1 # padding inside each cell
|
|
38
|
+
|
|
39
|
+
# compute column widths
|
|
24
40
|
widths = []
|
|
25
|
-
for ci in range(
|
|
41
|
+
for ci in range(ncols):
|
|
26
42
|
max_w = len(headers[ci])
|
|
27
43
|
for row in rows:
|
|
28
44
|
max_w = max(max_w, len(str(row[ci])))
|
|
29
|
-
widths.append(min(max_w +
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
widths.append(min(max_w + pad * 2, 80))
|
|
46
|
+
|
|
47
|
+
tl = "┌" # top-left
|
|
48
|
+
tm = "┬" # top-middle
|
|
49
|
+
tr = "┐" # top-right
|
|
50
|
+
ml = "├" # middle-left
|
|
51
|
+
mm = "┼" # middle-middle
|
|
52
|
+
mr = "┤" # middle-right
|
|
53
|
+
bl = "└" # bottom-left
|
|
54
|
+
bm = "┴" # bottom-middle
|
|
55
|
+
br = "┘" # bottom-right
|
|
56
|
+
ve = "│" # vertical edge
|
|
57
|
+
|
|
58
|
+
top = tl + tm.join("─" * w for w in widths) + tr
|
|
59
|
+
sep = ml + mm.join("─" * w for w in widths) + mr
|
|
60
|
+
bot = bl + bm.join("─" * w for w in widths) + br
|
|
61
|
+
|
|
62
|
+
header_cells = [h.center(widths[ci])[: widths[ci]] for ci, h in enumerate(headers)]
|
|
63
|
+
header_line = ve + ve.join(header_cells) + ve
|
|
33
64
|
|
|
34
65
|
print()
|
|
35
|
-
print(
|
|
36
|
-
print(
|
|
66
|
+
print(top)
|
|
67
|
+
print(header_line)
|
|
68
|
+
print(sep)
|
|
37
69
|
for row in rows:
|
|
38
|
-
|
|
39
|
-
print(
|
|
70
|
+
cells = [str(row[ci]).ljust(widths[ci])[: widths[ci]] for ci in range(ncols)]
|
|
71
|
+
print(ve + ve.join(cells) + ve)
|
|
72
|
+
print(bot)
|
|
40
73
|
print(_("fmt.rows_count", n=len(rows)))
|
|
41
74
|
|
|
42
75
|
|
|
@@ -349,6 +349,65 @@ L: dict[str, dict[str, str]] = {
|
|
|
349
349
|
"vacuum.done": {"ru": "✅", "en": "✅"},
|
|
350
350
|
"vacuum.canceled": {"ru": " Отменено.", "en": " Canceled."},
|
|
351
351
|
# ==================================================================
|
|
352
|
+
# VIEW
|
|
353
|
+
# ==================================================================
|
|
354
|
+
"view.header": {
|
|
355
|
+
"ru": "Просмотр сессии",
|
|
356
|
+
"en": "Session View",
|
|
357
|
+
},
|
|
358
|
+
"view.role_user": {
|
|
359
|
+
"ru": "Вы",
|
|
360
|
+
"en": "You",
|
|
361
|
+
},
|
|
362
|
+
"view.role_assistant": {
|
|
363
|
+
"ru": "Ассистент",
|
|
364
|
+
"en": "Assistant",
|
|
365
|
+
},
|
|
366
|
+
"view.no_messages": {
|
|
367
|
+
"ru": "⏳ Нет сообщений в этой сессии.",
|
|
368
|
+
"en": "⏳ No messages in this session.",
|
|
369
|
+
},
|
|
370
|
+
"view.no_sessions": {
|
|
371
|
+
"ru": "❌ Нет сессий для просмотра.",
|
|
372
|
+
"en": "❌ No sessions to view.",
|
|
373
|
+
},
|
|
374
|
+
"view.interactive_header": {
|
|
375
|
+
"ru": "Последние сессии:",
|
|
376
|
+
"en": "Recent sessions:",
|
|
377
|
+
},
|
|
378
|
+
"view.interactive_prompt": {
|
|
379
|
+
"ru": "Выбери номер (или Enter для отмены): ",
|
|
380
|
+
"en": "Pick a number (or Enter to cancel): ",
|
|
381
|
+
},
|
|
382
|
+
"view.interactive_error": {
|
|
383
|
+
"ru": "Введи число от 1 до {n}",
|
|
384
|
+
"en": "Enter a number from 1 to {n}",
|
|
385
|
+
},
|
|
386
|
+
"view.interactive_viewing": {
|
|
387
|
+
"ru": " Просмотр: {title}",
|
|
388
|
+
"en": " Viewing: {title}",
|
|
389
|
+
},
|
|
390
|
+
"view.interactive_abort": {
|
|
391
|
+
"ru": " Отменено.",
|
|
392
|
+
"en": " Canceled.",
|
|
393
|
+
},
|
|
394
|
+
"view.footer": {
|
|
395
|
+
"ru": "конец сессии",
|
|
396
|
+
"en": "end of session",
|
|
397
|
+
},
|
|
398
|
+
"view.flag.latest": {
|
|
399
|
+
"ru": "Просмотр самой свежей сессии",
|
|
400
|
+
"en": "View the most recent session",
|
|
401
|
+
},
|
|
402
|
+
"view.flag.no_pager": {
|
|
403
|
+
"ru": "Вывод без пейджера (просто печатать)",
|
|
404
|
+
"en": "Print without pager",
|
|
405
|
+
},
|
|
406
|
+
"view.flag.raw": {
|
|
407
|
+
"ru": "Без ANSI-цветов (обычный текст)",
|
|
408
|
+
"en": "Plain text, no ANSI colors",
|
|
409
|
+
},
|
|
410
|
+
# ==================================================================
|
|
352
411
|
# HELP
|
|
353
412
|
# ==================================================================
|
|
354
413
|
"help.header_box_top": {
|
|
@@ -412,6 +471,7 @@ L: dict[str, dict[str, str]] = {
|
|
|
412
471
|
"help.cmd.stats": {"ru": "Статистика БД", "en": "Database statistics"},
|
|
413
472
|
"help.cmd.todos": {"ru": "Задачи из сессий", "en": "Tasks from sessions"},
|
|
414
473
|
"help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
|
|
474
|
+
"help.cmd.view": {"ru": "Просмотр сессии с прокруткой", "en": "View session with scrolling"},
|
|
415
475
|
"help.cmd.help": {"ru": "Подробная справка", "en": "Detailed help"},
|
|
416
476
|
# ==================================================================
|
|
417
477
|
# HELP — примеры (epilog для argparse --help)
|
|
@@ -489,6 +549,26 @@ L: dict[str, dict[str, str]] = {
|
|
|
489
549
|
"en": "VACUUM + REINDEX + ANALYZE with confirmation",
|
|
490
550
|
},
|
|
491
551
|
"help.vacuum.e1": {"ru": "Без подтверждения", "en": "Skip confirmation"},
|
|
552
|
+
"help.view.e0": {
|
|
553
|
+
"ru": "Просмотр по ID",
|
|
554
|
+
"en": "View by ID",
|
|
555
|
+
},
|
|
556
|
+
"help.view.e1": {
|
|
557
|
+
"ru": "Самая свежая сессия",
|
|
558
|
+
"en": "Most recent session",
|
|
559
|
+
},
|
|
560
|
+
"help.view.e2": {
|
|
561
|
+
"ru": "Интерактивный выбор из списка",
|
|
562
|
+
"en": "Interactive picker",
|
|
563
|
+
},
|
|
564
|
+
"help.view.e3": {
|
|
565
|
+
"ru": "Без пейджера (простой вывод)",
|
|
566
|
+
"en": "Without pager (plain output)",
|
|
567
|
+
},
|
|
568
|
+
"help.view.e4": {
|
|
569
|
+
"ru": "Без ANSI-цветов",
|
|
570
|
+
"en": "Without ANSI colors",
|
|
571
|
+
},
|
|
492
572
|
"help.help.e0": {
|
|
493
573
|
"ru": "Общая справка по всем командам",
|
|
494
574
|
"en": "General help for all commands",
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Конвертация Markdown в ANSI-форматированный текст для терминала.
|
|
2
|
+
|
|
3
|
+
Режимы:
|
|
4
|
+
- rich (опционально): через `pip install opencode-db[rich]`
|
|
5
|
+
- fallback: stdlib (re) + ANSI escape codes, zero external deps
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
HAS_RICH: bool = False
|
|
13
|
+
_RICH_CONSOLE = None
|
|
14
|
+
try:
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
_RICH_CONSOLE = Console(force_terminal=True, color_system="truecolor")
|
|
18
|
+
HAS_RICH = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(text: str, styles: dict[str, str] | None = None, *, plain: bool = False) -> str:
|
|
24
|
+
"""Конвертирует Markdown-текст в ANSI-форматированный вывод.
|
|
25
|
+
|
|
26
|
+
При наличии rich использует его Markdown-рендерер (полное качество),
|
|
27
|
+
иначе — встроенный конвертер (базовые конструкции).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
text: исходный Markdown-текст
|
|
31
|
+
styles: dict с ANSI-кодами (только для fallback)
|
|
32
|
+
plain: True — вернуть как есть (без форматирования)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str с ANSI-escape последовательностями
|
|
36
|
+
"""
|
|
37
|
+
if not text:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
if plain:
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
if HAS_RICH and _RICH_CONSOLE is not None:
|
|
44
|
+
return _render_rich(text)
|
|
45
|
+
|
|
46
|
+
return _render_fallback(text, styles or {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ======================================================================
|
|
50
|
+
# RENDERER: rich
|
|
51
|
+
# ======================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_rich(text: str) -> str:
|
|
55
|
+
from rich.markdown import Markdown
|
|
56
|
+
|
|
57
|
+
with _RICH_CONSOLE.capture() as capture:
|
|
58
|
+
_RICH_CONSOLE.print(Markdown(text))
|
|
59
|
+
result = capture.get()
|
|
60
|
+
return result.rstrip("\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ======================================================================
|
|
64
|
+
# RENDERER: fallback (stdlib)
|
|
65
|
+
# ======================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_fallback(text: str, sty: dict[str, str]) -> str:
|
|
69
|
+
r = sty.get("R", "")
|
|
70
|
+
bold = sty.get("B", "")
|
|
71
|
+
dim = sty.get("D", "")
|
|
72
|
+
cyan = sty.get("C", "")
|
|
73
|
+
yellow = sty.get("Y", "")
|
|
74
|
+
gray = sty.get("M", "")
|
|
75
|
+
green = sty.get("G", "")
|
|
76
|
+
|
|
77
|
+
result = text
|
|
78
|
+
|
|
79
|
+
# fenced code blocks
|
|
80
|
+
result = re.sub(
|
|
81
|
+
r"```(\w*)\n(.*?)```",
|
|
82
|
+
lambda m: _fb_code_block(m.group(1), m.group(2), sty),
|
|
83
|
+
result,
|
|
84
|
+
flags=re.DOTALL,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# inline code
|
|
88
|
+
result = re.sub(r"`([^`]+)`", rf"{yellow}\1{r}", result)
|
|
89
|
+
|
|
90
|
+
# bold **...**
|
|
91
|
+
result = re.sub(r"\*\*(.+?)\*\*", rf"{bold}\1{r}", result)
|
|
92
|
+
|
|
93
|
+
# italic *...*
|
|
94
|
+
result = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", rf"{dim}\1{r}", result)
|
|
95
|
+
|
|
96
|
+
# headings
|
|
97
|
+
result = re.sub(
|
|
98
|
+
r"^#{1,3} (.+)$",
|
|
99
|
+
rf"{bold}{cyan}\1{r}",
|
|
100
|
+
result,
|
|
101
|
+
flags=re.MULTILINE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# blockquotes
|
|
105
|
+
result = re.sub(
|
|
106
|
+
r"^> (.+)$",
|
|
107
|
+
lambda m: _fb_blockquote(m.group(1), sty),
|
|
108
|
+
result,
|
|
109
|
+
flags=re.MULTILINE,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# horizontal rules
|
|
113
|
+
result = re.sub(
|
|
114
|
+
r"^---+$",
|
|
115
|
+
f"{gray}{'─' * 60}{r}",
|
|
116
|
+
result,
|
|
117
|
+
flags=re.MULTILINE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# unordered lists
|
|
121
|
+
result = re.sub(r"^(\s*)[-*] ", r"\1• ", result, flags=re.MULTILINE)
|
|
122
|
+
|
|
123
|
+
# ordered lists
|
|
124
|
+
result = re.sub(
|
|
125
|
+
r"^(\s*)(\d+)\. ",
|
|
126
|
+
lambda m: f"{m.group(1)}{green}{m.group(2)}.{r} ",
|
|
127
|
+
result,
|
|
128
|
+
flags=re.MULTILINE,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# links [text](url)
|
|
132
|
+
result = re.sub(
|
|
133
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
134
|
+
lambda m: f"{m.group(1)} ({gray}{m.group(2)}{r})",
|
|
135
|
+
result,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# tables: convert to box-drawing
|
|
139
|
+
result = re.sub(
|
|
140
|
+
r"^(\|.+)$(?:\n\|.+)+\n\|.+(?:\n\|.+)*",
|
|
141
|
+
lambda m: _fb_table(m.group(0), sty),
|
|
142
|
+
result,
|
|
143
|
+
flags=re.MULTILINE,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _fb_code_block(lang: str, body: str, sty: dict[str, str]) -> str:
|
|
150
|
+
r = sty.get("R", "")
|
|
151
|
+
gray = sty.get("M", "")
|
|
152
|
+
yellow = sty.get("Y", "")
|
|
153
|
+
lang_tag = f" {lang}" if lang else ""
|
|
154
|
+
header = f"{gray}┌─{lang_tag}{r}"
|
|
155
|
+
footer = f"{gray}└──{r}"
|
|
156
|
+
lines = body.strip().split("\n")
|
|
157
|
+
indented = "\n".join(f"{yellow}{line}{r}" for line in lines)
|
|
158
|
+
return f"{header}\n{indented}\n{footer}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fb_blockquote(line: str, sty: dict[str, str]) -> str:
|
|
162
|
+
gray = sty.get("M", "")
|
|
163
|
+
r = sty.get("R", "")
|
|
164
|
+
return f"{gray}│ {line}{r}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fb_table(text: str, sty: dict[str, str]) -> str:
|
|
168
|
+
"""Converts a Markdown table to box-drawing format."""
|
|
169
|
+
lines = [ln.strip() for ln in text.strip().split("\n") if ln.strip()]
|
|
170
|
+
if len(lines) < 3:
|
|
171
|
+
return text
|
|
172
|
+
|
|
173
|
+
# skip separator row (|--|--|)
|
|
174
|
+
data_rows = []
|
|
175
|
+
for line in lines:
|
|
176
|
+
if not line.strip().startswith("|"):
|
|
177
|
+
continue
|
|
178
|
+
if re.match(r"^\|[-| ]+\|$", line):
|
|
179
|
+
continue
|
|
180
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
181
|
+
data_rows.append(cells)
|
|
182
|
+
|
|
183
|
+
if len(data_rows) < 2:
|
|
184
|
+
return text
|
|
185
|
+
|
|
186
|
+
ncols = max(len(r) for r in data_rows)
|
|
187
|
+
pad = 1
|
|
188
|
+
|
|
189
|
+
widths: list[int] = []
|
|
190
|
+
for ci in range(ncols):
|
|
191
|
+
max_w = max(len(r[ci]) if ci < len(r) else 0 for r in data_rows)
|
|
192
|
+
widths.append(max_w + pad * 2)
|
|
193
|
+
|
|
194
|
+
tl, tm, tr = "┌", "┬", "┐"
|
|
195
|
+
ml, mm, mr = "├", "┼", "┤"
|
|
196
|
+
bl, bm, br = "└", "┴", "┘"
|
|
197
|
+
ve = "│"
|
|
198
|
+
r = sty.get("R", "")
|
|
199
|
+
gray = sty.get("M", "")
|
|
200
|
+
|
|
201
|
+
top = tl + tm.join("─" * w for w in widths) + tr
|
|
202
|
+
sep = ml + mm.join("─" * w for w in widths) + mr
|
|
203
|
+
bot = bl + bm.join("─" * w for w in widths) + br
|
|
204
|
+
|
|
205
|
+
header_cells = [
|
|
206
|
+
(data_rows[0][ci] if ci < len(data_rows[0]) else "").center(widths[ci])[: widths[ci]]
|
|
207
|
+
for ci in range(ncols)
|
|
208
|
+
]
|
|
209
|
+
header_line = f"{gray}{ve}{ve.join(header_cells)}{ve}{r}"
|
|
210
|
+
|
|
211
|
+
result = [f"{gray}{top}{r}", header_line, f"{gray}{sep}{r}"]
|
|
212
|
+
|
|
213
|
+
for row in data_rows[1:]:
|
|
214
|
+
cells = [
|
|
215
|
+
(row[ci] if ci < len(row) else "").ljust(widths[ci])[: widths[ci]]
|
|
216
|
+
for ci in range(ncols)
|
|
217
|
+
]
|
|
218
|
+
result.append(f"{ve}{ve.join(cells)}{ve}")
|
|
219
|
+
|
|
220
|
+
result.append(f"{gray}{bot}{r}")
|
|
221
|
+
return "\n".join(result)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -8,6 +8,10 @@ Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
|
|
|
8
8
|
Requires-Python: >=3.12
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
11
|
+
Provides-Extra: rich
|
|
12
|
+
Requires-Dist: rich>=13.0; extra == "rich"
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: opencode-db[rich]; extra == "all"
|
|
11
15
|
Dynamic: license-file
|
|
12
16
|
|
|
13
17
|
# opencode-db
|
|
@@ -25,12 +29,29 @@ pip install opencode-db
|
|
|
25
29
|
|
|
26
30
|
```bash
|
|
27
31
|
opencode-db list # recent sessions
|
|
32
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
28
33
|
opencode-db stats # database summary
|
|
29
34
|
opencode-db costs --total # total token costs
|
|
30
35
|
opencode-db export # export dialog to .md (interactive)
|
|
31
36
|
opencode-db help # full reference
|
|
32
37
|
```
|
|
33
38
|
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
42
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
43
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
44
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
45
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
46
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
47
|
+
|
|
48
|
+
## Optional extras
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
52
|
+
pip install opencode-db # zero external dependencies
|
|
53
|
+
```
|
|
54
|
+
|
|
34
55
|
## Requirements
|
|
35
56
|
|
|
36
57
|
- Python 3.12+
|
|
@@ -15,10 +15,12 @@ cmd_stats.py
|
|
|
15
15
|
cmd_todos.py
|
|
16
16
|
cmd_tree.py
|
|
17
17
|
cmd_vacuum.py
|
|
18
|
+
cmd_view.py
|
|
18
19
|
config.py
|
|
19
20
|
db.py
|
|
20
21
|
formatters.py
|
|
21
22
|
i18n.py
|
|
23
|
+
markdown_to_ansi.py
|
|
22
24
|
pyproject.toml
|
|
23
25
|
utils.py
|
|
24
26
|
commands/__init__.py
|
|
@@ -26,6 +28,7 @@ opencode_db.egg-info/PKG-INFO
|
|
|
26
28
|
opencode_db.egg-info/SOURCES.txt
|
|
27
29
|
opencode_db.egg-info/dependency_links.txt
|
|
28
30
|
opencode_db.egg-info/entry_points.txt
|
|
31
|
+
opencode_db.egg-info/requires.txt
|
|
29
32
|
opencode_db.egg-info/top_level.txt
|
|
30
33
|
tests/test_commands.py
|
|
31
34
|
tests/test_db.py
|
|
@@ -4,11 +4,15 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-db"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
|
|
9
9
|
readme = "README_PYPI.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
rich = ["rich>=13.0"]
|
|
14
|
+
all = ["opencode-db[rich]"]
|
|
15
|
+
|
|
12
16
|
[project.urls]
|
|
13
17
|
Homepage = "https://github.com/VasilevNStas/opencode-db"
|
|
14
18
|
Source = "https://github.com/VasilevNStas/opencode-db"
|
|
@@ -23,6 +27,7 @@ py-modules = [
|
|
|
23
27
|
"cmd_costs", "cmd_delete", "cmd_export", "cmd_help",
|
|
24
28
|
"cmd_info", "cmd_list", "cmd_projects", "cmd_prune",
|
|
25
29
|
"cmd_search", "cmd_stats", "cmd_todos", "cmd_tree", "cmd_vacuum",
|
|
30
|
+
"cmd_view", "markdown_to_ansi",
|
|
26
31
|
]
|
|
27
32
|
packages = ["commands"]
|
|
28
33
|
|
|
@@ -507,3 +507,195 @@ class TestPruneCommand:
|
|
|
507
507
|
args = _ns(older_than="100y", keep_last=None, project=None, dry_run=False, force=True)
|
|
508
508
|
assert run(args, db) == 0
|
|
509
509
|
assert _count_sessions(db) == 4
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class TestViewCommand:
|
|
513
|
+
def test_view_by_id(self, db) -> None:
|
|
514
|
+
from cmd_view import run
|
|
515
|
+
|
|
516
|
+
args = _ns(session_id="ses_001", latest=False, no_pager=True, raw=True)
|
|
517
|
+
assert run(args, db) == 0
|
|
518
|
+
|
|
519
|
+
def test_view_latest(self, db) -> None:
|
|
520
|
+
from cmd_view import run
|
|
521
|
+
|
|
522
|
+
args = _ns(session_id=None, latest=True, no_pager=True, raw=True)
|
|
523
|
+
assert run(args, db) == 0
|
|
524
|
+
|
|
525
|
+
def test_view_interactive(self, db) -> None:
|
|
526
|
+
from cmd_view import run
|
|
527
|
+
|
|
528
|
+
args = _ns(session_id=None, latest=False, no_pager=True, raw=True)
|
|
529
|
+
with patch("builtins.input", return_value="1"):
|
|
530
|
+
assert run(args, db) == 0
|
|
531
|
+
|
|
532
|
+
def test_view_interactive_abort(self, db) -> None:
|
|
533
|
+
from cmd_view import run
|
|
534
|
+
|
|
535
|
+
args = _ns(session_id=None, latest=False, no_pager=True, raw=True)
|
|
536
|
+
with patch("builtins.input", return_value=""):
|
|
537
|
+
assert run(args, db) == 1
|
|
538
|
+
|
|
539
|
+
def test_view_interactive_bad_number(self, db) -> None:
|
|
540
|
+
from cmd_view import run
|
|
541
|
+
|
|
542
|
+
args = _ns(session_id=None, latest=False, no_pager=True, raw=True)
|
|
543
|
+
with patch("builtins.input", side_effect=["99", "1"]):
|
|
544
|
+
assert run(args, db) == 0
|
|
545
|
+
|
|
546
|
+
def test_view_not_found(self, db) -> None:
|
|
547
|
+
from cmd_view import run
|
|
548
|
+
|
|
549
|
+
args = _ns(session_id="nonexistent", latest=False, no_pager=True, raw=True)
|
|
550
|
+
assert run(args, db) == 1
|
|
551
|
+
|
|
552
|
+
def test_view_unknown_session_prefix(self, db) -> None:
|
|
553
|
+
from cmd_view import run
|
|
554
|
+
|
|
555
|
+
args = _ns(session_id="zzz", latest=False, no_pager=True, raw=True)
|
|
556
|
+
assert run(args, db) == 1
|
|
557
|
+
|
|
558
|
+
def test_view_output_contains_session_data(self, db) -> None:
|
|
559
|
+
from cmd_view import _format_session, _init_styles
|
|
560
|
+
|
|
561
|
+
info = {
|
|
562
|
+
"id": "ses_001",
|
|
563
|
+
"title": "First session",
|
|
564
|
+
"model": '{"id": "gpt-4"}',
|
|
565
|
+
"agent": "assistant",
|
|
566
|
+
"cost": 0.05,
|
|
567
|
+
"tokens_input": 100,
|
|
568
|
+
"tokens_output": 200,
|
|
569
|
+
"tokens_reasoning": 50,
|
|
570
|
+
"tokens_cache_read": 10,
|
|
571
|
+
"tokens_cache_write": 20,
|
|
572
|
+
"time_created": 1000000,
|
|
573
|
+
"time_updated": 1003600,
|
|
574
|
+
"project_id": "proj_001",
|
|
575
|
+
"parent_id": None,
|
|
576
|
+
"directory": "/home/user/proj1",
|
|
577
|
+
"version": "v1",
|
|
578
|
+
"summary_additions": 10,
|
|
579
|
+
"summary_deletions": 5,
|
|
580
|
+
"summary_files": 3,
|
|
581
|
+
}
|
|
582
|
+
from db import get_messages
|
|
583
|
+
|
|
584
|
+
messages = get_messages(db, "ses_001")
|
|
585
|
+
sty = _init_styles(raw=True)
|
|
586
|
+
text = _format_session(db, info, messages, sty)
|
|
587
|
+
|
|
588
|
+
assert "ses_001" in text
|
|
589
|
+
assert "First session" in text
|
|
590
|
+
assert "gpt-4" in text
|
|
591
|
+
assert "Hello, how are you?" in text
|
|
592
|
+
assert "I'm fine, thank you!" in text
|
|
593
|
+
|
|
594
|
+
def test_format_part_ansi_text(self) -> None:
|
|
595
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
596
|
+
|
|
597
|
+
sty = _init_styles(raw=True)
|
|
598
|
+
result = _format_part_ansi({"type": "text", "text": "Hello world"}, sty)
|
|
599
|
+
assert result == "Hello world"
|
|
600
|
+
|
|
601
|
+
def test_format_part_ansi_reasoning(self) -> None:
|
|
602
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
603
|
+
|
|
604
|
+
sty = _init_styles(raw=True)
|
|
605
|
+
result = _format_part_ansi({"type": "reasoning", "text": "Let me think..."}, sty)
|
|
606
|
+
assert "Let me think..." in result
|
|
607
|
+
|
|
608
|
+
def test_format_part_ansi_sound(self) -> None:
|
|
609
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
610
|
+
|
|
611
|
+
sty = _init_styles(raw=True)
|
|
612
|
+
result = _format_part_ansi({"type": "sound"}, sty)
|
|
613
|
+
assert result
|
|
614
|
+
|
|
615
|
+
def test_format_part_ansi_tool_read(self) -> None:
|
|
616
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
617
|
+
|
|
618
|
+
sty = _init_styles(raw=True)
|
|
619
|
+
result = _format_part_ansi(
|
|
620
|
+
{
|
|
621
|
+
"type": "tool",
|
|
622
|
+
"tool": "read",
|
|
623
|
+
"input": {"filePath": "/path/to/file.py"},
|
|
624
|
+
"output": "content",
|
|
625
|
+
"status": "completed",
|
|
626
|
+
},
|
|
627
|
+
sty,
|
|
628
|
+
)
|
|
629
|
+
assert "read" in result
|
|
630
|
+
assert "/path/to/file.py" in result
|
|
631
|
+
|
|
632
|
+
def test_format_part_ansi_tool_bash(self) -> None:
|
|
633
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
634
|
+
|
|
635
|
+
sty = _init_styles(raw=True)
|
|
636
|
+
result = _format_part_ansi(
|
|
637
|
+
{
|
|
638
|
+
"type": "tool",
|
|
639
|
+
"tool": "bash",
|
|
640
|
+
"input": {"command": "ls -la"},
|
|
641
|
+
"output": "total 42\nfile.py",
|
|
642
|
+
"status": "completed",
|
|
643
|
+
},
|
|
644
|
+
sty,
|
|
645
|
+
)
|
|
646
|
+
assert "bash" in result
|
|
647
|
+
|
|
648
|
+
def test_format_part_ansi_empty_text(self) -> None:
|
|
649
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
650
|
+
|
|
651
|
+
sty = _init_styles(raw=True)
|
|
652
|
+
result = _format_part_ansi({"type": "text", "text": ""}, sty)
|
|
653
|
+
assert result == ""
|
|
654
|
+
|
|
655
|
+
def test_format_part_ansi_empty_reasoning(self) -> None:
|
|
656
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
657
|
+
|
|
658
|
+
sty = _init_styles(raw=True)
|
|
659
|
+
result = _format_part_ansi({"type": "reasoning", "text": ""}, sty)
|
|
660
|
+
assert result == ""
|
|
661
|
+
|
|
662
|
+
def test_format_part_ansi_unknown_type(self) -> None:
|
|
663
|
+
from cmd_view import _format_part_ansi, _init_styles
|
|
664
|
+
|
|
665
|
+
sty = _init_styles(raw=True)
|
|
666
|
+
result = _format_part_ansi({"type": "unknown_type"}, sty)
|
|
667
|
+
assert result == ""
|
|
668
|
+
|
|
669
|
+
def test_init_styles_raw_disables_ansi(self) -> None:
|
|
670
|
+
from cmd_view import _init_styles
|
|
671
|
+
|
|
672
|
+
sty = _init_styles(raw=True)
|
|
673
|
+
assert sty == {}
|
|
674
|
+
|
|
675
|
+
def test_view_no_messages_session(self, db) -> None:
|
|
676
|
+
from cmd_view import _format_session, _init_styles
|
|
677
|
+
|
|
678
|
+
info = {
|
|
679
|
+
"id": "ses_empty",
|
|
680
|
+
"title": None,
|
|
681
|
+
"model": None,
|
|
682
|
+
"agent": None,
|
|
683
|
+
"cost": None,
|
|
684
|
+
"tokens_input": None,
|
|
685
|
+
"tokens_output": None,
|
|
686
|
+
"tokens_reasoning": None,
|
|
687
|
+
"tokens_cache_read": None,
|
|
688
|
+
"tokens_cache_write": None,
|
|
689
|
+
"time_created": 1000000,
|
|
690
|
+
"time_updated": 1003600,
|
|
691
|
+
"project_id": None,
|
|
692
|
+
"parent_id": None,
|
|
693
|
+
"directory": None,
|
|
694
|
+
"version": None,
|
|
695
|
+
"summary_additions": 0,
|
|
696
|
+
"summary_deletions": 0,
|
|
697
|
+
"summary_files": 0,
|
|
698
|
+
}
|
|
699
|
+
sty = _init_styles(raw=True)
|
|
700
|
+
text = _format_session(db, info, [], sty)
|
|
701
|
+
assert "конец сессии" in text or "end of session" in text
|
opencode_db-0.6.2/README_PYPI.md
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# opencode-db
|
|
2
|
-
|
|
3
|
-
[](https://www.python.org/downloads/)
|
|
4
|
-
[](LICENSE)
|
|
5
|
-
|
|
6
|
-
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
pip install opencode-db
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
## Quick start
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
opencode-db list # recent sessions
|
|
16
|
-
opencode-db stats # database summary
|
|
17
|
-
opencode-db costs --total # total token costs
|
|
18
|
-
opencode-db export # export dialog to .md (interactive)
|
|
19
|
-
opencode-db help # full reference
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Requirements
|
|
23
|
-
|
|
24
|
-
- Python 3.12+
|
|
25
|
-
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
26
|
-
- macOS / Linux
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|