opencode-db 0.6.2__tar.gz → 0.7.2__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 (44) hide show
  1. opencode_db-0.7.2/PKG-INFO +62 -0
  2. {opencode_db-0.6.2 → opencode_db-0.7.2}/README.md +87 -14
  3. opencode_db-0.7.2/README_PYPI.md +46 -0
  4. {opencode_db-0.6.2 → opencode_db-0.7.2}/cli.py +9 -3
  5. opencode_db-0.7.2/cmd_view.py +363 -0
  6. {opencode_db-0.6.2 → opencode_db-0.7.2}/commands/__init__.py +2 -0
  7. {opencode_db-0.6.2 → opencode_db-0.7.2}/formatters.py +44 -11
  8. {opencode_db-0.6.2 → opencode_db-0.7.2}/i18n.py +80 -0
  9. opencode_db-0.7.2/markdown_to_ansi.py +221 -0
  10. opencode_db-0.7.2/opencode_db.egg-info/PKG-INFO +62 -0
  11. {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/SOURCES.txt +5 -0
  12. opencode_db-0.7.2/opencode_db.egg-info/requires.txt +6 -0
  13. {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/top_level.txt +3 -0
  14. {opencode_db-0.6.2 → opencode_db-0.7.2}/pyproject.toml +7 -2
  15. opencode_db-0.7.2/runtime.py +55 -0
  16. {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_commands.py +192 -0
  17. opencode_db-0.7.2/tests/test_runtime.py +21 -0
  18. {opencode_db-0.6.2 → opencode_db-0.7.2}/utils.py +1 -1
  19. opencode_db-0.6.2/PKG-INFO +0 -38
  20. opencode_db-0.6.2/README_PYPI.md +0 -26
  21. opencode_db-0.6.2/opencode_db.egg-info/PKG-INFO +0 -38
  22. {opencode_db-0.6.2 → opencode_db-0.7.2}/LICENSE +0 -0
  23. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_costs.py +0 -0
  24. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_delete.py +0 -0
  25. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_export.py +0 -0
  26. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_help.py +0 -0
  27. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_info.py +0 -0
  28. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_list.py +0 -0
  29. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_projects.py +0 -0
  30. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_prune.py +0 -0
  31. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_search.py +0 -0
  32. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_stats.py +0 -0
  33. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_todos.py +0 -0
  34. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_tree.py +0 -0
  35. {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_vacuum.py +0 -0
  36. {opencode_db-0.6.2 → opencode_db-0.7.2}/config.py +0 -0
  37. {opencode_db-0.6.2 → opencode_db-0.7.2}/db.py +0 -0
  38. {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/dependency_links.txt +0 -0
  39. {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/entry_points.txt +0 -0
  40. {opencode_db-0.6.2 → opencode_db-0.7.2}/setup.cfg +0 -0
  41. {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_db.py +0 -0
  42. {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_formatters.py +0 -0
  43. {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_i18n.py +0 -0
  44. {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_utils.py +0 -0
@@ -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
@@ -22,9 +22,10 @@ opencode-db costs --total # total token costs
22
22
 
23
23
  ### Requirements
24
24
 
25
- - Python 3.12+
25
+ - Python 3.12+ — `opencode-db` refuses to start on older interpreters with a clear message (no cryptic `ImportError`)
26
26
  - OpenCode (must have been run at least once — the database is created automatically)
27
27
  - macOS / Linux
28
+ - [`uv`](https://docs.astral.sh/uv/) — optional, but recommended for installing and running on systems whose default `python3` is older than 3.12
28
29
 
29
30
  ### Database location
30
31
 
@@ -51,13 +52,26 @@ opencode-db --db-path /path/to/opencode.db stats
51
52
 
52
53
  The `--db-path` flag takes precedence over the environment variable.
53
54
 
54
- ### One-command install (recommended)
55
+ ### Recommended: uv
56
+
57
+ If your system `python3` is older than 3.12 (for example, macOS ships 3.9), use `uv` — it installs the tool into an isolated environment with a supported interpreter:
58
+
59
+ ```bash
60
+ uv tool install opencode-db
61
+ # with enhanced Markdown rendering:
62
+ uv tool install "opencode-db[rich]"
63
+
64
+ # one-off run without installing:
65
+ uvx opencode-db stats
66
+ ```
67
+
68
+ ### One-command install
55
69
 
56
70
  ```bash
57
71
  curl -fsSL https://raw.githubusercontent.com/VasilevNStas/opencode-db/master/install.sh | sh
58
72
  ```
59
73
 
60
- Downloads everything to `~/.local/share/opencode-db/` and creates `~/.local/bin/opencode-db`.
74
+ Downloads everything to `~/.local/share/opencode-db/` and creates `~/.local/bin/opencode-db`. The installer looks for Python 3.12+ (including interpreters managed by `uv` or `pyenv`) and writes the discovered path into the launcher shebang.
61
75
 
62
76
  ### pip / pipx install
63
77
 
@@ -67,7 +81,7 @@ pipx install opencode-db
67
81
  pip install opencode-db
68
82
  ```
69
83
 
70
- [Zero external dependencies](https://treyhunner.com/2015/12/python-imports-and-path-manipulation/) — pure Python standard library only.
84
+ [Zero external dependencies](https://treyhunner.com/2015/12/python-imports-and-path-manipulation/) — pure Python standard library only (`rich` is optional).
71
85
 
72
86
  ### Manual install
73
87
 
@@ -82,7 +96,17 @@ git clone https://github.com/VasilevNStas/opencode-db.git ~/.local/share/opencod
82
96
  ```bash
83
97
  cat > ~/.local/bin/opencode-db << 'SCRIPT'
84
98
  #!/usr/bin/env python3
85
- import sys, os
99
+ import os
100
+ import sys
101
+
102
+ if sys.version_info < (3, 12):
103
+ print(
104
+ "opencode-db requires Python 3.12+; current: %s (%s)"
105
+ % (".".join(map(str, sys.version_info[:3])), sys.executable),
106
+ file=sys.stderr,
107
+ )
108
+ raise SystemExit(1)
109
+
86
110
  PKG_DIR = os.path.expanduser("~/.local/share/opencode-db")
87
111
  if PKG_DIR not in sys.path:
88
112
  sys.path.insert(0, PKG_DIR)
@@ -488,14 +512,16 @@ Example output:
488
512
  ~/.local/share/opencode-db/ ← all source code
489
513
  │
490
514
  ├── __init__.py ← executable entry point
491
- ├── __main__.py ← python -m opencode_db
515
+ ├── __main__.py ← python3 . (directory entry point)
492
516
  ├── cli.py ← argparse + command dispatcher
493
517
  │
494
518
  ├── config.py ← constants and paths
519
+ ├── runtime.py ← Python 3.12+ version guard
495
520
  ├── db.py ← SQLite queries
496
521
  ├── utils.py ← date/token formatting, validation
497
522
  ├── formatters.py ← tables, JSON, Markdown, snapshots
498
- ├── i18n.py ← localization (EN/RU, 238 keys)
523
+ ├── i18n.py ← localization (EN/RU, 280 keys)
524
+ ├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
499
525
  │
500
526
  ├── commands/
501
527
  │ └── __init__.py ← command registry
@@ -512,6 +538,7 @@ Example output:
512
538
  ├── cmd_stats.py ← opencode-db stats
513
539
  ├── cmd_todos.py ← opencode-db todos
514
540
  ├── cmd_vacuum.py ← opencode-db vacuum
541
+ ├── cmd_view.py ← opencode-db view
515
542
  └── cmd_help.py ← opencode-db help
516
543
 
517
544
  README.md ← this file (English)
@@ -522,10 +549,11 @@ opencode-db-commands.json ← OpenCode TUI custom commands confi
522
549
  ### How it works
523
550
 
524
551
  1. The launcher script at `~/.local/bin/opencode-db` (a shebang Python script) adds `~/.local/share/opencode-db/` to `sys.path` and calls `cli.main()`.
525
- 2. `cli.py` builds an `argparse` parser with subparsers from every command module and dispatches the call.
526
- 3. Each command is a separate `cmd_*.py` module exporting two functions: `register(subparsers)` and `run(args, db)`.
527
- 4. `db.py` opens a connection to OpenCode's SQLite database (`~/.local/share/opencode/opencode.db`).
528
- 5. Destructive operations (`delete`, `prune`) always require confirmation (or `--force`).
552
+ 2. `runtime.require_python()` checks the interpreter before importing `cli`/`db`; on Python < 3.12 it prints a clear message with remediation steps instead of failing with `ImportError`.
553
+ 3. `cli.py` builds an `argparse` parser with subparsers from every command module and dispatches the call.
554
+ 4. Each command is a separate `cmd_*.py` module exporting two functions: `register(subparsers)` and `run(args, db)`.
555
+ 5. `db.py` opens a connection to OpenCode's SQLite database (`~/.local/share/opencode/opencode.db`).
556
+ 6. Destructive operations (`delete`, `prune`) always require confirmation (or `--force`).
529
557
 
530
558
  ### Database schema (key tables)
531
559
 
@@ -617,7 +645,7 @@ opencode-db --lang en help # English help text
617
645
  opencode-db --lang en stats # English statistics
618
646
  ```
619
647
 
620
- The `--lang` flag applies to any command. All 238 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
648
+ The `--lang` flag applies to any command. All 280 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
621
649
 
622
650
  The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
623
651
 
@@ -659,10 +687,54 @@ Use a longer prefix or the full session ID (use `list --json` to find it).
659
687
 
660
688
  If the database is being used by another process (e.g., OpenCode is running), VACUUM may fail. Close OpenCode and try again.
661
689
 
690
+ ### `ImportError: cannot import name 'UTC' from 'datetime'`
691
+
692
+ This means `opencode-db` is running on Python older than 3.12 (for example, the system Python 3.9). Check:
693
+
694
+ ```bash
695
+ python3 --version
696
+ ```
697
+
698
+ Fix with `uv` (recommended):
699
+
700
+ ```bash
701
+ uv python install 3.13
702
+ uv tool install opencode-db
703
+ opencode-db stats
704
+ ```
705
+
706
+ Or run the repository checkout with an explicit interpreter:
707
+
708
+ ```bash
709
+ uvx --from . opencode-db stats
710
+ # alternative for a persistent dev environment:
711
+ uv run --python 3.13 opencode-db stats
712
+ ```
713
+
714
+ After changing the interpreter, rerun `install.sh` to regenerate the `~/.local/bin/opencode-db` launcher.
715
+
662
716
  ---
663
717
 
664
718
  ## Development
665
719
 
720
+ ### Local development
721
+
722
+ Requires Python 3.12+ and `pytest`/`ruff`:
723
+
724
+ ```bash
725
+ git clone https://github.com/VasilevNStas/opencode-db.git
726
+ cd opencode-db
727
+
728
+ uv venv --python 3.13
729
+ source .venv/bin/activate
730
+ uv pip install -e ".[rich]"
731
+ uv pip install pytest ruff
732
+
733
+ make check # check-python + ruff + 191 tests
734
+ ```
735
+
736
+ `make check` fails fast with a clear message if the active interpreter is older than 3.12.
737
+
666
738
  ### Adding a new command
667
739
 
668
740
  1. Create `cmd_xxx.py`:
@@ -692,10 +764,11 @@ The command will appear in `--help` automatically.
692
764
  python3 ~/.local/share/opencode-db list
693
765
  ```
694
766
 
695
- Or as a module:
767
+ Or from the repository root (Python 3.12+):
696
768
 
697
769
  ```bash
698
- python3 -m opencode_db list
770
+ python3 . list
771
+ uvx --from . opencode-db list
699
772
  ```
700
773
 
701
774
  ---
@@ -0,0 +1,46 @@
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
+ # recommended if your system python3 is older than 3.12:
12
+ uv tool install opencode-db
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ opencode-db list # recent sessions
19
+ opencode-db view # interactive session viewer (colored, scrollable)
20
+ opencode-db stats # database summary
21
+ opencode-db costs --total # total token costs
22
+ opencode-db export # export dialog to .md (interactive)
23
+ opencode-db help # full reference
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
29
+ - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
30
+ - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
31
+ - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
32
+ - **`costs`** / **`stats`** — аналитика токенов и расходов
33
+ - **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
34
+
35
+ ## Optional extras
36
+
37
+ ```bash
38
+ pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
39
+ pip install opencode-db # zero external dependencies
40
+ ```
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.12+ — the CLI checks the interpreter at startup and exits with a clear message instead of an `ImportError` on older versions
45
+ - [OpenCode](https://opencode.ai) (must have been run at least once)
46
+ - macOS / Linux
@@ -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:
@@ -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()