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.
Files changed (40) hide show
  1. {opencode_db-0.6.2 → opencode_db-0.7.0}/PKG-INFO +22 -1
  2. opencode_db-0.7.0/README_PYPI.md +43 -0
  3. opencode_db-0.7.0/cmd_view.py +363 -0
  4. {opencode_db-0.6.2 → opencode_db-0.7.0}/commands/__init__.py +2 -0
  5. {opencode_db-0.6.2 → opencode_db-0.7.0}/formatters.py +44 -11
  6. {opencode_db-0.6.2 → opencode_db-0.7.0}/i18n.py +80 -0
  7. opencode_db-0.7.0/markdown_to_ansi.py +221 -0
  8. {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/PKG-INFO +22 -1
  9. {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/SOURCES.txt +3 -0
  10. opencode_db-0.7.0/opencode_db.egg-info/requires.txt +6 -0
  11. {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/top_level.txt +2 -0
  12. {opencode_db-0.6.2 → opencode_db-0.7.0}/pyproject.toml +6 -1
  13. {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_commands.py +192 -0
  14. opencode_db-0.6.2/README_PYPI.md +0 -26
  15. {opencode_db-0.6.2 → opencode_db-0.7.0}/LICENSE +0 -0
  16. {opencode_db-0.6.2 → opencode_db-0.7.0}/README.md +0 -0
  17. {opencode_db-0.6.2 → opencode_db-0.7.0}/cli.py +0 -0
  18. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_costs.py +0 -0
  19. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_delete.py +0 -0
  20. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_export.py +0 -0
  21. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_help.py +0 -0
  22. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_info.py +0 -0
  23. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_list.py +0 -0
  24. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_projects.py +0 -0
  25. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_prune.py +0 -0
  26. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_search.py +0 -0
  27. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_stats.py +0 -0
  28. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_todos.py +0 -0
  29. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_tree.py +0 -0
  30. {opencode_db-0.6.2 → opencode_db-0.7.0}/cmd_vacuum.py +0 -0
  31. {opencode_db-0.6.2 → opencode_db-0.7.0}/config.py +0 -0
  32. {opencode_db-0.6.2 → opencode_db-0.7.0}/db.py +0 -0
  33. {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/dependency_links.txt +0 -0
  34. {opencode_db-0.6.2 → opencode_db-0.7.0}/opencode_db.egg-info/entry_points.txt +0 -0
  35. {opencode_db-0.6.2 → opencode_db-0.7.0}/setup.cfg +0 -0
  36. {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_db.py +0 -0
  37. {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_formatters.py +0 -0
  38. {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_i18n.py +0 -0
  39. {opencode_db-0.6.2 → opencode_db-0.7.0}/tests/test_utils.py +0 -0
  40. {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.6.2
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
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
4
+ [![License](https://img.shields.io/badge/license-MIT-green)](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(headers: list[str], rows: list[list], sep: str = " ") -> None:
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(len(headers)):
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 + 1, 80))
30
-
31
- line = sep.join(h.ljust(widths[i])[: widths[i]] for i, h in enumerate(headers))
32
- sep_line = "─" * len(line)
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(line)
36
- print(sep_line)
66
+ print(top)
67
+ print(header_line)
68
+ print(sep)
37
69
  for row in rows:
38
- line = sep.join(str(row[i]).ljust(widths[i])[: widths[i]] for i in range(len(headers)))
39
- print(line)
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.6.2
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
@@ -0,0 +1,6 @@
1
+
2
+ [all]
3
+ opencode-db[rich]
4
+
5
+ [rich]
6
+ rich>=13.0
@@ -12,9 +12,11 @@ cmd_stats
12
12
  cmd_todos
13
13
  cmd_tree
14
14
  cmd_vacuum
15
+ cmd_view
15
16
  commands
16
17
  config
17
18
  db
18
19
  formatters
19
20
  i18n
21
+ markdown_to_ansi
20
22
  utils
@@ -4,11 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-db"
7
- version = "0.6.2"
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
@@ -1,26 +0,0 @@
1
- # opencode-db
2
-
3
- [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
4
- [![License](https://img.shields.io/badge/license-MIT-green)](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