opencode-db 0.6.1__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.1 → 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.1 → opencode_db-0.7.0}/commands/__init__.py +2 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/formatters.py +59 -16
- {opencode_db-0.6.1 → opencode_db-0.7.0}/i18n.py +80 -0
- opencode_db-0.7.0/markdown_to_ansi.py +221 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/opencode_db.egg-info/PKG-INFO +22 -1
- {opencode_db-0.6.1 → 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.1 → opencode_db-0.7.0}/opencode_db.egg-info/top_level.txt +2 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/pyproject.toml +6 -1
- {opencode_db-0.6.1 → opencode_db-0.7.0}/tests/test_commands.py +192 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/tests/test_db.py +72 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/tests/test_formatters.py +45 -0
- opencode_db-0.6.1/README_PYPI.md +0 -26
- {opencode_db-0.6.1 → opencode_db-0.7.0}/LICENSE +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/README.md +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cli.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_costs.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_delete.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_export.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_help.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_info.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_list.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_projects.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_prune.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_search.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_stats.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_todos.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_tree.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/cmd_vacuum.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/config.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/db.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/setup.cfg +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/tests/test_i18n.py +0 -0
- {opencode_db-0.6.1 → opencode_db-0.7.0}/tests/test_utils.py +0 -0
- {opencode_db-0.6.1 → 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
|
|
|
@@ -1,43 +1,79 @@
|
|
|
1
1
|
"""Форматирование вывода: таблицы, JSON, Markdown."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
import json
|
|
4
6
|
import os
|
|
7
|
+
from typing import Any
|
|
5
8
|
|
|
6
9
|
from config import IGNORE_DIRS
|
|
7
10
|
from i18n import _
|
|
8
11
|
|
|
9
12
|
|
|
10
|
-
def print_json(data) -> None:
|
|
13
|
+
def print_json(data: Any) -> None:
|
|
11
14
|
"""Выводит данные в JSON-формате."""
|
|
12
15
|
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
13
16
|
|
|
14
17
|
|
|
15
|
-
def print_table_simple(
|
|
16
|
-
|
|
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
|
+
"""
|
|
17
32
|
if not rows:
|
|
18
33
|
print(_("empty"))
|
|
19
34
|
return
|
|
20
35
|
|
|
36
|
+
ncols = len(headers)
|
|
37
|
+
pad = 1 # padding inside each cell
|
|
38
|
+
|
|
39
|
+
# compute column widths
|
|
21
40
|
widths = []
|
|
22
|
-
for ci in range(
|
|
41
|
+
for ci in range(ncols):
|
|
23
42
|
max_w = len(headers[ci])
|
|
24
43
|
for row in rows:
|
|
25
44
|
max_w = max(max_w, len(str(row[ci])))
|
|
26
|
-
widths.append(min(max_w +
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
64
|
|
|
31
65
|
print()
|
|
32
|
-
print(
|
|
33
|
-
print(
|
|
66
|
+
print(top)
|
|
67
|
+
print(header_line)
|
|
68
|
+
print(sep)
|
|
34
69
|
for row in rows:
|
|
35
|
-
|
|
36
|
-
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)
|
|
37
73
|
print(_("fmt.rows_count", n=len(rows)))
|
|
38
74
|
|
|
39
75
|
|
|
40
|
-
def summarize_val(v, max_len=120) -> str:
|
|
76
|
+
def summarize_val(v: Any, max_len: int = 120) -> str:
|
|
41
77
|
"""Обрезает длинное значение."""
|
|
42
78
|
s = str(v)
|
|
43
79
|
if len(s) > max_len:
|
|
@@ -45,7 +81,7 @@ def summarize_val(v, max_len=120) -> str:
|
|
|
45
81
|
return s
|
|
46
82
|
|
|
47
83
|
|
|
48
|
-
def format_part_to_md(part, full=False):
|
|
84
|
+
def format_part_to_md(part: dict, full: bool = False) -> str:
|
|
49
85
|
"""Преобразует часть сообщения в строку Markdown.
|
|
50
86
|
|
|
51
87
|
Args:
|
|
@@ -149,7 +185,7 @@ def format_part_to_md(part, full=False):
|
|
|
149
185
|
return ""
|
|
150
186
|
|
|
151
187
|
|
|
152
|
-
def collect_snapshot(project_dir):
|
|
188
|
+
def collect_snapshot(project_dir: str) -> dict[str, Any]:
|
|
153
189
|
"""Собирает статистику по проекту для log.md.
|
|
154
190
|
|
|
155
191
|
Returns:
|
|
@@ -182,7 +218,14 @@ def collect_snapshot(project_dir):
|
|
|
182
218
|
return snapshot
|
|
183
219
|
|
|
184
220
|
|
|
185
|
-
def update_log(
|
|
221
|
+
def update_log(
|
|
222
|
+
output_dir: str,
|
|
223
|
+
title: str,
|
|
224
|
+
note: str | None,
|
|
225
|
+
dialog_filename: str | None,
|
|
226
|
+
snapshot: dict[str, Any],
|
|
227
|
+
now_str: str,
|
|
228
|
+
) -> None:
|
|
186
229
|
"""Создаёт или дополняет log.md в директории проекта."""
|
|
187
230
|
log_path = os.path.join(output_dir, "log.md")
|
|
188
231
|
heading = f"{title}: {note}" if note else title
|