opencode-db 0.6.2__py3-none-any.whl → 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli.py CHANGED
@@ -7,14 +7,20 @@
7
7
  - Единообразный интерфейс
8
8
  """
9
9
 
10
+ from __future__ import annotations
11
+
10
12
  import argparse
11
13
  import os
12
14
  import sys
13
15
  from typing import Any, Literal
14
16
 
15
- import commands
16
- from db import get_db
17
- from i18n import _, set_lang
17
+ from runtime import require_python
18
+
19
+ require_python()
20
+
21
+ import commands # noqa: E402
22
+ from db import get_db # noqa: E402
23
+ from i18n import _, set_lang # noqa: E402
18
24
 
19
25
 
20
26
  def build_parser() -> argparse.ArgumentParser:
cmd_view.py ADDED
@@ -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()
commands/__init__.py CHANGED
@@ -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
 
formatters.py CHANGED
@@ -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
 
i18n.py CHANGED
@@ -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",
markdown_to_ansi.py ADDED
@@ -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)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencode-db
3
+ Version: 0.7.2
4
+ Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
+ Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
+ Project-URL: Source, https://github.com/VasilevNStas/opencode-db
7
+ Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
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"
15
+ Dynamic: license-file
16
+
17
+ # opencode-db
18
+
19
+ [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
20
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
21
+
22
+ **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.
23
+
24
+ ```bash
25
+ pip install opencode-db
26
+
27
+ # recommended if your system python3 is older than 3.12:
28
+ uv tool install opencode-db
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```bash
34
+ opencode-db list # recent sessions
35
+ opencode-db view # interactive session viewer (colored, scrollable)
36
+ opencode-db stats # database summary
37
+ opencode-db costs --total # total token costs
38
+ opencode-db export # export dialog to .md (interactive)
39
+ opencode-db help # full reference
40
+ ```
41
+
42
+ ## Features
43
+
44
+ - **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
45
+ - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
46
+ - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
47
+ - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
48
+ - **`costs`** / **`stats`** — аналитика токенов и расходов
49
+ - **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
50
+
51
+ ## Optional extras
52
+
53
+ ```bash
54
+ pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
55
+ pip install opencode-db # zero external dependencies
56
+ ```
57
+
58
+ ## Requirements
59
+
60
+ - Python 3.12+ — the CLI checks the interpreter at startup and exits with a clear message instead of an `ImportError` on older versions
61
+ - [OpenCode](https://opencode.ai) (must have been run at least once)
62
+ - macOS / Linux
@@ -1,4 +1,4 @@
1
- cli.py,sha256=NFOmI4lcoiDmJTC2c1YqmZ02IRbNdhrmIYUd6Y25aOY,2296
1
+ cli.py,sha256=H-7TFu17dqAQv_bUvQsw0Tuly57Je53WzKs6yQOjajk,2428
2
2
  cmd_costs.py,sha256=LstYavRJp-ZwwianBjRGtB8XiEKByGwMf6XwOHDHZvU,6395
3
3
  cmd_delete.py,sha256=VJyaWcxr3UVIGqkZrlXs4IOkHzySbc9dt0W6sAL5IIo,11282
4
4
  cmd_export.py,sha256=OfnNE1J9z7qbGbPqp1TXYIUBZZCEcUefkJcL1qlMdRw,6627
@@ -12,15 +12,18 @@ cmd_stats.py,sha256=IE_cLoxUTPLG_UaDvjZfgCNDmRW1cNqH9mw6f1X9GXQ,4818
12
12
  cmd_todos.py,sha256=P47_HDIHm6ikHW9XsFEvPsl4jqm79X690oqXnD616d4,3182
13
13
  cmd_tree.py,sha256=OaP1xQ3cxxWkHTbST2GHVmg-re9FXINWR_FgL_X1HiU,3274
14
14
  cmd_vacuum.py,sha256=7Y6kdoKDMqIW_-Tvablge7v169tFGv3W65LB5GKgifI,2180
15
+ cmd_view.py,sha256=fy7YJB-GcfEMl8LVXE8ogN17kt95T5KSLiMmxqRkf2o,11781
15
16
  config.py,sha256=Og_iwTlmqzpuHj5rmzkhFz33Ag915RhOE8SGsCbSr0w,694
16
17
  db.py,sha256=5NlNM9m_g2frj0oNVgJ9IIEf5oK2n6rbJqDycoVC_Mo,11914
17
- formatters.py,sha256=kYzSw29UOZ90fN5jxC4DQOkT8BA5kM46sFXFbM6QKk0,6841
18
- i18n.py,sha256=DX3wKnF5At7qovrrYFdoiyvWc9tVLprYXNElXVeAiKI,33987
19
- utils.py,sha256=W5BrLO3s-ck0UFVJLAERlXHF6ck6iXQrf-wRmLxUe6c,4849
20
- commands/__init__.py,sha256=YETQJBFAkefRCNHqXTa67blS0g8766ApVcg6OhG81oE,1252
21
- opencode_db-0.6.2.dist-info/licenses/LICENSE,sha256=NhUr8ufTYb5u9F08J5bmDQ0xTVmCOexoq1ki_RdCqys,1210
22
- opencode_db-0.6.2.dist-info/METADATA,sha256=Ghcq12mCfmHMO5H482PPrCbyiYJpNSUuPG1SIUCa2_M,1264
23
- opencode_db-0.6.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
- opencode_db-0.6.2.dist-info/entry_points.txt,sha256=dMHc-RWkGEu0mTNvHHThSEKQdGbCdwdKUCO-cLF7TCw,41
25
- opencode_db-0.6.2.dist-info/top_level.txt,sha256=lKPHa0NndvZjjoZ5mIatAdvtFlPA3i-Fm_tEl3JvgTg,178
26
- opencode_db-0.6.2.dist-info/RECORD,,
18
+ formatters.py,sha256=ovgtaB-gZFTCtrYl5dPWLKDUL8zG-Zl5nVHxFj2nJY4,7768
19
+ i18n.py,sha256=7QHCc7-KcGfzA7odkesFpqfbi9XKuZ5fXZ2OTFazF68,36667
20
+ markdown_to_ansi.py,sha256=HSr_yOZ1t1tYz5Z0Bx3yRqKzKSWxJIcuS02EKMO1WtE,6181
21
+ runtime.py,sha256=BbKIOdGkrcgckMvOqJgfiEpp6vDAfwO0aI-uaC4-F_M,2155
22
+ utils.py,sha256=c7RqEuxbx340RzJ1kzG14ucuf88aUzuc2gbKYb4919E,4849
23
+ commands/__init__.py,sha256=0VZs89RtmsPYGkmgedKxPKaNtIgkMdx6HYHWHsuKCtE,1290
24
+ opencode_db-0.7.2.dist-info/licenses/LICENSE,sha256=NhUr8ufTYb5u9F08J5bmDQ0xTVmCOexoq1ki_RdCqys,1210
25
+ opencode_db-0.7.2.dist-info/METADATA,sha256=xB704HtWzrLTwEOtBrgPt_9BvP5Pzie1pXYpf1L5azk,2511
26
+ opencode_db-0.7.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
+ opencode_db-0.7.2.dist-info/entry_points.txt,sha256=dMHc-RWkGEu0mTNvHHThSEKQdGbCdwdKUCO-cLF7TCw,41
28
+ opencode_db-0.7.2.dist-info/top_level.txt,sha256=CnAP9fA7TDBA6mjuh2AWR2bo4ceTfTpxwLn-pe0Kcj4,212
29
+ opencode_db-0.7.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -12,9 +12,12 @@ 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
22
+ runtime
20
23
  utils
runtime.py ADDED
@@ -0,0 +1,55 @@
1
+ """Проверка минимальной версии Python.
2
+
3
+ Модуль намеренно написан без синтаксиса новее Python 3.8, чтобы его можно
4
+ было импортировать до проверки версии и показать понятную ошибку вместо
5
+ ImportError/SyntaxError.
6
+ """
7
+
8
+ import sys
9
+
10
+ MIN_PYTHON = (3, 12)
11
+ REQUIRED_VERSION = "3.12"
12
+
13
+
14
+ def format_version_error(version_info=None, executable=None) -> str:
15
+ """Возвращает текст ошибки для неподдерживаемой версии Python."""
16
+ info = version_info or sys.version_info
17
+ exe = executable or sys.executable
18
+ current = f"{info[0]}.{info[1]}.{info[2]}"
19
+
20
+ return (
21
+ f"opencode-db requires Python {REQUIRED_VERSION}+.\n"
22
+ f"opencode-db требует Python {REQUIRED_VERSION}+.\n"
23
+ "\n"
24
+ f"Current interpreter: Python {current} ({exe})\n"
25
+ f"Текущий интерпретатор: Python {current} ({exe})\n"
26
+ "\n"
27
+ "How to fix / Как исправить:\n"
28
+ " uv python install 3.13\n"
29
+ " uv venv --python 3.13\n"
30
+ " source .venv/bin/activate\n"
31
+ " pip install -e .\n"
32
+ "\n"
33
+ "Or run with uv / Или запуск через uv:\n"
34
+ " uv run --python 3.13 opencode-db --help\n"
35
+ " uv tool install --editable . && opencode-db --help\n"
36
+ "\n"
37
+ "After changing the interpreter, rerun install.sh to regenerate\n"
38
+ "the ~/.local/bin/opencode-db launcher."
39
+ )
40
+
41
+
42
+ def check_python_version(version_info=None):
43
+ """Возвращает None, если версия подходит, иначе текст ошибки."""
44
+ info = version_info or sys.version_info
45
+ if tuple(info[:2]) >= MIN_PYTHON:
46
+ return None
47
+ return format_version_error(info)
48
+
49
+
50
+ def require_python() -> None:
51
+ """Завершает процесс с понятной ошибкой, если Python слишком старый."""
52
+ message = check_python_version()
53
+ if message:
54
+ print(message, file=sys.stderr)
55
+ raise SystemExit(1)
utils.py CHANGED
@@ -50,7 +50,7 @@ def parse_date(date_str: str) -> datetime | None:
50
50
  return None
51
51
 
52
52
 
53
- def parse_time_spec(spec) -> None | datetime:
53
+ def parse_time_spec(spec) -> datetime | None:
54
54
  """Парсит спецификацию времени ('30d', '6m', '1y') в timedelta."""
55
55
  if not spec or not isinstance(spec, str):
56
56
  return None
@@ -1,38 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: opencode-db
3
- Version: 0.6.2
4
- Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
- Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
- Project-URL: Source, https://github.com/VasilevNStas/opencode-db
7
- Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
8
- Requires-Python: >=3.12
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Dynamic: license-file
12
-
13
- # opencode-db
14
-
15
- [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
16
- [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
17
-
18
- **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.
19
-
20
- ```bash
21
- pip install opencode-db
22
- ```
23
-
24
- ## Quick start
25
-
26
- ```bash
27
- opencode-db list # recent sessions
28
- opencode-db stats # database summary
29
- opencode-db costs --total # total token costs
30
- opencode-db export # export dialog to .md (interactive)
31
- opencode-db help # full reference
32
- ```
33
-
34
- ## Requirements
35
-
36
- - Python 3.12+
37
- - [OpenCode](https://opencode.ai) (must have been run at least once)
38
- - macOS / Linux