handoff-cli 0.3.6__tar.gz → 0.3.7__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 (43) hide show
  1. handoff_cli-0.3.6/README.md → handoff_cli-0.3.7/PKG-INFO +18 -0
  2. handoff_cli-0.3.6/PKG-INFO → handoff_cli-0.3.7/README.md +9 -9
  3. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/list.py +2 -1
  4. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/tail.py +2 -1
  5. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/config.py +45 -0
  6. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/jsonl_parser.py +42 -0
  7. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/jsonl_viewer.py +217 -105
  8. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/tui.py +51 -6
  9. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/configuration.zh-CN.md +15 -0
  10. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/pyproject.toml +1 -1
  11. handoff_cli-0.3.7/tests/test_markdown_parser.py +158 -0
  12. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/.github/workflows/publish.yml +0 -0
  13. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/.gitignore +0 -0
  14. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/CLAUDE.md +0 -0
  15. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/Makefile +0 -0
  16. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/README.zh-CN.md +0 -0
  17. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/__init__.py +0 -0
  18. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/backend.py +0 -0
  19. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/backend_types.yaml +0 -0
  20. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/__init__.py +0 -0
  21. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/env.py +0 -0
  22. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/init.py +0 -0
  23. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/new.py +0 -0
  24. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/resume.py +0 -0
  25. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/run.py +0 -0
  26. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/core.py +0 -0
  27. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/main.py +0 -0
  28. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-codex/SKILL.md +0 -0
  29. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-ds/SKILL.md +0 -0
  30. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-ds.toml +0 -0
  31. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-opus/SKILL.md +0 -0
  32. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/stream.py +0 -0
  33. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/user_config_template.yaml +0 -0
  34. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/TODO.md +0 -0
  35. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/claude-code.jpg +0 -0
  36. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/codex.jpg +0 -0
  37. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/handoff-hero.jpg +0 -0
  38. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/list-tui.jpg +0 -0
  39. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/parallel.jpg +0 -0
  40. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/shell.jpg +0 -0
  41. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/tail.jpg +0 -0
  42. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/cli-reference.zh-CN.md +0 -0
  43. {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/design.zh-CN.md +0 -0
@@ -1,3 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: handoff-cli
3
+ Version: 0.3.7
4
+ Summary: Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: pyyaml<7,>=6
7
+ Requires-Dist: textual<3,>=2
8
+ Description-Content-Type: text/markdown
9
+
1
10
  <div align="center">
2
11
  <img src="https://raw.githubusercontent.com/dazuiba/handoff/main/docs/assets/handoff-hero.jpg" width="100%" alt="hero">
3
12
 
@@ -139,6 +148,15 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
139
148
 
140
149
  </details>
141
150
 
151
+ <details>
152
+ <summary><b>Can I change the TUI theme?</b></summary>
153
+
154
+ <br>
155
+
156
+ Yes. Inside `handoff list` and `handoff tail`, press `D` to toggle between `textual-dark` and `textual-light`. Your choice is saved automatically to `~/.handoff/tui_state.json` and restored next time you run the TUI.
157
+
158
+ </details>
159
+
142
160
  <details>
143
161
  <summary><b>Can I dispatch several tasks at once?</b></summary>
144
162
 
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: handoff-cli
3
- Version: 0.3.6
4
- Summary: Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends
5
- Requires-Python: >=3.9
6
- Requires-Dist: pyyaml<7,>=6
7
- Requires-Dist: textual<3,>=2
8
- Description-Content-Type: text/markdown
9
-
10
1
  <div align="center">
11
2
  <img src="https://raw.githubusercontent.com/dazuiba/handoff/main/docs/assets/handoff-hero.jpg" width="100%" alt="hero">
12
3
 
@@ -148,6 +139,15 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
148
139
 
149
140
  </details>
150
141
 
142
+ <details>
143
+ <summary><b>Can I change the TUI theme?</b></summary>
144
+
145
+ <br>
146
+
147
+ Yes. Inside `handoff list` and `handoff tail`, press `D` to toggle between `textual-dark` and `textual-light`. Your choice is saved automatically to `~/.handoff/tui_state.json` and restored next time you run the TUI.
148
+
149
+ </details>
150
+
151
151
  <details>
152
152
  <summary><b>Can I dispatch several tasks at once?</b></summary>
153
153
 
@@ -47,7 +47,8 @@ def cmd_list(argv: list[str], config: Config):
47
47
  "FROM runs ORDER BY created_at DESC LIMIT 50"
48
48
  ).fetchall()
49
49
 
50
- app = RunListApp(rows, full_cwd, refresh_fn=_refresh_rows)
50
+ from ..config import read_tui_theme
51
+ app = RunListApp(rows, full_cwd, refresh_fn=_refresh_rows, theme_name=read_tui_theme())
51
52
  app.run(mouse=False)
52
53
  conn.close()
53
54
 
@@ -44,5 +44,6 @@ def cmd_tail(argv: list[str], config=None):
44
44
  "out_path": out_path,
45
45
  }
46
46
 
47
+ from ..config import read_tui_theme
47
48
  from ..jsonl_viewer import run_tail
48
- run_tail(jsonl_path, prompt_path, result_path, run_info)
49
+ run_tail(jsonl_path, prompt_path, result_path, run_info, theme_name=read_tui_theme())
@@ -18,6 +18,7 @@ Backend resolution:
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import json
21
22
  import os
22
23
  import re
23
24
  import sys
@@ -35,6 +36,50 @@ except ImportError:
35
36
 
36
37
  _BACKEND_TYPES_PATH = os.path.join(os.path.dirname(__file__), "backend_types.yaml")
37
38
  _USER_CONFIG_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "user_config_template.yaml")
39
+ DEFAULT_DARK_THEME = "textual-dark"
40
+ DEFAULT_LIGHT_THEME = "textual-light"
41
+
42
+ # ── TUI state persistence (theme, independent from config.yaml) ──────────
43
+
44
+ _TUI_STATE_FILENAME = "tui_state.json"
45
+
46
+
47
+ def _tui_state_path() -> str:
48
+ return os.path.join(user_config_dir(), _TUI_STATE_FILENAME)
49
+
50
+
51
+ def read_tui_theme() -> str:
52
+ """Read the persisted TUI theme name from ``~/.handoff/tui_state.json``.
53
+
54
+ Falls back to ``textual-dark`` when the file is missing, corrupt, or
55
+ the stored theme name is empty / invalid. Never raises.
56
+ """
57
+ path = _tui_state_path()
58
+ try:
59
+ if os.path.isfile(path):
60
+ with open(path, "r") as f:
61
+ data = json.load(f)
62
+ theme = data.get("theme", "")
63
+ if isinstance(theme, str) and theme.strip():
64
+ return theme.strip()
65
+ except (OSError, json.JSONDecodeError, ValueError):
66
+ pass
67
+ return DEFAULT_DARK_THEME
68
+
69
+
70
+ def write_tui_theme(theme: str) -> None:
71
+ """Persist the TUI theme name to ``~/.handoff/tui_state.json``.
72
+
73
+ Best-effort: failures are silently ignored so the app never crashes
74
+ on a write.
75
+ """
76
+ path = _tui_state_path()
77
+ try:
78
+ os.makedirs(os.path.dirname(path), exist_ok=True)
79
+ with open(path, "w") as f:
80
+ json.dump({"theme": theme}, f)
81
+ except OSError:
82
+ pass
38
83
 
39
84
  # Top-level config keys that belong to the mechanism layer or are otherwise
40
85
  # removed. Warn once and ignore. system_prompt is deliberately absent from
@@ -7,6 +7,8 @@ import json
7
7
  from dataclasses import dataclass
8
8
  from typing import TextIO
9
9
 
10
+ from rich.text import Text
11
+
10
12
 
11
13
  @dataclass
12
14
  class ParsedEvent:
@@ -160,6 +162,46 @@ def format_event_for_viewer(event: ParsedEvent) -> str | None:
160
162
  return f"`{ts}` {kind_mark} {_truncate(event.text)}"
161
163
 
162
164
 
165
+ def format_event_as_rich(event: ParsedEvent) -> Text | None:
166
+ """Format one parsed event as a rich Text with styled spans.
167
+
168
+ Returns None for result_text/error_text events (handled separately by
169
+ the viewer as Markdown content). The returned Text uses colour/emphasis
170
+ per kind: tool=cyan, text=default, result=green, error=red, task=yellow,
171
+ info=dim.
172
+ """
173
+ if event.kind in ("result_text", "error_text"):
174
+ return None
175
+
176
+ ts = event.ts or " " * 8
177
+ mark_map = {
178
+ "tool": "▷",
179
+ "text": "✎",
180
+ "result": "✓",
181
+ "error": "✗",
182
+ "task": "▶",
183
+ "info": "·",
184
+ }
185
+ colour_map = {
186
+ "tool": "cyan",
187
+ "text": "",
188
+ "result": "green",
189
+ "error": "red",
190
+ "task": "yellow",
191
+ "info": "dim",
192
+ }
193
+ mark = mark_map.get(event.kind, " ")
194
+ colour = colour_map.get(event.kind, "")
195
+
196
+ text = Text()
197
+ text.append(f"{ts:8}", style="dim")
198
+ text.append(" │ ", style="dim")
199
+ text.append(mark, style=colour)
200
+ text.append(" ")
201
+ text.append(_truncate(event.text))
202
+ return text
203
+
204
+
163
205
  def format_event_for_stream(event: ParsedEvent) -> str | None:
164
206
  """Return the single-line text stream shown during `handoff run`."""
165
207
  if event.kind != "text":
@@ -1,7 +1,7 @@
1
1
  """Shared JSONL viewer for handoff list (detail) and tail commands.
2
2
 
3
- Uses Textual to render Claude stream-json output: compact progress log,
4
- input prompt (markdown), and final result (markdown). No external cclean dependency.
3
+ Uses Textual to render Claude stream-json output: compact progress log
4
+ (RichLog), input prompt (Markdown), and final result (Markdown).
5
5
 
6
6
  Modes:
7
7
  - static: list detail page; Escape dismisses back to list
@@ -11,27 +11,54 @@ Modes:
11
11
  from __future__ import annotations
12
12
 
13
13
  import os
14
+ import re
14
15
  import asyncio
15
16
  from typing import Optional
16
17
 
18
+ from markdown_it import MarkdownIt
19
+ from rich.text import Text
17
20
  from textual import work
18
21
  from textual.app import App, ComposeResult
19
22
  from textual.screen import Screen
20
23
  from textual.widgets import (
21
24
  Footer,
25
+ Markdown,
26
+ RichLog,
27
+ Static,
22
28
  TabbedContent,
23
29
  TabPane,
24
- Static,
25
30
  )
26
31
  from textual.containers import VerticalScroll
27
32
  from textual.binding import Binding
28
- from .jsonl_parser import ParsedEvent, format_event_for_viewer, read_events
33
+ from .config import read_tui_theme
34
+ from .jsonl_parser import ParsedEvent, format_event_as_rich, read_events
35
+ from .tui import HandoffTuiApp
29
36
 
30
37
 
31
38
  # ═══════════════════════════════════════════════════════════════════════════════
32
39
  # JsonlViewerScreen
33
40
  # ═══════════════════════════════════════════════════════════════════════════════
34
41
 
42
+
43
+ def _markdown_parser_factory() -> MarkdownIt:
44
+ """Create a Markdown parser with all link/image/autolink tokens disabled.
45
+
46
+ Textual 2.1.2's Markdown renderer calls ``Style.from_meta({"@click": action})``
47
+ for every ``link_open`` / ``image`` token. The resulting meta data can contain
48
+ arbitrary strings (e.g. ``/tmp/a:1`` from ``[x](/tmp/a:1)``) which crash
49
+ Python's marshal with ``ValueError: bad marshal data (unknown type code)``
50
+ downstream in Textual's render pipeline.
51
+
52
+ Disabling the relevant MarkdownIt rules ensures such tokens are never emitted
53
+ and the input is rendered as plain text instead.
54
+ """
55
+ md = MarkdownIt("gfm-like", options_update={"linkify": False})
56
+ # Explicit markdown links e.g. [text](...), images e.g. ![alt](...),
57
+ # and autolinks e.g. <https://...>
58
+ md.disable(["link", "image", "autolink"])
59
+ return md
60
+
61
+
35
62
  class JsonlViewerScreen(Screen):
36
63
  """Shared JSONL viewer screen for handoff list (detail) and tail.
37
64
 
@@ -42,22 +69,26 @@ class JsonlViewerScreen(Screen):
42
69
  """
43
70
 
44
71
  BINDINGS = [
72
+ # Tab navigation (shown)
73
+ Binding("tab", "next_tab", "Next", show=True),
74
+ Binding("shift+tab", "prev_tab", "Prev", show=True),
75
+ # Actions (shown, ordered by frequency of use)
45
76
  Binding("escape", "back", "← Back", show=True),
46
- Binding("o", "go_resume", "Open in Claude", show=True),
47
- Binding("c", "copy_session", "Copy Session", show=True),
77
+ Binding("o", "go_resume", "Open", show=True),
78
+ Binding("c", "copy_session", "Copy", show=True),
48
79
  Binding("q", "quit", "Quit", show=True),
49
- Binding("tab", "next_tab", "Next Tab", show=True),
50
- Binding("shift+tab", "prev_tab", "Prev Tab", show=True),
51
- Binding("1", "show_tab('stream')", "Stream", show=True),
52
- Binding("2", "show_tab('output')", "Output", show=True),
53
- Binding("3", "show_tab('prompt')", "Prompt", show=True),
54
- Binding("4", "show_tab('result')", "Result", show=True),
55
- Binding("up,k", "scroll_active('up')", "Scroll up", show=False),
56
- Binding("down,j", "scroll_active('down')", "Scroll down", show=False),
57
- Binding("pageup", "scroll_active('page_up')", "Page up", show=False),
58
- Binding("pagedown", "scroll_active('page_down')", "Page down", show=False),
59
- Binding("home", "scroll_active('home')", "Top", show=False),
60
- Binding("end", "scroll_active('end')", "Bottom", show=False),
80
+ # Numeric tab shortcuts (hidden from Footer, keys still work)
81
+ Binding("1", "show_tab('stream')", "", show=False),
82
+ Binding("2", "show_tab('output')", "", show=False),
83
+ Binding("3", "show_tab('prompt')", "", show=False),
84
+ Binding("4", "show_tab('result')", "", show=False),
85
+ # Scrolling (hidden Textual built-in keymap covers these)
86
+ Binding("up,k", "scroll_active('up')", "", show=False),
87
+ Binding("down,j", "scroll_active('down')", "", show=False),
88
+ Binding("pageup", "scroll_active('page_up')", "", show=False),
89
+ Binding("pagedown", "scroll_active('page_down')", "", show=False),
90
+ Binding("home", "scroll_active('home')", "", show=False),
91
+ Binding("end", "scroll_active('end')", "", show=False),
61
92
  ]
62
93
 
63
94
  def __init__(
@@ -82,9 +113,8 @@ class JsonlViewerScreen(Screen):
82
113
  self._last_ts = ""
83
114
  self._fpos = 0
84
115
  self._out_fpos = 0
116
+ self._out_buffer = "" # partial last line buffer for .out parsing
85
117
  self._result_text: Optional[str] = None
86
- self._stream_raw = "" # accumulated stream content for incremental update
87
- self._out_raw = "" # accumulated .out.txt content
88
118
  self._last_stream_line = ""
89
119
  self._poll_interval = 0.5
90
120
  # Auto-follow state
@@ -103,52 +133,68 @@ class JsonlViewerScreen(Screen):
103
133
  )
104
134
  with TabbedContent(initial="stream"):
105
135
  with TabPane("1 Stream JSONL", id="stream"):
106
- with VerticalScroll(id="stream_scroll"):
107
- yield Static("Loading…", id="stream_text", markup=False)
136
+ yield RichLog(id="stream_log", auto_scroll=False, highlight=False, markup=False)
108
137
  with TabPane("2 Output .out", id="output"):
109
- with VerticalScroll(id="output_scroll"):
110
- yield Static("Loading…", id="output_text", markup=False)
138
+ yield RichLog(id="output_log", auto_scroll=False, highlight=False, markup=False)
111
139
  with TabPane("3 Prompt", id="prompt"):
112
140
  with VerticalScroll(id="prompt_scroll"):
113
- yield Static("", id="prompt_text", markup=False)
141
+ yield Static("", id="prompt_header")
142
+ yield Markdown(
143
+ "",
144
+ id="prompt_md",
145
+ parser_factory=_markdown_parser_factory,
146
+ open_links=False,
147
+ )
114
148
  with TabPane("4 Result", id="result"):
115
149
  with VerticalScroll(id="result_scroll"):
116
- yield Static("", id="result_text", markup=False)
150
+ yield Static("", id="result_header")
151
+ yield Markdown(
152
+ "",
153
+ id="result_md",
154
+ parser_factory=_markdown_parser_factory,
155
+ open_links=False,
156
+ )
117
157
  yield Footer()
118
158
 
119
159
  def on_mount(self) -> None:
120
- # Load prompt text
160
+ # ── Prompt tab ──────────────────────────────────────────────────────
121
161
  if os.path.isfile(self._p_path):
122
162
  try:
123
163
  with open(self._p_path, "r", encoding="utf-8", errors="replace") as f:
124
164
  pt = f.read().strip()
165
+ self.query_one("#prompt_header", Static).update(
166
+ self._header_line("Prompt", self._p_path)
167
+ )
125
168
  if pt:
126
- self.query_one("#prompt_text", Static).update(
127
- self._text_with_path("Prompt", self._p_path, pt)
128
- )
169
+ self.query_one("#prompt_md", Markdown).update(pt)
129
170
  except (OSError, UnicodeDecodeError):
130
171
  pass
131
172
  else:
132
- self.query_one("#prompt_text", Static).update(self._text_with_path("Prompt", self._p_path, ""))
173
+ self.query_one("#prompt_header", Static).update(
174
+ self._header_line("Prompt", self._p_path)
175
+ )
133
176
 
134
- # Load result text
177
+ # ── Result tab ──────────────────────────────────────────────────────
135
178
  if os.path.isfile(self._r_path):
136
179
  try:
137
180
  with open(self._r_path, "r", encoding="utf-8", errors="replace") as f:
138
181
  rt = f.read().strip()
182
+ self.query_one("#result_header", Static).update(
183
+ self._header_line("Result", self._r_path)
184
+ )
139
185
  if rt:
140
186
  self._result_text = rt
141
- self.query_one("#result_text", Static).update(
142
- self._text_with_path("Result", self._r_path, rt)
143
- )
187
+ self.query_one("#result_md", Markdown).update(rt)
144
188
  except (OSError, UnicodeDecodeError):
145
189
  pass
146
190
  else:
147
- self.query_one("#result_text", Static).update(self._text_with_path("Result", self._r_path, ""))
191
+ self.query_one("#result_header", Static).update(
192
+ self._header_line("Result", self._r_path)
193
+ )
148
194
 
149
- # Load JSONL stream.
150
- self._stream_raw = self._header_line("JSONL", self._jl_path)
151
- self.query_one("#stream_text", Static).update(self._stream_raw)
195
+ # ── Stream tab ──────────────────────────────────────────────────────
196
+ stream_log = self.query_one("#stream_log", RichLog)
197
+ stream_log.write(Text(self._header_line("JSONL", self._jl_path), style="dim"))
152
198
  if os.path.isfile(self._jl_path):
153
199
  with open(self._jl_path, "r", encoding="utf-8", errors="replace") as f:
154
200
  f.seek(self._fpos)
@@ -156,13 +202,14 @@ class JsonlViewerScreen(Screen):
156
202
  self._fpos = f.tell()
157
203
  self._append_events(events)
158
204
 
159
- self._out_raw = self._header_line("Output", self._o_path)
160
- self.query_one("#output_text", Static).update(self._out_raw)
205
+ # ── Output tab ──────────────────────────────────────────────────────
206
+ out_log = self.query_one("#output_log", RichLog)
207
+ out_log.write(Text(self._header_line("Output", self._o_path), style="dim"))
161
208
  self._append_output_from_file()
162
209
 
163
210
  # Scroll to bottom after initial load
164
- self._scroll_to_bottom("stream")
165
- self._scroll_to_bottom("output")
211
+ stream_log.scroll_end(animate=False)
212
+ out_log.scroll_end(animate=False)
166
213
 
167
214
  # Start poll worker for all modes (live updates for running runs)
168
215
  self._poll_jsonl()
@@ -181,6 +228,8 @@ class JsonlViewerScreen(Screen):
181
228
  self._append_events(events)
182
229
  except (OSError, UnicodeDecodeError):
183
230
  pass
231
+ except Exception:
232
+ pass # screen may have been unmounted
184
233
 
185
234
  self._append_output_from_file()
186
235
 
@@ -188,24 +237,31 @@ class JsonlViewerScreen(Screen):
188
237
  self._sync_auto_follow("stream")
189
238
  self._sync_auto_follow("output")
190
239
 
191
- await asyncio.sleep(self._poll_interval)
240
+ try:
241
+ await asyncio.sleep(self._poll_interval)
242
+ except asyncio.CancelledError:
243
+ break
192
244
 
193
245
  def on_unmount(self) -> None:
194
246
  """Ensure poll loop exits when screen is removed."""
195
247
  self._keep_polling = False
196
248
 
197
249
  def _sync_auto_follow(self, tab_id: str) -> None:
198
- """Update _auto_follow based on current scroll position."""
250
+ """Update _auto_follow based on current scroll position.
251
+
252
+ Both stream and output use RichLog (a ScrollView) directly;
253
+ no VerticalScroll wrapper to query.
254
+ """
199
255
  try:
200
- scroll = self.query_one(f"#{tab_id}_scroll", VerticalScroll)
201
- self._auto_follow[tab_id] = scroll.is_vertical_scroll_end
256
+ rl = self.query_one(f"#{tab_id}_log", RichLog)
257
+ self._auto_follow[tab_id] = rl.is_vertical_scroll_end
202
258
  except Exception:
203
259
  pass
204
260
 
205
261
  def _scroll_to_bottom(self, tab_id: str) -> None:
206
- """Scroll stream container to bottom."""
262
+ """Scroll the log widget to its end."""
207
263
  try:
208
- self.query_one(f"#{tab_id}_scroll", VerticalScroll).scroll_end(animate=False)
264
+ self.query_one(f"#{tab_id}_log", RichLog).scroll_end(animate=False)
209
265
  except Exception:
210
266
  pass
211
267
 
@@ -234,31 +290,25 @@ class JsonlViewerScreen(Screen):
234
290
  def _header_line(self, label: str, path: str) -> str:
235
291
  return f"{label}: {os.path.abspath(os.path.expanduser(path))}"
236
292
 
237
- def _text_with_path(self, label: str, path: str, body: str) -> str:
238
- header = self._header_line(label, path)
239
- return f"{header}\n\n{body}" if body else header
240
-
241
- def _append_text_block(self, tab_id: str, text: str) -> None:
242
- if not text:
243
- return
244
- self._sync_auto_follow(tab_id)
245
- attr = "_stream_raw" if tab_id == "stream" else "_out_raw"
246
- widget_id = "#stream_text" if tab_id == "stream" else "#output_text"
247
- current = getattr(self, attr) or ""
248
- updated = current + text if current.endswith("\n") or text.startswith("\n") else current + "\n" + text
249
- setattr(self, attr, updated)
250
- try:
251
- self.query_one(widget_id, Static).update(updated)
252
- except Exception:
253
- return
254
-
255
- new_count = text.count("\n") + (0 if text.endswith("\n") else 1)
256
- if self._auto_follow[tab_id]:
257
- self._scroll_to_bottom(tab_id)
258
- self._pending_new_count[tab_id] = 0
293
+ @staticmethod
294
+ def _format_out_line(line: str) -> Text:
295
+ """Format a single .out.txt line with an optional timestamp gutter.
296
+
297
+ Lines starting with ``HH:MM:SS`` get the timestamp rendered dim;
298
+ lines without a timestamp prefix (e.g. ``RESULT=...``) get an empty
299
+ 8-space gutter for alignment.
300
+ """
301
+ m = re.match(r"^(\d{2}:\d{2}:\d{2})\s+(.*)$", line)
302
+ t = Text()
303
+ if m:
304
+ t.append(f"{m.group(1):8}", style="dim")
305
+ t.append(" │ ", style="dim")
306
+ t.append(m.group(2))
259
307
  else:
260
- self._pending_new_count[tab_id] += max(new_count, 1)
261
- self._update_info_bar()
308
+ t.append(f"{'':8}", style="dim")
309
+ t.append(" │ ", style="dim")
310
+ t.append(line)
311
+ return t
262
312
 
263
313
  def _append_output_from_file(self) -> None:
264
314
  if not os.path.isfile(self._o_path):
@@ -267,57 +317,92 @@ class JsonlViewerScreen(Screen):
267
317
  size = os.path.getsize(self._o_path)
268
318
  if size < self._out_fpos:
269
319
  self._out_fpos = 0
320
+ self._out_buffer = ""
270
321
  with open(self._o_path, "r", encoding="utf-8", errors="replace") as f:
271
322
  f.seek(self._out_fpos)
272
323
  chunk = f.read()
273
324
  self._out_fpos = f.tell()
274
325
  except OSError:
275
326
  return
276
- self._append_text_block("output", chunk)
327
+
328
+ # Prepend leftover partial line from the previous read
329
+ if self._out_buffer:
330
+ chunk = self._out_buffer + chunk
331
+ self._out_buffer = ""
332
+
333
+ lines = chunk.split("\n")
334
+ # A chunk that doesn't end with \n means the last line is partial
335
+ if not chunk.endswith("\n"):
336
+ self._out_buffer = lines.pop()
337
+ elif lines and lines[-1] == "":
338
+ lines.pop() # trailing empty element from split
339
+
340
+ if not lines:
341
+ return
342
+
343
+ self._sync_auto_follow("output")
344
+
345
+ try:
346
+ out_log = self.query_one("#output_log", RichLog)
347
+ for line in lines:
348
+ out_log.write(self._format_out_line(line))
349
+
350
+ if self._auto_follow["output"]:
351
+ out_log.scroll_end(animate=False)
352
+ self._pending_new_count["output"] = 0
353
+ else:
354
+ self._pending_new_count["output"] += len(lines)
355
+ except Exception:
356
+ return
357
+
358
+ self._update_info_bar()
277
359
 
278
360
  def _append_events(self, events: list[ParsedEvent]) -> None:
279
361
  if not events:
280
362
  return
281
363
 
282
- new_lines: list[str] = []
364
+ rich_lines: list[Text] = []
283
365
  for event in events:
366
+ # result_text / error_text → Markdown tab
284
367
  if event.kind in ("result_text", "error_text"):
285
368
  self._result_text = event.text
286
369
  try:
287
- self.query_one("#result_text", Static).update(
288
- self._text_with_path("Result", self._r_path, self._result_text)
370
+ self.query_one("#result_header", Static).update(
371
+ self._header_line("Result", self._r_path)
289
372
  )
373
+ self.query_one("#result_md", Markdown).update(event.text)
290
374
  self.query_one(TabbedContent).active = "result"
291
375
  except Exception:
292
376
  pass
293
377
  continue
294
378
 
295
- line = format_event_for_viewer(event)
296
- if line and line != self._last_stream_line:
297
- new_lines.append(line)
298
- self._last_stream_line = line
379
+ line = format_event_as_rich(event)
380
+ if line is None:
381
+ continue
382
+ # Skip line if it's identical to the last one (JSONL dedup)
383
+ if line.plain == self._last_stream_line:
384
+ continue
385
+ self._last_stream_line = line.plain
386
+ rich_lines.append(line)
299
387
 
300
- if not new_lines:
388
+ if not rich_lines:
301
389
  return
302
390
 
303
- # Check if we should auto-follow before updating content
304
391
  self._sync_auto_follow("stream")
305
392
 
306
393
  try:
307
- current = self._stream_raw or ""
308
- appended = "\n".join(new_lines)
309
- self._stream_raw = current + "\n" + appended if current else appended
310
- self.query_one("#stream_text", Static).update(self._stream_raw)
394
+ stream_log = self.query_one("#stream_log", RichLog)
395
+ for rl in rich_lines:
396
+ stream_log.write(rl)
397
+
398
+ if self._auto_follow["stream"]:
399
+ stream_log.scroll_end(animate=False)
400
+ self._pending_new_count["stream"] = 0
401
+ else:
402
+ self._pending_new_count["stream"] += len(rich_lines)
311
403
  except Exception:
312
404
  return
313
405
 
314
- # Auto-scroll or track pending new content
315
- if self._auto_follow["stream"]:
316
- self._scroll_to_bottom("stream")
317
- self._pending_new_count["stream"] = 0
318
- else:
319
- self._pending_new_count["stream"] += len(new_lines)
320
-
321
406
  self._update_info_bar()
322
407
 
323
408
  # ── actions ──────────────────────────────────────────────────────────
@@ -385,22 +470,34 @@ class JsonlViewerScreen(Screen):
385
470
  def action_scroll_active(self, direction: str) -> None:
386
471
  try:
387
472
  active = self.query_one(TabbedContent).active
388
- scroll = self.query_one(f"#{active}_scroll", VerticalScroll)
389
473
  except Exception:
390
474
  return
391
475
 
476
+ if active in ("stream", "output"):
477
+ # Log tabs use RichLog (a ScrollView) — no VerticalScroll wrapper
478
+ try:
479
+ w = self.query_one(f"#{active}_log", RichLog)
480
+ except Exception:
481
+ return
482
+ else:
483
+ # Markdown tabs use a VerticalScroll wrapper
484
+ try:
485
+ w = self.query_one(f"#{active}_scroll", VerticalScroll)
486
+ except Exception:
487
+ return
488
+
392
489
  if direction == "up":
393
- scroll.scroll_up(animate=False)
490
+ w.scroll_up(animate=False)
394
491
  elif direction == "down":
395
- scroll.scroll_down(animate=False)
492
+ w.scroll_down(animate=False)
396
493
  elif direction == "page_up":
397
- scroll.scroll_page_up(animate=False)
494
+ w.scroll_page_up(animate=False)
398
495
  elif direction == "page_down":
399
- scroll.scroll_page_down(animate=False)
496
+ w.scroll_page_down(animate=False)
400
497
  elif direction == "home":
401
- scroll.scroll_home(animate=False)
498
+ w.scroll_home(animate=False)
402
499
  elif direction == "end":
403
- scroll.scroll_end(animate=False)
500
+ w.scroll_end(animate=False)
404
501
 
405
502
  if active in self._auto_follow:
406
503
  self._sync_auto_follow(active)
@@ -413,7 +510,7 @@ class JsonlViewerScreen(Screen):
413
510
  # Tail entry point
414
511
  # ═══════════════════════════════════════════════════════════════════════════════
415
512
 
416
- class JsonlTailApp(App):
513
+ class JsonlTailApp(HandoffTuiApp):
417
514
  """Standalone Textual app for `handoff tail`."""
418
515
 
419
516
  TITLE = "handoff tail"
@@ -425,13 +522,14 @@ class JsonlTailApp(App):
425
522
  out_path: str,
426
523
  result_path: str,
427
524
  run_info: dict,
525
+ theme_name: str | None = None,
428
526
  ):
429
527
  self._a_jl = jsonl_path
430
528
  self._a_pp = prompt_path
431
529
  self._a_op = out_path
432
530
  self._a_rp = result_path
433
531
  self._a_ri = run_info
434
- super().__init__()
532
+ super().__init__(theme_name=theme_name)
435
533
 
436
534
  def on_mount(self) -> None:
437
535
  self.push_screen(JsonlViewerScreen(
@@ -442,12 +540,26 @@ class JsonlTailApp(App):
442
540
  run_info=self._a_ri,
443
541
  mode="follow",
444
542
  ))
543
+ self.apply_initial_theme()
445
544
 
446
545
 
447
- def run_tail(jsonl_path: str, prompt_path: str, result_path: str, run_info: dict) -> None:
546
+ def run_tail(
547
+ jsonl_path: str,
548
+ prompt_path: str,
549
+ result_path: str,
550
+ run_info: dict,
551
+ theme_name: str | None = None,
552
+ ) -> None:
448
553
  """Entry point for `handoff tail`."""
449
554
  out_path = run_info.get("out_path", "")
450
- JsonlTailApp(jsonl_path, prompt_path, out_path, result_path, run_info).run(mouse=False)
555
+ JsonlTailApp(
556
+ jsonl_path,
557
+ prompt_path,
558
+ out_path,
559
+ result_path,
560
+ run_info,
561
+ theme_name=theme_name,
562
+ ).run(mouse=False)
451
563
 
452
564
 
453
565
  def make_viewer_screen(
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Optional, Callable
6
6
 
7
- from textual.app import App, ComposeResult
7
+ from textual.app import App, ComposeResult, InvalidThemeError
8
8
  from textual.containers import Container
9
9
  from textual.screen import Screen
10
10
  from textual.widgets import DataTable, Footer, Static
@@ -12,12 +12,50 @@ from textual.binding import Binding
12
12
  from textual.coordinate import Coordinate
13
13
  from textual.message import Message
14
14
 
15
+ from .config import DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, read_tui_theme, write_tui_theme
15
16
  from .core import format_run_row, task_paths
16
17
 
17
18
  # Seconds between DB polls for auto-refresh.
18
19
  POLL_INTERVAL = 5.0
19
20
 
20
21
 
22
+ class HandoffTuiApp(App):
23
+ """Shared Textual app behavior for handoff TUI screens."""
24
+
25
+ BINDINGS = [
26
+ Binding("d", "cycle_theme", "Theme", show=True),
27
+ ]
28
+
29
+ def __init__(self, *args, theme_name: str | None = None, **kwargs):
30
+ self._initial_theme_name = theme_name or read_tui_theme()
31
+ super().__init__(*args, **kwargs)
32
+
33
+ def apply_initial_theme(self) -> None:
34
+ self._set_theme(self._initial_theme_name, quiet=False)
35
+
36
+ def _set_theme(self, theme_name: str, *, quiet: bool) -> str:
37
+ try:
38
+ self.theme = theme_name
39
+ return theme_name
40
+ except InvalidThemeError:
41
+ self.theme = DEFAULT_DARK_THEME
42
+ if not quiet:
43
+ self.notify(
44
+ f"Unknown theme: {theme_name}. Using {DEFAULT_DARK_THEME}.",
45
+ severity="warning",
46
+ timeout=3,
47
+ )
48
+ return DEFAULT_DARK_THEME
49
+
50
+ def action_cycle_theme(self) -> None:
51
+ next_theme = (
52
+ DEFAULT_LIGHT_THEME if self.current_theme.dark else DEFAULT_DARK_THEME
53
+ )
54
+ applied_theme = self._set_theme(next_theme, quiet=False)
55
+ write_tui_theme(applied_theme)
56
+ self.notify(f"Theme saved: {applied_theme}", severity="information", timeout=2)
57
+
58
+
21
59
  class RunListScreen(Screen):
22
60
  """Main screen showing the run list in a DataTable.
23
61
 
@@ -30,8 +68,8 @@ class RunListScreen(Screen):
30
68
 
31
69
  BINDINGS = [
32
70
  Binding("right,space", "select_run", "Detail", show=True),
33
- Binding("o", "go_resume", "Open in Claude", show=True),
34
- Binding("c", "copy_session", "Copy Session", show=True),
71
+ Binding("o", "go_resume", "Open", show=True),
72
+ Binding("c", "copy_session", "Copy", show=True),
35
73
  Binding("q", "quit", "Quit", show=True),
36
74
  ]
37
75
 
@@ -273,7 +311,7 @@ class RunListScreen(Screen):
273
311
  self._rebuild_table()
274
312
 
275
313
 
276
- class RunListApp(App):
314
+ class RunListApp(HandoffTuiApp):
277
315
  """Textual app wrapping the run list screen.
278
316
 
279
317
  Usage:
@@ -296,12 +334,18 @@ class RunListApp(App):
296
334
  }
297
335
  """
298
336
 
299
- def __init__(self, rows: list, full_cwd: bool = False, refresh_fn: Callable[[], list] | None = None):
337
+ def __init__(
338
+ self,
339
+ rows: list,
340
+ full_cwd: bool = False,
341
+ refresh_fn: Callable[[], list] | None = None,
342
+ theme_name: str | None = None,
343
+ ):
300
344
  self._rows = rows
301
345
  self._full_cwd = full_cwd
302
346
  self._refresh_fn = refresh_fn
303
347
  self._action_result: Optional[str] = None
304
- super().__init__()
348
+ super().__init__(theme_name=theme_name)
305
349
 
306
350
  @property
307
351
  def action_result(self) -> Optional[str]:
@@ -310,6 +354,7 @@ class RunListApp(App):
310
354
  def on_mount(self) -> None:
311
355
  screen = RunListScreen(self._rows, self._full_cwd, refresh_fn=self._refresh_fn)
312
356
  self.push_screen(screen)
357
+ self.apply_initial_theme()
313
358
 
314
359
  def on_screen_dismiss(self, event: Screen.Dismissed) -> None:
315
360
  """Capture action result when a screen is dismissed."""
@@ -146,5 +146,20 @@ system_prompt: |
146
146
  | --- | --- |
147
147
  | `system_prompt` | 覆盖内置 system_prompt |
148
148
  | `include` | 引用其他 YAML 文件 |
149
+ | `tui.theme` | TUI 主题名;默认 `textual-dark`,可选 `textual-light` 及其他 Textual 内置主题 |
150
+
151
+ ## TUI 主题
152
+
153
+ `handoff list` 和 `handoff tail` 共用同一份 TUI 主题配置:
154
+
155
+ ```yaml
156
+ tui:
157
+ theme: textual-dark
158
+ ```
159
+
160
+ - 默认值是 `textual-dark`
161
+ - 常见亮色可用 `textual-light`
162
+ - 运行时可在 TUI 底部按 `D` 在默认深色 / 亮色主题之间切换
163
+ - 如果你填了不存在的主题名,程序会自动回退到 `textual-dark`,不会因为主题配置崩溃
149
164
 
150
165
  机制层(`cli/backend_types.yaml`)定义了 claude/codex 两种 type 的 `command`、`pty`、`session_flags`、`session_id_flags`、`continue_id_flags`、`resume_flags`。这些是程序行为,不可覆盖——想了解完整启动逻辑请直接读那个文件。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "handoff-cli"
3
- version = "0.3.6"
3
+ version = "0.3.7"
4
4
  description = "Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Verify that _markdown_parser_factory suppresses link/image/autolink tokens.
3
+
4
+ In Textual 2.1.2, ``Style.from_meta({"@click": link_action})`` (called when
5
+ ``link_open`` / ``image`` tokens are present) crashes Python's marshal with
6
+ ``ValueError: bad marshal data (unknown type code)`` when the href/alt string
7
+ contains characters such as ``:`` (e.g. ``/tmp/a:1`` from ``[x](/tmp/a:1)``).
8
+
9
+ This test ensures ``_markdown_parser_factory`` from ``cli.jsonl_viewer`` never
10
+ produces those tokens, regardless of content.
11
+ """
12
+
13
+ import sys
14
+ import os
15
+
16
+ # Ensure project root is on sys.path so imports resolve
17
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
18
+
19
+ from markdown_it import MarkdownIt
20
+
21
+ from cli.jsonl_viewer import _markdown_parser_factory
22
+
23
+
24
+ def _collect_inline_types(md: MarkdownIt, text: str) -> list[str]:
25
+ """Return all inline token types produced for *text*."""
26
+ types: list[str] = []
27
+ for block in md.parse(text):
28
+ if block.children:
29
+ for child in block.children:
30
+ types.append(child.type)
31
+ return types
32
+
33
+
34
+ def test_explicit_link_is_plain_text():
35
+ """[x](/tmp/a:1) must NOT produce link_open/link_close."""
36
+ md = _markdown_parser_factory()
37
+ types = _collect_inline_types(md, "[x](/tmp/a:1)")
38
+ assert "link_open" not in types, f"link_open present: {types}"
39
+ assert "link_close" not in types, f"link_close present: {types}"
40
+ # The entire thing should be a single text token
41
+ assert "text" in types, f"no text token: {types}"
42
+ print(f" [x](/tmp/a:1) -> types={types} OK")
43
+
44
+
45
+ def test_image_is_plain_text():
46
+ """![alt](/tmp/a.png) must NOT produce an image token."""
47
+ md = _markdown_parser_factory()
48
+ types = _collect_inline_types(md, "![alt](/tmp/a.png)")
49
+ assert "image" not in types, f"image present: {types}"
50
+ assert "text" in types, f"no text token: {types}"
51
+ print(f" ![alt](/tmp/a.png) -> types={types} OK")
52
+
53
+
54
+ def test_autolink_is_plain_text():
55
+ """<https://example.com> must NOT produce link_open."""
56
+ md = _markdown_parser_factory()
57
+ types = _collect_inline_types(md, "<https://example.com>")
58
+ assert "link_open" not in types, f"link_open present: {types}"
59
+ assert "text" in types, f"no text token: {types}"
60
+ print(f" <https://example.com> -> types={types} OK")
61
+
62
+
63
+ def test_bold_still_works():
64
+ """**bold** must still produce strong_open/strong_close."""
65
+ md = _markdown_parser_factory()
66
+ types = _collect_inline_types(md, "**bold**")
67
+ assert "strong_open" in types, f"strong_open missing: {types}"
68
+ assert "strong_close" in types, f"strong_close missing: {types}"
69
+ print(f" **bold** -> types={types} OK")
70
+
71
+
72
+ def test_italic_still_works():
73
+ """*italic* must still produce emphasis tokens."""
74
+ md = _markdown_parser_factory()
75
+ types = _collect_inline_types(md, "*italic*")
76
+ assert "text" in types, f"text missing: {types}"
77
+ print(f" *italic* -> types={types} OK")
78
+
79
+
80
+ def test_inline_code_still_works():
81
+ """`code` must still produce code_inline."""
82
+ md = _markdown_parser_factory()
83
+ types = _collect_inline_types(md, "some `code`")
84
+ assert "code_inline" in types, f"code_inline missing: {types}"
85
+ print(f" some `code` -> types={types} OK")
86
+
87
+
88
+ def test_heading_still_works():
89
+ """# Heading must still produce heading_open block."""
90
+ md = _markdown_parser_factory()
91
+ block_types = [t.type for t in md.parse("# Heading")]
92
+ assert "heading_open" in block_types, f"heading_open missing: {block_types}"
93
+ print(f" # Heading -> types={block_types} OK")
94
+
95
+
96
+ def test_lists_still_work():
97
+ """Bullet lists must still produce list_item_open."""
98
+ md = _markdown_parser_factory()
99
+ block_types = [t.type for t in md.parse("- a\n- b")]
100
+ assert "list_item_open" in block_types, f"list_item_open missing: {block_types}"
101
+ print(f" - a\\n- b -> types={block_types} OK")
102
+
103
+
104
+ def test_url_without_markdown_syntax_is_not_linkified():
105
+ """A bare URL without []() should not be linkified (linkify=False)."""
106
+ md = _markdown_parser_factory()
107
+ types = _collect_inline_types(md, "https://example.com/path")
108
+ # No linkify means bare URL stays as plain text
109
+ assert "link_open" not in types, f"link_open present with linkify=False: {types}"
110
+ assert "text" in types, f"text missing: {types}"
111
+ print(f" https://example.com/path -> types={types} OK")
112
+
113
+
114
+ def test_link_and_image_mixed_with_other_content():
115
+ """A paragraph mixing links, images, and bold must handle all correctly."""
116
+ md = _markdown_parser_factory()
117
+ text = "**bold** [link](/tmp/x) `code`"
118
+ types = _collect_inline_types(md, text)
119
+ assert "link_open" not in types, f"link_open still present: {types}"
120
+ assert "strong_open" in types, f"strong_open missing: {types}"
121
+ assert "code_inline" in types, f"code_inline missing: {types}"
122
+ print(f" mixed content -> types={types} OK")
123
+
124
+
125
+ def main():
126
+ print("=== markdown-it parser regression tests ===")
127
+ print()
128
+ tests = [
129
+ ("explicit link → plain text", test_explicit_link_is_plain_text),
130
+ ("image → plain text", test_image_is_plain_text),
131
+ ("autolink → plain text", test_autolink_is_plain_text),
132
+ ("bold unaffected", test_bold_still_works),
133
+ ("italic unaffected", test_italic_still_works),
134
+ ("inline code unaffected", test_inline_code_still_works),
135
+ ("heading unaffected", test_heading_still_works),
136
+ ("lists unaffected", test_lists_still_work),
137
+ ("bare URL not linkified", test_url_without_markdown_syntax_is_not_linkified),
138
+ ("mixed content", test_link_and_image_mixed_with_other_content),
139
+ ]
140
+ failures = 0
141
+ for label, fn in tests:
142
+ try:
143
+ fn()
144
+ except AssertionError as e:
145
+ print(f" FAIL [{label}]: {e}")
146
+ failures += 1
147
+
148
+ print()
149
+ if failures:
150
+ print(f"FAILED: {failures} test(s)")
151
+ sys.exit(1)
152
+ else:
153
+ print(f"All {len(tests)} tests PASSED")
154
+ sys.exit(0)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes