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/tui.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Textual-based TUI for interactive run listing and detail viewing in handoff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Callable
|
|
6
|
+
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.containers import Container
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import DataTable, Footer, Static
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.coordinate import Coordinate
|
|
13
|
+
from textual.message import Message
|
|
14
|
+
|
|
15
|
+
from .core import format_run_row, task_paths
|
|
16
|
+
|
|
17
|
+
# Seconds between DB polls for auto-refresh.
|
|
18
|
+
POLL_INTERVAL = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunListScreen(Screen):
|
|
22
|
+
"""Main screen showing the run list in a DataTable.
|
|
23
|
+
|
|
24
|
+
Key bindings:
|
|
25
|
+
Enter / → — open detail view for the selected run
|
|
26
|
+
G — resume the selected run's session
|
|
27
|
+
C — copy session UUID to clipboard
|
|
28
|
+
Q / Esc — quit
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
BINDINGS = [
|
|
32
|
+
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),
|
|
35
|
+
Binding("q", "quit", "Quit", show=True),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
rows: list,
|
|
41
|
+
full_cwd: bool = False,
|
|
42
|
+
refresh_fn: Callable[[], list] | None = None,
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
id: str | None = None,
|
|
45
|
+
classes: str | None = None,
|
|
46
|
+
):
|
|
47
|
+
self._rows = rows # sqlite3.Row objects
|
|
48
|
+
self._full_cwd = full_cwd
|
|
49
|
+
self._result: Optional[str] = None # "resume:<run_id>" or None
|
|
50
|
+
self._refresh_fn = refresh_fn
|
|
51
|
+
self._fingerprint: str = "" # change-detection fingerprint
|
|
52
|
+
self._dirty: bool = False # data changed while detail view was active
|
|
53
|
+
self._pending_cursor_run_id: str | None = None # cursor-restore target
|
|
54
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def action_result(self) -> Optional[str]:
|
|
58
|
+
return self._result
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
count = len(self._rows)
|
|
62
|
+
run_label = "run" if count == 1 else "runs"
|
|
63
|
+
yield Static(f" handoff runs · {count} recent {run_label}", id="title_bar")
|
|
64
|
+
yield DataTable(id="run_table", cursor_type="row")
|
|
65
|
+
yield Footer()
|
|
66
|
+
|
|
67
|
+
def on_mount(self) -> None:
|
|
68
|
+
table = self.query_one("#run_table", DataTable)
|
|
69
|
+
table.add_columns("RUN", "DATE", "PROMPT", "CWD", "STATUS")
|
|
70
|
+
|
|
71
|
+
if not self._rows:
|
|
72
|
+
table.add_row("(no runs)", "", "", "", "")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
for row in self._rows:
|
|
76
|
+
fmt = format_run_row(row, self._full_cwd)
|
|
77
|
+
table.add_row(
|
|
78
|
+
fmt["id"],
|
|
79
|
+
fmt["date"],
|
|
80
|
+
fmt["prompt"][:40],
|
|
81
|
+
fmt["cwd"],
|
|
82
|
+
fmt.get("status", ""),
|
|
83
|
+
key=fmt["id"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
table.focus()
|
|
87
|
+
|
|
88
|
+
# Start periodic DB polling for auto-refresh
|
|
89
|
+
if self._refresh_fn is not None:
|
|
90
|
+
self._fingerprint = self._compute_fingerprint(self._rows)
|
|
91
|
+
self.set_interval(POLL_INTERVAL, self._poll_refresh)
|
|
92
|
+
|
|
93
|
+
def _selected_row(self):
|
|
94
|
+
"""Return the sqlite3.Row for the currently selected table row."""
|
|
95
|
+
table = self.query_one("#run_table", DataTable)
|
|
96
|
+
if table.row_count == 0:
|
|
97
|
+
return None
|
|
98
|
+
rc = table.cursor_row
|
|
99
|
+
if rc >= len(self._rows):
|
|
100
|
+
return None
|
|
101
|
+
return self._rows[rc]
|
|
102
|
+
|
|
103
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
104
|
+
"""Handle Enter key on a DataTable row."""
|
|
105
|
+
event.stop()
|
|
106
|
+
self._open_detail()
|
|
107
|
+
|
|
108
|
+
def action_select_run(self) -> None:
|
|
109
|
+
"""Open detail view for the selected run."""
|
|
110
|
+
self._open_detail()
|
|
111
|
+
|
|
112
|
+
def _open_detail(self) -> None:
|
|
113
|
+
"""Shared detail-opening logic."""
|
|
114
|
+
row = self._selected_row()
|
|
115
|
+
if row is None:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
jsonl_path = row["jsonl_path"]
|
|
119
|
+
run_id = row["run_id"]
|
|
120
|
+
prompt_path, out_path, result_path = task_paths(run_id)
|
|
121
|
+
|
|
122
|
+
run_info = {
|
|
123
|
+
"run_id": run_id,
|
|
124
|
+
"date": row["created_at"],
|
|
125
|
+
"cwd": row["cwd"],
|
|
126
|
+
"uuid": row["uuid"],
|
|
127
|
+
"out_path": out_path,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
from .jsonl_viewer import make_viewer_screen
|
|
131
|
+
viewer = make_viewer_screen(jsonl_path, prompt_path, out_path, result_path, run_info)
|
|
132
|
+
self.app.push_screen(viewer)
|
|
133
|
+
|
|
134
|
+
def action_go_resume(self) -> None:
|
|
135
|
+
"""Resume the selected session."""
|
|
136
|
+
row = self._selected_row()
|
|
137
|
+
if row is None:
|
|
138
|
+
return
|
|
139
|
+
self._result = f"resume:{row['run_id']}"
|
|
140
|
+
# Write result to app so cmd_list can read it after run() returns
|
|
141
|
+
if hasattr(self.app, '_action_result'):
|
|
142
|
+
self.app._action_result = self._result
|
|
143
|
+
self.app.exit()
|
|
144
|
+
|
|
145
|
+
def action_copy_session(self) -> None:
|
|
146
|
+
"""Copy session UUID to clipboard."""
|
|
147
|
+
import subprocess
|
|
148
|
+
row = self._selected_row()
|
|
149
|
+
if row is None:
|
|
150
|
+
return
|
|
151
|
+
uid = row["uuid"]
|
|
152
|
+
if uid:
|
|
153
|
+
try:
|
|
154
|
+
subprocess.run(["pbcopy"], input=uid, text=True, check=True)
|
|
155
|
+
self.notify(f"Copied: {uid}", severity="information", timeout=3)
|
|
156
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
157
|
+
self.notify("Copy failed: pbcopy not available", severity="error")
|
|
158
|
+
|
|
159
|
+
def action_quit(self) -> None:
|
|
160
|
+
self.app.exit()
|
|
161
|
+
|
|
162
|
+
# ── auto-refresh ───────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _compute_fingerprint(rows: list) -> str:
|
|
166
|
+
"""Lightweight change-detection fingerprint: run_id:status per row."""
|
|
167
|
+
return "|".join(f"{r['run_id']}:{r['status']}" for r in rows)
|
|
168
|
+
|
|
169
|
+
def _save_cursor_run_id(self) -> None:
|
|
170
|
+
"""Remember the currently selected run_id before a table rebuild."""
|
|
171
|
+
if not self._rows:
|
|
172
|
+
self._pending_cursor_run_id = None
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
table = self.query_one("#run_table", DataTable)
|
|
176
|
+
if table.row_count > 0:
|
|
177
|
+
rc = table.cursor_row
|
|
178
|
+
if 0 <= rc < len(self._rows):
|
|
179
|
+
self._pending_cursor_run_id = self._rows[rc]["run_id"]
|
|
180
|
+
return
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
self._pending_cursor_run_id = None
|
|
184
|
+
|
|
185
|
+
def _restore_cursor(self) -> None:
|
|
186
|
+
"""Move DataTable cursor to the previously selected run_id."""
|
|
187
|
+
if self._pending_cursor_run_id is None:
|
|
188
|
+
return
|
|
189
|
+
target_id = self._pending_cursor_run_id
|
|
190
|
+
self._pending_cursor_run_id = None
|
|
191
|
+
|
|
192
|
+
for i, row in enumerate(self._rows):
|
|
193
|
+
if row["run_id"] == target_id:
|
|
194
|
+
try:
|
|
195
|
+
table = self.query_one("#run_table", DataTable)
|
|
196
|
+
if i < table.row_count:
|
|
197
|
+
table.cursor_coordinate = Coordinate(i, 0)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
def _rebuild_table(self) -> None:
|
|
203
|
+
"""Clear and repopulate the DataTable from self._rows in place."""
|
|
204
|
+
table = self.query_one("#run_table", DataTable)
|
|
205
|
+
is_active = self.app.screen is self
|
|
206
|
+
had_focus = table.has_focus if is_active else False
|
|
207
|
+
|
|
208
|
+
table.clear()
|
|
209
|
+
|
|
210
|
+
if not self._rows:
|
|
211
|
+
table.add_row("(no runs)", "", "", "", "")
|
|
212
|
+
self.query_one("#title_bar", Static).update(" handoff runs · 0 runs")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
for row in self._rows:
|
|
216
|
+
fmt = format_run_row(row, self._full_cwd)
|
|
217
|
+
table.add_row(
|
|
218
|
+
fmt["id"],
|
|
219
|
+
fmt["date"],
|
|
220
|
+
fmt["prompt"][:40],
|
|
221
|
+
fmt["cwd"],
|
|
222
|
+
fmt.get("status", ""),
|
|
223
|
+
key=fmt["id"],
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Refresh title-bar count
|
|
227
|
+
count = len(self._rows)
|
|
228
|
+
run_label = "run" if count == 1 else "runs"
|
|
229
|
+
self.query_one("#title_bar", Static).update(
|
|
230
|
+
f" handoff runs · {count} recent {run_label}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
self._restore_cursor()
|
|
234
|
+
|
|
235
|
+
if had_focus:
|
|
236
|
+
table.focus()
|
|
237
|
+
|
|
238
|
+
def _poll_refresh(self) -> None:
|
|
239
|
+
"""Periodic timer callback: check for new/changed runs from the DB."""
|
|
240
|
+
if self._refresh_fn is None:
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
fresh_rows = self._refresh_fn()
|
|
245
|
+
if fresh_rows is None:
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
new_fp = self._compute_fingerprint(fresh_rows)
|
|
249
|
+
if new_fp == self._fingerprint:
|
|
250
|
+
return # nothing changed
|
|
251
|
+
except Exception:
|
|
252
|
+
return # transient DB error — skip this tick
|
|
253
|
+
|
|
254
|
+
# Data changed — save cursor, update rows/fingerprint, rebuild
|
|
255
|
+
self._save_cursor_run_id()
|
|
256
|
+
self._fingerprint = new_fp
|
|
257
|
+
self._rows = fresh_rows
|
|
258
|
+
|
|
259
|
+
if self.app.screen is self:
|
|
260
|
+
# List screen is active → rebuild immediately.
|
|
261
|
+
self._rebuild_table()
|
|
262
|
+
else:
|
|
263
|
+
# Detail view (or another screen) is on top → defer rebuild so the
|
|
264
|
+
# user isn't kicked back to the list. Data is already updated;
|
|
265
|
+
# rebuild happens on the next poll tick after the screen resumes.
|
|
266
|
+
self._dirty = True
|
|
267
|
+
|
|
268
|
+
def _on_screen_resume(self) -> None:
|
|
269
|
+
"""Called by Textual when this screen becomes active again after a pop."""
|
|
270
|
+
super()._on_screen_resume()
|
|
271
|
+
if self._dirty:
|
|
272
|
+
self._dirty = False
|
|
273
|
+
self._rebuild_table()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class RunListApp(App):
|
|
277
|
+
"""Textual app wrapping the run list screen.
|
|
278
|
+
|
|
279
|
+
Usage:
|
|
280
|
+
app = RunListApp(rows, full_cwd)
|
|
281
|
+
app.run()
|
|
282
|
+
if app.action_result:
|
|
283
|
+
# app.action_result == "resume:<run_id>"
|
|
284
|
+
...
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
TITLE = "handoff list"
|
|
288
|
+
CSS = """
|
|
289
|
+
#title_bar {
|
|
290
|
+
dock: top;
|
|
291
|
+
height: 1;
|
|
292
|
+
background: $accent;
|
|
293
|
+
color: $text;
|
|
294
|
+
text-style: bold;
|
|
295
|
+
padding: 0 1;
|
|
296
|
+
}
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
def __init__(self, rows: list, full_cwd: bool = False, refresh_fn: Callable[[], list] | None = None):
|
|
300
|
+
self._rows = rows
|
|
301
|
+
self._full_cwd = full_cwd
|
|
302
|
+
self._refresh_fn = refresh_fn
|
|
303
|
+
self._action_result: Optional[str] = None
|
|
304
|
+
super().__init__()
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def action_result(self) -> Optional[str]:
|
|
308
|
+
return self._action_result
|
|
309
|
+
|
|
310
|
+
def on_mount(self) -> None:
|
|
311
|
+
screen = RunListScreen(self._rows, self._full_cwd, refresh_fn=self._refresh_fn)
|
|
312
|
+
self.push_screen(screen)
|
|
313
|
+
|
|
314
|
+
def on_screen_dismiss(self, event: Screen.Dismissed) -> None:
|
|
315
|
+
"""Capture action result when a screen is dismissed."""
|
|
316
|
+
if event.result and isinstance(event.result, str):
|
|
317
|
+
self._action_result = event.result
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# handoff 配置 — 这是唯一需要你编辑的配置文件。
|
|
2
|
+
# 改坏了可从模板恢复:
|
|
3
|
+
# https://github.com/dazuiba/handoff/blob/main/cli/user_config_template.yaml
|
|
4
|
+
# claude/codex 的拉起方式(flags、PTY)是程序承诺,不在本文件配置;
|
|
5
|
+
# 运行 `handoff env` 可拿到 backend_types.yaml 的路径,了解完整配置逻辑。
|
|
6
|
+
#
|
|
7
|
+
# backends 里第一个条目就是默认目标(handoff run 不带 --backend 时用它)。
|
|
8
|
+
# env 由你全权书写:{model} 会替换为该 backend 解析出的模型(--pro 时取 pro_model),
|
|
9
|
+
# "${VAR}" 会展开同名环境变量。
|
|
10
|
+
|
|
11
|
+
backends:
|
|
12
|
+
deepseek:
|
|
13
|
+
type: claude
|
|
14
|
+
model: deepseek-v4-flash
|
|
15
|
+
pro_model: "deepseek-v4-pro[1m]"
|
|
16
|
+
env:
|
|
17
|
+
ANTHROPIC_BASE_URL: https://api.deepseek.com/anthropic
|
|
18
|
+
ANTHROPIC_AUTH_TOKEN: "${DEEPSEEK_API_KEY}" # 或直接写 sk-...
|
|
19
|
+
ANTHROPIC_MODEL: "{model}"
|
|
20
|
+
|
|
21
|
+
opus: # 走本机 claude 登录态,零配置
|
|
22
|
+
type: claude
|
|
23
|
+
model: claude-opus-4-8
|
|
24
|
+
pro_model: claude-opus-4-8
|
|
25
|
+
env:
|
|
26
|
+
ANTHROPIC_MODEL: "{model}"
|
|
27
|
+
|
|
28
|
+
codex: # 走本机 codex 登录态,零配置;模型经 -m 传入
|
|
29
|
+
type: codex
|
|
30
|
+
model: gpt-5.5
|
|
31
|
+
pro_model: gpt-5.5
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
cli/__init__.py,sha256=vRRy2vh-UJy8UDMs5J8GfQlNarcuD0m21GaXF4RIMqk,50
|
|
2
|
+
cli/backend.py,sha256=LqPDh1DNnGn-BNnJ6CwES29acEsQO1n63-53wKNv5Qk,8459
|
|
3
|
+
cli/backend_types.yaml,sha256=YE6dYmf6jw9s9sV3cBlL9kgprmbKDoMoqED6si6MN-A,3448
|
|
4
|
+
cli/config.py,sha256=4x0mL7_VH9NXVBDCyw-rIxRQO6oednJm4xCn1R7d54I,12561
|
|
5
|
+
cli/core.py,sha256=22JbNyKMWWXaG0BQmx7nJTuDhnJh0pjmpmqlbY6o64U,10725
|
|
6
|
+
cli/jsonl_parser.py,sha256=7VTXKA2ZNmYz8B5rjFXdXtwOE_aY3F1s3EL2pITXh-8,6020
|
|
7
|
+
cli/jsonl_viewer.py,sha256=j5yXhWDL7KJSmWkwh6__xd2vMsiEJo6aXKsLFq2ExmE,17068
|
|
8
|
+
cli/main.py,sha256=xFQehyV0xse9RjJ_AgLVSdQ75THiMew8TeXAJkDhsio,2924
|
|
9
|
+
cli/stream.py,sha256=jETZHxJv9RabDAJx_PaHRs03yW9Az6DPM8SKl3TS4KE,9767
|
|
10
|
+
cli/tui.py,sha256=vidzKWPQlFbyuIrit4xeXF-M_yr2FsR-zh2hZ3Nj8RU,10823
|
|
11
|
+
cli/user_config_template.yaml,sha256=FrJ79O5chLSZdMBFEmYJfiovBSjtrAZ04gY1QHKWf_4,1216
|
|
12
|
+
cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
cli/commands/env.py,sha256=R1YFkMAfryMCZM1137VUxPTb6PaeE94PkUXd8K-xeHM,1122
|
|
14
|
+
cli/commands/init.py,sha256=LwXV3PX7LlarY5O_vvRwidxTbcdRqB9qrXQ-gy6skSI,4423
|
|
15
|
+
cli/commands/list.py,sha256=RNhwhyhdYoUovpfUtZz4NhSZrOzIo8Qearp9Ubo967Y,2387
|
|
16
|
+
cli/commands/resume.py,sha256=xQCus9Pssyxc0RIkq2HUpoJ1SMjyBF3aLUwuOBERcx4,6570
|
|
17
|
+
cli/commands/run.py,sha256=wtpuZVZTihADzZnD40-JiS1Cz_v3F2jSwnOSJP18ACk,6799
|
|
18
|
+
cli/commands/tail.py,sha256=SWYVlaveDTh5Oki3WN6n54dtuRdZoZvsqEf563TNSPc,1255
|
|
19
|
+
cli/skills/handoff-ds.toml,sha256=8_apohuSlKX7f68VU4GBX8DJphfpwROgLTsQtsc10I4,4491
|
|
20
|
+
cli/skills/handoff-codex/SKILL.md,sha256=ANzdFwMGEX7yCO64SD5SRMw4NdEDxkRnUzgtjx457T4,4417
|
|
21
|
+
cli/skills/handoff-ds/SKILL.md,sha256=hclOl-TQOj1TosXRKNvf3Vmxd7iwW7o1FTBRjdRLnrY,4414
|
|
22
|
+
cli/skills/handoff-opus/SKILL.md,sha256=9ytlywSpkOr4d63VQVTDhSELRCjkRBpKwRHCiPke8OE,4380
|
|
23
|
+
handoff_cli-0.3.0.dist-info/METADATA,sha256=9kdKJZuKiLsRKDhuBHyx5iD1DC3xXVLhJgjMLKaweyY,258
|
|
24
|
+
handoff_cli-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
25
|
+
handoff_cli-0.3.0.dist-info/entry_points.txt,sha256=u4w90AE_EVu8KlnbQ58AhDpj_FfLeIp3XNHxSdmG2aA,42
|
|
26
|
+
handoff_cli-0.3.0.dist-info/RECORD,,
|