handoff-cli 0.3.0__py3-none-any.whl
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.
- cli/__init__.py +3 -0
- cli/backend.py +224 -0
- cli/backend_types.yaml +91 -0
- cli/commands/__init__.py +0 -0
- cli/commands/env.py +30 -0
- cli/commands/init.py +129 -0
- cli/commands/list.py +81 -0
- cli/commands/resume.py +179 -0
- cli/commands/run.py +211 -0
- cli/commands/tail.py +48 -0
- cli/config.py +351 -0
- cli/core.py +302 -0
- cli/jsonl_parser.py +182 -0
- cli/jsonl_viewer.py +440 -0
- cli/main.py +98 -0
- cli/skills/handoff-codex/SKILL.md +77 -0
- cli/skills/handoff-ds/SKILL.md +77 -0
- cli/skills/handoff-ds.toml +52 -0
- cli/skills/handoff-opus/SKILL.md +77 -0
- cli/stream.py +286 -0
- cli/tui.py +317 -0
- cli/user_config_template.yaml +31 -0
- handoff_cli-0.3.0.dist-info/METADATA +7 -0
- handoff_cli-0.3.0.dist-info/RECORD +26 -0
- handoff_cli-0.3.0.dist-info/WHEEL +4 -0
- handoff_cli-0.3.0.dist-info/entry_points.txt +2 -0
cli/jsonl_parser.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Shared JSONL parsing and formatting helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ParsedEvent:
|
|
13
|
+
"""One parsed event extracted from a JSONL line."""
|
|
14
|
+
|
|
15
|
+
ts: str
|
|
16
|
+
text: str
|
|
17
|
+
kind: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _extract_time(obj: dict) -> str:
|
|
21
|
+
ts_str = obj.get("timestamp", "")
|
|
22
|
+
if ts_str and isinstance(ts_str, str):
|
|
23
|
+
try:
|
|
24
|
+
dt = datetime.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
25
|
+
# Convert to local timezone (ISO timestamps from Claude are UTC)
|
|
26
|
+
if dt.tzinfo is not None:
|
|
27
|
+
dt = dt.astimezone()
|
|
28
|
+
return dt.strftime("%H:%M:%S")
|
|
29
|
+
except (ValueError, TypeError):
|
|
30
|
+
pass
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collapse_whitespace(text: str) -> str:
|
|
35
|
+
return " ".join(text.split())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _truncate(text: str, n: int = 80) -> str:
|
|
39
|
+
collapsed = _collapse_whitespace(text)
|
|
40
|
+
if len(collapsed) <= n:
|
|
41
|
+
return collapsed
|
|
42
|
+
return collapsed[: n - 1] + "…"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _short_tool_id(tool_id: str) -> str:
|
|
46
|
+
if "_" in tool_id:
|
|
47
|
+
return tool_id.split("_")[-1][:8]
|
|
48
|
+
return tool_id[:8]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_jsonl_line(line: str, prev_ts: str = "") -> list[ParsedEvent]:
|
|
52
|
+
"""Parse one JSONL line into zero or more logical events."""
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line.startswith("{"):
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
obj = json.loads(line)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
ts = _extract_time(obj) or prev_ts
|
|
63
|
+
t = obj.get("type", "")
|
|
64
|
+
events: list[ParsedEvent] = []
|
|
65
|
+
|
|
66
|
+
if t == "stream_event":
|
|
67
|
+
se = obj.get("event", {})
|
|
68
|
+
et = se.get("type", "")
|
|
69
|
+
if et == "content_block_start":
|
|
70
|
+
cb = se.get("content_block", {})
|
|
71
|
+
if cb.get("type") == "tool_use":
|
|
72
|
+
name = cb.get("name", "?")
|
|
73
|
+
tool_id = cb.get("id", "")
|
|
74
|
+
events.append(ParsedEvent(ts, f"{name} {_short_tool_id(tool_id)}", "tool"))
|
|
75
|
+
elif et == "message_start":
|
|
76
|
+
model = se.get("message", {}).get("model", "")
|
|
77
|
+
if model:
|
|
78
|
+
events.append(ParsedEvent(ts, f"model: {model}", "info"))
|
|
79
|
+
|
|
80
|
+
elif t == "assistant":
|
|
81
|
+
for content in obj.get("message", {}).get("content", []):
|
|
82
|
+
ct = content.get("type", "")
|
|
83
|
+
if ct == "text":
|
|
84
|
+
text = content.get("text", "")
|
|
85
|
+
if isinstance(text, str) and text.strip():
|
|
86
|
+
events.append(ParsedEvent(ts, text, "text"))
|
|
87
|
+
elif ct == "tool_use":
|
|
88
|
+
name = content.get("name", "?")
|
|
89
|
+
tool_id = content.get("id", "")
|
|
90
|
+
events.append(ParsedEvent(ts, f"{name} {_short_tool_id(tool_id)}", "tool"))
|
|
91
|
+
|
|
92
|
+
elif t == "user":
|
|
93
|
+
content = obj.get("message", {}).get("content", [])
|
|
94
|
+
if isinstance(content, list):
|
|
95
|
+
for item in content:
|
|
96
|
+
if isinstance(item, dict) and item.get("type") == "tool_result":
|
|
97
|
+
result = item.get("content", "")
|
|
98
|
+
if isinstance(result, str) and result.strip():
|
|
99
|
+
events.append(ParsedEvent(ts, result, "info"))
|
|
100
|
+
|
|
101
|
+
elif t == "system":
|
|
102
|
+
subtype = obj.get("subtype", "")
|
|
103
|
+
if subtype == "status":
|
|
104
|
+
status = obj.get("status", "")
|
|
105
|
+
if status:
|
|
106
|
+
events.append(ParsedEvent(ts, f"status: {status}", "info"))
|
|
107
|
+
elif subtype == "task_started":
|
|
108
|
+
desc = obj.get("description", "")
|
|
109
|
+
if desc:
|
|
110
|
+
events.append(ParsedEvent(ts, desc, "task"))
|
|
111
|
+
|
|
112
|
+
elif t == "result":
|
|
113
|
+
subtype = obj.get("subtype", "")
|
|
114
|
+
is_success = subtype == "success" and not obj.get("is_error", False)
|
|
115
|
+
duration = obj.get("duration_ms", 0)
|
|
116
|
+
cost = obj.get("total_cost_usd", 0)
|
|
117
|
+
turns = obj.get("num_turns", 0)
|
|
118
|
+
dur_str = f"{duration / 1000:.0f}s" if duration else "?"
|
|
119
|
+
summary = f"Done {dur_str} {turns} turns ${cost:.4f}"
|
|
120
|
+
if is_success:
|
|
121
|
+
events.append(ParsedEvent(ts, summary, "result"))
|
|
122
|
+
else:
|
|
123
|
+
events.append(ParsedEvent(ts, f"ERROR: {summary}", "error"))
|
|
124
|
+
|
|
125
|
+
result_text = obj.get("result", "")
|
|
126
|
+
if isinstance(result_text, str) and result_text:
|
|
127
|
+
result_kind = "result_text" if is_success else "error_text"
|
|
128
|
+
events.append(ParsedEvent(ts, result_text, result_kind))
|
|
129
|
+
|
|
130
|
+
return events
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_events(handle: TextIO, prev_ts: str = "") -> tuple[list[ParsedEvent], str]:
|
|
134
|
+
"""Read all remaining lines from a file handle into parsed events."""
|
|
135
|
+
last_ts = prev_ts
|
|
136
|
+
events: list[ParsedEvent] = []
|
|
137
|
+
for line in handle:
|
|
138
|
+
parsed = parse_jsonl_line(line, last_ts)
|
|
139
|
+
for event in parsed:
|
|
140
|
+
if event.ts:
|
|
141
|
+
last_ts = event.ts
|
|
142
|
+
events.append(event)
|
|
143
|
+
return events, last_ts
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def format_event_for_viewer(event: ParsedEvent) -> str | None:
|
|
147
|
+
"""Format one parsed event into a compact list-view line."""
|
|
148
|
+
if event.kind == "result_text":
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
ts = event.ts or " " * 8
|
|
152
|
+
kind_mark = {
|
|
153
|
+
"tool": "▷",
|
|
154
|
+
"text": "✎",
|
|
155
|
+
"result": "✓",
|
|
156
|
+
"error": "✗",
|
|
157
|
+
"task": "▶",
|
|
158
|
+
"info": "·",
|
|
159
|
+
}.get(event.kind, " ")
|
|
160
|
+
return f"`{ts}` {kind_mark} {_truncate(event.text)}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def format_event_for_stream(event: ParsedEvent) -> str | None:
|
|
164
|
+
"""Return the single-line text stream shown during `handoff run`."""
|
|
165
|
+
if event.kind != "text":
|
|
166
|
+
return None
|
|
167
|
+
text = _collapse_whitespace(event.text)
|
|
168
|
+
return text or None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def extract_result(jsonl_path: str) -> str | None:
|
|
172
|
+
"""Return the last successful result text from a JSONL file."""
|
|
173
|
+
try:
|
|
174
|
+
with open(jsonl_path, "r") as handle:
|
|
175
|
+
last_result = None
|
|
176
|
+
events, _ = read_events(handle)
|
|
177
|
+
for event in events:
|
|
178
|
+
if event.kind == "result_text":
|
|
179
|
+
last_result = event.text
|
|
180
|
+
return last_result
|
|
181
|
+
except FileNotFoundError:
|
|
182
|
+
return None
|
cli/jsonl_viewer.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Shared JSONL viewer for handoff list (detail) and tail commands.
|
|
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.
|
|
5
|
+
|
|
6
|
+
Modes:
|
|
7
|
+
- static: list detail page; Escape dismisses back to list
|
|
8
|
+
- follow: `handoff tail`; Escape / Q exit the app
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from textual import work
|
|
18
|
+
from textual.app import App, ComposeResult
|
|
19
|
+
from textual.screen import Screen
|
|
20
|
+
from textual.widgets import (
|
|
21
|
+
Footer,
|
|
22
|
+
TabbedContent,
|
|
23
|
+
TabPane,
|
|
24
|
+
Static,
|
|
25
|
+
)
|
|
26
|
+
from textual.containers import VerticalScroll
|
|
27
|
+
from textual.binding import Binding
|
|
28
|
+
from .jsonl_parser import ParsedEvent, format_event_for_viewer, read_events
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
# JsonlViewerScreen
|
|
33
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
|
|
35
|
+
class JsonlViewerScreen(Screen):
|
|
36
|
+
"""Shared JSONL viewer screen for handoff list (detail) and tail.
|
|
37
|
+
|
|
38
|
+
Layout:
|
|
39
|
+
- RunInfoBar (top bar)
|
|
40
|
+
- TabbedContent (Stream / Prompt / Result)
|
|
41
|
+
- Footer (key bindings)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
BINDINGS = [
|
|
45
|
+
Binding("escape,left", "back", "Back", show=True),
|
|
46
|
+
Binding("o", "go_resume", "Open in Claude", show=True),
|
|
47
|
+
Binding("c", "copy_session", "Copy Session", show=True),
|
|
48
|
+
Binding("q", "quit", "Quit", show=True),
|
|
49
|
+
Binding("1", "show_tab('stream')", "Stream", show=True),
|
|
50
|
+
Binding("2", "show_tab('output')", "Output", show=True),
|
|
51
|
+
Binding("3", "show_tab('prompt')", "Prompt", show=True),
|
|
52
|
+
Binding("4", "show_tab('result')", "Result", show=True),
|
|
53
|
+
Binding("up,k", "scroll_active('up')", "Scroll up", show=False),
|
|
54
|
+
Binding("down,j", "scroll_active('down')", "Scroll down", show=False),
|
|
55
|
+
Binding("pageup", "scroll_active('page_up')", "Page up", show=False),
|
|
56
|
+
Binding("pagedown", "scroll_active('page_down')", "Page down", show=False),
|
|
57
|
+
Binding("home", "scroll_active('home')", "Top", show=False),
|
|
58
|
+
Binding("end", "scroll_active('end')", "Bottom", show=False),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
jsonl_path: str,
|
|
64
|
+
prompt_path: str,
|
|
65
|
+
out_path: str,
|
|
66
|
+
result_path: str,
|
|
67
|
+
run_info: dict,
|
|
68
|
+
mode: str = "static",
|
|
69
|
+
name: str | None = None,
|
|
70
|
+
id: str | None = None,
|
|
71
|
+
classes: str | None = None,
|
|
72
|
+
):
|
|
73
|
+
# Store parameters before super().__init__
|
|
74
|
+
self._jl_path = jsonl_path
|
|
75
|
+
self._p_path = prompt_path
|
|
76
|
+
self._o_path = out_path
|
|
77
|
+
self._r_path = result_path
|
|
78
|
+
self._r_info = run_info
|
|
79
|
+
self._mode = mode
|
|
80
|
+
self._last_ts = ""
|
|
81
|
+
self._fpos = 0
|
|
82
|
+
self._out_fpos = 0
|
|
83
|
+
self._result_text: Optional[str] = None
|
|
84
|
+
self._stream_raw = "" # accumulated stream content for incremental update
|
|
85
|
+
self._out_raw = "" # accumulated .out.txt content
|
|
86
|
+
self._last_stream_line = ""
|
|
87
|
+
self._poll_interval = 0.5
|
|
88
|
+
# Auto-follow state
|
|
89
|
+
self._auto_follow = {"stream": True, "output": True}
|
|
90
|
+
self._pending_new_count = {"stream": 0, "output": 0}
|
|
91
|
+
self._keep_polling = True
|
|
92
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
93
|
+
|
|
94
|
+
def compose(self) -> ComposeResult:
|
|
95
|
+
ri = self._r_info
|
|
96
|
+
yield Static(
|
|
97
|
+
f"Run: {ri.get('run_id', '?')} · "
|
|
98
|
+
f"{ri.get('date', '?')} · "
|
|
99
|
+
f"cwd: {ri.get('cwd', '?')}",
|
|
100
|
+
id="info_bar",
|
|
101
|
+
)
|
|
102
|
+
with TabbedContent(initial="stream"):
|
|
103
|
+
with TabPane("Stream JSONL", id="stream"):
|
|
104
|
+
with VerticalScroll(id="stream_scroll"):
|
|
105
|
+
yield Static("Loading…", id="stream_text", markup=False)
|
|
106
|
+
with TabPane("Output .out", id="output"):
|
|
107
|
+
with VerticalScroll(id="output_scroll"):
|
|
108
|
+
yield Static("Loading…", id="output_text", markup=False)
|
|
109
|
+
with TabPane("Prompt", id="prompt"):
|
|
110
|
+
with VerticalScroll(id="prompt_scroll"):
|
|
111
|
+
yield Static("", id="prompt_text", markup=False)
|
|
112
|
+
with TabPane("Result", id="result"):
|
|
113
|
+
with VerticalScroll(id="result_scroll"):
|
|
114
|
+
yield Static("", id="result_text", markup=False)
|
|
115
|
+
yield Footer()
|
|
116
|
+
|
|
117
|
+
def on_mount(self) -> None:
|
|
118
|
+
# Load prompt text
|
|
119
|
+
if os.path.isfile(self._p_path):
|
|
120
|
+
try:
|
|
121
|
+
with open(self._p_path, "r", encoding="utf-8", errors="replace") as f:
|
|
122
|
+
pt = f.read().strip()
|
|
123
|
+
if pt:
|
|
124
|
+
self.query_one("#prompt_text", Static).update(
|
|
125
|
+
self._text_with_path("Prompt", self._p_path, pt)
|
|
126
|
+
)
|
|
127
|
+
except (OSError, UnicodeDecodeError):
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
self.query_one("#prompt_text", Static).update(self._text_with_path("Prompt", self._p_path, ""))
|
|
131
|
+
|
|
132
|
+
# Load result text
|
|
133
|
+
if os.path.isfile(self._r_path):
|
|
134
|
+
try:
|
|
135
|
+
with open(self._r_path, "r", encoding="utf-8", errors="replace") as f:
|
|
136
|
+
rt = f.read().strip()
|
|
137
|
+
if rt:
|
|
138
|
+
self._result_text = rt
|
|
139
|
+
self.query_one("#result_text", Static).update(
|
|
140
|
+
self._text_with_path("Result", self._r_path, rt)
|
|
141
|
+
)
|
|
142
|
+
except (OSError, UnicodeDecodeError):
|
|
143
|
+
pass
|
|
144
|
+
else:
|
|
145
|
+
self.query_one("#result_text", Static).update(self._text_with_path("Result", self._r_path, ""))
|
|
146
|
+
|
|
147
|
+
# Load JSONL stream.
|
|
148
|
+
self._stream_raw = self._header_line("JSONL", self._jl_path)
|
|
149
|
+
self.query_one("#stream_text", Static).update(self._stream_raw)
|
|
150
|
+
if os.path.isfile(self._jl_path):
|
|
151
|
+
with open(self._jl_path, "r", encoding="utf-8", errors="replace") as f:
|
|
152
|
+
f.seek(self._fpos)
|
|
153
|
+
events, self._last_ts = read_events(f, self._last_ts)
|
|
154
|
+
self._fpos = f.tell()
|
|
155
|
+
self._append_events(events)
|
|
156
|
+
|
|
157
|
+
self._out_raw = self._header_line("Output", self._o_path)
|
|
158
|
+
self.query_one("#output_text", Static).update(self._out_raw)
|
|
159
|
+
self._append_output_from_file()
|
|
160
|
+
|
|
161
|
+
# Scroll to bottom after initial load
|
|
162
|
+
self._scroll_to_bottom("stream")
|
|
163
|
+
self._scroll_to_bottom("output")
|
|
164
|
+
|
|
165
|
+
# Start poll worker for all modes (live updates for running runs)
|
|
166
|
+
self._poll_jsonl()
|
|
167
|
+
|
|
168
|
+
# ── follow worker ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
@work(exclusive=True, thread=False)
|
|
171
|
+
async def _poll_jsonl(self) -> None:
|
|
172
|
+
while self._keep_polling:
|
|
173
|
+
if os.path.isfile(self._jl_path):
|
|
174
|
+
try:
|
|
175
|
+
with open(self._jl_path, "r", encoding="utf-8", errors="replace") as f:
|
|
176
|
+
f.seek(self._fpos)
|
|
177
|
+
events, self._last_ts = read_events(f, self._last_ts)
|
|
178
|
+
self._fpos = f.tell()
|
|
179
|
+
self._append_events(events)
|
|
180
|
+
except (OSError, UnicodeDecodeError):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
self._append_output_from_file()
|
|
184
|
+
|
|
185
|
+
# Check scroll position to update auto-follow state
|
|
186
|
+
self._sync_auto_follow("stream")
|
|
187
|
+
self._sync_auto_follow("output")
|
|
188
|
+
|
|
189
|
+
await asyncio.sleep(self._poll_interval)
|
|
190
|
+
|
|
191
|
+
def on_unmount(self) -> None:
|
|
192
|
+
"""Ensure poll loop exits when screen is removed."""
|
|
193
|
+
self._keep_polling = False
|
|
194
|
+
|
|
195
|
+
def _sync_auto_follow(self, tab_id: str) -> None:
|
|
196
|
+
"""Update _auto_follow based on current scroll position."""
|
|
197
|
+
try:
|
|
198
|
+
scroll = self.query_one(f"#{tab_id}_scroll", VerticalScroll)
|
|
199
|
+
self._auto_follow[tab_id] = scroll.is_vertical_scroll_end
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _scroll_to_bottom(self, tab_id: str) -> None:
|
|
204
|
+
"""Scroll stream container to bottom."""
|
|
205
|
+
try:
|
|
206
|
+
self.query_one(f"#{tab_id}_scroll", VerticalScroll).scroll_end(animate=False)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def _update_info_bar(self) -> None:
|
|
211
|
+
"""Update info bar with auto-follow status."""
|
|
212
|
+
try:
|
|
213
|
+
ri = self._r_info
|
|
214
|
+
parts = [
|
|
215
|
+
f"Run: {ri.get('run_id', '?')}",
|
|
216
|
+
ri.get("date", "?"),
|
|
217
|
+
f"cwd: {ri.get('cwd', '?')}",
|
|
218
|
+
]
|
|
219
|
+
status_parts = []
|
|
220
|
+
for tab_id, label in (("stream", "stream"), ("output", "output")):
|
|
221
|
+
if self._auto_follow[tab_id]:
|
|
222
|
+
status_parts.append(f"{label}: follow")
|
|
223
|
+
elif self._pending_new_count[tab_id]:
|
|
224
|
+
status_parts.append(f"{label}: {self._pending_new_count[tab_id]} new")
|
|
225
|
+
else:
|
|
226
|
+
status_parts.append(f"{label}: paused")
|
|
227
|
+
parts.append(" | ".join(status_parts))
|
|
228
|
+
self.query_one("#info_bar", Static).update(" · ".join(parts))
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def _header_line(self, label: str, path: str) -> str:
|
|
233
|
+
return f"{label}: {os.path.abspath(os.path.expanduser(path))}"
|
|
234
|
+
|
|
235
|
+
def _text_with_path(self, label: str, path: str, body: str) -> str:
|
|
236
|
+
header = self._header_line(label, path)
|
|
237
|
+
return f"{header}\n\n{body}" if body else header
|
|
238
|
+
|
|
239
|
+
def _append_text_block(self, tab_id: str, text: str) -> None:
|
|
240
|
+
if not text:
|
|
241
|
+
return
|
|
242
|
+
self._sync_auto_follow(tab_id)
|
|
243
|
+
attr = "_stream_raw" if tab_id == "stream" else "_out_raw"
|
|
244
|
+
widget_id = "#stream_text" if tab_id == "stream" else "#output_text"
|
|
245
|
+
current = getattr(self, attr) or ""
|
|
246
|
+
updated = current + text if current.endswith("\n") or text.startswith("\n") else current + "\n" + text
|
|
247
|
+
setattr(self, attr, updated)
|
|
248
|
+
try:
|
|
249
|
+
self.query_one(widget_id, Static).update(updated)
|
|
250
|
+
except Exception:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
new_count = text.count("\n") + (0 if text.endswith("\n") else 1)
|
|
254
|
+
if self._auto_follow[tab_id]:
|
|
255
|
+
self._scroll_to_bottom(tab_id)
|
|
256
|
+
self._pending_new_count[tab_id] = 0
|
|
257
|
+
else:
|
|
258
|
+
self._pending_new_count[tab_id] += max(new_count, 1)
|
|
259
|
+
self._update_info_bar()
|
|
260
|
+
|
|
261
|
+
def _append_output_from_file(self) -> None:
|
|
262
|
+
if not os.path.isfile(self._o_path):
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
size = os.path.getsize(self._o_path)
|
|
266
|
+
if size < self._out_fpos:
|
|
267
|
+
self._out_fpos = 0
|
|
268
|
+
with open(self._o_path, "r", encoding="utf-8", errors="replace") as f:
|
|
269
|
+
f.seek(self._out_fpos)
|
|
270
|
+
chunk = f.read()
|
|
271
|
+
self._out_fpos = f.tell()
|
|
272
|
+
except OSError:
|
|
273
|
+
return
|
|
274
|
+
self._append_text_block("output", chunk)
|
|
275
|
+
|
|
276
|
+
def _append_events(self, events: list[ParsedEvent]) -> None:
|
|
277
|
+
if not events:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
new_lines: list[str] = []
|
|
281
|
+
for event in events:
|
|
282
|
+
if event.kind in ("result_text", "error_text"):
|
|
283
|
+
self._result_text = event.text
|
|
284
|
+
try:
|
|
285
|
+
self.query_one("#result_text", Static).update(
|
|
286
|
+
self._text_with_path("Result", self._r_path, self._result_text)
|
|
287
|
+
)
|
|
288
|
+
self.query_one(TabbedContent).active = "result"
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
line = format_event_for_viewer(event)
|
|
294
|
+
if line and line != self._last_stream_line:
|
|
295
|
+
new_lines.append(line)
|
|
296
|
+
self._last_stream_line = line
|
|
297
|
+
|
|
298
|
+
if not new_lines:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Check if we should auto-follow before updating content
|
|
302
|
+
self._sync_auto_follow("stream")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
current = self._stream_raw or ""
|
|
306
|
+
appended = "\n".join(new_lines)
|
|
307
|
+
self._stream_raw = current + "\n" + appended if current else appended
|
|
308
|
+
self.query_one("#stream_text", Static).update(self._stream_raw)
|
|
309
|
+
except Exception:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
# Auto-scroll or track pending new content
|
|
313
|
+
if self._auto_follow["stream"]:
|
|
314
|
+
self._scroll_to_bottom("stream")
|
|
315
|
+
self._pending_new_count["stream"] = 0
|
|
316
|
+
else:
|
|
317
|
+
self._pending_new_count["stream"] += len(new_lines)
|
|
318
|
+
|
|
319
|
+
self._update_info_bar()
|
|
320
|
+
|
|
321
|
+
# ── actions ──────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
def action_back(self) -> None:
|
|
324
|
+
self._keep_polling = False
|
|
325
|
+
if self._mode == "static":
|
|
326
|
+
self.dismiss()
|
|
327
|
+
else:
|
|
328
|
+
self.app.exit()
|
|
329
|
+
|
|
330
|
+
def action_go_resume(self) -> None:
|
|
331
|
+
self._keep_polling = False
|
|
332
|
+
rid = self._r_info.get("run_id", "")
|
|
333
|
+
if hasattr(self.app, "_action_result"):
|
|
334
|
+
self.app._action_result = f"resume:{rid}"
|
|
335
|
+
self.app.exit()
|
|
336
|
+
|
|
337
|
+
def action_copy_session(self) -> None:
|
|
338
|
+
import subprocess
|
|
339
|
+
uid = self._r_info.get("uuid", "")
|
|
340
|
+
if uid:
|
|
341
|
+
try:
|
|
342
|
+
subprocess.run(["pbcopy"], input=uid, text=True, check=True)
|
|
343
|
+
self.notify(f"Copied: {uid}", severity="information", timeout=3)
|
|
344
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
345
|
+
self.notify("Copy failed: pbcopy not available", severity="error")
|
|
346
|
+
|
|
347
|
+
def action_quit(self) -> None:
|
|
348
|
+
self._keep_polling = False
|
|
349
|
+
self.app.exit()
|
|
350
|
+
|
|
351
|
+
def action_show_tab(self, tab_id: str) -> None:
|
|
352
|
+
try:
|
|
353
|
+
self.query_one(TabbedContent).active = tab_id
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
def action_scroll_active(self, direction: str) -> None:
|
|
358
|
+
try:
|
|
359
|
+
active = self.query_one(TabbedContent).active
|
|
360
|
+
scroll = self.query_one(f"#{active}_scroll", VerticalScroll)
|
|
361
|
+
except Exception:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
if direction == "up":
|
|
365
|
+
scroll.scroll_up(animate=False)
|
|
366
|
+
elif direction == "down":
|
|
367
|
+
scroll.scroll_down(animate=False)
|
|
368
|
+
elif direction == "page_up":
|
|
369
|
+
scroll.scroll_page_up(animate=False)
|
|
370
|
+
elif direction == "page_down":
|
|
371
|
+
scroll.scroll_page_down(animate=False)
|
|
372
|
+
elif direction == "home":
|
|
373
|
+
scroll.scroll_home(animate=False)
|
|
374
|
+
elif direction == "end":
|
|
375
|
+
scroll.scroll_end(animate=False)
|
|
376
|
+
|
|
377
|
+
if active in self._auto_follow:
|
|
378
|
+
self._sync_auto_follow(active)
|
|
379
|
+
if self._auto_follow[active]:
|
|
380
|
+
self._pending_new_count[active] = 0
|
|
381
|
+
self._update_info_bar()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
385
|
+
# Tail entry point
|
|
386
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
387
|
+
|
|
388
|
+
class JsonlTailApp(App):
|
|
389
|
+
"""Standalone Textual app for `handoff tail`."""
|
|
390
|
+
|
|
391
|
+
TITLE = "handoff tail"
|
|
392
|
+
|
|
393
|
+
def __init__(
|
|
394
|
+
self,
|
|
395
|
+
jsonl_path: str,
|
|
396
|
+
prompt_path: str,
|
|
397
|
+
out_path: str,
|
|
398
|
+
result_path: str,
|
|
399
|
+
run_info: dict,
|
|
400
|
+
):
|
|
401
|
+
self._a_jl = jsonl_path
|
|
402
|
+
self._a_pp = prompt_path
|
|
403
|
+
self._a_op = out_path
|
|
404
|
+
self._a_rp = result_path
|
|
405
|
+
self._a_ri = run_info
|
|
406
|
+
super().__init__()
|
|
407
|
+
|
|
408
|
+
def on_mount(self) -> None:
|
|
409
|
+
self.push_screen(JsonlViewerScreen(
|
|
410
|
+
jsonl_path=self._a_jl,
|
|
411
|
+
prompt_path=self._a_pp,
|
|
412
|
+
out_path=self._a_op,
|
|
413
|
+
result_path=self._a_rp,
|
|
414
|
+
run_info=self._a_ri,
|
|
415
|
+
mode="follow",
|
|
416
|
+
))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def run_tail(jsonl_path: str, prompt_path: str, result_path: str, run_info: dict) -> None:
|
|
420
|
+
"""Entry point for `handoff tail`."""
|
|
421
|
+
out_path = run_info.get("out_path", "")
|
|
422
|
+
JsonlTailApp(jsonl_path, prompt_path, out_path, result_path, run_info).run(mouse=False)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def make_viewer_screen(
|
|
426
|
+
jsonl_path: str,
|
|
427
|
+
prompt_path: str,
|
|
428
|
+
out_path: str,
|
|
429
|
+
result_path: str,
|
|
430
|
+
run_info: dict,
|
|
431
|
+
) -> JsonlViewerScreen:
|
|
432
|
+
"""Create a viewer screen for embedding in another Textual app (list detail)."""
|
|
433
|
+
return JsonlViewerScreen(
|
|
434
|
+
jsonl_path=jsonl_path,
|
|
435
|
+
prompt_path=prompt_path,
|
|
436
|
+
out_path=out_path,
|
|
437
|
+
result_path=result_path,
|
|
438
|
+
run_info=run_info,
|
|
439
|
+
mode="static",
|
|
440
|
+
)
|