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.
- handoff_cli-0.3.6/README.md → handoff_cli-0.3.7/PKG-INFO +18 -0
- handoff_cli-0.3.6/PKG-INFO → handoff_cli-0.3.7/README.md +9 -9
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/list.py +2 -1
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/tail.py +2 -1
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/config.py +45 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/jsonl_parser.py +42 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/jsonl_viewer.py +217 -105
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/tui.py +51 -6
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/configuration.zh-CN.md +15 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/pyproject.toml +1 -1
- handoff_cli-0.3.7/tests/test_markdown_parser.py +158 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/.github/workflows/publish.yml +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/.gitignore +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/CLAUDE.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/Makefile +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/README.zh-CN.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/__init__.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/backend.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/backend_types.yaml +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/__init__.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/env.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/init.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/new.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/resume.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/commands/run.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/core.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/main.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-codex/SKILL.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-ds/SKILL.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-ds.toml +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/skills/handoff-opus/SKILL.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/stream.py +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/cli/user_config_template.yaml +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/TODO.md +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/claude-code.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/codex.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/handoff-hero.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/list-tui.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/parallel.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/shell.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/assets/tail.jpg +0 -0
- {handoff_cli-0.3.6 → handoff_cli-0.3.7}/docs/cli-reference.zh-CN.md +0 -0
- {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
|
-
|
|
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 (
|
|
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 .
|
|
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. ,
|
|
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
|
|
47
|
-
Binding("c", "copy_session", "Copy
|
|
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
|
-
|
|
50
|
-
Binding("
|
|
51
|
-
Binding("
|
|
52
|
-
Binding("
|
|
53
|
-
Binding("
|
|
54
|
-
|
|
55
|
-
Binding("up,k", "scroll_active('up')", "
|
|
56
|
-
Binding("down,j", "scroll_active('down')", "
|
|
57
|
-
Binding("pageup", "scroll_active('page_up')", "
|
|
58
|
-
Binding("pagedown", "scroll_active('page_down')", "
|
|
59
|
-
Binding("home", "scroll_active('home')", "
|
|
60
|
-
Binding("end", "scroll_active('end')", "
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
#
|
|
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("#
|
|
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("#
|
|
173
|
+
self.query_one("#prompt_header", Static).update(
|
|
174
|
+
self._header_line("Prompt", self._p_path)
|
|
175
|
+
)
|
|
133
176
|
|
|
134
|
-
#
|
|
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("#
|
|
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("#
|
|
191
|
+
self.query_one("#result_header", Static).update(
|
|
192
|
+
self._header_line("Result", self._r_path)
|
|
193
|
+
)
|
|
148
194
|
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
self.
|
|
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
|
-
|
|
160
|
-
self.query_one("#
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
self._auto_follow[tab_id] =
|
|
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
|
|
262
|
+
"""Scroll the log widget to its end."""
|
|
207
263
|
try:
|
|
208
|
-
self.query_one(f"#{tab_id}
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("#
|
|
288
|
-
self.
|
|
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 =
|
|
296
|
-
if line
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
490
|
+
w.scroll_up(animate=False)
|
|
394
491
|
elif direction == "down":
|
|
395
|
-
|
|
492
|
+
w.scroll_down(animate=False)
|
|
396
493
|
elif direction == "page_up":
|
|
397
|
-
|
|
494
|
+
w.scroll_page_up(animate=False)
|
|
398
495
|
elif direction == "page_down":
|
|
399
|
-
|
|
496
|
+
w.scroll_page_down(animate=False)
|
|
400
497
|
elif direction == "home":
|
|
401
|
-
|
|
498
|
+
w.scroll_home(animate=False)
|
|
402
499
|
elif direction == "end":
|
|
403
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
34
|
-
Binding("c", "copy_session", "Copy
|
|
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(
|
|
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__(
|
|
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`。这些是程序行为,不可覆盖——想了解完整启动逻辑请直接读那个文件。
|
|
@@ -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
|
+
""" must NOT produce an image token."""
|
|
47
|
+
md = _markdown_parser_factory()
|
|
48
|
+
types = _collect_inline_types(md, "")
|
|
49
|
+
assert "image" not in types, f"image present: {types}"
|
|
50
|
+
assert "text" in types, f"no text token: {types}"
|
|
51
|
+
print(f"  -> 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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|