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.
- patchfeld/__init__.py +1 -0
- patchfeld/__main__.py +32 -0
- patchfeld/actions.py +34 -0
- patchfeld/activity/__init__.py +0 -0
- patchfeld/activity/log.py +237 -0
- patchfeld/agents/__init__.py +0 -0
- patchfeld/agents/child_tools.py +66 -0
- patchfeld/agents/fake_sdk_adapter.py +45 -0
- patchfeld/agents/manager.py +365 -0
- patchfeld/agents/permission_grants.py +98 -0
- patchfeld/agents/permission_inbox.py +91 -0
- patchfeld/agents/request_inbox.py +65 -0
- patchfeld/agents/sdk_adapter.py +49 -0
- patchfeld/agents/session.py +250 -0
- patchfeld/agents/sort.py +66 -0
- patchfeld/agents/state.py +81 -0
- patchfeld/app.py +1433 -0
- patchfeld/config.py +128 -0
- patchfeld/events.py +260 -0
- patchfeld/layout/__init__.py +0 -0
- patchfeld/layout/custom_widgets.py +82 -0
- patchfeld/layout/defaults.py +33 -0
- patchfeld/layout/engine.py +241 -0
- patchfeld/layout/local_widgets.py +188 -0
- patchfeld/layout/registry.py +69 -0
- patchfeld/layout/spec.py +104 -0
- patchfeld/layout/splitter.py +170 -0
- patchfeld/layout/titles.py +70 -0
- patchfeld/orchestrator/__init__.py +0 -0
- patchfeld/orchestrator/formatting.py +15 -0
- patchfeld/orchestrator/session.py +785 -0
- patchfeld/orchestrator/tabs_tools.py +149 -0
- patchfeld/orchestrator/tools.py +976 -0
- patchfeld/persistence/__init__.py +0 -0
- patchfeld/persistence/agents_index.py +68 -0
- patchfeld/persistence/atomic.py +47 -0
- patchfeld/persistence/layout_store.py +25 -0
- patchfeld/persistence/layouts_store.py +61 -0
- patchfeld/persistence/orchestrator_sessions.py +127 -0
- patchfeld/persistence/paths.py +48 -0
- patchfeld/persistence/themes_store.py +44 -0
- patchfeld/persistence/transcript_store.py +64 -0
- patchfeld/persistence/workspace_store.py +25 -0
- patchfeld/theme/__init__.py +0 -0
- patchfeld/theme/engine.py +75 -0
- patchfeld/theme/spec.py +31 -0
- patchfeld/widgets/__init__.py +0 -0
- patchfeld/widgets/_file_lang.py +36 -0
- patchfeld/widgets/_terminal_keys.py +89 -0
- patchfeld/widgets/_terminal_render.py +147 -0
- patchfeld/widgets/activity_feed.py +365 -0
- patchfeld/widgets/agent_table.py +236 -0
- patchfeld/widgets/agent_transcript.py +85 -0
- patchfeld/widgets/change_cwd_screen.py +39 -0
- patchfeld/widgets/chrome.py +210 -0
- patchfeld/widgets/diff_viewer.py +52 -0
- patchfeld/widgets/file_editor.py +258 -0
- patchfeld/widgets/file_tree.py +33 -0
- patchfeld/widgets/file_viewer.py +77 -0
- patchfeld/widgets/history_screen.py +58 -0
- patchfeld/widgets/layout_switcher.py +126 -0
- patchfeld/widgets/log_tail.py +113 -0
- patchfeld/widgets/markdown.py +65 -0
- patchfeld/widgets/new_tab_screen.py +31 -0
- patchfeld/widgets/notebook.py +45 -0
- patchfeld/widgets/orchestrator_chat.py +73 -0
- patchfeld/widgets/permission_modal.py +185 -0
- patchfeld/widgets/permission_request_bar.py +90 -0
- patchfeld/widgets/resume_screen.py +179 -0
- patchfeld/widgets/rich_transcript.py +606 -0
- patchfeld/widgets/system_usage.py +244 -0
- patchfeld/widgets/terminal.py +251 -0
- patchfeld/widgets/theme_switcher.py +63 -0
- patchfeld/widgets/transcript_screen.py +39 -0
- patchfeld/workspace/__init__.py +3 -0
- patchfeld/workspace/spec.py +72 -0
- patchfeld-0.2.0.dist-info/METADATA +584 -0
- patchfeld-0.2.0.dist-info/RECORD +81 -0
- patchfeld-0.2.0.dist-info/WHEEL +4 -0
- patchfeld-0.2.0.dist-info/entry_points.txt +3 -0
- 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
|
+
|