agent-session-bridge 0.2.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.
- agent_session_bridge-0.2.0.dist-info/METADATA +288 -0
- agent_session_bridge-0.2.0.dist-info/RECORD +37 -0
- agent_session_bridge-0.2.0.dist-info/WHEEL +5 -0
- agent_session_bridge-0.2.0.dist-info/entry_points.txt +3 -0
- agent_session_bridge-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_session_bridge-0.2.0.dist-info/top_level.txt +1 -0
- session_bridge/__init__.py +0 -0
- session_bridge/_ids.py +43 -0
- session_bridge/cli.py +473 -0
- session_bridge/convert.py +130 -0
- session_bridge/handshake.py +175 -0
- session_bridge/ir.py +246 -0
- session_bridge/place.py +98 -0
- session_bridge/readers/__init__.py +0 -0
- session_bridge/readers/_content.py +42 -0
- session_bridge/readers/_jsonl.py +50 -0
- session_bridge/readers/_pending.py +63 -0
- session_bridge/readers/claude_code.py +226 -0
- session_bridge/readers/codex.py +203 -0
- session_bridge/readers/hermes.py +167 -0
- session_bridge/skill_install.py +134 -0
- session_bridge/skills/session-handoff/SKILL.md +119 -0
- session_bridge/tui/__init__.py +0 -0
- session_bridge/tui/actions.py +123 -0
- session_bridge/tui/app.py +65 -0
- session_bridge/tui/discovery.py +208 -0
- session_bridge/tui/options.py +86 -0
- session_bridge/tui/register.py +475 -0
- session_bridge/tui/screens.py +710 -0
- session_bridge/tui/summary.py +41 -0
- session_bridge/writers/__init__.py +0 -0
- session_bridge/writers/_common.py +288 -0
- session_bridge/writers/claude_code.py +152 -0
- session_bridge/writers/codex.py +154 -0
- session_bridge/writers/codex_db.py +302 -0
- session_bridge/writers/hermes.py +142 -0
- session_bridge/writers/hermes_db.py +294 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""Textual screens for the session-bridge TUI convert wizard.
|
|
2
|
+
|
|
3
|
+
Linear flow: PickerScreen -> SummaryScreen -> OptionsScreen -> DryRunScreen ->
|
|
4
|
+
ResultScreen. Screens pass data forward via constructor args; ``escape`` pops
|
|
5
|
+
back. All parsing/conversion/writing runs in thread workers so the UI never
|
|
6
|
+
blocks, and every failure renders as a panel — a traceback escaping textual
|
|
7
|
+
would corrupt the terminal.
|
|
8
|
+
|
|
9
|
+
This module is the only one (besides app.py) that imports textual; all logic
|
|
10
|
+
lives in the textual-free sibling modules (discovery/options/summary/actions).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
# Session content (ids, cwds, previews, error text) is untrusted and must never
|
|
18
|
+
# reach a markup-parsing sink unescaped: "[red]" in a transcript would otherwise
|
|
19
|
+
# raise MarkupError and take down the whole app.
|
|
20
|
+
from rich.markup import escape
|
|
21
|
+
from textual import work
|
|
22
|
+
from textual.app import ComposeResult
|
|
23
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
24
|
+
from textual.screen import Screen
|
|
25
|
+
from textual.widgets import (
|
|
26
|
+
Button,
|
|
27
|
+
DataTable,
|
|
28
|
+
Footer,
|
|
29
|
+
Header,
|
|
30
|
+
Input,
|
|
31
|
+
Label,
|
|
32
|
+
Select,
|
|
33
|
+
Static,
|
|
34
|
+
Switch,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from ..convert import HARNESSES, ConversionResult, default_output_name, read_session
|
|
38
|
+
from ..ir import Session
|
|
39
|
+
from .actions import execute_writes, run_conversion
|
|
40
|
+
from .discovery import SessionEntry, discover_sessions
|
|
41
|
+
from .options import ConvertOptions, build_cli_command, resolve_output, validate_options
|
|
42
|
+
from .summary import summarize_session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _human_size(n: int) -> str:
|
|
46
|
+
for unit in ("B", "KB", "MB"):
|
|
47
|
+
if n < 1024:
|
|
48
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
49
|
+
n /= 1024
|
|
50
|
+
return f"{n:.1f}GB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _one_line(text: str, limit: int = 80) -> str:
|
|
54
|
+
flat = " ".join(text.split())
|
|
55
|
+
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PickerScreen(Screen):
|
|
59
|
+
"""Discovered sessions across all three harness stores, newest first."""
|
|
60
|
+
|
|
61
|
+
BINDINGS = [
|
|
62
|
+
("r", "rescan", "Rescan"),
|
|
63
|
+
("q", "quit", "Quit"),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.entries: list[SessionEntry] = []
|
|
69
|
+
self._by_key: dict[str, SessionEntry] = {}
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
yield Header()
|
|
73
|
+
yield Static("scanning session stores…", id="picker-status")
|
|
74
|
+
yield DataTable(id="sessions")
|
|
75
|
+
yield Footer()
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
table = self.query_one(DataTable)
|
|
79
|
+
table.cursor_type = "row"
|
|
80
|
+
table.add_columns("harness", "session id", "cwd", "modified", "size", "preview")
|
|
81
|
+
self._load()
|
|
82
|
+
|
|
83
|
+
@work(thread=True, exclusive=True)
|
|
84
|
+
def _load(self) -> None:
|
|
85
|
+
app = self.app
|
|
86
|
+
entries = discover_sessions(
|
|
87
|
+
claude_home=getattr(app, "claude_home", None),
|
|
88
|
+
codex_home=getattr(app, "codex_home", None),
|
|
89
|
+
hermes_home=getattr(app, "hermes_home", None),
|
|
90
|
+
)
|
|
91
|
+
app.call_from_thread(self._populate, entries)
|
|
92
|
+
|
|
93
|
+
def _populate(self, entries: list[SessionEntry]) -> None:
|
|
94
|
+
self.entries = entries
|
|
95
|
+
self._by_key = {str(e.path): e for e in entries}
|
|
96
|
+
status = self.query_one("#picker-status", Static)
|
|
97
|
+
table = self.query_one(DataTable)
|
|
98
|
+
table.clear()
|
|
99
|
+
for e in entries:
|
|
100
|
+
table.add_row(
|
|
101
|
+
e.harness,
|
|
102
|
+
escape(_one_line(e.session_id or "?", 40)),
|
|
103
|
+
escape(_one_line(e.cwd or "-", 40)),
|
|
104
|
+
datetime.fromtimestamp(e.mtime).strftime("%Y-%m-%d %H:%M"),
|
|
105
|
+
_human_size(e.size),
|
|
106
|
+
escape(_one_line(e.preview or "")),
|
|
107
|
+
key=str(e.path),
|
|
108
|
+
)
|
|
109
|
+
status.update(
|
|
110
|
+
f"{len(entries)} session(s) found — enter to select, r to rescan, q to quit"
|
|
111
|
+
if entries
|
|
112
|
+
else "no sessions found in ~/.claude, ~/.codex, or ~/.hermes — r to rescan"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def action_rescan(self) -> None:
|
|
116
|
+
self.query_one("#picker-status", Static).update("rescanning…")
|
|
117
|
+
self._load()
|
|
118
|
+
|
|
119
|
+
def action_quit(self) -> None:
|
|
120
|
+
self.app.exit()
|
|
121
|
+
|
|
122
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
123
|
+
# Resolve by row key, not cursor index: a rescan can repopulate the
|
|
124
|
+
# table between the selection event being posted and handled.
|
|
125
|
+
entry = self._by_key.get(event.row_key.value or "")
|
|
126
|
+
if entry is not None:
|
|
127
|
+
self.app.push_screen(SummaryScreen(entry))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SummaryScreen(Screen):
|
|
131
|
+
"""Full parse of the chosen session (inspect-equivalent), kept for later screens."""
|
|
132
|
+
|
|
133
|
+
BINDINGS = [
|
|
134
|
+
("c", "convert", "Convert"),
|
|
135
|
+
("g", "register", "Register"),
|
|
136
|
+
("escape", "app.pop_screen", "Back"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def __init__(self, entry: SessionEntry) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
self.entry = entry
|
|
142
|
+
self.session: Session | None = None
|
|
143
|
+
|
|
144
|
+
def compose(self) -> ComposeResult:
|
|
145
|
+
yield Header()
|
|
146
|
+
with VerticalScroll():
|
|
147
|
+
yield Static(f"parsing {escape(str(self.entry.path))}…", id="summary-body")
|
|
148
|
+
yield Footer()
|
|
149
|
+
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
self._parse()
|
|
152
|
+
|
|
153
|
+
@work(thread=True, exclusive=True)
|
|
154
|
+
def _parse(self) -> None:
|
|
155
|
+
try:
|
|
156
|
+
session = read_session(self.entry.harness, self.entry.path)
|
|
157
|
+
except Exception as exc: # any parse failure -> panel, never a crash
|
|
158
|
+
self.app.call_from_thread(self._show_error, exc)
|
|
159
|
+
return
|
|
160
|
+
self.app.call_from_thread(self._render_summary, session)
|
|
161
|
+
|
|
162
|
+
def _show_error(self, exc: Exception) -> None:
|
|
163
|
+
self.query_one("#summary-body", Static).update(
|
|
164
|
+
f"[b red]could not parse this session[/]\n\n{escape(str(exc))}\n\nescape to go back"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# NB: not `_render` — that name is textual's internal Widget._render hook.
|
|
168
|
+
def _render_summary(self, session: Session) -> None:
|
|
169
|
+
self.session = session
|
|
170
|
+
rows = summarize_session(session)
|
|
171
|
+
width = max(len(label) for label, _ in rows)
|
|
172
|
+
body = "\n".join(
|
|
173
|
+
f"[b]{label:<{width}}[/] {escape(value)}" for label, value in rows
|
|
174
|
+
)
|
|
175
|
+
self.query_one("#summary-body", Static).update(
|
|
176
|
+
body + "\n\n[dim]c to configure a conversion, escape to go back[/]"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def action_convert(self) -> None:
|
|
180
|
+
if self.session is not None:
|
|
181
|
+
self.app.push_screen(OptionsScreen(self.entry, self.session))
|
|
182
|
+
|
|
183
|
+
def action_register(self) -> None:
|
|
184
|
+
if self.session is not None:
|
|
185
|
+
self.app.push_screen(RegisterFormScreen(self.entry))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class OptionsScreen(Screen):
|
|
189
|
+
"""Convert options form; placement fields only exist for a claude-code target."""
|
|
190
|
+
|
|
191
|
+
BINDINGS = [("escape", "app.pop_screen", "Back")]
|
|
192
|
+
|
|
193
|
+
def __init__(self, entry: SessionEntry, session: Session) -> None:
|
|
194
|
+
super().__init__()
|
|
195
|
+
self.entry = entry
|
|
196
|
+
self.session = session
|
|
197
|
+
# Default to converting *away* from the source harness.
|
|
198
|
+
self.initial_target = next(h for h in HARNESSES if h != entry.harness)
|
|
199
|
+
self._auto_output = default_output_name(entry.path, self.initial_target)
|
|
200
|
+
|
|
201
|
+
def compose(self) -> ComposeResult:
|
|
202
|
+
yield Header()
|
|
203
|
+
with VerticalScroll():
|
|
204
|
+
yield Label("Target harness")
|
|
205
|
+
yield Select(
|
|
206
|
+
[(h, h) for h in HARNESSES],
|
|
207
|
+
value=self.initial_target,
|
|
208
|
+
allow_blank=False,
|
|
209
|
+
id="target",
|
|
210
|
+
)
|
|
211
|
+
yield Label("Output path")
|
|
212
|
+
yield Input(
|
|
213
|
+
value=default_output_name(self.entry.path, self.initial_target),
|
|
214
|
+
id="output",
|
|
215
|
+
)
|
|
216
|
+
with Horizontal(classes="switch-row"):
|
|
217
|
+
yield Switch(value=True, id="handshake")
|
|
218
|
+
yield Label("prepend resume handshake")
|
|
219
|
+
with Horizontal(classes="switch-row"):
|
|
220
|
+
yield Switch(value=False, id="stub")
|
|
221
|
+
yield Label("stub open tool calls (synthetic interrupted results)")
|
|
222
|
+
yield Label("Also write handshake markdown to (optional)")
|
|
223
|
+
yield Input(placeholder="e.g. resume.md", id="handshake-out")
|
|
224
|
+
with Vertical(id="place-group"):
|
|
225
|
+
with Horizontal(classes="switch-row"):
|
|
226
|
+
yield Switch(value=False, id="place")
|
|
227
|
+
yield Label("place under ~/.claude/projects so `claude --resume` finds it")
|
|
228
|
+
yield Label("Project cwd for placement")
|
|
229
|
+
yield Input(value=self.entry.cwd or "", id="place-cwd")
|
|
230
|
+
yield Label("Session id (blank: fresh uuid)")
|
|
231
|
+
yield Input(id="session-id")
|
|
232
|
+
with Horizontal(classes="switch-row"):
|
|
233
|
+
yield Switch(value=False, id="force")
|
|
234
|
+
yield Label("overwrite an existing transcript at that id")
|
|
235
|
+
yield Static("", id="form-errors")
|
|
236
|
+
yield Button("Continue to dry run", variant="primary", id="continue")
|
|
237
|
+
yield Footer()
|
|
238
|
+
|
|
239
|
+
def on_mount(self) -> None:
|
|
240
|
+
self._sync_target(self.initial_target)
|
|
241
|
+
|
|
242
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
243
|
+
if event.select.id == "target":
|
|
244
|
+
self._sync_target(str(event.value))
|
|
245
|
+
|
|
246
|
+
def _sync_target(self, target: str) -> None:
|
|
247
|
+
# Placement is claude-code-only: hide (not just disable) the group so a
|
|
248
|
+
# target flip can't smuggle stale place options into the built command.
|
|
249
|
+
self.query_one("#place-group").display = target == "claude-code"
|
|
250
|
+
# Refresh the output default on target flips, but never clobber a path
|
|
251
|
+
# the user has edited by hand.
|
|
252
|
+
out = self.query_one("#output", Input)
|
|
253
|
+
new_default = default_output_name(self.entry.path, target)
|
|
254
|
+
if out.value == self._auto_output:
|
|
255
|
+
out.value = new_default
|
|
256
|
+
self._auto_output = new_default
|
|
257
|
+
|
|
258
|
+
def _build_options(self) -> ConvertOptions:
|
|
259
|
+
target = str(self.query_one("#target", Select).value)
|
|
260
|
+
placing = (
|
|
261
|
+
target == "claude-code" and self.query_one("#place", Switch).value
|
|
262
|
+
)
|
|
263
|
+
output = self.query_one("#output", Input).value.strip()
|
|
264
|
+
handshake_out = self.query_one("#handshake-out", Input).value.strip()
|
|
265
|
+
session_id = self.query_one("#session-id", Input).value.strip()
|
|
266
|
+
return ConvertOptions(
|
|
267
|
+
source=self.entry.harness,
|
|
268
|
+
target=target,
|
|
269
|
+
path=str(self.entry.path),
|
|
270
|
+
output=output or None,
|
|
271
|
+
no_handshake=not self.query_one("#handshake", Switch).value,
|
|
272
|
+
stub_open_calls=self.query_one("#stub", Switch).value,
|
|
273
|
+
handshake_out=handshake_out or None,
|
|
274
|
+
place_claude_cwd=(
|
|
275
|
+
self.query_one("#place-cwd", Input).value.strip() or None
|
|
276
|
+
)
|
|
277
|
+
if placing
|
|
278
|
+
else None,
|
|
279
|
+
session_id=(session_id or None) if placing else None,
|
|
280
|
+
force=self.query_one("#force", Switch).value if placing else False,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
284
|
+
# Enter in any form field submits, like a regular form.
|
|
285
|
+
self._continue()
|
|
286
|
+
|
|
287
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
288
|
+
if event.button.id == "continue":
|
|
289
|
+
self._continue()
|
|
290
|
+
|
|
291
|
+
def _continue(self) -> None:
|
|
292
|
+
opts = self._build_options()
|
|
293
|
+
errors = validate_options(opts)
|
|
294
|
+
# ConvertOptions can't represent "placement wanted but no cwd" (the CLI
|
|
295
|
+
# drives placement purely off the --place-claude-cwd value), so catch
|
|
296
|
+
# that state here where the switch is visible: silently dropping an
|
|
297
|
+
# explicit placement request would violate the never-silent invariant.
|
|
298
|
+
if (
|
|
299
|
+
self.query_one("#place-group").display
|
|
300
|
+
and self.query_one("#place", Switch).value
|
|
301
|
+
and not opts.place_claude_cwd
|
|
302
|
+
):
|
|
303
|
+
errors.append(
|
|
304
|
+
"placement is enabled but no project cwd was given — fill in "
|
|
305
|
+
"the cwd or turn the placement switch off"
|
|
306
|
+
)
|
|
307
|
+
if errors:
|
|
308
|
+
self.query_one("#form-errors", Static).update(
|
|
309
|
+
"[b red]" + "\n".join(errors) + "[/]"
|
|
310
|
+
)
|
|
311
|
+
return
|
|
312
|
+
self.app.push_screen(DryRunScreen(opts))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class DryRunScreen(Screen):
|
|
316
|
+
"""Run the pure conversion and show everything BEFORE any file is written."""
|
|
317
|
+
|
|
318
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
319
|
+
|
|
320
|
+
def __init__(self, opts: ConvertOptions) -> None:
|
|
321
|
+
super().__init__()
|
|
322
|
+
self.opts = opts
|
|
323
|
+
self.result: ConversionResult | None = None
|
|
324
|
+
self._writing = False
|
|
325
|
+
|
|
326
|
+
def action_back(self) -> None:
|
|
327
|
+
# Popping mid-write would discard the outcome (including any error);
|
|
328
|
+
# the write worker pushes ResultScreen when it finishes.
|
|
329
|
+
if not self._writing:
|
|
330
|
+
self.app.pop_screen()
|
|
331
|
+
|
|
332
|
+
def compose(self) -> ComposeResult:
|
|
333
|
+
yield Header()
|
|
334
|
+
with VerticalScroll():
|
|
335
|
+
yield Static("converting (nothing written yet)…", id="dryrun-body")
|
|
336
|
+
with Horizontal(id="dryrun-buttons"):
|
|
337
|
+
yield Button("Write files", variant="success", id="write", disabled=True)
|
|
338
|
+
yield Button("Cancel", id="cancel")
|
|
339
|
+
yield Footer()
|
|
340
|
+
|
|
341
|
+
def on_mount(self) -> None:
|
|
342
|
+
self._convert()
|
|
343
|
+
|
|
344
|
+
@work(thread=True, exclusive=True)
|
|
345
|
+
def _convert(self) -> None:
|
|
346
|
+
try:
|
|
347
|
+
result = run_conversion(self.opts)
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
self.app.call_from_thread(self._show_error, exc)
|
|
350
|
+
return
|
|
351
|
+
self.app.call_from_thread(self._render_dryrun, result)
|
|
352
|
+
|
|
353
|
+
def _show_error(self, exc: Exception) -> None:
|
|
354
|
+
self.query_one("#dryrun-body", Static).update(
|
|
355
|
+
f"[b red]conversion failed[/]\n\n{escape(str(exc))}\n\nescape to go back"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _render_dryrun(self, result: ConversionResult) -> None:
|
|
359
|
+
self.result = result
|
|
360
|
+
opts = self.opts
|
|
361
|
+
out = resolve_output(opts)
|
|
362
|
+
lines = [
|
|
363
|
+
f"[b]will write[/] {len(result.records)} records -> {escape(out)}",
|
|
364
|
+
]
|
|
365
|
+
if opts.handshake_out:
|
|
366
|
+
lines.append(f"[b]handshake copy[/] -> {escape(opts.handshake_out)}")
|
|
367
|
+
if opts.place_claude_cwd:
|
|
368
|
+
lines.append(
|
|
369
|
+
f"[b]placement[/] under ~/.claude/projects for cwd "
|
|
370
|
+
f"{escape(opts.place_claude_cwd)}"
|
|
371
|
+
)
|
|
372
|
+
if result.report.warnings:
|
|
373
|
+
lines.append(f"\n[b]{len(result.report.warnings)} conversion note(s):[/]")
|
|
374
|
+
lines.extend(f" [yellow]- {escape(w)}[/]" for w in result.report.warnings)
|
|
375
|
+
else:
|
|
376
|
+
lines.append("\n[green]lossless conversion (no warnings).[/]")
|
|
377
|
+
lines.append("\n[b]equivalent CLI command:[/]")
|
|
378
|
+
lines.append(f" [dim]{escape(build_cli_command(opts))}[/]")
|
|
379
|
+
self.query_one("#dryrun-body", Static).update("\n".join(lines))
|
|
380
|
+
write = self.query_one("#write", Button)
|
|
381
|
+
write.disabled = False
|
|
382
|
+
# While Write was disabled, focus defaulted to Cancel — move it so
|
|
383
|
+
# plain Enter confirms rather than silently cancelling.
|
|
384
|
+
write.focus()
|
|
385
|
+
|
|
386
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
387
|
+
if event.button.id == "cancel":
|
|
388
|
+
self.action_back()
|
|
389
|
+
elif event.button.id == "write" and self.result is not None and not self._writing:
|
|
390
|
+
self._writing = True
|
|
391
|
+
self.query_one("#write", Button).disabled = True
|
|
392
|
+
self.query_one("#cancel", Button).disabled = True
|
|
393
|
+
self._write()
|
|
394
|
+
|
|
395
|
+
@work(thread=True, exclusive=True)
|
|
396
|
+
def _write(self) -> None:
|
|
397
|
+
outcome = execute_writes(
|
|
398
|
+
self.result,
|
|
399
|
+
self.opts,
|
|
400
|
+
claude_home=getattr(self.app, "claude_home", None),
|
|
401
|
+
)
|
|
402
|
+
self.app.call_from_thread(self.app.push_screen, ResultScreen(outcome))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class ResultScreen(Screen):
|
|
406
|
+
"""Outcome of the writes: paths, resume hint, or a rendered error."""
|
|
407
|
+
|
|
408
|
+
BINDINGS = [
|
|
409
|
+
("n", "new_conversion", "Another conversion"),
|
|
410
|
+
("q", "quit", "Quit"),
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
def __init__(self, outcome) -> None:
|
|
414
|
+
super().__init__()
|
|
415
|
+
self.outcome = outcome
|
|
416
|
+
|
|
417
|
+
def compose(self) -> ComposeResult:
|
|
418
|
+
yield Header()
|
|
419
|
+
with VerticalScroll():
|
|
420
|
+
yield Static(self._body(), id="result-body")
|
|
421
|
+
yield Footer()
|
|
422
|
+
|
|
423
|
+
def _body(self) -> str:
|
|
424
|
+
o = self.outcome
|
|
425
|
+
lines = []
|
|
426
|
+
if o.output_path:
|
|
427
|
+
lines.append(
|
|
428
|
+
f"[green]wrote {o.record_count} records -> {escape(str(o.output_path))}[/]"
|
|
429
|
+
)
|
|
430
|
+
if o.handshake_path:
|
|
431
|
+
lines.append(f"wrote resume handshake -> {escape(str(o.handshake_path))}")
|
|
432
|
+
if o.placed_path:
|
|
433
|
+
lines.append(f"placed resumable session -> {escape(str(o.placed_path))}")
|
|
434
|
+
if o.resume_hint:
|
|
435
|
+
lines.append(f"\n[b]resume with:[/] {escape(o.resume_hint)}")
|
|
436
|
+
if o.error:
|
|
437
|
+
lines.append(f"\n[b red]error:[/] {escape(o.error)}")
|
|
438
|
+
lines.append("\n[dim]n for another conversion, q to quit[/]")
|
|
439
|
+
return "\n".join(lines)
|
|
440
|
+
|
|
441
|
+
def action_new_conversion(self) -> None:
|
|
442
|
+
while not isinstance(self.app.screen, PickerScreen):
|
|
443
|
+
self.app.pop_screen()
|
|
444
|
+
|
|
445
|
+
def action_quit(self) -> None:
|
|
446
|
+
self.app.exit()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class RegisterFormScreen(Screen):
|
|
450
|
+
"""Registration options: write the session into a harness's SQLite store."""
|
|
451
|
+
|
|
452
|
+
BINDINGS = [("escape", "app.pop_screen", "Back")]
|
|
453
|
+
|
|
454
|
+
def __init__(self, entry: SessionEntry) -> None:
|
|
455
|
+
super().__init__()
|
|
456
|
+
self.entry = entry
|
|
457
|
+
|
|
458
|
+
def compose(self) -> ComposeResult:
|
|
459
|
+
app_codex_home = getattr(self.app, "codex_home", None)
|
|
460
|
+
yield Header()
|
|
461
|
+
with VerticalScroll():
|
|
462
|
+
yield Label("Target store")
|
|
463
|
+
yield Select(
|
|
464
|
+
[("Hermes (state.db)", "hermes"), ("Codex (state_5.sqlite)", "codex")],
|
|
465
|
+
value="hermes",
|
|
466
|
+
allow_blank=False,
|
|
467
|
+
id="store",
|
|
468
|
+
)
|
|
469
|
+
yield Label("Title (optional)")
|
|
470
|
+
yield Input(placeholder=f"resumed from {self.entry.harness}", id="reg-title")
|
|
471
|
+
yield Label("Session id (blank: generated)")
|
|
472
|
+
yield Input(id="reg-session-id")
|
|
473
|
+
with Horizontal(classes="switch-row"):
|
|
474
|
+
yield Switch(value=False, id="reg-stub")
|
|
475
|
+
yield Label("stub open tool calls (synthetic interrupted results)")
|
|
476
|
+
with Horizontal(classes="switch-row"):
|
|
477
|
+
yield Switch(value=True, id="reg-backup")
|
|
478
|
+
yield Label("back up the store before writing (recommended)")
|
|
479
|
+
with Vertical(id="hermes-group"):
|
|
480
|
+
yield Label("Hermes state.db path (blank: ~/.hermes/state.db)")
|
|
481
|
+
yield Input(id="hermes-db")
|
|
482
|
+
yield Label("Model to store (blank: keep source id — may not route)")
|
|
483
|
+
yield Input(id="hermes-model")
|
|
484
|
+
with Vertical(id="codex-group"):
|
|
485
|
+
yield Label("Project cwd Codex resumes from (required)")
|
|
486
|
+
yield Input(value=self.entry.cwd or "", id="codex-cwd")
|
|
487
|
+
yield Label("Codex home (blank: ~/.codex)")
|
|
488
|
+
yield Input(value=str(app_codex_home) if app_codex_home else "", id="codex-home")
|
|
489
|
+
yield Label("Model (blank: infer most recently used)")
|
|
490
|
+
yield Input(id="codex-model")
|
|
491
|
+
yield Label("Model provider")
|
|
492
|
+
yield Input(value="openai", id="codex-provider")
|
|
493
|
+
yield Static("", id="reg-errors")
|
|
494
|
+
yield Button("Continue to plan", variant="primary", id="reg-continue")
|
|
495
|
+
yield Footer()
|
|
496
|
+
|
|
497
|
+
def on_mount(self) -> None:
|
|
498
|
+
self._sync_store("hermes")
|
|
499
|
+
|
|
500
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
501
|
+
if event.select.id == "store":
|
|
502
|
+
self._sync_store(str(event.value))
|
|
503
|
+
|
|
504
|
+
def _sync_store(self, store: str) -> None:
|
|
505
|
+
self.query_one("#hermes-group").display = store == "hermes"
|
|
506
|
+
self.query_one("#codex-group").display = store == "codex"
|
|
507
|
+
|
|
508
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
509
|
+
# Enter in any form field submits, mirroring OptionsScreen.
|
|
510
|
+
self._continue()
|
|
511
|
+
|
|
512
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
513
|
+
if event.button.id == "reg-continue":
|
|
514
|
+
self._continue()
|
|
515
|
+
|
|
516
|
+
def _continue(self) -> None:
|
|
517
|
+
from .register import CodexRegisterOptions, HermesRegisterOptions
|
|
518
|
+
|
|
519
|
+
store = str(self.query_one("#store", Select).value)
|
|
520
|
+
title = self.query_one("#reg-title", Input).value.strip() or None
|
|
521
|
+
session_id = self.query_one("#reg-session-id", Input).value.strip() or None
|
|
522
|
+
stub = self.query_one("#reg-stub", Switch).value
|
|
523
|
+
no_backup = not self.query_one("#reg-backup", Switch).value
|
|
524
|
+
if store == "hermes":
|
|
525
|
+
opts = HermesRegisterOptions(
|
|
526
|
+
source=self.entry.harness,
|
|
527
|
+
path=str(self.entry.path),
|
|
528
|
+
db=self.query_one("#hermes-db", Input).value.strip() or None,
|
|
529
|
+
model=self.query_one("#hermes-model", Input).value.strip() or None,
|
|
530
|
+
title=title,
|
|
531
|
+
session_id=session_id,
|
|
532
|
+
no_backup=no_backup,
|
|
533
|
+
stub_open_calls=stub,
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
cwd = self.query_one("#codex-cwd", Input).value.strip()
|
|
537
|
+
if not cwd:
|
|
538
|
+
self.query_one("#reg-errors", Static).update(
|
|
539
|
+
"[b red]Codex registration needs the project cwd it will "
|
|
540
|
+
"resume from[/]"
|
|
541
|
+
)
|
|
542
|
+
return
|
|
543
|
+
opts = CodexRegisterOptions(
|
|
544
|
+
source=self.entry.harness,
|
|
545
|
+
path=str(self.entry.path),
|
|
546
|
+
cwd=cwd,
|
|
547
|
+
codex_home=self.query_one("#codex-home", Input).value.strip() or None,
|
|
548
|
+
title=title,
|
|
549
|
+
model=self.query_one("#codex-model", Input).value.strip() or None,
|
|
550
|
+
model_provider=self.query_one("#codex-provider", Input).value.strip()
|
|
551
|
+
or "openai",
|
|
552
|
+
session_id=session_id,
|
|
553
|
+
no_backup=no_backup,
|
|
554
|
+
stub_open_calls=stub,
|
|
555
|
+
)
|
|
556
|
+
self.app.push_screen(RegisterPlanScreen(store, opts))
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class RegisterPlanScreen(Screen):
|
|
560
|
+
"""Everything the registration will do, shown BEFORE the SQLite mutation."""
|
|
561
|
+
|
|
562
|
+
BINDINGS = [("escape", "back", "Back")]
|
|
563
|
+
|
|
564
|
+
def __init__(self, store: str, opts) -> None:
|
|
565
|
+
super().__init__()
|
|
566
|
+
self.store = store
|
|
567
|
+
self.opts = opts
|
|
568
|
+
self.plan = None
|
|
569
|
+
self._writing = False
|
|
570
|
+
|
|
571
|
+
def action_back(self) -> None:
|
|
572
|
+
if not self._writing:
|
|
573
|
+
self.app.pop_screen()
|
|
574
|
+
|
|
575
|
+
def compose(self) -> ComposeResult:
|
|
576
|
+
yield Header()
|
|
577
|
+
with VerticalScroll():
|
|
578
|
+
yield Static("planning registration (nothing written yet)…", id="plan-body")
|
|
579
|
+
with Horizontal(id="plan-buttons"):
|
|
580
|
+
yield Button("Register", variant="success", id="register", disabled=True)
|
|
581
|
+
yield Button("Cancel", id="plan-cancel")
|
|
582
|
+
yield Footer()
|
|
583
|
+
|
|
584
|
+
def on_mount(self) -> None:
|
|
585
|
+
self._prepare()
|
|
586
|
+
|
|
587
|
+
@work(thread=True, exclusive=True)
|
|
588
|
+
def _prepare(self) -> None:
|
|
589
|
+
from .register import RegisterPlan, prepare_codex_register, prepare_hermes_register
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
if self.store == "hermes":
|
|
593
|
+
plan = prepare_hermes_register(
|
|
594
|
+
self.opts, hermes_home=getattr(self.app, "hermes_home", None)
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
plan = prepare_codex_register(
|
|
598
|
+
self.opts, codex_home=getattr(self.app, "codex_home", None)
|
|
599
|
+
)
|
|
600
|
+
except Exception as exc:
|
|
601
|
+
# prepare_* promise never to raise; if that regresses, render the
|
|
602
|
+
# failure rather than crash the terminal.
|
|
603
|
+
plan = RegisterPlan(
|
|
604
|
+
store=self.store, session=None, warnings=[], notes=[],
|
|
605
|
+
session_id=None, db_path=None, model=None, cli_command="",
|
|
606
|
+
opts=self.opts, error=str(exc),
|
|
607
|
+
)
|
|
608
|
+
self.app.call_from_thread(self._render_plan, plan)
|
|
609
|
+
|
|
610
|
+
def _render_plan(self, plan) -> None:
|
|
611
|
+
self.plan = plan
|
|
612
|
+
body = self.query_one("#plan-body", Static)
|
|
613
|
+
lines = []
|
|
614
|
+
if plan.error:
|
|
615
|
+
# Error plans still carry the computed loss disclosure — show it,
|
|
616
|
+
# like the CLI prints its conversion notes before failing.
|
|
617
|
+
lines.append(f"[b red]cannot register[/]\n\n{escape(plan.error)}\n")
|
|
618
|
+
else:
|
|
619
|
+
lines += [
|
|
620
|
+
f"[b]store[/] {self.store} -> {escape(str(plan.db_path))}",
|
|
621
|
+
f"[b]session id[/] {escape(str(plan.session_id))}",
|
|
622
|
+
f"[b]model[/] {escape(plan.model or '(source model id)')}",
|
|
623
|
+
"[b]backup[/] "
|
|
624
|
+
+ ("taken before writing" if not self.opts.no_backup else "[yellow]DISABLED[/]"),
|
|
625
|
+
]
|
|
626
|
+
if plan.warnings:
|
|
627
|
+
lines.append(f"\n[b]{len(plan.warnings)} conversion note(s):[/]")
|
|
628
|
+
lines.extend(f" [yellow]- {escape(w)}[/]" for w in plan.warnings)
|
|
629
|
+
elif not plan.error:
|
|
630
|
+
lines.append("\n[green]lossless registration (no warnings).[/]")
|
|
631
|
+
for note in plan.notes:
|
|
632
|
+
lines.append(f"[dim]note: {escape(note)}[/]")
|
|
633
|
+
if plan.cli_command:
|
|
634
|
+
lines.append("\n[b]equivalent CLI command:[/]")
|
|
635
|
+
lines.append(f" [dim]{escape(plan.cli_command)}[/]")
|
|
636
|
+
if plan.error:
|
|
637
|
+
lines.append("\n[dim]escape to go back[/]")
|
|
638
|
+
body.update("\n".join(lines))
|
|
639
|
+
if not plan.error:
|
|
640
|
+
register = self.query_one("#register", Button)
|
|
641
|
+
register.disabled = False
|
|
642
|
+
# Same focus handoff as DryRunScreen: Enter should confirm, not
|
|
643
|
+
# hit the Cancel button that had default focus while disabled.
|
|
644
|
+
register.focus()
|
|
645
|
+
|
|
646
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
647
|
+
if event.button.id == "plan-cancel":
|
|
648
|
+
self.action_back()
|
|
649
|
+
elif (
|
|
650
|
+
event.button.id == "register"
|
|
651
|
+
and self.plan is not None
|
|
652
|
+
and self.plan.error is None
|
|
653
|
+
and not self._writing
|
|
654
|
+
):
|
|
655
|
+
self._writing = True
|
|
656
|
+
self.query_one("#register", Button).disabled = True
|
|
657
|
+
self.query_one("#plan-cancel", Button).disabled = True
|
|
658
|
+
self._execute()
|
|
659
|
+
|
|
660
|
+
@work(thread=True, exclusive=True)
|
|
661
|
+
def _execute(self) -> None:
|
|
662
|
+
from .register import execute_register
|
|
663
|
+
|
|
664
|
+
outcome = execute_register(self.plan)
|
|
665
|
+
self.app.call_from_thread(self.app.push_screen, RegisterResultScreen(outcome))
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
class RegisterResultScreen(Screen):
|
|
669
|
+
"""Outcome of the registration: rows written, backup path, resume hint."""
|
|
670
|
+
|
|
671
|
+
BINDINGS = [
|
|
672
|
+
("n", "new_conversion", "Another session"),
|
|
673
|
+
("q", "quit", "Quit"),
|
|
674
|
+
]
|
|
675
|
+
|
|
676
|
+
def __init__(self, outcome) -> None:
|
|
677
|
+
super().__init__()
|
|
678
|
+
self.outcome = outcome
|
|
679
|
+
|
|
680
|
+
def compose(self) -> ComposeResult:
|
|
681
|
+
yield Header()
|
|
682
|
+
with VerticalScroll():
|
|
683
|
+
yield Static(self._body(), id="reg-result-body")
|
|
684
|
+
yield Footer()
|
|
685
|
+
|
|
686
|
+
def _body(self) -> str:
|
|
687
|
+
o = self.outcome
|
|
688
|
+
lines = []
|
|
689
|
+
if o.backup_path:
|
|
690
|
+
lines.append(f"backed up store -> {escape(str(o.backup_path))}")
|
|
691
|
+
if o.error:
|
|
692
|
+
lines.append(f"\n[b red]registration failed:[/] {escape(o.error)}")
|
|
693
|
+
else:
|
|
694
|
+
lines.append(
|
|
695
|
+
f"[green]registered session {escape(str(o.session_id))} "
|
|
696
|
+
f"into {escape(str(o.db_path))}[/]"
|
|
697
|
+
)
|
|
698
|
+
if o.rollout_path:
|
|
699
|
+
lines.append(f"wrote Codex rollout -> {escape(str(o.rollout_path))}")
|
|
700
|
+
if o.resume_hint:
|
|
701
|
+
lines.append(f"\n[b]resume with:[/] {escape(o.resume_hint)}")
|
|
702
|
+
lines.append("\n[dim]n for another session, q to quit[/]")
|
|
703
|
+
return "\n".join(lines)
|
|
704
|
+
|
|
705
|
+
def action_new_conversion(self) -> None:
|
|
706
|
+
while not isinstance(self.app.screen, PickerScreen):
|
|
707
|
+
self.app.pop_screen()
|
|
708
|
+
|
|
709
|
+
def action_quit(self) -> None:
|
|
710
|
+
self.app.exit()
|