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/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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: handoff-cli
3
+ Version: 0.3.0
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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ handoff = cli.main:main