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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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)