opencode-db 0.6.1__tar.gz → 0.7.0__tar.gz

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