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/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
+ )