deep-agent-cli 0.1.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/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/cli/app.py
ADDED
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from functools import partial
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from zoneinfo import ZoneInfo
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit import ANSI, Application
|
|
16
|
+
from prompt_toolkit.buffer import Buffer
|
|
17
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
18
|
+
from prompt_toolkit.filters import Condition
|
|
19
|
+
from prompt_toolkit.formatted_text import FormattedText, to_formatted_text
|
|
20
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
21
|
+
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
|
22
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
23
|
+
from prompt_toolkit.keys import Keys
|
|
24
|
+
from prompt_toolkit.layout import BufferControl, ConditionalContainer, Float, FloatContainer, HSplit, Layout, VSplit, Window
|
|
25
|
+
from prompt_toolkit.layout.containers import WindowAlign
|
|
26
|
+
from prompt_toolkit.layout.controls import FormattedTextControl, UIContent, UIControl
|
|
27
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
28
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
29
|
+
from prompt_toolkit.data_structures import Point
|
|
30
|
+
from prompt_toolkit.mouse_events import MouseButton, MouseEventType
|
|
31
|
+
from prompt_toolkit.styles import Style
|
|
32
|
+
from prompt_toolkit.utils import get_cwidth
|
|
33
|
+
|
|
34
|
+
from agent.attachments import (
|
|
35
|
+
MAX_IMAGES_PER_MESSAGE,
|
|
36
|
+
ImageAttachmentRef,
|
|
37
|
+
image_attachment_from_path,
|
|
38
|
+
)
|
|
39
|
+
from agent.cli.clipboard import (
|
|
40
|
+
ClipboardAdapter,
|
|
41
|
+
ClipboardError,
|
|
42
|
+
ClipboardFiles,
|
|
43
|
+
ClipboardImage,
|
|
44
|
+
ClipboardText,
|
|
45
|
+
ClipboardUnavailable,
|
|
46
|
+
windows_path_to_wsl,
|
|
47
|
+
)
|
|
48
|
+
from agent.cli.commands import Command, command_table
|
|
49
|
+
from agent.cli.gitinfo import REFRESH_SECONDS, GitProbe, GitSummary
|
|
50
|
+
from agent.cli.input import Keymap
|
|
51
|
+
from agent.cli.interactions import InteractionController
|
|
52
|
+
from agent.cli.previews import is_mutation_tool, normalize_file_mutation
|
|
53
|
+
from agent.cli.rendering import (
|
|
54
|
+
RenderedUnit,
|
|
55
|
+
TranscriptDocument,
|
|
56
|
+
TranscriptRenderer,
|
|
57
|
+
render_interaction,
|
|
58
|
+
render_review,
|
|
59
|
+
)
|
|
60
|
+
from agent.cli.session_controller import SessionController
|
|
61
|
+
from agent.cli.state import CliState, ToolBlock
|
|
62
|
+
from agent.config import DEFAULT_UI_TIMEZONE, ModelProfile, require_keybindings_outside_workspace
|
|
63
|
+
from agent.permission import (
|
|
64
|
+
PERMISSION_ALLOW_WARNING,
|
|
65
|
+
PermissionMode,
|
|
66
|
+
allow_mode_available,
|
|
67
|
+
allow_mode_unavailable_reason,
|
|
68
|
+
parse_permission_mode,
|
|
69
|
+
permission_mode_label,
|
|
70
|
+
)
|
|
71
|
+
from agent.runner import AgentRunner, InterruptKind, RunEvent, RunResult, UnknownInterruptError
|
|
72
|
+
|
|
73
|
+
# Workspace/model/status on the left; git and context usage on the right.
|
|
74
|
+
FOOTER_LINES = 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_context_window(window: int) -> str:
|
|
78
|
+
"""1000000 -> "1.0m", 200000 -> "200k"."""
|
|
79
|
+
if window >= 1_000_000:
|
|
80
|
+
return f"{window / 1_000_000:.1f}m"
|
|
81
|
+
if window >= 1_000:
|
|
82
|
+
return f"{round(window / 1_000)}k"
|
|
83
|
+
return str(window)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_context_usage(usage: dict[str, int], window: int) -> str:
|
|
87
|
+
"""Right-hand footer label, empty when the profile declares no window."""
|
|
88
|
+
if window <= 0:
|
|
89
|
+
return ""
|
|
90
|
+
label = f"{format_context_window(window)} Context"
|
|
91
|
+
used = usage.get("total_tokens") or usage.get("input_tokens") or 0
|
|
92
|
+
if used <= 0:
|
|
93
|
+
return label
|
|
94
|
+
percent = min(100.0, used / window * 100)
|
|
95
|
+
return f"{label} · {percent:.1f}% used"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def model_display_name(profile: ModelProfile) -> str:
|
|
99
|
+
"""Include the configured source when two providers expose the same model."""
|
|
100
|
+
return f"{profile.model} · {profile.source}" if profile.source else profile.model
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SlashCompleter(Completer):
|
|
104
|
+
def __init__(self, commands: tuple[Command, ...]) -> None:
|
|
105
|
+
self.commands = commands
|
|
106
|
+
self.accepted_text: str | None = None
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _rank(name: str, query: str) -> tuple[int, int, int] | None:
|
|
110
|
+
if name == query:
|
|
111
|
+
return (0, 0, len(name))
|
|
112
|
+
if name.startswith(query):
|
|
113
|
+
return (1, 0, len(name))
|
|
114
|
+
offset = 0
|
|
115
|
+
gaps = 0
|
|
116
|
+
for char in query:
|
|
117
|
+
found = name.find(char, offset)
|
|
118
|
+
if found < 0:
|
|
119
|
+
return None
|
|
120
|
+
gaps += found - offset
|
|
121
|
+
offset = found + 1
|
|
122
|
+
return (2, gaps, len(name))
|
|
123
|
+
|
|
124
|
+
def get_completions(self, document, complete_event): # type: ignore[no-untyped-def]
|
|
125
|
+
before = document.text_before_cursor
|
|
126
|
+
if "\n" in before or not before.startswith("/") or " " in before:
|
|
127
|
+
return
|
|
128
|
+
if before == self.accepted_text:
|
|
129
|
+
return
|
|
130
|
+
prefix = before[1:].lower()
|
|
131
|
+
candidates = []
|
|
132
|
+
for index, command in enumerate(self.commands):
|
|
133
|
+
rank = self._rank(command.name.lower(), prefix)
|
|
134
|
+
if rank is not None:
|
|
135
|
+
candidates.append((rank, index, command))
|
|
136
|
+
for _, _, command in sorted(candidates):
|
|
137
|
+
yield Completion(
|
|
138
|
+
f"/{command.name}",
|
|
139
|
+
start_position=-len(before),
|
|
140
|
+
display_meta=command.description,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _ScrollableTextControl(FormattedTextControl):
|
|
145
|
+
"""Formatted text that consumes clicks and routes wheel events to the transcript."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, *args: Any, on_scroll: Any = None, **kwargs: Any) -> None:
|
|
148
|
+
self._on_scroll = on_scroll
|
|
149
|
+
super().__init__(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
def mouse_handler(self, mouse_event): # type: ignore[no-untyped-def]
|
|
152
|
+
if self._on_scroll is not None:
|
|
153
|
+
if mouse_event.event_type is MouseEventType.SCROLL_UP:
|
|
154
|
+
self._on_scroll(-3)
|
|
155
|
+
return None
|
|
156
|
+
if mouse_event.event_type is MouseEventType.SCROLL_DOWN:
|
|
157
|
+
self._on_scroll(3)
|
|
158
|
+
return None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _BackToBottomControl(_ScrollableTextControl):
|
|
163
|
+
"""Make the whole return-to-bottom hint clickable without stealing wheel events."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, *args: Any, on_click: Any, **kwargs: Any) -> None:
|
|
166
|
+
self._on_click = on_click
|
|
167
|
+
super().__init__(*args, **kwargs)
|
|
168
|
+
|
|
169
|
+
def mouse_handler(self, mouse_event): # type: ignore[no-untyped-def]
|
|
170
|
+
if mouse_event.event_type is MouseEventType.MOUSE_DOWN and mouse_event.button is MouseButton.LEFT:
|
|
171
|
+
self._on_click()
|
|
172
|
+
return None
|
|
173
|
+
return super().mouse_handler(mouse_event)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _TranscriptControl(UIControl):
|
|
177
|
+
"""Expose cached transcript lines to prompt_toolkit without splitting them again."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, document: Any, cursor: Any, on_scroll: Any) -> None:
|
|
180
|
+
self._document = document
|
|
181
|
+
self._cursor = cursor
|
|
182
|
+
self._on_scroll = on_scroll
|
|
183
|
+
|
|
184
|
+
def create_content(self, width: int, height: int) -> UIContent:
|
|
185
|
+
document = self._document()
|
|
186
|
+
return UIContent(
|
|
187
|
+
get_line=document.get_line,
|
|
188
|
+
line_count=document.line_count,
|
|
189
|
+
cursor_position=self._cursor(),
|
|
190
|
+
show_cursor=False,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def preferred_height(self, width: int, max_available_height: int, wrap_lines: bool, get_line_prefix: Any) -> int:
|
|
194
|
+
return min(max_available_height, self._document().line_count)
|
|
195
|
+
|
|
196
|
+
def mouse_handler(self, mouse_event): # type: ignore[no-untyped-def]
|
|
197
|
+
if mouse_event.event_type is MouseEventType.SCROLL_UP:
|
|
198
|
+
self._on_scroll(-3)
|
|
199
|
+
elif mouse_event.event_type is MouseEventType.SCROLL_DOWN:
|
|
200
|
+
self._on_scroll(3)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _EditorScrollControl(BufferControl):
|
|
205
|
+
"""Input box: wheel scrolls the transcript instead of the empty editor."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, *args: Any, on_scroll: Any = None, **kwargs: Any) -> None:
|
|
208
|
+
self._on_scroll = on_scroll
|
|
209
|
+
super().__init__(*args, **kwargs)
|
|
210
|
+
|
|
211
|
+
def mouse_handler(self, mouse_event): # type: ignore[no-untyped-def]
|
|
212
|
+
if self._on_scroll is not None and mouse_event.event_type in {
|
|
213
|
+
MouseEventType.SCROLL_UP, MouseEventType.SCROLL_DOWN,
|
|
214
|
+
}:
|
|
215
|
+
self._on_scroll(-3 if mouse_event.event_type is MouseEventType.SCROLL_UP else 3)
|
|
216
|
+
return None
|
|
217
|
+
return super().mouse_handler(mouse_event)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _TranscriptWindow(Window):
|
|
221
|
+
"""Transcript viewport whose scroll offset is authoritative, not cursor-derived.
|
|
222
|
+
|
|
223
|
+
prompt_toolkit's scrollers only ever move ``vertical_scroll`` far enough to keep
|
|
224
|
+
a cursor visible. Disguising the scroll anchor as a cursor therefore left the
|
|
225
|
+
viewport stuck: ``max(previous_scroll, ...)`` blocked downward movement and
|
|
226
|
+
``min(..., get_max_vertical_scroll())`` blocked upward movement, so the
|
|
227
|
+
viewport barely responded in either direction. This window computes the offset
|
|
228
|
+
outright and skips the cursor-chasing arithmetic entirely.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, *args: Any, viewport: Any = None, **kwargs: Any) -> None:
|
|
232
|
+
self._viewport = viewport
|
|
233
|
+
super().__init__(*args, **kwargs)
|
|
234
|
+
|
|
235
|
+
def _scroll(self, ui_content: Any, width: int, height: int) -> None:
|
|
236
|
+
self.horizontal_scroll = 0
|
|
237
|
+
self.vertical_scroll_2 = 0
|
|
238
|
+
maximum = max(0, ui_content.line_count - height)
|
|
239
|
+
self.vertical_scroll = self._viewport.transcript_offset(maximum) if self._viewport is not None else maximum
|
|
240
|
+
|
|
241
|
+
class CliApplication:
|
|
242
|
+
"""pi-inspired terminal presentation over the existing synchronous runner."""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
runner: AgentRunner,
|
|
247
|
+
*,
|
|
248
|
+
config_dir: Path | None = None,
|
|
249
|
+
input: Any = None,
|
|
250
|
+
output: Any = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
self.runner = runner
|
|
253
|
+
if config_dir is not None:
|
|
254
|
+
sandbox = runner.settings.sandbox if runner.settings is not None else runner._sandbox_config
|
|
255
|
+
workspace = sandbox.workspace if sandbox is not None else Path.cwd()
|
|
256
|
+
require_keybindings_outside_workspace(config_dir, workspace)
|
|
257
|
+
_register_terminal_sequences()
|
|
258
|
+
self.state = CliState()
|
|
259
|
+
self.commands = command_table()
|
|
260
|
+
self.command_by_name = {item.name: item for item in self.commands}
|
|
261
|
+
keymap_path = (config_dir / "keybindings.json") if config_dir else None
|
|
262
|
+
self.keymap = Keymap.load(keymap_path)
|
|
263
|
+
self.interaction: InteractionController | None = None
|
|
264
|
+
self.sessions = SessionController(self)
|
|
265
|
+
self._reviewing = False
|
|
266
|
+
self.clipboard = ClipboardAdapter()
|
|
267
|
+
self._io_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="deep-agent-io")
|
|
268
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
269
|
+
self._run_task: asyncio.Task[None] | None = None
|
|
270
|
+
self._compacting = False
|
|
271
|
+
self._last_ctrl_c = 0.0
|
|
272
|
+
self._exiting = False
|
|
273
|
+
self._transcript_line_count = 1
|
|
274
|
+
# None = stick to bottom (follow new output); int = pinned scroll row.
|
|
275
|
+
self._transcript_anchor: int | None = None
|
|
276
|
+
timezone_name = runner.settings.ui_timezone if runner.settings is not None else DEFAULT_UI_TIMEZONE
|
|
277
|
+
self._renderer = TranscriptRenderer(ZoneInfo(timezone_name))
|
|
278
|
+
self._git = GitProbe(self._workspace())
|
|
279
|
+
self._git_summary = GitSummary()
|
|
280
|
+
self._git_task: asyncio.Task[None] | None = None
|
|
281
|
+
|
|
282
|
+
self.slash_completer = SlashCompleter(self.commands)
|
|
283
|
+
self.buffer = Buffer(
|
|
284
|
+
multiline=True,
|
|
285
|
+
history=InMemoryHistory(),
|
|
286
|
+
completer=self.slash_completer,
|
|
287
|
+
complete_while_typing=True,
|
|
288
|
+
)
|
|
289
|
+
self.transcript_control = _TranscriptControl(
|
|
290
|
+
document=self._transcript_document,
|
|
291
|
+
cursor=self._transcript_cursor,
|
|
292
|
+
on_scroll=self.scroll_transcript,
|
|
293
|
+
)
|
|
294
|
+
self.interaction_control = _ScrollableTextControl(
|
|
295
|
+
text=self._interaction_text, focusable=False, on_scroll=self.scroll_transcript,
|
|
296
|
+
)
|
|
297
|
+
self.attachment_control = _ScrollableTextControl(
|
|
298
|
+
text=self._attachment_text, focusable=False, on_scroll=self.scroll_transcript,
|
|
299
|
+
)
|
|
300
|
+
self.footer_control = _ScrollableTextControl(
|
|
301
|
+
text=self._footer_text, focusable=False, on_scroll=self.scroll_transcript,
|
|
302
|
+
)
|
|
303
|
+
self.back_to_bottom_control = _BackToBottomControl(
|
|
304
|
+
text="↓ Back to bottom · esc",
|
|
305
|
+
focusable=False,
|
|
306
|
+
on_scroll=self.scroll_transcript,
|
|
307
|
+
on_click=self.follow_transcript,
|
|
308
|
+
)
|
|
309
|
+
self.editor_control = _EditorScrollControl(
|
|
310
|
+
buffer=self.buffer, focusable=True, on_scroll=self.scroll_transcript,
|
|
311
|
+
)
|
|
312
|
+
self.bindings = self._create_bindings()
|
|
313
|
+
|
|
314
|
+
interaction_visible = Condition(lambda: self.interaction is not None)
|
|
315
|
+
editor_visible = Condition(lambda: self.interaction is None or self.interaction.accepts_text)
|
|
316
|
+
attachments_visible = Condition(lambda: bool(self.state.attachments) and self.interaction is None)
|
|
317
|
+
back_to_bottom_visible = Condition(self.transcript_away_from_bottom)
|
|
318
|
+
|
|
319
|
+
def editor_height() -> Any:
|
|
320
|
+
lines = max(1, self.buffer.document.line_count)
|
|
321
|
+
try:
|
|
322
|
+
rows = self.application.output.get_size().rows
|
|
323
|
+
except Exception: # noqa: BLE001
|
|
324
|
+
rows = 30
|
|
325
|
+
maximum = max(3, min(10, rows // 3))
|
|
326
|
+
return Dimension(min=1, preferred=min(lines, maximum), max=maximum)
|
|
327
|
+
|
|
328
|
+
def interaction_height() -> Any:
|
|
329
|
+
if self.interaction is None:
|
|
330
|
+
return Dimension(min=3)
|
|
331
|
+
text = render_interaction(self.interaction, self._width())
|
|
332
|
+
lines = text.count("\n") + 1 if text else 3
|
|
333
|
+
try:
|
|
334
|
+
rows = self.application.output.get_size().rows
|
|
335
|
+
except Exception: # noqa: BLE001
|
|
336
|
+
rows = 30
|
|
337
|
+
maximum = max(8, rows - 8)
|
|
338
|
+
return Dimension(min=3, preferred=min(max(lines, 3), maximum), max=maximum)
|
|
339
|
+
|
|
340
|
+
self.transcript_window = _TranscriptWindow(
|
|
341
|
+
self.transcript_control,
|
|
342
|
+
# Rich already wraps each captured transcript line to the viewport.
|
|
343
|
+
# Re-wrapping those padded lines here makes physical rows diverge
|
|
344
|
+
# from _transcript_line_count, shifting the viewport and editor.
|
|
345
|
+
wrap_lines=False,
|
|
346
|
+
height=Dimension(min=3, weight=1),
|
|
347
|
+
always_hide_cursor=True,
|
|
348
|
+
viewport=self,
|
|
349
|
+
)
|
|
350
|
+
body = HSplit([
|
|
351
|
+
self.transcript_window,
|
|
352
|
+
ConditionalContainer(
|
|
353
|
+
Window(
|
|
354
|
+
self.attachment_control,
|
|
355
|
+
wrap_lines=True,
|
|
356
|
+
height=lambda: len(self.state.attachments),
|
|
357
|
+
dont_extend_height=True,
|
|
358
|
+
style="class:attachments",
|
|
359
|
+
),
|
|
360
|
+
filter=attachments_visible,
|
|
361
|
+
),
|
|
362
|
+
ConditionalContainer(
|
|
363
|
+
Window(
|
|
364
|
+
self.back_to_bottom_control,
|
|
365
|
+
height=1,
|
|
366
|
+
dont_extend_height=True,
|
|
367
|
+
align=WindowAlign.CENTER,
|
|
368
|
+
style="class:back-to-bottom",
|
|
369
|
+
),
|
|
370
|
+
filter=back_to_bottom_visible,
|
|
371
|
+
),
|
|
372
|
+
ConditionalContainer(
|
|
373
|
+
Window(height=1, char="─", style="class:interaction-divider"),
|
|
374
|
+
filter=interaction_visible,
|
|
375
|
+
),
|
|
376
|
+
ConditionalContainer(
|
|
377
|
+
Window(
|
|
378
|
+
self.interaction_control,
|
|
379
|
+
wrap_lines=True,
|
|
380
|
+
height=interaction_height,
|
|
381
|
+
dont_extend_height=True,
|
|
382
|
+
always_hide_cursor=True,
|
|
383
|
+
style="class:interaction",
|
|
384
|
+
),
|
|
385
|
+
filter=interaction_visible,
|
|
386
|
+
),
|
|
387
|
+
ConditionalContainer(
|
|
388
|
+
HSplit([
|
|
389
|
+
Window(height=1, char=" ", style="class:editor"),
|
|
390
|
+
VSplit([
|
|
391
|
+
Window(FormattedTextControl("› "), width=2, style="class:editor"),
|
|
392
|
+
Window(
|
|
393
|
+
self.editor_control,
|
|
394
|
+
wrap_lines=True,
|
|
395
|
+
height=editor_height,
|
|
396
|
+
dont_extend_height=True,
|
|
397
|
+
style="class:editor",
|
|
398
|
+
),
|
|
399
|
+
]),
|
|
400
|
+
Window(height=1, char=" ", style="class:editor"),
|
|
401
|
+
]),
|
|
402
|
+
filter=editor_visible,
|
|
403
|
+
),
|
|
404
|
+
Window(
|
|
405
|
+
self.footer_control,
|
|
406
|
+
height=FOOTER_LINES,
|
|
407
|
+
style="class:footer",
|
|
408
|
+
dont_extend_height=True,
|
|
409
|
+
),
|
|
410
|
+
])
|
|
411
|
+
self.application: Application[None] = Application(
|
|
412
|
+
layout=Layout(FloatContainer(
|
|
413
|
+
content=body,
|
|
414
|
+
floats=[Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=8, scroll_offset=1))],
|
|
415
|
+
), focused_element=self.editor_control),
|
|
416
|
+
key_bindings=self.bindings,
|
|
417
|
+
full_screen=True,
|
|
418
|
+
mouse_support=Condition(lambda: not self._exiting),
|
|
419
|
+
style=Style.from_dict({
|
|
420
|
+
"editor": "bg:#303030 #ffffff",
|
|
421
|
+
"footer": "#858585",
|
|
422
|
+
"footer-workspace": "ansigreen",
|
|
423
|
+
"footer-resume-id": "ansicyan",
|
|
424
|
+
"footer-model": "ansiyellow",
|
|
425
|
+
"interaction": "#d0d0d0",
|
|
426
|
+
"attachments": "#72d5e8",
|
|
427
|
+
"back-to-bottom": "bg:#163a5f #d7edff",
|
|
428
|
+
"interaction-divider": "#3b82f6",
|
|
429
|
+
"completion-menu": "bg:#15191d #d0d0d0",
|
|
430
|
+
"completion-menu.completion.current": "bg:#3b5c73 #ffffff",
|
|
431
|
+
"completion-menu.meta.completion.current": "bg:#3b5c73 #ffffff",
|
|
432
|
+
}),
|
|
433
|
+
input=input,
|
|
434
|
+
output=output,
|
|
435
|
+
refresh_interval=0.1,
|
|
436
|
+
)
|
|
437
|
+
self.application.ttimeoutlen = 0.05
|
|
438
|
+
self.application.timeoutlen = 0.10
|
|
439
|
+
if self.keymap.warning:
|
|
440
|
+
self.state.add_system(self.keymap.warning, error=True)
|
|
441
|
+
if self.runner.on_event is None:
|
|
442
|
+
self.runner.on_event = self._on_event_thread
|
|
443
|
+
|
|
444
|
+
def run(self) -> None:
|
|
445
|
+
def capture_loop() -> None:
|
|
446
|
+
self._loop = asyncio.get_running_loop()
|
|
447
|
+
self._start_git_watch()
|
|
448
|
+
self.sessions.schedule_start(self._loop)
|
|
449
|
+
|
|
450
|
+
with self._capture_filesystem_warnings():
|
|
451
|
+
self.application.run(pre_run=capture_loop)
|
|
452
|
+
|
|
453
|
+
async def run_async(self) -> None:
|
|
454
|
+
self._loop = asyncio.get_running_loop()
|
|
455
|
+
self._start_git_watch()
|
|
456
|
+
with self._capture_filesystem_warnings():
|
|
457
|
+
await self.sessions.start()
|
|
458
|
+
await self.application.run_async()
|
|
459
|
+
|
|
460
|
+
@contextmanager
|
|
461
|
+
def _capture_filesystem_warnings(self) -> Iterator[None]:
|
|
462
|
+
"""Keep backend warnings out of stderr while the full-screen UI is active."""
|
|
463
|
+
logger = logging.getLogger("deepagents.backends.filesystem")
|
|
464
|
+
previous_handlers = logger.handlers[:]
|
|
465
|
+
previous_propagate = logger.propagate
|
|
466
|
+
|
|
467
|
+
class TranscriptHandler(logging.Handler):
|
|
468
|
+
def emit(handler_self, record: logging.LogRecord) -> None:
|
|
469
|
+
if record.levelno < logging.WARNING:
|
|
470
|
+
return
|
|
471
|
+
message = record.getMessage()
|
|
472
|
+
if self._loop is not None and self._loop.is_running():
|
|
473
|
+
self._loop.call_soon_threadsafe(self._show_filesystem_warning, message)
|
|
474
|
+
else:
|
|
475
|
+
self._show_filesystem_warning(message)
|
|
476
|
+
|
|
477
|
+
handler = TranscriptHandler()
|
|
478
|
+
logger.handlers = [handler]
|
|
479
|
+
logger.propagate = False
|
|
480
|
+
try:
|
|
481
|
+
yield
|
|
482
|
+
finally:
|
|
483
|
+
logger.handlers = previous_handlers
|
|
484
|
+
logger.propagate = previous_propagate
|
|
485
|
+
handler.close()
|
|
486
|
+
|
|
487
|
+
def _show_filesystem_warning(self, message: str) -> None:
|
|
488
|
+
self.state.add_system(message)
|
|
489
|
+
self.application.invalidate()
|
|
490
|
+
|
|
491
|
+
def _start_git_watch(self) -> None:
|
|
492
|
+
if self._git_task is not None:
|
|
493
|
+
return
|
|
494
|
+
self._git_task = asyncio.get_running_loop().create_task(self._watch_git())
|
|
495
|
+
|
|
496
|
+
async def _watch_git(self) -> None:
|
|
497
|
+
"""Keep the footer's git summary fresh without ever blocking a render pass."""
|
|
498
|
+
while True:
|
|
499
|
+
summary = await self._git.refresh()
|
|
500
|
+
if summary != self._git_summary:
|
|
501
|
+
self._git_summary = summary
|
|
502
|
+
self.application.invalidate()
|
|
503
|
+
await asyncio.sleep(REFRESH_SECONDS)
|
|
504
|
+
|
|
505
|
+
def continue_session_message(self) -> str | None:
|
|
506
|
+
if not self.runner.thread_has_content():
|
|
507
|
+
return None
|
|
508
|
+
model = self.runner.current_model()
|
|
509
|
+
model_name = model_display_name(model) if model is not None else "fixed model"
|
|
510
|
+
return (
|
|
511
|
+
"To continue this session, run:\n"
|
|
512
|
+
"\n"
|
|
513
|
+
f" deep-agent resume {self.runner.thread_id}\n"
|
|
514
|
+
"\n"
|
|
515
|
+
f"Or run deep-agent resume and select. Model: {model_name}"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def set_status(self, text: str) -> None:
|
|
519
|
+
self.state.status = text
|
|
520
|
+
self.application.invalidate()
|
|
521
|
+
|
|
522
|
+
def _switch_model(self, id_or_prefix: str) -> ModelProfile:
|
|
523
|
+
previous = self.runner.current_model()
|
|
524
|
+
profile = self.runner.switch_model(id_or_prefix)
|
|
525
|
+
if previous is None or previous.id != profile.id:
|
|
526
|
+
self.state.usage.clear()
|
|
527
|
+
return profile
|
|
528
|
+
|
|
529
|
+
def show_help(self) -> None:
|
|
530
|
+
commands = "\n".join(
|
|
531
|
+
f"/{item.name:<9} {item.description}"
|
|
532
|
+
for item in self.commands
|
|
533
|
+
)
|
|
534
|
+
self.state.add_system(
|
|
535
|
+
"Keyboard\n"
|
|
536
|
+
"Enter submit · Ctrl+J newline · Alt+Enter follow-up · Esc cancel+restore · "
|
|
537
|
+
"PgUp/PgDn scroll · Ctrl+P next model · Alt+P prev model · Alt+Up restore queue · "
|
|
538
|
+
"Ctrl+O tools · Ctrl+R review · F2 pending · Ctrl+T thinking · "
|
|
539
|
+
"Ctrl+V/Alt+V paste image/text · "
|
|
540
|
+
"Ctrl+C clear/exit · Ctrl+D exit\n\n"
|
|
541
|
+
f"Commands\n{commands}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def show_status(self) -> None:
|
|
545
|
+
prepared = self.runner.prepared
|
|
546
|
+
mode = prepared.execution_mode.value
|
|
547
|
+
tool_count = len(prepared.exposed_tool_names) + len(prepared.filesystem_tools) + 3
|
|
548
|
+
model = self.runner.current_model()
|
|
549
|
+
model_line = (
|
|
550
|
+
f"Model: {model_display_name(model)}\nInputs: {', '.join(model.input)}\n"
|
|
551
|
+
if model is not None else "Model: (fixed)\n"
|
|
552
|
+
)
|
|
553
|
+
perm = permission_mode_label(self.runner.permission_mode())
|
|
554
|
+
self.state.add_system(
|
|
555
|
+
f"Status: {'running' if self.state.running else 'idle'}\n"
|
|
556
|
+
f"{model_line}"
|
|
557
|
+
f"Permission: {perm}\n"
|
|
558
|
+
f"Thread: {self.runner.thread_id}\nSandbox: {mode}\nTools: {tool_count}"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def show_session(self) -> None:
|
|
562
|
+
store = self.runner.session_store
|
|
563
|
+
model = self.runner.current_model()
|
|
564
|
+
model_line = (
|
|
565
|
+
f"Model: {model_display_name(model)}\n" if model is not None else ""
|
|
566
|
+
)
|
|
567
|
+
if store is None:
|
|
568
|
+
self.state.add_system(
|
|
569
|
+
f"Session: {self.runner.thread_id}\n"
|
|
570
|
+
f"{model_line}"
|
|
571
|
+
"Checkpointer: in-memory\nPersistent /resume is not configured."
|
|
572
|
+
)
|
|
573
|
+
return
|
|
574
|
+
info = store.get(self.runner.thread_id)
|
|
575
|
+
status = info.status if info else "unknown"
|
|
576
|
+
stop_reason = info.last_run_status.value if info else "unknown"
|
|
577
|
+
title = info.title if info else ""
|
|
578
|
+
created_at = info.created_at.astimezone().isoformat(sep=" ", timespec="seconds") if info else "unknown"
|
|
579
|
+
updated_at = info.updated_at.astimezone().isoformat(sep=" ", timespec="seconds") if info else "unknown"
|
|
580
|
+
self.state.add_system(
|
|
581
|
+
f"Session: {self.runner.thread_id}\n"
|
|
582
|
+
f"Title: {title}\n"
|
|
583
|
+
f"Created: {created_at}\n"
|
|
584
|
+
f"Updated: {updated_at}\n"
|
|
585
|
+
f"Status: {status}\n"
|
|
586
|
+
f"Last run: {stop_reason}\n"
|
|
587
|
+
f"{model_line}"
|
|
588
|
+
f"Checkpointer: sqlite\n"
|
|
589
|
+
f"Persistent store: {self.runner.state_path}"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def new_session(self) -> None:
|
|
593
|
+
self.sessions.new_session()
|
|
594
|
+
|
|
595
|
+
async def resume_session(self, arg: str = "") -> None:
|
|
596
|
+
await self.sessions.resume(arg)
|
|
597
|
+
|
|
598
|
+
def _apply_session_snapshot(self, snapshot: Any) -> None:
|
|
599
|
+
self.sessions.apply_snapshot(snapshot)
|
|
600
|
+
|
|
601
|
+
async def select_model(self, arg: str = "") -> None:
|
|
602
|
+
if self.state.running:
|
|
603
|
+
self.set_status("Cancel the active run before switching models")
|
|
604
|
+
return
|
|
605
|
+
if self.sessions.waiting:
|
|
606
|
+
self.set_status(self.sessions.wait_status())
|
|
607
|
+
return
|
|
608
|
+
if self.interaction is not None:
|
|
609
|
+
self.set_status("Finish the current interaction before switching models")
|
|
610
|
+
return
|
|
611
|
+
if self.state.attachments:
|
|
612
|
+
self.set_status("Clear pending images before switching models")
|
|
613
|
+
return
|
|
614
|
+
profiles = self.runner.list_models()
|
|
615
|
+
if not profiles:
|
|
616
|
+
self.state.add_system(
|
|
617
|
+
"/model is unavailable: configure llm.models in the agent config",
|
|
618
|
+
error=True,
|
|
619
|
+
)
|
|
620
|
+
return
|
|
621
|
+
try:
|
|
622
|
+
if arg.strip():
|
|
623
|
+
groups = {item.id.split("/", 1)[0] for item in profiles if "/" in item.id}
|
|
624
|
+
if arg.strip() in groups:
|
|
625
|
+
self._show_model_choices(arg.strip(), profiles)
|
|
626
|
+
return
|
|
627
|
+
profile = self._switch_model(arg.strip())
|
|
628
|
+
self.state.add_system(f"Switched model to {model_display_name(profile)}")
|
|
629
|
+
self.set_status(f"Model: {model_display_name(profile)}")
|
|
630
|
+
return
|
|
631
|
+
if any("/" in item.id for item in profiles):
|
|
632
|
+
self._show_model_sources(profiles)
|
|
633
|
+
return
|
|
634
|
+
self._show_model_choices("", profiles)
|
|
635
|
+
except (KeyError, RuntimeError) as exc:
|
|
636
|
+
self.state.add_system(str(exc), error=True)
|
|
637
|
+
|
|
638
|
+
def _show_model_sources(self, profiles: list[ModelProfile]) -> None:
|
|
639
|
+
current = self.runner.current_model()
|
|
640
|
+
current_source = current.id.split("/", 1)[0] if current and "/" in current.id else ""
|
|
641
|
+
sources = list(dict.fromkeys(item.id.split("/", 1)[0] for item in profiles))
|
|
642
|
+
options = [
|
|
643
|
+
{"value": source, "label": source + (" · current" if source == current_source else "")}
|
|
644
|
+
for source in sources
|
|
645
|
+
]
|
|
646
|
+
self.interaction = InteractionController(
|
|
647
|
+
kind="model_source", title="Select model source", question="Choose a model source",
|
|
648
|
+
fields=[{"id": "source", "type": "single_select", "label": "Source", "required": True, "options": options}],
|
|
649
|
+
)
|
|
650
|
+
if current_source in sources:
|
|
651
|
+
self.interaction.option_index = sources.index(current_source)
|
|
652
|
+
self.set_status("Select a source · Enter confirm · Esc cancel")
|
|
653
|
+
self.application.invalidate()
|
|
654
|
+
|
|
655
|
+
def _show_model_choices(self, source: str, profiles: list[ModelProfile]) -> None:
|
|
656
|
+
if source:
|
|
657
|
+
profiles = [item for item in profiles if item.id.startswith(f"{source}/")]
|
|
658
|
+
if not profiles:
|
|
659
|
+
raise KeyError(f"Unknown model source: {source}")
|
|
660
|
+
current = self.runner.current_model()
|
|
661
|
+
current_id = current.id if current else ""
|
|
662
|
+
options = []
|
|
663
|
+
for item in profiles:
|
|
664
|
+
name = item.id.split("/", 1)[1] if source else model_display_name(item)
|
|
665
|
+
options.append({
|
|
666
|
+
"value": item.id,
|
|
667
|
+
"label": name + (" · current" if item.id == current_id else ""),
|
|
668
|
+
"description": item.model if source and name != item.model else "",
|
|
669
|
+
})
|
|
670
|
+
self.interaction = InteractionController(
|
|
671
|
+
kind="model", title=f"Select model · {source}" if source else "Select model",
|
|
672
|
+
question="Choose a model", values={"source": source} if source else {},
|
|
673
|
+
fields=[{"id": "model", "type": "single_select", "label": "Model", "required": True, "options": options}],
|
|
674
|
+
)
|
|
675
|
+
for index, option in enumerate(options):
|
|
676
|
+
if option["value"] == current_id:
|
|
677
|
+
self.interaction.option_index = index
|
|
678
|
+
break
|
|
679
|
+
self.set_status("Select a model · Enter confirm · Esc back" if source else "Select a model · Enter confirm · Esc cancel")
|
|
680
|
+
self.application.invalidate()
|
|
681
|
+
|
|
682
|
+
async def compact_command(self, arg: str = "") -> None:
|
|
683
|
+
if arg.strip():
|
|
684
|
+
self.state.add_system("Usage: /compact", error=True)
|
|
685
|
+
return
|
|
686
|
+
if self.state.running or self.interaction is not None:
|
|
687
|
+
self.set_status("Finish the current run or interaction before compacting")
|
|
688
|
+
return
|
|
689
|
+
if self.sessions.waiting:
|
|
690
|
+
self.set_status(self.sessions.wait_status())
|
|
691
|
+
return
|
|
692
|
+
self._compacting = True
|
|
693
|
+
self.state.running = True
|
|
694
|
+
self.set_status("Compacting context…")
|
|
695
|
+
try:
|
|
696
|
+
result = await self._run_blocking(self.runner.compact_context)
|
|
697
|
+
except Exception as exc: # noqa: BLE001
|
|
698
|
+
self.state.add_system(f"Context compaction failed: {exc}", error=True)
|
|
699
|
+
self.set_status("Compaction failed")
|
|
700
|
+
return
|
|
701
|
+
finally:
|
|
702
|
+
self._compacting = False
|
|
703
|
+
self.state.running = False
|
|
704
|
+
self.application.invalidate()
|
|
705
|
+
if result.status == "ineligible":
|
|
706
|
+
if result.percent is not None:
|
|
707
|
+
notice = (
|
|
708
|
+
"Manual compaction is allowed at about 42.5% of the context window; "
|
|
709
|
+
f"current usage: {result.percent:.1f}%."
|
|
710
|
+
)
|
|
711
|
+
if result.percent >= 42.5:
|
|
712
|
+
notice += " Deep Agents has not accepted the latest model usage as eligible."
|
|
713
|
+
self.state.add_system(notice)
|
|
714
|
+
elif result.window_tokens > 0:
|
|
715
|
+
self.state.add_system(
|
|
716
|
+
"Manual compaction is allowed at about 42.5% of the context window; "
|
|
717
|
+
"current usage is unknown because the model has not reported token usage."
|
|
718
|
+
)
|
|
719
|
+
else:
|
|
720
|
+
self.state.add_system(
|
|
721
|
+
"Manual compaction requires about 85,000 tokens with the current unknown "
|
|
722
|
+
"context window; current usage percentage is unavailable. Configure context_window to show it."
|
|
723
|
+
)
|
|
724
|
+
self.set_status("Manual compaction unavailable")
|
|
725
|
+
elif result.status == "nothing_to_compact":
|
|
726
|
+
self.state.add_system("There are no older messages to compact yet.")
|
|
727
|
+
self.set_status("No older context to compact")
|
|
728
|
+
elif result.status == "compacted":
|
|
729
|
+
self.state.usage.clear()
|
|
730
|
+
self.state.add_system(result.message)
|
|
731
|
+
self.set_status("Context compacted")
|
|
732
|
+
else:
|
|
733
|
+
self.state.add_system(result.message or "Context compaction failed", error=True)
|
|
734
|
+
self.set_status("Compaction failed")
|
|
735
|
+
|
|
736
|
+
async def select_permission(self, arg: str = "") -> None:
|
|
737
|
+
if self.state.running:
|
|
738
|
+
self.set_status("Cancel the active run before changing permission mode")
|
|
739
|
+
return
|
|
740
|
+
if self.sessions.waiting:
|
|
741
|
+
self.set_status(self.sessions.wait_status())
|
|
742
|
+
return
|
|
743
|
+
if self.interaction is not None:
|
|
744
|
+
self.set_status("Finish the current interaction before changing permission mode")
|
|
745
|
+
return
|
|
746
|
+
raw = arg.strip()
|
|
747
|
+
if raw:
|
|
748
|
+
mode = parse_permission_mode(raw)
|
|
749
|
+
if mode is None:
|
|
750
|
+
hint = "ask or allow" if self._allow_available() else "ask"
|
|
751
|
+
self.state.add_system(
|
|
752
|
+
f"Unknown permission mode. Use /permission {hint}",
|
|
753
|
+
error=True,
|
|
754
|
+
)
|
|
755
|
+
return
|
|
756
|
+
await self._apply_permission_mode(mode)
|
|
757
|
+
return
|
|
758
|
+
current = self.runner.permission_mode()
|
|
759
|
+
options = [
|
|
760
|
+
{
|
|
761
|
+
"value": PermissionMode.ASK.value,
|
|
762
|
+
"label": "ask · require approval for every execute and side-effect tool"
|
|
763
|
+
+ (" · current" if current is PermissionMode.ASK else ""),
|
|
764
|
+
},
|
|
765
|
+
]
|
|
766
|
+
if self._allow_available():
|
|
767
|
+
options.append({
|
|
768
|
+
"value": PermissionMode.ALLOW.value,
|
|
769
|
+
"label": "allow · auto-approve all tools; sandbox network open (SANDBOXED, HIGH RISK)"
|
|
770
|
+
+ (" · current" if current is PermissionMode.ALLOW else ""),
|
|
771
|
+
})
|
|
772
|
+
if len(options) == 1:
|
|
773
|
+
self.state.add_system(
|
|
774
|
+
"Permission mode is locked to ask because ALLOW requires SANDBOXED execution.",
|
|
775
|
+
)
|
|
776
|
+
self.set_status("Permission: ask")
|
|
777
|
+
return
|
|
778
|
+
self.interaction = InteractionController(
|
|
779
|
+
kind="permission",
|
|
780
|
+
title="Permission mode",
|
|
781
|
+
question="Choose how tools are approved before they run",
|
|
782
|
+
fields=[{
|
|
783
|
+
"id": "mode",
|
|
784
|
+
"type": "single_select",
|
|
785
|
+
"label": "Mode",
|
|
786
|
+
"required": True,
|
|
787
|
+
"options": options,
|
|
788
|
+
}],
|
|
789
|
+
)
|
|
790
|
+
self.interaction.option_index = 0 if current is PermissionMode.ASK else 1
|
|
791
|
+
self.set_status("Select permission mode · Enter confirm · Esc cancel")
|
|
792
|
+
self.application.invalidate()
|
|
793
|
+
|
|
794
|
+
def _allow_available(self) -> bool:
|
|
795
|
+
return allow_mode_available(self.runner.prepared.execution_mode)
|
|
796
|
+
|
|
797
|
+
async def _apply_permission_mode(self, mode: PermissionMode) -> None:
|
|
798
|
+
if mode is PermissionMode.ALLOW and not self._allow_available():
|
|
799
|
+
self.state.add_system(
|
|
800
|
+
allow_mode_unavailable_reason(self.runner.prepared.execution_mode),
|
|
801
|
+
error=True,
|
|
802
|
+
)
|
|
803
|
+
return
|
|
804
|
+
if mode is PermissionMode.ALLOW and self.runner.permission_mode() is not PermissionMode.ALLOW:
|
|
805
|
+
self._begin_allow_permission_confirm()
|
|
806
|
+
return
|
|
807
|
+
try:
|
|
808
|
+
applied = self.runner.set_permission_mode(mode)
|
|
809
|
+
except (RuntimeError, ValueError) as exc:
|
|
810
|
+
self.state.add_system(str(exc), error=True)
|
|
811
|
+
return
|
|
812
|
+
label = permission_mode_label(applied)
|
|
813
|
+
self.state.add_system(f"Permission mode: {label}")
|
|
814
|
+
self.set_status(f"Permission: {applied.value}")
|
|
815
|
+
|
|
816
|
+
def _begin_allow_permission_confirm(self) -> None:
|
|
817
|
+
self.interaction = InteractionController(
|
|
818
|
+
kind="permission_confirm",
|
|
819
|
+
title="Enable ALLOW? (HIGH RISK)",
|
|
820
|
+
question=PERMISSION_ALLOW_WARNING,
|
|
821
|
+
fields=[{
|
|
822
|
+
"id": "confirm",
|
|
823
|
+
"type": "text",
|
|
824
|
+
"label": 'Type ALLOW to confirm auto-approve + sandbox network open',
|
|
825
|
+
"required": True,
|
|
826
|
+
"options": [],
|
|
827
|
+
}],
|
|
828
|
+
)
|
|
829
|
+
self.set_status("Type ALLOW to confirm · Esc cancel")
|
|
830
|
+
self.application.invalidate()
|
|
831
|
+
|
|
832
|
+
def cycle_model(self, *, delta: int = 1) -> None:
|
|
833
|
+
if self.state.running:
|
|
834
|
+
self.set_status("Cancel the active run before switching models")
|
|
835
|
+
return
|
|
836
|
+
if self.sessions.waiting:
|
|
837
|
+
self.set_status(self.sessions.wait_status())
|
|
838
|
+
return
|
|
839
|
+
if self.interaction is not None:
|
|
840
|
+
self.set_status("Finish the current interaction before switching models")
|
|
841
|
+
return
|
|
842
|
+
if self.state.attachments:
|
|
843
|
+
self.set_status("Clear pending images before switching models")
|
|
844
|
+
return
|
|
845
|
+
profiles = self.runner.list_models()
|
|
846
|
+
if len(profiles) < 2:
|
|
847
|
+
if not profiles:
|
|
848
|
+
self.set_status("No models configured in the agent config")
|
|
849
|
+
else:
|
|
850
|
+
current = self.runner.current_model()
|
|
851
|
+
label = model_display_name(current or profiles[0])
|
|
852
|
+
self.set_status(f"Only one model configured: {label}")
|
|
853
|
+
return
|
|
854
|
+
current = self.runner.current_model()
|
|
855
|
+
current_id = current.id if current else profiles[0].id
|
|
856
|
+
index = next((i for i, item in enumerate(profiles) if item.id == current_id), 0)
|
|
857
|
+
nxt = profiles[(index + delta) % len(profiles)]
|
|
858
|
+
try:
|
|
859
|
+
profile = self._switch_model(nxt.id)
|
|
860
|
+
except (KeyError, RuntimeError) as exc:
|
|
861
|
+
self.state.add_system(str(exc), error=True)
|
|
862
|
+
return
|
|
863
|
+
self.state.add_system(f"Switched model to {model_display_name(profile)}")
|
|
864
|
+
self.set_status(f"Model: {model_display_name(profile)}")
|
|
865
|
+
self.application.invalidate()
|
|
866
|
+
|
|
867
|
+
def _restore_queued_to_editor(self, buffer: Any) -> list[str]:
|
|
868
|
+
"""Pull unapplied steering/follow-up back into the editor (pi Esc / Alt+Up)."""
|
|
869
|
+
from agent.cli.state import MessageBlock
|
|
870
|
+
|
|
871
|
+
restored_parts = self.runner.take_unapplied_messages()
|
|
872
|
+
self.state.blocks = [
|
|
873
|
+
block for block in self.state.blocks
|
|
874
|
+
if not (isinstance(block, MessageBlock) and block.pending)
|
|
875
|
+
]
|
|
876
|
+
self.state.pending_user_ids.clear()
|
|
877
|
+
if restored_parts:
|
|
878
|
+
existing = buffer.text.strip()
|
|
879
|
+
restored = "\n\n".join(restored_parts)
|
|
880
|
+
text = f"{existing}\n\n{restored}" if existing else restored
|
|
881
|
+
buffer.text = text
|
|
882
|
+
buffer.cursor_position = len(text)
|
|
883
|
+
return restored_parts
|
|
884
|
+
|
|
885
|
+
def _apply_session_snapshot(self, snapshot: Any) -> None:
|
|
886
|
+
self.state.load_transcript(snapshot.transcript)
|
|
887
|
+
self.state.todos = list(snapshot.todos)
|
|
888
|
+
self.interaction = None
|
|
889
|
+
self._reviewing = False
|
|
890
|
+
self.state.add_system(
|
|
891
|
+
f"Resumed session {snapshot.info.id} (last run: {snapshot.info.last_run_status.value})"
|
|
892
|
+
)
|
|
893
|
+
for notice in snapshot.notices:
|
|
894
|
+
self.state.add_system(notice)
|
|
895
|
+
if not self._reopen_pending_interaction(notify_missing=False):
|
|
896
|
+
self.set_status("Ready")
|
|
897
|
+
self._transcript_anchor = None
|
|
898
|
+
self._renderer.clear()
|
|
899
|
+
self.state.usage = dict(self.runner.latest_usage())
|
|
900
|
+
self.application.invalidate()
|
|
901
|
+
|
|
902
|
+
def exit(self) -> None:
|
|
903
|
+
if self._exiting:
|
|
904
|
+
return
|
|
905
|
+
self._exiting = True
|
|
906
|
+
# Stop mouse reports before prompt_toolkit leaves the alternate screen.
|
|
907
|
+
# Otherwise motion generated during shutdown can reach the shell.
|
|
908
|
+
self.application.output.disable_mouse_support()
|
|
909
|
+
self.application.output.flush()
|
|
910
|
+
self.sessions.notify_exit()
|
|
911
|
+
if self.state.running:
|
|
912
|
+
self.runner.request_cancel()
|
|
913
|
+
if self._git_task is not None:
|
|
914
|
+
self._git_task.cancel()
|
|
915
|
+
self._git_task = None
|
|
916
|
+
self._io_executor.shutdown(wait=False, cancel_futures=True)
|
|
917
|
+
self.application.exit()
|
|
918
|
+
|
|
919
|
+
async def _run_blocking(self, func: Any, /, *args: Any, **kwargs: Any) -> Any:
|
|
920
|
+
loop = asyncio.get_running_loop()
|
|
921
|
+
return await loop.run_in_executor(self._io_executor, partial(func, *args, **kwargs))
|
|
922
|
+
|
|
923
|
+
def _workspace(self) -> Path:
|
|
924
|
+
sandbox = (
|
|
925
|
+
self.runner.settings.sandbox
|
|
926
|
+
if self.runner.settings is not None else self.runner._sandbox_config
|
|
927
|
+
)
|
|
928
|
+
return Path(sandbox.workspace) if sandbox is not None else Path.cwd()
|
|
929
|
+
|
|
930
|
+
def _width(self) -> int:
|
|
931
|
+
try:
|
|
932
|
+
columns = self.application.output.get_size().columns
|
|
933
|
+
except Exception: # noqa: BLE001
|
|
934
|
+
columns = 100
|
|
935
|
+
return max(20, columns)
|
|
936
|
+
|
|
937
|
+
def _transcript_cursor(self) -> Point:
|
|
938
|
+
last = max(0, self._transcript_line_count - 1)
|
|
939
|
+
return Point(x=0, y=min(last, self.transcript_top()))
|
|
940
|
+
|
|
941
|
+
def transcript_anchor(self) -> int | None:
|
|
942
|
+
"""Top row to pin the viewport to, or None while following new output."""
|
|
943
|
+
return self._transcript_anchor
|
|
944
|
+
|
|
945
|
+
def transcript_rows(self) -> int:
|
|
946
|
+
"""Total transcript rows as of the most recent paint."""
|
|
947
|
+
return self._transcript_line_count
|
|
948
|
+
|
|
949
|
+
def transcript_viewport_rows(self) -> int:
|
|
950
|
+
"""Visible transcript rows; from the viewport once it has painted."""
|
|
951
|
+
info = self.transcript_window.render_info
|
|
952
|
+
if info is not None:
|
|
953
|
+
return max(1, info.window_height)
|
|
954
|
+
try:
|
|
955
|
+
rows = self.application.output.get_size().rows
|
|
956
|
+
except Exception: # noqa: BLE001
|
|
957
|
+
rows = 24
|
|
958
|
+
return max(1, rows)
|
|
959
|
+
|
|
960
|
+
def transcript_max_scroll(self) -> int:
|
|
961
|
+
return max(0, self.transcript_rows() - self.transcript_viewport_rows())
|
|
962
|
+
|
|
963
|
+
def transcript_top(self) -> int:
|
|
964
|
+
"""Row currently sitting at the top of the viewport."""
|
|
965
|
+
maximum = self.transcript_max_scroll()
|
|
966
|
+
if self._transcript_anchor is None:
|
|
967
|
+
return maximum
|
|
968
|
+
return min(maximum, max(0, self._transcript_anchor))
|
|
969
|
+
|
|
970
|
+
def transcript_offset(self, maximum: int) -> int:
|
|
971
|
+
"""Resolve a painted viewport's offset and restore following at its tail."""
|
|
972
|
+
anchor = self._transcript_anchor
|
|
973
|
+
if anchor is None or anchor >= maximum:
|
|
974
|
+
self._transcript_anchor = None
|
|
975
|
+
return maximum
|
|
976
|
+
return max(0, anchor)
|
|
977
|
+
|
|
978
|
+
def transcript_away_from_bottom(self) -> bool:
|
|
979
|
+
"""Only offer the return action while the transcript tail is off screen."""
|
|
980
|
+
return self._transcript_anchor is not None and self.transcript_top() < self.transcript_max_scroll()
|
|
981
|
+
|
|
982
|
+
def follow_transcript(self) -> None:
|
|
983
|
+
self._transcript_anchor = None
|
|
984
|
+
self.application.invalidate()
|
|
985
|
+
|
|
986
|
+
def scroll_transcript(self, delta: int) -> None:
|
|
987
|
+
self.scroll_transcript_to(self.transcript_top() + delta)
|
|
988
|
+
|
|
989
|
+
def scroll_transcript_to(self, row: int) -> None:
|
|
990
|
+
maximum = self.transcript_max_scroll()
|
|
991
|
+
nxt = min(maximum, max(0, row))
|
|
992
|
+
self._transcript_anchor = None if nxt >= maximum else nxt
|
|
993
|
+
self.application.invalidate()
|
|
994
|
+
|
|
995
|
+
def _transcript_document(self) -> TranscriptDocument:
|
|
996
|
+
if self._reviewing:
|
|
997
|
+
rendered = render_review(self._review_calls(), self._width())
|
|
998
|
+
unit = RenderedUnit(to_formatted_text(ANSI(rendered)), rendered.count("\n") + 1)
|
|
999
|
+
document = TranscriptDocument((unit,), (0,), unit.line_count)
|
|
1000
|
+
else:
|
|
1001
|
+
document = self._renderer.render_document(self.state, self._width())
|
|
1002
|
+
self._transcript_line_count = document.line_count
|
|
1003
|
+
return document
|
|
1004
|
+
|
|
1005
|
+
def _interaction_text(self): # type: ignore[no-untyped-def]
|
|
1006
|
+
return to_formatted_text(ANSI(render_interaction(self.interaction, self._width())))
|
|
1007
|
+
|
|
1008
|
+
def _attachment_text(self): # type: ignore[no-untyped-def]
|
|
1009
|
+
fragments = []
|
|
1010
|
+
for index, ref in enumerate(self.state.attachments, 1):
|
|
1011
|
+
size = f"{ref.size / (1024 * 1024):.1f} MiB" if ref.size >= 1024 * 1024 else f"{ref.size / 1024:.0f} KiB"
|
|
1012
|
+
fragments.append(("class:attachments", f" ▣ Image #{index} {ref.filename} · {size}\n"))
|
|
1013
|
+
return FormattedText(fragments)
|
|
1014
|
+
|
|
1015
|
+
def _footer_text(self): # type: ignore[no-untyped-def]
|
|
1016
|
+
pending = self.runner.control.pending_steering_count() + self.runner.control.pending_follow_up_count()
|
|
1017
|
+
queue = f" · queued {pending}" if pending else ""
|
|
1018
|
+
mode = self.runner.prepared.execution_mode.value
|
|
1019
|
+
perm = self.runner.permission_mode().value
|
|
1020
|
+
spinner = " ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[int(time.monotonic() * 10) % 11] if self.state.running else ""
|
|
1021
|
+
width = self._width()
|
|
1022
|
+
model = self.runner.current_model()
|
|
1023
|
+
model_label = model_display_name(model) if model is not None else "fixed model"
|
|
1024
|
+
resume_id = self.runner.thread_id[:8]
|
|
1025
|
+
context = format_context_usage(self.state.usage, self.runner.context_window())
|
|
1026
|
+
# Row 1: workspace · model · resume on the left, git on the right.
|
|
1027
|
+
# Row 2: status/sandbox/permission on the left, context on the right.
|
|
1028
|
+
# The left side always truncates before the right side.
|
|
1029
|
+
git = self._git_summary.label() or "⎇ no git"
|
|
1030
|
+
status = f" {spinner}{self.state.status}{queue} · {mode} · perm:{perm}"
|
|
1031
|
+
git_fit = _truncate_cells(git, width)
|
|
1032
|
+
available = max(1, width - get_cwidth(git_fit) - 1)
|
|
1033
|
+
fragments: list[tuple[str, str]] = []
|
|
1034
|
+
if available < 6 + get_cwidth(resume_id) + 2:
|
|
1035
|
+
# Extremely narrow: keep the session handle and the git label only.
|
|
1036
|
+
resume_fit = _truncate_cells(resume_id, available)
|
|
1037
|
+
gap = " " * max(1, width - get_cwidth(resume_fit) - get_cwidth(git_fit))
|
|
1038
|
+
fragments.append(("class:footer-resume-id", resume_fit))
|
|
1039
|
+
fragments.append(("class:footer", gap + git_fit + "\n"))
|
|
1040
|
+
else:
|
|
1041
|
+
model_width = max(1, available - get_cwidth(resume_id) - 6)
|
|
1042
|
+
model_fit = _truncate_cells(model_label, model_width)
|
|
1043
|
+
workspace_width = max(1, available - get_cwidth(model_fit) - get_cwidth(resume_id) - 6)
|
|
1044
|
+
workspace_fit = _truncate_cells(f" {self._workspace()}", workspace_width)
|
|
1045
|
+
left_width = get_cwidth(workspace_fit) + get_cwidth(model_fit) + get_cwidth(resume_id) + 6
|
|
1046
|
+
first_padding = " " * max(1, width - left_width - get_cwidth(git_fit))
|
|
1047
|
+
if workspace_fit.startswith(" "):
|
|
1048
|
+
fragments.append(("class:footer", " "))
|
|
1049
|
+
fragments.append(("class:footer-workspace", workspace_fit[1:]))
|
|
1050
|
+
else:
|
|
1051
|
+
fragments.append(("class:footer-workspace", workspace_fit))
|
|
1052
|
+
fragments.append(("class:footer", " · "))
|
|
1053
|
+
fragments.append(("class:footer-model", model_fit))
|
|
1054
|
+
fragments.append(("class:footer", " · "))
|
|
1055
|
+
fragments.append(("class:footer-resume-id", resume_id))
|
|
1056
|
+
fragments.append(("class:footer", first_padding + git_fit + "\n"))
|
|
1057
|
+
fragments.append(("class:footer", _fit_footer_line(status, context, width)))
|
|
1058
|
+
return FormattedText(fragments)
|
|
1059
|
+
|
|
1060
|
+
def _create_bindings(self) -> KeyBindings:
|
|
1061
|
+
kb = KeyBindings()
|
|
1062
|
+
interaction_active = Condition(lambda: self.interaction is not None)
|
|
1063
|
+
|
|
1064
|
+
def bind(action: str): # type: ignore[no-untyped-def]
|
|
1065
|
+
def register(handler): # type: ignore[no-untyped-def]
|
|
1066
|
+
registered = False
|
|
1067
|
+
for sequence in self.keymap.sequences(action):
|
|
1068
|
+
try:
|
|
1069
|
+
kb.add(*sequence)(handler)
|
|
1070
|
+
registered = True
|
|
1071
|
+
except (KeyError, ValueError) as exc:
|
|
1072
|
+
self.keymap.warning = f"Invalid keybinding for {action}: {' '.join(sequence)} ({exc})"
|
|
1073
|
+
if not registered:
|
|
1074
|
+
for sequence in Keymap().sequences(action):
|
|
1075
|
+
try:
|
|
1076
|
+
kb.add(*sequence)(handler)
|
|
1077
|
+
except (KeyError, ValueError):
|
|
1078
|
+
continue
|
|
1079
|
+
return handler
|
|
1080
|
+
return register
|
|
1081
|
+
|
|
1082
|
+
@bind("submit")
|
|
1083
|
+
def submit(event) -> None: # type: ignore[no-untyped-def]
|
|
1084
|
+
if self._accept_command_completion(event.current_buffer):
|
|
1085
|
+
return
|
|
1086
|
+
self._submit_buffer("steer")
|
|
1087
|
+
|
|
1088
|
+
completion_active = Condition(lambda: self.interaction is None and self.buffer.complete_state is not None)
|
|
1089
|
+
|
|
1090
|
+
@kb.add("up", filter=completion_active)
|
|
1091
|
+
def completion_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1092
|
+
event.current_buffer.complete_previous()
|
|
1093
|
+
|
|
1094
|
+
@kb.add("down", filter=completion_active)
|
|
1095
|
+
def completion_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1096
|
+
event.current_buffer.complete_next()
|
|
1097
|
+
|
|
1098
|
+
@kb.add("tab", filter=completion_active)
|
|
1099
|
+
def completion_tab(event) -> None: # type: ignore[no-untyped-def]
|
|
1100
|
+
self._accept_command_completion(event.current_buffer)
|
|
1101
|
+
|
|
1102
|
+
@bind("newline")
|
|
1103
|
+
def newline(event) -> None: # type: ignore[no-untyped-def]
|
|
1104
|
+
event.current_buffer.insert_text("\n")
|
|
1105
|
+
|
|
1106
|
+
@bind("follow_up")
|
|
1107
|
+
def follow_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1108
|
+
self._submit_buffer("followUp")
|
|
1109
|
+
|
|
1110
|
+
@bind("image_paste")
|
|
1111
|
+
def clipboard_paste(event) -> None: # type: ignore[no-untyped-def]
|
|
1112
|
+
if self.interaction is not None:
|
|
1113
|
+
return
|
|
1114
|
+
self.set_status("Reading clipboard…")
|
|
1115
|
+
asyncio.create_task(self._paste_clipboard())
|
|
1116
|
+
|
|
1117
|
+
@kb.add(Keys.BracketedPaste, eager=True)
|
|
1118
|
+
def bracketed_paste(event) -> None: # type: ignore[no-untyped-def]
|
|
1119
|
+
self._handle_pasted_text(event.data.replace("\r\n", "\n").replace("\r", "\n"))
|
|
1120
|
+
|
|
1121
|
+
@kb.add("backspace", filter=Condition(
|
|
1122
|
+
lambda: self.interaction is None and not self.buffer.text and bool(self.state.attachments)
|
|
1123
|
+
))
|
|
1124
|
+
def remove_last_attachment(event) -> None: # type: ignore[no-untyped-def]
|
|
1125
|
+
removed = self.state.attachments.pop()
|
|
1126
|
+
self.set_status(f"Removed image: {removed.filename}")
|
|
1127
|
+
|
|
1128
|
+
@kb.add("up", filter=interaction_active)
|
|
1129
|
+
def interaction_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1130
|
+
assert self.interaction
|
|
1131
|
+
self.interaction.move(-1)
|
|
1132
|
+
self.application.invalidate()
|
|
1133
|
+
|
|
1134
|
+
@kb.add("down", filter=interaction_active)
|
|
1135
|
+
def interaction_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1136
|
+
assert self.interaction
|
|
1137
|
+
self.interaction.move(1)
|
|
1138
|
+
self.application.invalidate()
|
|
1139
|
+
|
|
1140
|
+
@kb.add("pageup")
|
|
1141
|
+
def page_up(event) -> None: # type: ignore[no-untyped-def]
|
|
1142
|
+
self.scroll_transcript(-10)
|
|
1143
|
+
|
|
1144
|
+
@kb.add("pagedown")
|
|
1145
|
+
def page_down(event) -> None: # type: ignore[no-untyped-def]
|
|
1146
|
+
self.scroll_transcript(10)
|
|
1147
|
+
|
|
1148
|
+
@kb.add("c-home")
|
|
1149
|
+
def scroll_top(event) -> None: # type: ignore[no-untyped-def]
|
|
1150
|
+
self.scroll_transcript_to(0)
|
|
1151
|
+
|
|
1152
|
+
@kb.add("c-end")
|
|
1153
|
+
def scroll_bottom(event) -> None: # type: ignore[no-untyped-def]
|
|
1154
|
+
self.scroll_transcript_to(self.transcript_rows())
|
|
1155
|
+
|
|
1156
|
+
@kb.add(" ", filter=interaction_active)
|
|
1157
|
+
def interaction_toggle(event) -> None: # type: ignore[no-untyped-def]
|
|
1158
|
+
assert self.interaction
|
|
1159
|
+
if self.interaction.current.get("type") == "multi_select":
|
|
1160
|
+
self.interaction.toggle()
|
|
1161
|
+
else:
|
|
1162
|
+
event.current_buffer.insert_text(" ")
|
|
1163
|
+
|
|
1164
|
+
@kb.add("tab", filter=interaction_active)
|
|
1165
|
+
def interaction_next(event) -> None: # type: ignore[no-untyped-def]
|
|
1166
|
+
self._submit_buffer("steer")
|
|
1167
|
+
|
|
1168
|
+
@kb.add("s-tab", filter=interaction_active)
|
|
1169
|
+
def interaction_previous(event) -> None: # type: ignore[no-untyped-def]
|
|
1170
|
+
assert self.interaction
|
|
1171
|
+
if self.interaction.index > 0:
|
|
1172
|
+
self.interaction.index -= 1
|
|
1173
|
+
self.interaction.option_index = 0
|
|
1174
|
+
previous = self.interaction.values.get(str(self.interaction.current.get("id") or ""), "")
|
|
1175
|
+
if isinstance(previous, str):
|
|
1176
|
+
event.current_buffer.text = previous
|
|
1177
|
+
event.current_buffer.cursor_position = len(previous)
|
|
1178
|
+
|
|
1179
|
+
@bind("interrupt")
|
|
1180
|
+
def escape(event) -> None: # type: ignore[no-untyped-def]
|
|
1181
|
+
if self.interaction is None and event.current_buffer.text.startswith("/") and "\n" not in event.current_buffer.text:
|
|
1182
|
+
self.slash_completer.accepted_text = None
|
|
1183
|
+
event.current_buffer.reset()
|
|
1184
|
+
self.application.invalidate()
|
|
1185
|
+
elif self.transcript_away_from_bottom():
|
|
1186
|
+
self.follow_transcript()
|
|
1187
|
+
elif event.current_buffer.complete_state is not None:
|
|
1188
|
+
event.current_buffer.cancel_completion()
|
|
1189
|
+
elif self.sessions.waiting:
|
|
1190
|
+
self.sessions.cancel_wait()
|
|
1191
|
+
elif self._reviewing:
|
|
1192
|
+
self._close_review()
|
|
1193
|
+
elif self.interaction is not None:
|
|
1194
|
+
self._finish_interaction(cancelled=True)
|
|
1195
|
+
elif self._compacting:
|
|
1196
|
+
self.set_status("Wait for compaction to finish")
|
|
1197
|
+
elif self.state.running:
|
|
1198
|
+
restored = self._restore_queued_to_editor(event.current_buffer)
|
|
1199
|
+
self.runner.request_cancel()
|
|
1200
|
+
if restored:
|
|
1201
|
+
self.set_status("Cancelling… · queued messages restored")
|
|
1202
|
+
else:
|
|
1203
|
+
self.set_status("Cancelling…")
|
|
1204
|
+
self.application.invalidate()
|
|
1205
|
+
|
|
1206
|
+
@bind("clear_or_exit")
|
|
1207
|
+
def ctrl_c(event) -> None: # type: ignore[no-untyped-def]
|
|
1208
|
+
if self.interaction is not None:
|
|
1209
|
+
self._finish_interaction(cancelled=True)
|
|
1210
|
+
return
|
|
1211
|
+
now = time.monotonic()
|
|
1212
|
+
if now - self._last_ctrl_c < 0.5:
|
|
1213
|
+
self.exit()
|
|
1214
|
+
return
|
|
1215
|
+
event.current_buffer.reset()
|
|
1216
|
+
self.state.attachments.clear()
|
|
1217
|
+
self._last_ctrl_c = now
|
|
1218
|
+
self.set_status("Input cleared · press Ctrl+C again to exit")
|
|
1219
|
+
|
|
1220
|
+
@bind("exit")
|
|
1221
|
+
def ctrl_d(event) -> None: # type: ignore[no-untyped-def]
|
|
1222
|
+
if not event.current_buffer.text:
|
|
1223
|
+
self.exit()
|
|
1224
|
+
else:
|
|
1225
|
+
event.current_buffer.delete()
|
|
1226
|
+
|
|
1227
|
+
@bind("tools_expand")
|
|
1228
|
+
def tools_expand(event) -> None: # type: ignore[no-untyped-def]
|
|
1229
|
+
self.state.tools_expanded = not self.state.tools_expanded
|
|
1230
|
+
self.set_status(f"Tool output: {'expanded' if self.state.tools_expanded else 'collapsed'}")
|
|
1231
|
+
|
|
1232
|
+
@bind("review_diff")
|
|
1233
|
+
def review_diff(event) -> None: # type: ignore[no-untyped-def]
|
|
1234
|
+
self._toggle_review()
|
|
1235
|
+
|
|
1236
|
+
@bind("reopen_interaction")
|
|
1237
|
+
def reopen_interaction(event) -> None: # type: ignore[no-untyped-def]
|
|
1238
|
+
self._reopen_pending_interaction()
|
|
1239
|
+
|
|
1240
|
+
@bind("thinking_toggle")
|
|
1241
|
+
def thinking_toggle(event) -> None: # type: ignore[no-untyped-def]
|
|
1242
|
+
self.state.thinking_collapsed = not self.state.thinking_collapsed
|
|
1243
|
+
self.set_status(f"Thinking: {'collapsed' if self.state.thinking_collapsed else 'expanded'}")
|
|
1244
|
+
|
|
1245
|
+
@bind("dequeue")
|
|
1246
|
+
def dequeue(event) -> None: # type: ignore[no-untyped-def]
|
|
1247
|
+
restored = self._restore_queued_to_editor(event.current_buffer)
|
|
1248
|
+
if not restored:
|
|
1249
|
+
self.set_status("No queued messages")
|
|
1250
|
+
return
|
|
1251
|
+
self.set_status("Queued messages restored")
|
|
1252
|
+
self.application.invalidate()
|
|
1253
|
+
|
|
1254
|
+
@bind("model_cycle_forward")
|
|
1255
|
+
def model_forward(event) -> None: # type: ignore[no-untyped-def]
|
|
1256
|
+
self.cycle_model(delta=1)
|
|
1257
|
+
|
|
1258
|
+
@bind("model_cycle_backward")
|
|
1259
|
+
def model_backward(event) -> None: # type: ignore[no-untyped-def]
|
|
1260
|
+
self.cycle_model(delta=-1)
|
|
1261
|
+
|
|
1262
|
+
return kb
|
|
1263
|
+
|
|
1264
|
+
def _accept_command_completion(self, buffer: Buffer) -> bool:
|
|
1265
|
+
state = buffer.complete_state
|
|
1266
|
+
if self.interaction is not None or state is None or not state.completions:
|
|
1267
|
+
return False
|
|
1268
|
+
completion = state.current_completion or state.completions[0]
|
|
1269
|
+
buffer.apply_completion(completion)
|
|
1270
|
+
self.slash_completer.accepted_text = buffer.document.text_before_cursor
|
|
1271
|
+
self.application.invalidate()
|
|
1272
|
+
return True
|
|
1273
|
+
|
|
1274
|
+
def _submit_buffer(self, queue_mode: str) -> None:
|
|
1275
|
+
self.slash_completer.accepted_text = None
|
|
1276
|
+
if self.sessions.waiting:
|
|
1277
|
+
self.set_status(self.sessions.wait_status())
|
|
1278
|
+
return
|
|
1279
|
+
text = self.buffer.text.strip()
|
|
1280
|
+
if self.interaction is not None:
|
|
1281
|
+
was_text = self.interaction.accepts_text
|
|
1282
|
+
complete = self.interaction.accept(text)
|
|
1283
|
+
if was_text or complete:
|
|
1284
|
+
self.buffer.reset(append_to_history=bool(text))
|
|
1285
|
+
if complete:
|
|
1286
|
+
self._finish_interaction()
|
|
1287
|
+
self.application.invalidate()
|
|
1288
|
+
return
|
|
1289
|
+
if not text and not self.state.attachments:
|
|
1290
|
+
return
|
|
1291
|
+
if self._compacting:
|
|
1292
|
+
self.set_status("Wait for compaction to finish")
|
|
1293
|
+
return
|
|
1294
|
+
if text.startswith("/") and "\n" not in text:
|
|
1295
|
+
self.buffer.reset(append_to_history=True)
|
|
1296
|
+
asyncio.create_task(self._dispatch_command(text))
|
|
1297
|
+
return
|
|
1298
|
+
if self.state.running:
|
|
1299
|
+
if self.state.attachments:
|
|
1300
|
+
self.set_status("Wait for the active run before sending images")
|
|
1301
|
+
return
|
|
1302
|
+
self.buffer.reset(append_to_history=bool(text))
|
|
1303
|
+
if queue_mode == "followUp":
|
|
1304
|
+
self.runner.follow_up(text)
|
|
1305
|
+
self.set_status("Follow-up queued")
|
|
1306
|
+
else:
|
|
1307
|
+
self.runner.steer(text)
|
|
1308
|
+
self.set_status("Steering queued")
|
|
1309
|
+
self.application.invalidate()
|
|
1310
|
+
return
|
|
1311
|
+
self.buffer.reset(append_to_history=bool(text))
|
|
1312
|
+
self._start_run(text, image_refs=tuple(self.state.attachments))
|
|
1313
|
+
|
|
1314
|
+
async def _dispatch_command(self, text: str) -> None:
|
|
1315
|
+
name, _, arg = text[1:].partition(" ")
|
|
1316
|
+
command = self.command_by_name.get(name)
|
|
1317
|
+
if command is None:
|
|
1318
|
+
self.state.add_system(f"Unknown command: /{name}", error=True)
|
|
1319
|
+
else:
|
|
1320
|
+
await command.handler(self, arg.strip())
|
|
1321
|
+
self.application.invalidate()
|
|
1322
|
+
|
|
1323
|
+
def _start_run(
|
|
1324
|
+
self,
|
|
1325
|
+
text: str,
|
|
1326
|
+
*,
|
|
1327
|
+
resume_call: Any | None = None,
|
|
1328
|
+
image_refs: tuple[ImageAttachmentRef, ...] = (),
|
|
1329
|
+
) -> None:
|
|
1330
|
+
if resume_call is None:
|
|
1331
|
+
self.state.add_user(text, attachments=image_refs)
|
|
1332
|
+
self.state.attachments.clear()
|
|
1333
|
+
self.state.running = True
|
|
1334
|
+
self.set_status("Working… Esc to cancel")
|
|
1335
|
+
|
|
1336
|
+
async def work() -> None:
|
|
1337
|
+
if resume_call is None:
|
|
1338
|
+
try:
|
|
1339
|
+
result = await self._run_blocking(
|
|
1340
|
+
self.runner.invoke_with_attachment_refs,
|
|
1341
|
+
text,
|
|
1342
|
+
image_refs=image_refs,
|
|
1343
|
+
on_event=self._on_event_thread,
|
|
1344
|
+
)
|
|
1345
|
+
except Exception as exc: # noqa: BLE001
|
|
1346
|
+
self.state.attachments[:] = image_refs
|
|
1347
|
+
self._apply_event(RunEvent(type="run_failed", content=str(exc)))
|
|
1348
|
+
return
|
|
1349
|
+
if result.status == "failed" and image_refs:
|
|
1350
|
+
self.state.attachments[:] = image_refs
|
|
1351
|
+
else:
|
|
1352
|
+
try:
|
|
1353
|
+
result = await self._run_blocking(resume_call)
|
|
1354
|
+
except Exception as exc: # noqa: BLE001
|
|
1355
|
+
self._apply_event(RunEvent(type="run_failed", content=str(exc)))
|
|
1356
|
+
return
|
|
1357
|
+
self._handle_result(result)
|
|
1358
|
+
|
|
1359
|
+
self._run_task = asyncio.create_task(work())
|
|
1360
|
+
|
|
1361
|
+
async def image_command(self, arg: str) -> None:
|
|
1362
|
+
value = arg.strip()
|
|
1363
|
+
if not value:
|
|
1364
|
+
if not self.state.attachments:
|
|
1365
|
+
self.state.add_system("No images attached. Use /image <path>.")
|
|
1366
|
+
return
|
|
1367
|
+
lines = [f"Image #{index}: {ref.filename} ({ref.size} bytes)" for index, ref in enumerate(self.state.attachments, 1)]
|
|
1368
|
+
self.state.add_system("Pending images\n" + "\n".join(lines))
|
|
1369
|
+
return
|
|
1370
|
+
if value.lower() == "clear":
|
|
1371
|
+
count = len(self.state.attachments)
|
|
1372
|
+
self.state.attachments.clear()
|
|
1373
|
+
self.set_status(f"Cleared {count} pending image(s)")
|
|
1374
|
+
return
|
|
1375
|
+
if value.lower() == "clipboard":
|
|
1376
|
+
self.set_status("Reading clipboard…")
|
|
1377
|
+
await self._paste_clipboard(images_only=True)
|
|
1378
|
+
return
|
|
1379
|
+
await self._attach_path(value)
|
|
1380
|
+
|
|
1381
|
+
async def attachments_command(self, arg: str) -> None:
|
|
1382
|
+
if arg.strip().lower() != "cleanup":
|
|
1383
|
+
self.state.add_system("Usage: /attachments cleanup", error=True)
|
|
1384
|
+
return
|
|
1385
|
+
try:
|
|
1386
|
+
result = await self._run_blocking(
|
|
1387
|
+
self.runner.cleanup_attachments,
|
|
1388
|
+
protected=tuple(self.state.attachments),
|
|
1389
|
+
)
|
|
1390
|
+
except Exception as exc: # noqa: BLE001
|
|
1391
|
+
self.state.add_system(f"Attachment cleanup failed: {exc}", error=True)
|
|
1392
|
+
return
|
|
1393
|
+
if result.skipped_busy:
|
|
1394
|
+
self.state.add_system("Attachment cleanup skipped: this workspace is in use by another process.", error=True)
|
|
1395
|
+
return
|
|
1396
|
+
self.state.add_system(
|
|
1397
|
+
f"Attachment cleanup: scanned {result.scanned}, kept {result.kept}, "
|
|
1398
|
+
f"deleted {result.deleted}, freed {result.freed_bytes} bytes."
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
async def _attach_path(self, value: str) -> bool:
|
|
1402
|
+
if not self.runner.supports_input("image"):
|
|
1403
|
+
self.set_status("Current model does not support image input")
|
|
1404
|
+
return False
|
|
1405
|
+
if len(self.state.attachments) >= MAX_IMAGES_PER_MESSAGE:
|
|
1406
|
+
self.set_status(f"A message can contain at most {MAX_IMAGES_PER_MESSAGE} images")
|
|
1407
|
+
return False
|
|
1408
|
+
try:
|
|
1409
|
+
path = windows_path_to_wsl(value)
|
|
1410
|
+
attachment = await self._run_blocking(image_attachment_from_path, path)
|
|
1411
|
+
ref = await self._run_blocking(self.runner.store_image, attachment)
|
|
1412
|
+
except (OSError, ValueError, ClipboardError) as exc:
|
|
1413
|
+
self.state.add_system(str(exc), error=True)
|
|
1414
|
+
return False
|
|
1415
|
+
self.state.attachments.append(ref)
|
|
1416
|
+
self.set_status(f"Attached image: {ref.filename}")
|
|
1417
|
+
return True
|
|
1418
|
+
|
|
1419
|
+
def _handle_pasted_text(self, payload: str) -> None:
|
|
1420
|
+
trimmed = payload.strip()
|
|
1421
|
+
if trimmed and "\n" not in trimmed:
|
|
1422
|
+
try:
|
|
1423
|
+
path = windows_path_to_wsl(trimmed)
|
|
1424
|
+
looks_like_image = path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
1425
|
+
if path.is_file() and looks_like_image:
|
|
1426
|
+
if self.runner.supports_input("image"):
|
|
1427
|
+
asyncio.create_task(self._attach_pasted_path(trimmed, payload))
|
|
1428
|
+
return
|
|
1429
|
+
self.set_status("Image path pasted as text: current model does not support images")
|
|
1430
|
+
except (OSError, ClipboardError):
|
|
1431
|
+
pass
|
|
1432
|
+
self.buffer.insert_text(payload)
|
|
1433
|
+
|
|
1434
|
+
async def _attach_pasted_path(self, value: str, original_payload: str) -> None:
|
|
1435
|
+
if not await self._attach_path(value):
|
|
1436
|
+
self.buffer.insert_text(original_payload)
|
|
1437
|
+
self.application.invalidate()
|
|
1438
|
+
|
|
1439
|
+
async def _paste_clipboard(self, *, images_only: bool = False) -> None:
|
|
1440
|
+
content = await self._run_blocking(self.clipboard.inspect)
|
|
1441
|
+
if isinstance(content, ClipboardText):
|
|
1442
|
+
if images_only:
|
|
1443
|
+
self.set_status("Clipboard does not contain an image")
|
|
1444
|
+
return
|
|
1445
|
+
self.buffer.insert_text(content.text)
|
|
1446
|
+
self.application.invalidate()
|
|
1447
|
+
return
|
|
1448
|
+
if isinstance(content, ClipboardUnavailable):
|
|
1449
|
+
self.set_status(content.reason)
|
|
1450
|
+
return
|
|
1451
|
+
if not self.runner.supports_input("image"):
|
|
1452
|
+
self.set_status("Current model does not support image input")
|
|
1453
|
+
return
|
|
1454
|
+
if isinstance(content, ClipboardFiles):
|
|
1455
|
+
attached = 0
|
|
1456
|
+
for value in content.paths:
|
|
1457
|
+
if len(self.state.attachments) >= MAX_IMAGES_PER_MESSAGE:
|
|
1458
|
+
break
|
|
1459
|
+
if Path(value).suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
|
1460
|
+
continue
|
|
1461
|
+
if await self._attach_path(value):
|
|
1462
|
+
attached += 1
|
|
1463
|
+
if not attached:
|
|
1464
|
+
self.set_status("Clipboard contains no supported image files")
|
|
1465
|
+
return
|
|
1466
|
+
if isinstance(content, ClipboardImage):
|
|
1467
|
+
if len(self.state.attachments) >= MAX_IMAGES_PER_MESSAGE:
|
|
1468
|
+
self.set_status(f"A message can contain at most {MAX_IMAGES_PER_MESSAGE} images")
|
|
1469
|
+
return
|
|
1470
|
+
temp: Path | None = None
|
|
1471
|
+
try:
|
|
1472
|
+
temp = await self._run_blocking(self.clipboard.export_image)
|
|
1473
|
+
await self._attach_path(str(temp))
|
|
1474
|
+
except (OSError, ClipboardError, ValueError) as exc:
|
|
1475
|
+
self.state.add_system(str(exc), error=True)
|
|
1476
|
+
finally:
|
|
1477
|
+
if temp is not None:
|
|
1478
|
+
temp.unlink(missing_ok=True)
|
|
1479
|
+
|
|
1480
|
+
def _on_event_thread(self, event: RunEvent) -> None:
|
|
1481
|
+
if self._loop is not None:
|
|
1482
|
+
self._loop.call_soon_threadsafe(self._apply_event, event)
|
|
1483
|
+
else:
|
|
1484
|
+
self._apply_event(event)
|
|
1485
|
+
|
|
1486
|
+
def _apply_event(self, event: RunEvent) -> None:
|
|
1487
|
+
self.state.apply(event)
|
|
1488
|
+
self.application.invalidate()
|
|
1489
|
+
|
|
1490
|
+
def _handle_result(self, result: RunResult) -> None:
|
|
1491
|
+
if result.status == "paused":
|
|
1492
|
+
self.state.running = False
|
|
1493
|
+
self.state.add_system("Paused at a checkpoint. Submit /pause again is unnecessary; press Enter to resume.")
|
|
1494
|
+
if result.status in {"waiting_confirmation", "waiting_human", "paused"}:
|
|
1495
|
+
self._reopen_pending_interaction()
|
|
1496
|
+
self.application.invalidate()
|
|
1497
|
+
|
|
1498
|
+
def _finish_interaction(self, *, cancelled: bool = False) -> None:
|
|
1499
|
+
interaction = self.interaction
|
|
1500
|
+
if interaction is None:
|
|
1501
|
+
return
|
|
1502
|
+
self._reviewing = False
|
|
1503
|
+
if interaction.kind == "resume":
|
|
1504
|
+
if cancelled:
|
|
1505
|
+
self.interaction = None
|
|
1506
|
+
self.set_status("Resume cancelled")
|
|
1507
|
+
return
|
|
1508
|
+
session_id = str(interaction.values.get("session") or "")
|
|
1509
|
+
self.interaction = None
|
|
1510
|
+
if not session_id:
|
|
1511
|
+
self.state.add_system("No session selected", error=True)
|
|
1512
|
+
return
|
|
1513
|
+
self.sessions.begin_switch(session_id)
|
|
1514
|
+
return
|
|
1515
|
+
if interaction.kind == "model_source":
|
|
1516
|
+
if cancelled:
|
|
1517
|
+
self.interaction = None
|
|
1518
|
+
self.set_status("Model switch cancelled")
|
|
1519
|
+
return
|
|
1520
|
+
source = str(interaction.values.get("source") or "")
|
|
1521
|
+
try:
|
|
1522
|
+
self._show_model_choices(source, self.runner.list_models())
|
|
1523
|
+
except KeyError as exc:
|
|
1524
|
+
self.interaction = None
|
|
1525
|
+
self.state.add_system(str(exc), error=True)
|
|
1526
|
+
return
|
|
1527
|
+
if interaction.kind == "model":
|
|
1528
|
+
if cancelled:
|
|
1529
|
+
source = str(interaction.values.get("source") or "")
|
|
1530
|
+
if source:
|
|
1531
|
+
self._show_model_sources(self.runner.list_models())
|
|
1532
|
+
return
|
|
1533
|
+
self.interaction = None
|
|
1534
|
+
self.set_status("Model switch cancelled")
|
|
1535
|
+
return
|
|
1536
|
+
model_id = str(interaction.values.get("model") or "")
|
|
1537
|
+
self.interaction = None
|
|
1538
|
+
if not model_id:
|
|
1539
|
+
self.state.add_system("No model selected", error=True)
|
|
1540
|
+
return
|
|
1541
|
+
try:
|
|
1542
|
+
profile = self._switch_model(model_id)
|
|
1543
|
+
except (KeyError, RuntimeError) as exc:
|
|
1544
|
+
self.state.add_system(str(exc), error=True)
|
|
1545
|
+
return
|
|
1546
|
+
self.state.add_system(f"Switched model to {model_display_name(profile)}")
|
|
1547
|
+
self.set_status(f"Model: {model_display_name(profile)}")
|
|
1548
|
+
return
|
|
1549
|
+
if interaction.kind == "permission":
|
|
1550
|
+
if cancelled:
|
|
1551
|
+
self.interaction = None
|
|
1552
|
+
self.set_status("Permission change cancelled")
|
|
1553
|
+
return
|
|
1554
|
+
mode_raw = str(interaction.values.get("mode") or "")
|
|
1555
|
+
self.interaction = None
|
|
1556
|
+
mode = parse_permission_mode(mode_raw)
|
|
1557
|
+
if mode is None:
|
|
1558
|
+
self.state.add_system("No permission mode selected", error=True)
|
|
1559
|
+
return
|
|
1560
|
+
if mode is PermissionMode.ALLOW and not allow_mode_available(self.runner.prepared.execution_mode):
|
|
1561
|
+
self.state.add_system(
|
|
1562
|
+
allow_mode_unavailable_reason(self.runner.prepared.execution_mode),
|
|
1563
|
+
error=True,
|
|
1564
|
+
)
|
|
1565
|
+
return
|
|
1566
|
+
if mode is PermissionMode.ALLOW and self.runner.permission_mode() is not PermissionMode.ALLOW:
|
|
1567
|
+
self._begin_allow_permission_confirm()
|
|
1568
|
+
return
|
|
1569
|
+
try:
|
|
1570
|
+
applied = self.runner.set_permission_mode(mode)
|
|
1571
|
+
except (RuntimeError, ValueError) as exc:
|
|
1572
|
+
self.state.add_system(str(exc), error=True)
|
|
1573
|
+
return
|
|
1574
|
+
self.state.add_system(f"Permission mode: {permission_mode_label(applied)}")
|
|
1575
|
+
self.set_status(f"Permission: {applied.value}")
|
|
1576
|
+
return
|
|
1577
|
+
if interaction.kind == "permission_confirm":
|
|
1578
|
+
if cancelled:
|
|
1579
|
+
self.interaction = None
|
|
1580
|
+
self.set_status("ALLOW mode cancelled")
|
|
1581
|
+
return
|
|
1582
|
+
typed = str(interaction.values.get("confirm") or "").strip()
|
|
1583
|
+
self.interaction = None
|
|
1584
|
+
if typed != "ALLOW":
|
|
1585
|
+
self.state.add_system(
|
|
1586
|
+
'ALLOW not enabled. Type ALLOW exactly to confirm, or use /permission ask.',
|
|
1587
|
+
error=True,
|
|
1588
|
+
)
|
|
1589
|
+
self.set_status("Permission: ask")
|
|
1590
|
+
return
|
|
1591
|
+
try:
|
|
1592
|
+
applied = self.runner.set_permission_mode(PermissionMode.ALLOW)
|
|
1593
|
+
except (RuntimeError, ValueError) as exc:
|
|
1594
|
+
self.state.add_system(str(exc), error=True)
|
|
1595
|
+
return
|
|
1596
|
+
self.state.add_system(f"Permission mode: {permission_mode_label(applied)}")
|
|
1597
|
+
self.set_status(f"Permission: {applied.value}")
|
|
1598
|
+
return
|
|
1599
|
+
if cancelled and interaction.kind in {"pause", "approval", "human"}:
|
|
1600
|
+
self._dismiss_pending_interrupt(interaction.kind)
|
|
1601
|
+
return
|
|
1602
|
+
if interaction.kind == "pause":
|
|
1603
|
+
if interaction.values.get("continue") == "continue":
|
|
1604
|
+
self.interaction = None
|
|
1605
|
+
self._start_run("", resume_call=partial(
|
|
1606
|
+
self.runner.continue_run, on_event=self._on_event_thread,
|
|
1607
|
+
))
|
|
1608
|
+
return
|
|
1609
|
+
self._dismiss_pending_interrupt("pause")
|
|
1610
|
+
return
|
|
1611
|
+
if interaction.kind == "approval":
|
|
1612
|
+
ids = [item for item in interaction.tool_call_ids if item]
|
|
1613
|
+
approved = str(interaction.values.get("approved") or "") == "approve"
|
|
1614
|
+
self.interaction = None
|
|
1615
|
+
if approved and len(ids) == 1:
|
|
1616
|
+
resume_call = partial(
|
|
1617
|
+
self.runner.approve_tool, ids[0], on_event=self._on_event_thread,
|
|
1618
|
+
)
|
|
1619
|
+
elif approved:
|
|
1620
|
+
resume_call = partial(
|
|
1621
|
+
self.runner._decide_listed_tools, ids, approved=True,
|
|
1622
|
+
on_event=self._on_event_thread,
|
|
1623
|
+
)
|
|
1624
|
+
elif len(ids) == 1:
|
|
1625
|
+
resume_call = partial(
|
|
1626
|
+
self.runner.reject_tool, ids[0], on_event=self._on_event_thread,
|
|
1627
|
+
)
|
|
1628
|
+
else:
|
|
1629
|
+
resume_call = partial(
|
|
1630
|
+
self.runner._decide_listed_tools, ids, approved=False,
|
|
1631
|
+
on_event=self._on_event_thread,
|
|
1632
|
+
)
|
|
1633
|
+
self._start_run("", resume_call=resume_call)
|
|
1634
|
+
return
|
|
1635
|
+
values = dict(interaction.values)
|
|
1636
|
+
self.interaction = None
|
|
1637
|
+
self._start_run("", resume_call=partial(
|
|
1638
|
+
self.runner.submit_human_input, values, on_event=self._on_event_thread,
|
|
1639
|
+
))
|
|
1640
|
+
|
|
1641
|
+
def _dismiss_pending_interrupt(self, kind: str) -> None:
|
|
1642
|
+
self.interaction = None
|
|
1643
|
+
self._reviewing = False
|
|
1644
|
+
label = {"approval": "Approval", "human": "Input", "pause": "Pause"}.get(kind, "Input")
|
|
1645
|
+
self.state.add_system(f"{label} still pending · F2 to decide")
|
|
1646
|
+
self.set_status("Ready")
|
|
1647
|
+
|
|
1648
|
+
def _reopen_pending_interaction(self, *, notify_missing: bool = True) -> bool:
|
|
1649
|
+
try:
|
|
1650
|
+
interrupt = self.runner.current_interrupt()
|
|
1651
|
+
except UnknownInterruptError as exc:
|
|
1652
|
+
self.state.add_system(str(exc), error=True)
|
|
1653
|
+
self.set_status("Unknown interrupt")
|
|
1654
|
+
return False
|
|
1655
|
+
if interrupt is None:
|
|
1656
|
+
if notify_missing:
|
|
1657
|
+
self.state.add_system("No pending interaction")
|
|
1658
|
+
self.set_status("No pending interaction")
|
|
1659
|
+
return False
|
|
1660
|
+
if interrupt.kind is InterruptKind.WAITING_CONFIRMATION:
|
|
1661
|
+
self.interaction = InteractionController.approval(list(interrupt.pending_tools))
|
|
1662
|
+
self.set_status("Waiting for input")
|
|
1663
|
+
elif interrupt.kind is InterruptKind.WAITING_HUMAN:
|
|
1664
|
+
self.interaction = InteractionController.human(interrupt.payload)
|
|
1665
|
+
self.set_status("Waiting for input")
|
|
1666
|
+
elif interrupt.kind is InterruptKind.PAUSED:
|
|
1667
|
+
self.interaction = InteractionController.pause()
|
|
1668
|
+
self.set_status("Paused")
|
|
1669
|
+
else:
|
|
1670
|
+
self.state.add_system(f"Unsupported interrupt: {interrupt.kind}", error=True)
|
|
1671
|
+
return False
|
|
1672
|
+
self.application.invalidate()
|
|
1673
|
+
return True
|
|
1674
|
+
|
|
1675
|
+
def _review_calls(self) -> list[tuple[str, dict[str, Any]]]:
|
|
1676
|
+
if self.interaction is not None and self.interaction.kind == "approval":
|
|
1677
|
+
found = [
|
|
1678
|
+
(str(call.get("name") or "tool"), call.get("args") if isinstance(call.get("args"), dict) else {})
|
|
1679
|
+
for call in self.interaction.calls
|
|
1680
|
+
if is_mutation_tool(str(call.get("name") or ""))
|
|
1681
|
+
]
|
|
1682
|
+
if found:
|
|
1683
|
+
return found
|
|
1684
|
+
for index in range(len(self.state.blocks) - 1, -1, -1):
|
|
1685
|
+
block = self.state.blocks[index]
|
|
1686
|
+
if isinstance(block, ToolBlock) and is_mutation_tool(block.name):
|
|
1687
|
+
mutation = normalize_file_mutation(block)
|
|
1688
|
+
if mutation is not None and mutation.operation in {"create", "modify"}:
|
|
1689
|
+
first = index
|
|
1690
|
+
while first > 0:
|
|
1691
|
+
prior = self.state.blocks[first - 1]
|
|
1692
|
+
prior_mutation = normalize_file_mutation(prior) if isinstance(prior, ToolBlock) else None
|
|
1693
|
+
if prior_mutation is None or prior_mutation.operation != mutation.operation:
|
|
1694
|
+
break
|
|
1695
|
+
if mutation.operation == "modify" and prior_mutation.path != mutation.path:
|
|
1696
|
+
break
|
|
1697
|
+
first -= 1
|
|
1698
|
+
return [
|
|
1699
|
+
(item.name, item.arguments)
|
|
1700
|
+
for item in self.state.blocks[first:index + 1]
|
|
1701
|
+
if isinstance(item, ToolBlock)
|
|
1702
|
+
]
|
|
1703
|
+
return [(block.name, block.arguments)]
|
|
1704
|
+
return []
|
|
1705
|
+
|
|
1706
|
+
def _toggle_review(self) -> None:
|
|
1707
|
+
if self._reviewing:
|
|
1708
|
+
self._close_review()
|
|
1709
|
+
return
|
|
1710
|
+
if not self._review_calls():
|
|
1711
|
+
self.set_status("No pending review")
|
|
1712
|
+
return
|
|
1713
|
+
self._reviewing = True
|
|
1714
|
+
self._transcript_anchor = 0
|
|
1715
|
+
self.set_status("Reviewing diff · Esc or Ctrl+R to close")
|
|
1716
|
+
|
|
1717
|
+
def _close_review(self) -> None:
|
|
1718
|
+
self._reviewing = False
|
|
1719
|
+
self._transcript_anchor = None
|
|
1720
|
+
self.set_status("Waiting for input" if self.interaction is not None else "Ready")
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def default_config_dir() -> Path:
|
|
1724
|
+
configured = os.environ.get("DEEP_AGENT_CONFIG_DIR")
|
|
1725
|
+
return Path(configured).expanduser() if configured else Path.home() / ".deep-agent"
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _fit_footer_line(left: str, right: str, width: int) -> str:
|
|
1729
|
+
available = max(1, width)
|
|
1730
|
+
if not right:
|
|
1731
|
+
left = _truncate_cells(left, available)
|
|
1732
|
+
return left + (" " * max(0, available - get_cwidth(left)))
|
|
1733
|
+
left_fit, gap, right_fit = _fit_footer_parts(left, right, width)
|
|
1734
|
+
return f"{left_fit}{gap}{right_fit}"
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _fit_footer_parts(left: str, right: str, width: int) -> tuple[str, str, str]:
|
|
1738
|
+
available = max(1, width)
|
|
1739
|
+
right_fit = _truncate_cells(right, available)
|
|
1740
|
+
remaining = max(0, available - get_cwidth(right_fit) - 1)
|
|
1741
|
+
left_fit = _truncate_cells(left, remaining)
|
|
1742
|
+
gap = " " * max(1, available - get_cwidth(left_fit) - get_cwidth(right_fit))
|
|
1743
|
+
return left_fit, gap, right_fit
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _truncate_cells(value: str, width: int) -> str:
|
|
1747
|
+
if width <= 0:
|
|
1748
|
+
return ""
|
|
1749
|
+
if get_cwidth(value) <= width:
|
|
1750
|
+
return value
|
|
1751
|
+
if width == 1:
|
|
1752
|
+
return "…"
|
|
1753
|
+
result: list[str] = []
|
|
1754
|
+
used = 0
|
|
1755
|
+
for char in value:
|
|
1756
|
+
char_width = get_cwidth(char)
|
|
1757
|
+
if used + char_width > width - 1:
|
|
1758
|
+
break
|
|
1759
|
+
result.append(char)
|
|
1760
|
+
used += char_width
|
|
1761
|
+
return "".join(result) + "…"
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _register_terminal_sequences() -> None:
|
|
1765
|
+
# Kitty/CSI-u and xterm modified-key encodings used for Shift+Enter.
|
|
1766
|
+
# Ctrl+J remains the portable fallback when a terminal does not distinguish it.
|
|
1767
|
+
ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlJ)
|
|
1768
|
+
ANSI_SEQUENCES.setdefault("\x1b[13;2~", Keys.ControlJ)
|