patchfeld 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.
Files changed (81) hide show
  1. patchfeld/__init__.py +1 -0
  2. patchfeld/__main__.py +32 -0
  3. patchfeld/actions.py +34 -0
  4. patchfeld/activity/__init__.py +0 -0
  5. patchfeld/activity/log.py +237 -0
  6. patchfeld/agents/__init__.py +0 -0
  7. patchfeld/agents/child_tools.py +66 -0
  8. patchfeld/agents/fake_sdk_adapter.py +45 -0
  9. patchfeld/agents/manager.py +365 -0
  10. patchfeld/agents/permission_grants.py +98 -0
  11. patchfeld/agents/permission_inbox.py +91 -0
  12. patchfeld/agents/request_inbox.py +65 -0
  13. patchfeld/agents/sdk_adapter.py +49 -0
  14. patchfeld/agents/session.py +250 -0
  15. patchfeld/agents/sort.py +66 -0
  16. patchfeld/agents/state.py +81 -0
  17. patchfeld/app.py +1433 -0
  18. patchfeld/config.py +128 -0
  19. patchfeld/events.py +260 -0
  20. patchfeld/layout/__init__.py +0 -0
  21. patchfeld/layout/custom_widgets.py +82 -0
  22. patchfeld/layout/defaults.py +33 -0
  23. patchfeld/layout/engine.py +241 -0
  24. patchfeld/layout/local_widgets.py +188 -0
  25. patchfeld/layout/registry.py +69 -0
  26. patchfeld/layout/spec.py +104 -0
  27. patchfeld/layout/splitter.py +170 -0
  28. patchfeld/layout/titles.py +70 -0
  29. patchfeld/orchestrator/__init__.py +0 -0
  30. patchfeld/orchestrator/formatting.py +15 -0
  31. patchfeld/orchestrator/session.py +785 -0
  32. patchfeld/orchestrator/tabs_tools.py +149 -0
  33. patchfeld/orchestrator/tools.py +976 -0
  34. patchfeld/persistence/__init__.py +0 -0
  35. patchfeld/persistence/agents_index.py +68 -0
  36. patchfeld/persistence/atomic.py +47 -0
  37. patchfeld/persistence/layout_store.py +25 -0
  38. patchfeld/persistence/layouts_store.py +61 -0
  39. patchfeld/persistence/orchestrator_sessions.py +127 -0
  40. patchfeld/persistence/paths.py +48 -0
  41. patchfeld/persistence/themes_store.py +44 -0
  42. patchfeld/persistence/transcript_store.py +64 -0
  43. patchfeld/persistence/workspace_store.py +25 -0
  44. patchfeld/theme/__init__.py +0 -0
  45. patchfeld/theme/engine.py +75 -0
  46. patchfeld/theme/spec.py +31 -0
  47. patchfeld/widgets/__init__.py +0 -0
  48. patchfeld/widgets/_file_lang.py +36 -0
  49. patchfeld/widgets/_terminal_keys.py +89 -0
  50. patchfeld/widgets/_terminal_render.py +147 -0
  51. patchfeld/widgets/activity_feed.py +365 -0
  52. patchfeld/widgets/agent_table.py +236 -0
  53. patchfeld/widgets/agent_transcript.py +85 -0
  54. patchfeld/widgets/change_cwd_screen.py +39 -0
  55. patchfeld/widgets/chrome.py +210 -0
  56. patchfeld/widgets/diff_viewer.py +52 -0
  57. patchfeld/widgets/file_editor.py +258 -0
  58. patchfeld/widgets/file_tree.py +33 -0
  59. patchfeld/widgets/file_viewer.py +77 -0
  60. patchfeld/widgets/history_screen.py +58 -0
  61. patchfeld/widgets/layout_switcher.py +126 -0
  62. patchfeld/widgets/log_tail.py +113 -0
  63. patchfeld/widgets/markdown.py +65 -0
  64. patchfeld/widgets/new_tab_screen.py +31 -0
  65. patchfeld/widgets/notebook.py +45 -0
  66. patchfeld/widgets/orchestrator_chat.py +73 -0
  67. patchfeld/widgets/permission_modal.py +185 -0
  68. patchfeld/widgets/permission_request_bar.py +90 -0
  69. patchfeld/widgets/resume_screen.py +179 -0
  70. patchfeld/widgets/rich_transcript.py +606 -0
  71. patchfeld/widgets/system_usage.py +244 -0
  72. patchfeld/widgets/terminal.py +251 -0
  73. patchfeld/widgets/theme_switcher.py +63 -0
  74. patchfeld/widgets/transcript_screen.py +39 -0
  75. patchfeld/workspace/__init__.py +3 -0
  76. patchfeld/workspace/spec.py +72 -0
  77. patchfeld-0.2.0.dist-info/METADATA +584 -0
  78. patchfeld-0.2.0.dist-info/RECORD +81 -0
  79. patchfeld-0.2.0.dist-info/WHEEL +4 -0
  80. patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
  81. patchfeld-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,65 @@
1
+ from pathlib import Path
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Markdown as _TxMarkdown
6
+
7
+
8
+ class MarkdownPanel(VerticalScroll):
9
+ """Renders markdown text from `source` or `file_path`, wrapped in a
10
+ VerticalScroll so long documents are scrollable. The internal
11
+ `_markdown` attribute holds the source string for tests.
12
+
13
+ The class is named `MarkdownPanel` rather than `Markdown` to avoid
14
+ a CSS type-selector collision with `textual.widgets.Markdown`. Textual's
15
+ Markdown widget declares `Markdown { height: auto; overflow-y: hidden; }`
16
+ in its DEFAULT_CSS; because Textual matches type selectors by class
17
+ `__name__` and (when SCOPED_CSS=True) only prepends the scope when the
18
+ rule's first selector differs from the scope name, those rules would
19
+ otherwise leak onto our outer container — sizing it to its content and
20
+ suppressing the scrollbar. With distinct class names, each widget's
21
+ DEFAULT_CSS stays in its own lane. The public alias `Markdown` below
22
+ preserves the existing import surface and registry name."""
23
+
24
+ DEFAULT_CSS = """
25
+ MarkdownPanel {
26
+ border: round $surface-lighten-2;
27
+ padding: 0 1;
28
+ }
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ source: str | None = None,
35
+ file_path: str | None = None,
36
+ ) -> None:
37
+ super().__init__()
38
+ if source is None and file_path is not None:
39
+ try:
40
+ source = Path(file_path).read_text(encoding="utf-8")
41
+ except FileNotFoundError:
42
+ source = f"*File not found: {file_path}*"
43
+ except Exception as e:
44
+ source = f"*Error loading {file_path}: {e}*"
45
+ if source is None:
46
+ source = ""
47
+ self._markdown = source
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield _TxMarkdown(self._markdown)
51
+
52
+ @classmethod
53
+ def default_border_title(cls, props: dict) -> str:
54
+ file_path = props.get("file_path")
55
+ if file_path:
56
+ return f"Markdown: {Path(file_path).name}"
57
+ return "Markdown"
58
+
59
+
60
+ # Public alias: keep `from patchfeld.widgets.markdown import Markdown` working
61
+ # and the registry name "Markdown" stable. Aliasing does NOT change the
62
+ # class's `__name__` (still "MarkdownPanel"), which is the property Textual
63
+ # uses for CSS type-selector matching — so the leak from textual's own
64
+ # Markdown rule is avoided.
65
+ Markdown = MarkdownPanel
@@ -0,0 +1,31 @@
1
+ from textual.app import ComposeResult
2
+ from textual.containers import Vertical
3
+ from textual.screen import ModalScreen
4
+ from textual.widgets import Input, Static
5
+
6
+
7
+ class NewTabScreen(ModalScreen[str | None]):
8
+ """Tiny modal that asks for a tab title and dismisses with the entered
9
+ string (or None on escape)."""
10
+
11
+ DEFAULT_CSS = """
12
+ NewTabScreen { align: center middle; }
13
+ NewTabScreen > Vertical {
14
+ width: 50; height: auto; padding: 1 2;
15
+ background: $surface; border: round $primary;
16
+ }
17
+ """
18
+
19
+ BINDINGS = [("escape", "cancel", "cancel")]
20
+
21
+ def compose(self) -> ComposeResult:
22
+ with Vertical():
23
+ yield Static("New tab title:")
24
+ yield Input(placeholder="e.g., Logs", id="new-tab-input")
25
+
26
+ def on_input_submitted(self, event: Input.Submitted) -> None:
27
+ title = (event.value or "").strip()
28
+ self.dismiss(title or None)
29
+
30
+ def action_cancel(self) -> None:
31
+ self.dismiss(None)
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+
3
+ from textual.widgets import TextArea
4
+
5
+
6
+ class Notebook(TextArea):
7
+ """Persistent scratch buffer at <cwd>/.patchfeld/scratch/<name>.md."""
8
+
9
+ DEFAULT_CSS = """
10
+ Notebook {
11
+ border: round $surface-lighten-2;
12
+ }
13
+ """
14
+
15
+ def __init__(self, *, name: str) -> None:
16
+ super().__init__("", language="markdown")
17
+ self._name = name
18
+
19
+ def _path(self) -> Path:
20
+ cwd = getattr(self.app, "cwd", Path.cwd())
21
+ return Path(cwd) / ".patchfeld" / "scratch" / f"{self._name}.md"
22
+
23
+ def on_mount(self) -> None:
24
+ path = self._path()
25
+ if path.exists():
26
+ try:
27
+ self.text = path.read_text(encoding="utf-8")
28
+ except Exception:
29
+ pass
30
+
31
+ def _save(self) -> None:
32
+ path = self._path()
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ path.write_text(self.text, encoding="utf-8")
35
+
36
+ def on_text_area_changed(self, _event) -> None:
37
+ # Saves on every keystroke. Cheap for a scratchpad-sized file.
38
+ self._save()
39
+
40
+ @classmethod
41
+ def default_border_title(cls, props: dict) -> str:
42
+ name = props.get("name")
43
+ if name:
44
+ return f"Note: {name}"
45
+ return "Note"
@@ -0,0 +1,73 @@
1
+ from textual.app import ComposeResult
2
+ from textual.binding import Binding
3
+ from textual.containers import Vertical
4
+ from textual.widgets import Input
5
+
6
+ from patchfeld.events import EventBus, UserMessageToOrchestrator
7
+ from patchfeld.widgets.rich_transcript import RichTranscript
8
+
9
+
10
+ class OrchestratorChat(Vertical):
11
+ """Manager-Claude chat panel: RichTranscript + input box."""
12
+
13
+ AGENT_ID = "orchestrator"
14
+ DEFAULT_BORDER_TITLE = "Orchestrator"
15
+
16
+ DEFAULT_CSS = """
17
+ OrchestratorChat {
18
+ border: round $primary;
19
+ padding: 0 1;
20
+ }
21
+ OrchestratorChat > RichTranscript {
22
+ height: 1fr;
23
+ }
24
+ OrchestratorChat #orch-input {
25
+ dock: bottom;
26
+ height: 3;
27
+ }
28
+ """
29
+
30
+ # priority=True so the binding fires even when the Input child has
31
+ # focus — without it, ctrl+c is consumed by Textual's default driver
32
+ # handling (which would quit the app).
33
+ BINDINGS = [
34
+ Binding("ctrl+c", "interrupt", "interrupt orchestrator", priority=True),
35
+ ]
36
+
37
+ def __init__(self, *, event_bus: EventBus | None = None) -> None:
38
+ super().__init__()
39
+ self._bus = event_bus
40
+
41
+ def compose(self) -> ComposeResult:
42
+ path = None
43
+ try:
44
+ orch = getattr(self.app, "orchestrator", None)
45
+ if orch is not None:
46
+ path = orch.active_transcript_path
47
+ except Exception:
48
+ path = None
49
+ yield RichTranscript(
50
+ agent_id=self.AGENT_ID, event_bus=self._bus, transcript_path=path,
51
+ )
52
+ yield Input(
53
+ placeholder=(
54
+ "Message orchestrator… "
55
+ "(/reset, /resume, /rename, /help, ctrl+c to interrupt)"
56
+ ),
57
+ id="orch-input",
58
+ )
59
+
60
+ def on_input_submitted(self, event: Input.Submitted) -> None:
61
+ if not event.value.strip():
62
+ return
63
+ text = event.value
64
+ bus = self._bus or getattr(self.app, "event_bus", None)
65
+ event.input.value = ""
66
+ if bus is not None:
67
+ bus.publish(UserMessageToOrchestrator(text))
68
+
69
+ async def action_interrupt(self) -> None:
70
+ orch = getattr(self.app, "orchestrator", None)
71
+ if orch is None:
72
+ return
73
+ await orch.interrupt()
@@ -0,0 +1,185 @@
1
+ from typing import Callable, Literal
2
+
3
+ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Vertical, Horizontal, VerticalScroll
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label, Static
9
+
10
+ from patchfeld.agents.permission_grants import PermissionGrants
11
+ from patchfeld.agents.permission_inbox import PermissionInbox
12
+ from patchfeld.events import (
13
+ EventBus, PermissionRequested, PermissionResolved,
14
+ )
15
+
16
+
17
+ _ORCHESTRATOR = "orchestrator"
18
+
19
+
20
+ class PermissionModal(ModalScreen[None]):
21
+ """Global permission-prompt modal.
22
+
23
+ Subscribes to PermissionRequested. While at least one request is
24
+ pending, displays the head of the queue with Allow/Deny buttons +
25
+ explicit-scope variants. Resolves directly via the session's
26
+ PermissionInbox; uses the bus to receive new requests and to broadcast
27
+ PermissionResolved so the per-agent transcript bar can clear its
28
+ inline view.
29
+ """
30
+
31
+ DEFAULT_CSS = """
32
+ PermissionModal { align: center middle; }
33
+ PermissionModal > Vertical {
34
+ width: 90; height: auto; padding: 1 2;
35
+ background: $surface; border: round $warning;
36
+ }
37
+ PermissionModal #title { text-style: bold; }
38
+ PermissionModal #scope-hint { color: $text-muted; text-style: italic; }
39
+ PermissionModal #buttons { height: 3; align-horizontal: center; }
40
+ PermissionModal Button { margin: 0 1; }
41
+ PermissionModal #tool-args-scroll {
42
+ max-height: 8;
43
+ height: auto;
44
+ border: round $surface-lighten-1;
45
+ margin: 1 0;
46
+ padding: 0 1;
47
+ }
48
+ PermissionModal #tool-args {
49
+ width: auto;
50
+ }
51
+ """
52
+
53
+ BINDINGS = [Binding("escape", "deny_once", "deny once")]
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ inbox_lookup: Callable[[str], PermissionInbox | None],
59
+ grants: PermissionGrants,
60
+ initial_request: PermissionRequested | None = None,
61
+ ) -> None:
62
+ super().__init__()
63
+ self._inbox_lookup = inbox_lookup
64
+ self._grants = grants
65
+ self._queue: list[PermissionRequested] = []
66
+ self._current_request: PermissionRequested | None = initial_request
67
+ self._unsub_req = lambda: None
68
+ self._unsub_res = lambda: None
69
+
70
+ def compose(self) -> ComposeResult:
71
+ with Vertical():
72
+ yield Static("Permission requested", id="title")
73
+ yield Label("(no pending request)", id="prompt")
74
+ yield Label("", id="agent")
75
+ with VerticalScroll(id="tool-args-scroll"):
76
+ yield Static("", id="tool-args")
77
+ yield Label("", id="scope-hint")
78
+ with Horizontal(id="buttons"):
79
+ yield Button("Allow once", id="allow-once", variant="success")
80
+ yield Button(
81
+ "Allow for the rest of this run",
82
+ id="allow-session", variant="success",
83
+ )
84
+ yield Button("Always allow", id="allow-always", variant="success")
85
+ yield Button("Deny once", id="deny-once", variant="error")
86
+ yield Button("Always deny", id="deny-always", variant="error")
87
+
88
+ def on_mount(self) -> None:
89
+ bus: EventBus = self.app.event_bus
90
+ self._unsub_req = bus.subscribe(PermissionRequested, self._on_request)
91
+ self._unsub_res = bus.subscribe(PermissionResolved, self._on_resolved_elsewhere)
92
+ if self._current_request is not None:
93
+ self._render_current()
94
+
95
+ def on_unmount(self) -> None:
96
+ self._unsub_req()
97
+ self._unsub_res()
98
+
99
+ def _on_request(self, event: PermissionRequested) -> None:
100
+ if self._current_request is None:
101
+ self._current_request = event
102
+ self._render_current()
103
+ else:
104
+ self._queue.append(event)
105
+
106
+ def _on_resolved_elsewhere(self, event: PermissionResolved) -> None:
107
+ if (self._current_request is not None
108
+ and self._current_request.request_id == event.request_id):
109
+ self._advance()
110
+ return
111
+ self._queue = [q for q in self._queue if q.request_id != event.request_id]
112
+
113
+ def _render_current(self) -> None:
114
+ req = self._current_request
115
+ if req is None:
116
+ return
117
+ self.query_one("#prompt", Label).update(
118
+ req.title or f"Allow {req.tool_name}?"
119
+ )
120
+ self.query_one("#agent", Label).update(f"agent: {req.agent_name}")
121
+ import json as _json
122
+ try:
123
+ pretty = _json.dumps(req.tool_input, indent=2, ensure_ascii=False)
124
+ except (TypeError, ValueError):
125
+ pretty = repr(req.tool_input)
126
+ self.query_one("#tool-args", Static).update(
127
+ f"{req.tool_name}\n{pretty}"
128
+ )
129
+ if req.agent_name == _ORCHESTRATOR:
130
+ scope_text = "Always applies to the orchestrator session"
131
+ else:
132
+ scope_text = f"Always applies to future agents named {req.agent_name!r}"
133
+ self.query_one("#scope-hint", Label).update(scope_text)
134
+
135
+ def _advance(self) -> None:
136
+ if self._queue:
137
+ self._current_request = self._queue.pop(0)
138
+ self._render_current()
139
+ else:
140
+ self._current_request = None
141
+ self.dismiss(None)
142
+
143
+ def on_button_pressed(self, event: Button.Pressed) -> None:
144
+ if self._current_request is None:
145
+ return
146
+ bid = event.button.id
147
+ if bid == "allow-once":
148
+ self._resolve("allow", scope=None)
149
+ elif bid == "allow-session":
150
+ self._resolve("allow", scope="session")
151
+ elif bid == "allow-always":
152
+ self._resolve("allow", scope="persistent")
153
+ elif bid == "deny-once":
154
+ self._resolve("deny", scope=None)
155
+ elif bid == "deny-always":
156
+ self._resolve("deny", scope="persistent")
157
+
158
+ def action_deny_once(self) -> None:
159
+ if self._current_request is None:
160
+ self.dismiss(None)
161
+ return
162
+ self._resolve("deny", scope=None)
163
+
164
+ def _resolve(self, behavior: Literal["allow", "deny"], *, scope: Literal["persistent", "session"] | None) -> None:
165
+ req = self._current_request
166
+ if req is None:
167
+ return
168
+ if scope is not None:
169
+ self._grants.remember(
170
+ agent_name=req.agent_name, tool_name=req.tool_name,
171
+ behavior=behavior, scope=scope,
172
+ )
173
+ result = (
174
+ PermissionResultAllow() if behavior == "allow"
175
+ else PermissionResultDeny(message="user denied")
176
+ )
177
+ inbox = self._inbox_lookup(req.agent_id)
178
+ if inbox is not None:
179
+ inbox.resolve(req.request_id, result)
180
+ # Advance before publishing so _on_resolved_elsewhere skips this ID.
181
+ self._advance()
182
+ self.app.event_bus.publish(PermissionResolved(
183
+ agent_id=req.agent_id, request_id=req.request_id,
184
+ behavior=behavior,
185
+ ))
@@ -0,0 +1,90 @@
1
+ from typing import Literal, TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from patchfeld.agents.permission_grants import PermissionGrants
5
+
6
+ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal
9
+ from textual.widgets import Button, Static
10
+
11
+ from patchfeld.events import PermissionRequested, PermissionResolved
12
+
13
+
14
+ class PermissionRequestBar(Horizontal):
15
+ """A single inline approval row mounted at the top of an AgentTranscript
16
+ while a permission request is pending for that agent.
17
+
18
+ Clicks call into the agent's PermissionInbox via the App's manager;
19
+ coordination with the global modal happens through PermissionResolved.
20
+ """
21
+
22
+ DEFAULT_CSS = """
23
+ PermissionRequestBar {
24
+ height: 3;
25
+ background: $warning-darken-2;
26
+ padding: 0 1;
27
+ }
28
+ PermissionRequestBar Static.label { width: 1fr; }
29
+ PermissionRequestBar Button { margin: 0 1; }
30
+ """
31
+
32
+ def __init__(self, *, request: PermissionRequested) -> None:
33
+ super().__init__()
34
+ self._request = request
35
+
36
+ @property
37
+ def request_id(self) -> str:
38
+ return self._request.request_id
39
+
40
+ def compose(self) -> ComposeResult:
41
+ req = self._request
42
+ title = req.title or f"Allow {req.tool_name}?"
43
+ yield Static(
44
+ f"⚠ {title} — {req.tool_name}({_short(req.tool_input)})",
45
+ classes="label",
46
+ )
47
+ yield Button("Allow", id="bar-allow-once", variant="success")
48
+ yield Button("Always allow", id="bar-allow-always", variant="success")
49
+ yield Button("Deny", id="bar-deny-once", variant="error")
50
+
51
+ def on_button_pressed(self, event: Button.Pressed) -> None:
52
+ bid = event.button.id
53
+ grants = getattr(self.app, "_permission_grants", None)
54
+ if bid == "bar-allow-once":
55
+ self._resolve("allow", scope=None, grants=grants)
56
+ elif bid == "bar-allow-always":
57
+ self._resolve("allow", scope="persistent", grants=grants)
58
+ elif bid == "bar-deny-once":
59
+ self._resolve("deny", scope=None, grants=grants)
60
+
61
+ def _resolve(
62
+ self,
63
+ behavior: Literal["allow", "deny"],
64
+ *,
65
+ scope: Literal["persistent", "session"] | None,
66
+ grants: "PermissionGrants | None",
67
+ ) -> None:
68
+ req = self._request
69
+ if scope is not None and grants is not None:
70
+ grants.remember(
71
+ agent_name=req.agent_name, tool_name=req.tool_name,
72
+ behavior=behavior, scope=scope,
73
+ )
74
+ manager = getattr(self.app, "manager", None)
75
+ inbox = manager.get_permission_inbox(req.agent_id) if manager else None
76
+ if inbox is not None:
77
+ result = (
78
+ PermissionResultAllow() if behavior == "allow"
79
+ else PermissionResultDeny(message="user denied")
80
+ )
81
+ inbox.resolve(req.request_id, result)
82
+ self.app.event_bus.publish(PermissionResolved(
83
+ agent_id=req.agent_id, request_id=req.request_id,
84
+ behavior=behavior,
85
+ ))
86
+
87
+
88
+ def _short(value: object, limit: int = 80) -> str:
89
+ s = repr(value)
90
+ return s if len(s) <= limit else s[: limit - 1] + "…"
@@ -0,0 +1,179 @@
1
+ import time
2
+
3
+ from textual.binding import Binding
4
+ from textual.containers import Container
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import DataTable, Footer, Input, Label
7
+
8
+ from patchfeld.persistence.orchestrator_sessions import (
9
+ OrchestratorSessionEntry,
10
+ OrchestratorSessionsIndex,
11
+ )
12
+
13
+
14
+ class ResumeScreen(ModalScreen[str | None]):
15
+ """Pick a past orchestrator session. Esc dismisses with None;
16
+ Enter dismisses with the selected session_id."""
17
+
18
+ DEFAULT_CSS = """
19
+ ResumeScreen {
20
+ align: center middle;
21
+ }
22
+ ResumeScreen > Container {
23
+ width: 80%;
24
+ height: 70%;
25
+ border: thick $primary;
26
+ background: $surface;
27
+ padding: 1 2;
28
+ }
29
+ ResumeScreen DataTable {
30
+ height: 1fr;
31
+ }
32
+ """
33
+
34
+ BINDINGS = [
35
+ Binding("escape", "dismiss_none", "cancel"),
36
+ Binding("enter", "select", "resume"),
37
+ Binding("e", "rename_row", "rename"),
38
+ ]
39
+
40
+ def __init__(self, *, index: OrchestratorSessionsIndex) -> None:
41
+ super().__init__()
42
+ self._index = index
43
+ self._ordered_ids: list[str] = []
44
+
45
+ def compose(self):
46
+ with Container():
47
+ yield Label("Resume orchestrator session · e: rename"
48
+ " · enter: resume · esc: cancel")
49
+ yield DataTable(cursor_type="row")
50
+ yield Footer()
51
+
52
+ def on_mount(self) -> None:
53
+ self._refresh_table()
54
+
55
+ def _refresh_table(self) -> None:
56
+ table = self.query_one(DataTable)
57
+ table.clear(columns=True)
58
+ table.add_columns("when", "title", "turns", "tokens", "id")
59
+ entries = sorted(self._index.list(), key=lambda e: e.last_activity, reverse=True)
60
+ now = time.time()
61
+ self._ordered_ids = []
62
+ for e in entries:
63
+ table.add_row(
64
+ _relative_time(now - e.last_activity),
65
+ _display_title(e),
66
+ str(e.num_turns),
67
+ f"{e.tokens_in}/{e.tokens_out}",
68
+ e.session_id,
69
+ key=e.session_id,
70
+ )
71
+ self._ordered_ids.append(e.session_id)
72
+ table.focus()
73
+
74
+ def _row_session_ids(self) -> list[str]:
75
+ return list(self._ordered_ids)
76
+
77
+ def action_dismiss_none(self) -> None:
78
+ self.dismiss(None)
79
+
80
+ def action_select(self) -> None:
81
+ table = self.query_one(DataTable)
82
+ if table.row_count == 0:
83
+ self.dismiss(None)
84
+ return
85
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
86
+ self.dismiss(str(row_key.value))
87
+
88
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
89
+ # Stop the event so the App's global on_data_table_row_selected
90
+ # handler doesn't also fire and open a TranscriptScreen with our
91
+ # session_id (which isn't a real agent_id).
92
+ event.stop()
93
+ self.dismiss(str(event.row_key.value))
94
+
95
+ def action_rename_row(self) -> None:
96
+ table = self.query_one(DataTable)
97
+ if table.row_count == 0:
98
+ return
99
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
100
+ sid = str(row_key.value)
101
+ entry = self._index.get(sid)
102
+ if entry is None:
103
+ return
104
+ current = entry.title or ""
105
+
106
+ def _on_renamed(new_title: str | None) -> None:
107
+ if new_title is None:
108
+ return # cancelled
109
+ cleaned = new_title.strip()
110
+ self._index.set_title(sid, cleaned if cleaned else None)
111
+ self._refresh_table()
112
+
113
+ self.app.push_screen(_RenameTitleScreen(initial=current), _on_renamed)
114
+
115
+
116
+ class _RenameTitleScreen(ModalScreen[str | None]):
117
+ """Tiny single-input modal for renaming a session title.
118
+
119
+ Returns the entered string (possibly empty) or None on cancel.
120
+ """
121
+
122
+ DEFAULT_CSS = """
123
+ _RenameTitleScreen {
124
+ align: center middle;
125
+ }
126
+ _RenameTitleScreen > Container {
127
+ width: 60%;
128
+ height: 7;
129
+ border: thick $primary;
130
+ background: $surface;
131
+ padding: 1 2;
132
+ }
133
+ """
134
+
135
+ BINDINGS = [Binding("escape", "cancel", "cancel")]
136
+
137
+ def __init__(self, *, initial: str = "") -> None:
138
+ super().__init__()
139
+ self._initial = initial
140
+
141
+ def compose(self):
142
+ with Container():
143
+ yield Label("Rename session title (enter to save, esc to cancel):")
144
+ yield Input(value=self._initial, id="rename-input")
145
+
146
+ def on_mount(self) -> None:
147
+ self.query_one("#rename-input", Input).focus()
148
+
149
+ def on_input_submitted(self, event: Input.Submitted) -> None:
150
+ self.dismiss(event.value)
151
+
152
+ def action_cancel(self) -> None:
153
+ self.dismiss(None)
154
+
155
+
156
+ def _relative_time(seconds: float) -> str:
157
+ if seconds < 60:
158
+ return f"{int(seconds)}s ago"
159
+ if seconds < 3600:
160
+ return f"{int(seconds / 60)}m ago"
161
+ if seconds < 86400:
162
+ return f"{int(seconds / 3600)}h ago"
163
+ return f"{int(seconds / 86400)}d ago"
164
+
165
+
166
+ def _truncate(s: str, n: int) -> str:
167
+ if len(s) <= n:
168
+ return s
169
+ return s[: n - 1] + "…"
170
+
171
+
172
+ def _display_title(entry: OrchestratorSessionEntry) -> str:
173
+ if entry.title:
174
+ return _truncate(entry.title, 80)
175
+ if entry.first_user_message:
176
+ return _truncate(entry.first_user_message, 80)
177
+ return "(no title)"
178
+
179
+