From 3d8181430be7071061ae0e9aaa83bd446cd955b0 Mon Sep 17 00:00:00 2001 From: VasilevNStas Date: Thu, 25 Jun 2026 20:24:07 +0300 Subject: [PATCH] feat: view command with ANSI colors + Markdown rendering - New view command: interactive session viewer with pager - Markdown to ANSI renderer (stdlib fallback, optional rich) - pip install opencode-db[rich] for enhanced rendering - Table formatting: box-drawing in print_table_simple - Compact message layout in view - Full date+time in message headers - Bump version to 0.7.0 --- README_PYPI.md | 17 ++ cmd_view.py | 363 +++++++++++++++++++++++++++++++++++++++++ commands/__init__.py | 2 + formatters.py | 55 +++++-- i18n.py | 80 +++++++++ markdown_to_ansi.py | 221 +++++++++++++++++++++++++ pyproject.toml | 7 +- tests/test_commands.py | 192 ++++++++++++++++++++++ 8 files changed, 925 insertions(+), 12 deletions(-) create mode 100644 cmd_view.py create mode 100644 markdown_to_ansi.py diff --git a/README_PYPI.md b/README_PYPI.md index d83dbe4..bc6abc4 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -13,12 +13,29 @@ pip install opencode-db ```bash opencode-db list # recent sessions +opencode-db view # interactive session viewer (colored, scrollable) opencode-db stats # database summary opencode-db costs --total # total token costs opencode-db export # export dialog to .md (interactive) opencode-db help # full reference ``` +## Features + +- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less` +- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям +- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian) +- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами +- **`costs`** / **`stats`** — аналитика токенов и расходов +- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД + +## Optional extras + +```bash +pip install opencode-db[rich] # enhanced Markdown rendering (recommended) +pip install opencode-db # zero external dependencies +``` + ## Requirements - Python 3.12+ diff --git a/cmd_view.py b/cmd_view.py new file mode 100644 index 0000000..f4a9a19 --- /dev/null +++ b/cmd_view.py @@ -0,0 +1,363 @@ +"""Command view: просмотр сессии в терминале с прокруткой. + +Выводит отформатированный диалог с ANSI-цветами, +автоматически использует $PAGER (less -R) для прокрутки. + +Режимы выбора сессии: + - view — по ID (полный или префикс) + - view --latest — самая свежая сессия + - view (без аргументов) — интерактивный выбор из списка +""" + +import argparse +import os +import shutil +import subprocess +import sys + +from db import ( + SessionError, + get_latest_session, + get_messages, + get_project_name, + get_recent_sessions, + get_session_info, + get_session_title, + parse_model, +) +from i18n import _ +from markdown_to_ansi import render as md_render +from utils import build_help_epilog, format_cost, format_tokens, format_ts + +_VIEW_EXAMPLES = [ + ("", "help.view.e0"), + ("--latest", "help.view.e1"), + ("", "help.view.e2"), + ("--no-pager", "help.view.e3"), + ("--raw", "help.view.e4"), +] + + +def register(subparsers) -> None: + p = subparsers.add_parser( + "view", + help=_("help.cmd.view"), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=build_help_epilog("view", _VIEW_EXAMPLES), + ) + p.add_argument( + "session_id", + nargs="?", + help="Session ID (optional — interactive mode if omitted)", + ) + p.add_argument("--latest", "-l", action="store_true", help=_("view.flag.latest")) + p.add_argument("--no-pager", "-P", action="store_true", help=_("view.flag.no_pager")) + p.add_argument("--raw", "-R", action="store_true", help=_("view.flag.raw")) + + +def run(args, db) -> int: + if args.session_id: + return _view_by_id(db, args.session_id, args.no_pager, args.raw) + if args.latest: + return _view_latest(db, args.no_pager, args.raw) + return _view_interactive(db, args.no_pager, args.raw) + + +# ====================================================================== +# Selection modes +# ====================================================================== + + +def _view_by_id(db, session_id, no_pager, raw) -> int: + try: + info = get_session_info(db, session_id) + except SessionError as e: + print(e.message) + return 1 + return _do_view(db, info, no_pager, raw) + + +def _view_latest(db, no_pager, raw) -> int: + try: + session_id = get_latest_session(db) + info = get_session_info(db, session_id) + except SessionError as e: + print(e.message) + return 1 + title = get_session_title(info) + print(_("session.latest", title=title)) + return _do_view(db, info, no_pager, raw) + + +def _view_interactive(db, no_pager, raw) -> int: + sessions = get_recent_sessions(db) + if not sessions: + print(_("view.no_sessions")) + return 1 + + print() + print(f" {'─' * 60}") + print(f" {_('view.interactive_header')}") + print(f" {'─' * 60}") + + for i, s in enumerate(sessions, 1): + title = get_session_title(s) + created = format_ts(s["time_created"], "%Y-%m-%d %H:%M") + cost = format_cost(s["cost"]) + print(f" {i:2d}. {s['id'][:24]} {created} {title[:40]:40s} {cost}") + + print(f" {'─' * 60}") + + while True: + try: + choice = input(" " + _("view.interactive_prompt")).strip() + except (EOFError, KeyboardInterrupt): + print() + return 1 + + if not choice: + print(_("view.interactive_abort")) + return 1 + + try: + idx = int(choice) - 1 + if 0 <= idx < len(sessions): + break + except ValueError: + pass + + print(f" {_('view.interactive_error', n=len(sessions))}") + + session_id = sessions[idx]["id"] + title = get_session_title(sessions[idx]) + print(_("view.interactive_viewing", title=title)) + try: + info = get_session_info(db, session_id) + except SessionError as e: + print(e.message) + return 1 + return _do_view(db, info, no_pager, raw) + + +# ====================================================================== +# Display engine +# ====================================================================== + + +def _init_styles(raw=False): + """Return style dict. Empty dict = no ANSI codes.""" + if raw or not sys.stdout.isatty(): + return {} + return { + "R": "\033[0m", + "B": "\033[1m", + "D": "\033[2m", + "G": "\033[32m", + "C": "\033[36m", + "Y": "\033[33m", + "M": "\033[90m", + } + + +def _do_view(db, info, no_pager, raw) -> int: + sty = _init_styles(raw) + messages = get_messages(db, info["id"]) + text = _format_session(db, info, messages, sty) + + use_pager = not no_pager and sys.stdout.isatty() and len(text) > 1000 + if use_pager: + _page_text(text) + else: + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + sys.stdout.flush() + + return 0 + + +def _format_session(db, info, messages, sty) -> str: + """Build the full formatted session view string.""" + title = get_session_title(info) + model = parse_model(info) + created = format_ts(info["time_created"]) + msg_count = len(messages) + + duration = "—" + if info["time_updated"] and info["time_created"]: + duration_sec = (info["time_updated"] - info["time_created"]) / 1000 + if duration_sec < 60: + duration = _("info.dur_sec", n=int(duration_sec)) + elif duration_sec < 3600: + duration = _("info.dur_min", n=int(duration_sec / 60)) + else: + hours = int(duration_sec / 3600) + mins = int((duration_sec % 3600) / 60) + duration = _("info.dur_hours", h=hours, m=mins) + + ti = info["tokens_input"] + to = info["tokens_output"] + tr = info["tokens_reasoning"] + tokens_str = f"{format_tokens(ti)} in · {format_tokens(to)} out" + if tr: + tokens_str += f" · {format_tokens(tr)} reason" + + cost = format_cost(info["cost"]) + + project_name = None + if info["project_id"]: + project_name = get_project_name(db, info["project_id"]) + + sep = f"{sty.get('M', '')}{'─' * 60}{sty.get('R', '')}" + lr = sty.get("R", "") + + lines = [] + lines.append("") + lines.append(f"{sty.get('B', '')}{sty.get('C', '')}🔍 {_('view.header')}{lr}") + lines.append(sep) + lines.append(f" {_('info.id')}: {info['id']}") + lines.append(f" {_('list.header.title')}: {title}") + lines.append(f" {_('info.model')}: {model}") + lines.append(f" {_('info.agent')}: {info['agent'] or '—'}") + lines.append(f" {_('info.created')}: {created}") + lines.append(f" {_('info.duration')}: {duration}") + lines.append(f" {_('info.tokens_total')}: {tokens_str}") + lines.append(f" {_('info.cost')}: {cost}") + if project_name: + lines.append(f" {_('info.project')}: {project_name}") + lines.append(f" {_('info.messages')}: {msg_count}") + lines.append(sep) + lines.append("") + + if not messages: + lines.append(f" {sty.get('M', '')}{_('view.no_messages')}{lr}") + lines.append("") + lines.append(sep) + lines.append("") + else: + for msg in messages: + role = msg.get("role", "unknown") + ts = format_ts(msg.get("time"), "%Y-%m-%d %H:%M:%S") if msg.get("time") else "" + agent = msg.get("agent", "") + + if role == "user": + role_str = f"{sty.get('G', '')}👤 {_('view.role_user')} [{ts}]{lr}" + elif role == "assistant": + agent_tag = f" ({agent})" if agent else "" + role_str = f"{sty.get('C', '')}🤖 {_('view.role_assistant')}{agent_tag} [{ts}]{lr}" + else: + role_str = f"{sty.get('Y', '')}⚙️ {role} [{ts}]{lr}" + + lines.append(sep) + lines.append(role_str) + lines.append(sep) + + for i, part in enumerate(msg.get("parts", [])): + formatted = _format_part_ansi(part, sty) + if formatted: + if i > 0: + lines.append("") + lines.append(formatted) + + lines.append(f"{sty.get('M', '')}─── {_('view.footer')} ───{lr}") + lines.append("") + + return "\n".join(lines) + + +# ====================================================================== +# Part formatting (ANSI version of format_part_to_md) +# ====================================================================== + + +def _format_part_ansi(part, sty) -> str: + """Форматирует часть сообщения с ANSI-цветами.""" + t = part.get("type", "") + + if t == "text": + text = part.get("text", "").strip() + if not text: + return "" + return md_render(text, sty, plain=not sty) + + if t == "reasoning": + text = part.get("text", "").strip() + if not text: + return "" + title = f"{sty.get('Y', '')}{sty.get('D', '')}💭 {_('md.reasoning')}:{sty.get('R', '')}" + body = f"{sty.get('D', '')}{text}{sty.get('R', '')}" + return f"{title}\n{body}" + + if t == "sound": + return f"{sty.get('Y', '')}*🔊 {_('md.sound')}*{sty.get('R', '')}" + + if t == "tool": + tool_name = part.get("tool", "?") + t_input = part.get("input", {}) + t_output = part.get("output", "") + t_status = part.get("status", "") + + icon = {"completed": "✅", "error": "❌", "running": "⏳"}.get(t_status, "🔧") + y = sty.get("Y", "") + r = sty.get("R", "") + + if tool_name == "read": + fp = t_input.get("filePath", "") + return f"{y}{icon} read:{r} `{fp}`" + if tool_name == "write": + fp = t_input.get("filePath", "") + return f"{y}{icon} write:{r} `{fp}`" + if tool_name == "edit": + fp = t_input.get("filePath", "") + return f"{y}{icon} edit:{r} `{fp}`" + if tool_name == "glob": + pat = t_input.get("pattern", "") + pth = t_input.get("path", "") + if pth: + return f"{y}{icon} glob:{r} `{pat}` in `{pth}`" + return f"{y}{icon} glob:{r} `{pat}`" + if tool_name == "grep": + pat = t_input.get("pattern", "") + return f"{y}{icon} grep:{r} `{pat}`" + + lines = [f"{y}{icon} {tool_name}{r}"] + if t_input: + for k, v in t_input.items(): + s = str(v) + if len(s) > 200: + s = s[:200] + "..." + lines.append(f" {k}: {s}") + if t_output: + out = str(t_output).strip() + if out: + if len(out) > 300: + out = out[:300] + "..." + lines.append(f" → {out}") + return "\n".join(lines) + + return "" + + +# ====================================================================== +# Pager +# ====================================================================== + + +def _page_text(text: str) -> None: + """Pipe text through $PAGER or less -R.""" + pager_cmd = os.environ.get("PAGER", "") + try: + if pager_cmd: + subprocess.run(pager_cmd, input=text, text=True, shell=True) + elif shutil.which("less"): + subprocess.run(["less", "-R"], input=text, text=True) + else: + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + sys.stdout.flush() + except OSError: + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/commands/__init__.py b/commands/__init__.py index 43804c3..1e55437 100644 --- a/commands/__init__.py +++ b/commands/__init__.py @@ -23,6 +23,7 @@ import cmd_todos import cmd_tree import cmd_vacuum +import cmd_view # Словарь {имя_команды: модуль} # Модуль должен иметь register(subparsers) и run(args, db) @@ -39,6 +40,7 @@ "stats": cmd_stats, "todos": cmd_todos, "vacuum": cmd_vacuum, + "view": cmd_view, "help": cmd_help, } diff --git a/formatters.py b/formatters.py index 59f4831..917a0e6 100644 --- a/formatters.py +++ b/formatters.py @@ -15,28 +15,61 @@ def print_json(data: Any) -> None: print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) -def print_table_simple(headers: list[str], rows: list[list], sep: str = " ") -> None: - """Выводит таблицу с авто-шириной колонок.""" +def print_table_simple( + headers: list[str], + rows: list[list], + sep: str = " ", +) -> None: + """Выводит таблицу с рамкой и колонками. + + Формат: + ┌──────┬───────────┐ + │ ID │ Title │ + ├──────┼───────────┤ + │ abc │ Session 1 │ + └──────┴───────────┘ + """ if not rows: print(_("empty")) return + ncols = len(headers) + pad = 1 # padding inside each cell + + # compute column widths widths = [] - for ci in range(len(headers)): + for ci in range(ncols): max_w = len(headers[ci]) for row in rows: max_w = max(max_w, len(str(row[ci]))) - widths.append(min(max_w + 1, 80)) - - line = sep.join(h.ljust(widths[i])[: widths[i]] for i, h in enumerate(headers)) - sep_line = "─" * len(line) + widths.append(min(max_w + pad * 2, 80)) + + tl = "┌" # top-left + tm = "┬" # top-middle + tr = "┐" # top-right + ml = "├" # middle-left + mm = "┼" # middle-middle + mr = "┤" # middle-right + bl = "└" # bottom-left + bm = "┴" # bottom-middle + br = "┘" # bottom-right + ve = "│" # vertical edge + + top = tl + tm.join("─" * w for w in widths) + tr + sep = ml + mm.join("─" * w for w in widths) + mr + bot = bl + bm.join("─" * w for w in widths) + br + + header_cells = [h.center(widths[ci])[: widths[ci]] for ci, h in enumerate(headers)] + header_line = ve + ve.join(header_cells) + ve print() - print(line) - print(sep_line) + print(top) + print(header_line) + print(sep) for row in rows: - line = sep.join(str(row[i]).ljust(widths[i])[: widths[i]] for i in range(len(headers))) - print(line) + cells = [str(row[ci]).ljust(widths[ci])[: widths[ci]] for ci in range(ncols)] + print(ve + ve.join(cells) + ve) + print(bot) print(_("fmt.rows_count", n=len(rows))) diff --git a/i18n.py b/i18n.py index 16046b3..6399c4c 100644 --- a/i18n.py +++ b/i18n.py @@ -349,6 +349,65 @@ "vacuum.done": {"ru": "✅", "en": "✅"}, "vacuum.canceled": {"ru": " Отменено.", "en": " Canceled."}, # ================================================================== + # VIEW + # ================================================================== + "view.header": { + "ru": "Просмотр сессии", + "en": "Session View", + }, + "view.role_user": { + "ru": "Вы", + "en": "You", + }, + "view.role_assistant": { + "ru": "Ассистент", + "en": "Assistant", + }, + "view.no_messages": { + "ru": "⏳ Нет сообщений в этой сессии.", + "en": "⏳ No messages in this session.", + }, + "view.no_sessions": { + "ru": "❌ Нет сессий для просмотра.", + "en": "❌ No sessions to view.", + }, + "view.interactive_header": { + "ru": "Последние сессии:", + "en": "Recent sessions:", + }, + "view.interactive_prompt": { + "ru": "Выбери номер (или Enter для отмены): ", + "en": "Pick a number (or Enter to cancel): ", + }, + "view.interactive_error": { + "ru": "Введи число от 1 до {n}", + "en": "Enter a number from 1 to {n}", + }, + "view.interactive_viewing": { + "ru": " Просмотр: {title}", + "en": " Viewing: {title}", + }, + "view.interactive_abort": { + "ru": " Отменено.", + "en": " Canceled.", + }, + "view.footer": { + "ru": "конец сессии", + "en": "end of session", + }, + "view.flag.latest": { + "ru": "Просмотр самой свежей сессии", + "en": "View the most recent session", + }, + "view.flag.no_pager": { + "ru": "Вывод без пейджера (просто печатать)", + "en": "Print without pager", + }, + "view.flag.raw": { + "ru": "Без ANSI-цветов (обычный текст)", + "en": "Plain text, no ANSI colors", + }, + # ================================================================== # HELP # ================================================================== "help.header_box_top": { @@ -412,6 +471,7 @@ "help.cmd.stats": {"ru": "Статистика БД", "en": "Database statistics"}, "help.cmd.todos": {"ru": "Задачи из сессий", "en": "Tasks from sessions"}, "help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"}, + "help.cmd.view": {"ru": "Просмотр сессии с прокруткой", "en": "View session with scrolling"}, "help.cmd.help": {"ru": "Подробная справка", "en": "Detailed help"}, # ================================================================== # HELP — примеры (epilog для argparse --help) @@ -489,6 +549,26 @@ "en": "VACUUM + REINDEX + ANALYZE with confirmation", }, "help.vacuum.e1": {"ru": "Без подтверждения", "en": "Skip confirmation"}, + "help.view.e0": { + "ru": "Просмотр по ID", + "en": "View by ID", + }, + "help.view.e1": { + "ru": "Самая свежая сессия", + "en": "Most recent session", + }, + "help.view.e2": { + "ru": "Интерактивный выбор из списка", + "en": "Interactive picker", + }, + "help.view.e3": { + "ru": "Без пейджера (простой вывод)", + "en": "Without pager (plain output)", + }, + "help.view.e4": { + "ru": "Без ANSI-цветов", + "en": "Without ANSI colors", + }, "help.help.e0": { "ru": "Общая справка по всем командам", "en": "General help for all commands", diff --git a/markdown_to_ansi.py b/markdown_to_ansi.py new file mode 100644 index 0000000..4f886fc --- /dev/null +++ b/markdown_to_ansi.py @@ -0,0 +1,221 @@ +"""Конвертация Markdown в ANSI-форматированный текст для терминала. + +Режимы: + - rich (опционально): через `pip install opencode-db[rich]` + - fallback: stdlib (re) + ANSI escape codes, zero external deps +""" + +from __future__ import annotations + +import re + +HAS_RICH: bool = False +_RICH_CONSOLE = None +try: + from rich.console import Console + + _RICH_CONSOLE = Console(force_terminal=True, color_system="truecolor") + HAS_RICH = True +except ImportError: + pass + + +def render(text: str, styles: dict[str, str] | None = None, *, plain: bool = False) -> str: + """Конвертирует Markdown-текст в ANSI-форматированный вывод. + + При наличии rich использует его Markdown-рендерер (полное качество), + иначе — встроенный конвертер (базовые конструкции). + + Args: + text: исходный Markdown-текст + styles: dict с ANSI-кодами (только для fallback) + plain: True — вернуть как есть (без форматирования) + + Returns: + str с ANSI-escape последовательностями + """ + if not text: + return "" + + if plain: + return text + + if HAS_RICH and _RICH_CONSOLE is not None: + return _render_rich(text) + + return _render_fallback(text, styles or {}) + + +# ====================================================================== +# RENDERER: rich +# ====================================================================== + + +def _render_rich(text: str) -> str: + from rich.markdown import Markdown + + with _RICH_CONSOLE.capture() as capture: + _RICH_CONSOLE.print(Markdown(text)) + result = capture.get() + return result.rstrip("\n") + + +# ====================================================================== +# RENDERER: fallback (stdlib) +# ====================================================================== + + +def _render_fallback(text: str, sty: dict[str, str]) -> str: + r = sty.get("R", "") + bold = sty.get("B", "") + dim = sty.get("D", "") + cyan = sty.get("C", "") + yellow = sty.get("Y", "") + gray = sty.get("M", "") + green = sty.get("G", "") + + result = text + + # fenced code blocks + result = re.sub( + r"```(\w*)\n(.*?)```", + lambda m: _fb_code_block(m.group(1), m.group(2), sty), + result, + flags=re.DOTALL, + ) + + # inline code + result = re.sub(r"`([^`]+)`", rf"{yellow}\1{r}", result) + + # bold **...** + result = re.sub(r"\*\*(.+?)\*\*", rf"{bold}\1{r}", result) + + # italic *...* + result = re.sub(r"(? (.+)$", + lambda m: _fb_blockquote(m.group(1), sty), + result, + flags=re.MULTILINE, + ) + + # horizontal rules + result = re.sub( + r"^---+$", + f"{gray}{'─' * 60}{r}", + result, + flags=re.MULTILINE, + ) + + # unordered lists + result = re.sub(r"^(\s*)[-*] ", r"\1• ", result, flags=re.MULTILINE) + + # ordered lists + result = re.sub( + r"^(\s*)(\d+)\. ", + lambda m: f"{m.group(1)}{green}{m.group(2)}.{r} ", + result, + flags=re.MULTILINE, + ) + + # links [text](url) + result = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", + lambda m: f"{m.group(1)} ({gray}{m.group(2)}{r})", + result, + ) + + # tables: convert to box-drawing + result = re.sub( + r"^(\|.+)$(?:\n\|.+)+\n\|.+(?:\n\|.+)*", + lambda m: _fb_table(m.group(0), sty), + result, + flags=re.MULTILINE, + ) + + return result + + +def _fb_code_block(lang: str, body: str, sty: dict[str, str]) -> str: + r = sty.get("R", "") + gray = sty.get("M", "") + yellow = sty.get("Y", "") + lang_tag = f" {lang}" if lang else "" + header = f"{gray}┌─{lang_tag}{r}" + footer = f"{gray}└──{r}" + lines = body.strip().split("\n") + indented = "\n".join(f"{yellow}{line}{r}" for line in lines) + return f"{header}\n{indented}\n{footer}" + + +def _fb_blockquote(line: str, sty: dict[str, str]) -> str: + gray = sty.get("M", "") + r = sty.get("R", "") + return f"{gray}│ {line}{r}" + + +def _fb_table(text: str, sty: dict[str, str]) -> str: + """Converts a Markdown table to box-drawing format.""" + lines = [ln.strip() for ln in text.strip().split("\n") if ln.strip()] + if len(lines) < 3: + return text + + # skip separator row (|--|--|) + data_rows = [] + for line in lines: + if not line.strip().startswith("|"): + continue + if re.match(r"^\|[-| ]+\|$", line): + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + data_rows.append(cells) + + if len(data_rows) < 2: + return text + + ncols = max(len(r) for r in data_rows) + pad = 1 + + widths: list[int] = [] + for ci in range(ncols): + max_w = max(len(r[ci]) if ci < len(r) else 0 for r in data_rows) + widths.append(max_w + pad * 2) + + tl, tm, tr = "┌", "┬", "┐" + ml, mm, mr = "├", "┼", "┤" + bl, bm, br = "└", "┴", "┘" + ve = "│" + r = sty.get("R", "") + gray = sty.get("M", "") + + top = tl + tm.join("─" * w for w in widths) + tr + sep = ml + mm.join("─" * w for w in widths) + mr + bot = bl + bm.join("─" * w for w in widths) + br + + header_cells = [ + (data_rows[0][ci] if ci < len(data_rows[0]) else "").center(widths[ci])[: widths[ci]] + for ci in range(ncols) + ] + header_line = f"{gray}{ve}{ve.join(header_cells)}{ve}{r}" + + result = [f"{gray}{top}{r}", header_line, f"{gray}{sep}{r}"] + + for row in data_rows[1:]: + cells = [ + (row[ci] if ci < len(row) else "").ljust(widths[ci])[: widths[ci]] + for ci in range(ncols) + ] + result.append(f"{ve}{ve.join(cells)}{ve}") + + result.append(f"{gray}{bot}{r}") + return "\n".join(result) diff --git a/pyproject.toml b/pyproject.toml index af49312..4f17149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "opencode-db" -version = "0.6.2" +version = "0.7.0" description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions" readme = "README_PYPI.md" requires-python = ">=3.12" +[project.optional-dependencies] +rich = ["rich>=13.0"] +all = ["opencode-db[rich]"] + [project.urls] Homepage = "https://github.com/VasilevNStas/opencode-db" Source = "https://github.com/VasilevNStas/opencode-db" @@ -23,6 +27,7 @@ py-modules = [ "cmd_costs", "cmd_delete", "cmd_export", "cmd_help", "cmd_info", "cmd_list", "cmd_projects", "cmd_prune", "cmd_search", "cmd_stats", "cmd_todos", "cmd_tree", "cmd_vacuum", + "cmd_view", "markdown_to_ansi", ] packages = ["commands"] diff --git a/tests/test_commands.py b/tests/test_commands.py index 8f85a78..02d07b7 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -507,3 +507,195 @@ def test_prune_none_to_delete(self, db) -> None: args = _ns(older_than="100y", keep_last=None, project=None, dry_run=False, force=True) assert run(args, db) == 0 assert _count_sessions(db) == 4 + + +class TestViewCommand: + def test_view_by_id(self, db) -> None: + from cmd_view import run + + args = _ns(session_id="ses_001", latest=False, no_pager=True, raw=True) + assert run(args, db) == 0 + + def test_view_latest(self, db) -> None: + from cmd_view import run + + args = _ns(session_id=None, latest=True, no_pager=True, raw=True) + assert run(args, db) == 0 + + def test_view_interactive(self, db) -> None: + from cmd_view import run + + args = _ns(session_id=None, latest=False, no_pager=True, raw=True) + with patch("builtins.input", return_value="1"): + assert run(args, db) == 0 + + def test_view_interactive_abort(self, db) -> None: + from cmd_view import run + + args = _ns(session_id=None, latest=False, no_pager=True, raw=True) + with patch("builtins.input", return_value=""): + assert run(args, db) == 1 + + def test_view_interactive_bad_number(self, db) -> None: + from cmd_view import run + + args = _ns(session_id=None, latest=False, no_pager=True, raw=True) + with patch("builtins.input", side_effect=["99", "1"]): + assert run(args, db) == 0 + + def test_view_not_found(self, db) -> None: + from cmd_view import run + + args = _ns(session_id="nonexistent", latest=False, no_pager=True, raw=True) + assert run(args, db) == 1 + + def test_view_unknown_session_prefix(self, db) -> None: + from cmd_view import run + + args = _ns(session_id="zzz", latest=False, no_pager=True, raw=True) + assert run(args, db) == 1 + + def test_view_output_contains_session_data(self, db) -> None: + from cmd_view import _format_session, _init_styles + + info = { + "id": "ses_001", + "title": "First session", + "model": '{"id": "gpt-4"}', + "agent": "assistant", + "cost": 0.05, + "tokens_input": 100, + "tokens_output": 200, + "tokens_reasoning": 50, + "tokens_cache_read": 10, + "tokens_cache_write": 20, + "time_created": 1000000, + "time_updated": 1003600, + "project_id": "proj_001", + "parent_id": None, + "directory": "/home/user/proj1", + "version": "v1", + "summary_additions": 10, + "summary_deletions": 5, + "summary_files": 3, + } + from db import get_messages + + messages = get_messages(db, "ses_001") + sty = _init_styles(raw=True) + text = _format_session(db, info, messages, sty) + + assert "ses_001" in text + assert "First session" in text + assert "gpt-4" in text + assert "Hello, how are you?" in text + assert "I'm fine, thank you!" in text + + def test_format_part_ansi_text(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "text", "text": "Hello world"}, sty) + assert result == "Hello world" + + def test_format_part_ansi_reasoning(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "reasoning", "text": "Let me think..."}, sty) + assert "Let me think..." in result + + def test_format_part_ansi_sound(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "sound"}, sty) + assert result + + def test_format_part_ansi_tool_read(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi( + { + "type": "tool", + "tool": "read", + "input": {"filePath": "/path/to/file.py"}, + "output": "content", + "status": "completed", + }, + sty, + ) + assert "read" in result + assert "/path/to/file.py" in result + + def test_format_part_ansi_tool_bash(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi( + { + "type": "tool", + "tool": "bash", + "input": {"command": "ls -la"}, + "output": "total 42\nfile.py", + "status": "completed", + }, + sty, + ) + assert "bash" in result + + def test_format_part_ansi_empty_text(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "text", "text": ""}, sty) + assert result == "" + + def test_format_part_ansi_empty_reasoning(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "reasoning", "text": ""}, sty) + assert result == "" + + def test_format_part_ansi_unknown_type(self) -> None: + from cmd_view import _format_part_ansi, _init_styles + + sty = _init_styles(raw=True) + result = _format_part_ansi({"type": "unknown_type"}, sty) + assert result == "" + + def test_init_styles_raw_disables_ansi(self) -> None: + from cmd_view import _init_styles + + sty = _init_styles(raw=True) + assert sty == {} + + def test_view_no_messages_session(self, db) -> None: + from cmd_view import _format_session, _init_styles + + info = { + "id": "ses_empty", + "title": None, + "model": None, + "agent": None, + "cost": None, + "tokens_input": None, + "tokens_output": None, + "tokens_reasoning": None, + "tokens_cache_read": None, + "tokens_cache_write": None, + "time_created": 1000000, + "time_updated": 1003600, + "project_id": None, + "parent_id": None, + "directory": None, + "version": None, + "summary_additions": 0, + "summary_deletions": 0, + "summary_files": 0, + } + sty = _init_styles(raw=True) + text = _format_session(db, info, [], sty) + assert "конец сессии" in text or "end of session" in text