copper-pilot-cli 0.1.1__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 (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,1224 @@
1
+ """Curated Textual widgets adapted from the pinned dcode presentation fork."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import stat
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from difflib import unified_diff
12
+ from pathlib import Path
13
+ from time import monotonic
14
+ from typing import Any
15
+
16
+ from textual import events, on
17
+ from textual.app import ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.containers import Container, Horizontal, Vertical, VerticalScroll
20
+ from textual.content import Content
21
+ from textual.message import Message
22
+ from textual.reactive import var
23
+ from textual.screen import ModalScreen
24
+ from textual.timer import Timer
25
+ from textual.widget import Widget
26
+ from textual.widgets import Checkbox, Input, Label, Markdown, OptionList, Static, TextArea
27
+ from textual.widgets.option_list import Option
28
+
29
+ from copper_pilot_cli.copper_config import paths
30
+ from copper_pilot_cli.copper_tools import ApprovalRequest, canonical_tool_name
31
+ from copper_pilot_cli.sessions import ThreadInfo, list_threads
32
+
33
+ MAX_WIDGET_TEXT = 64 * 1024
34
+ _TERMINAL_ESCAPE = re.compile(r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
35
+
36
+
37
+ def literal_text(value: object, limit: int = MAX_WIDGET_TEXT) -> Content:
38
+ """Render untrusted hosted/local text without interpreting Rich markup."""
39
+ text = _TERMINAL_ESCAPE.sub("", str(value))
40
+ text = "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
41
+ if len(text) > limit:
42
+ text = text[:limit] + "\n[display truncated]"
43
+ return Content(text)
44
+
45
+
46
+ def bounded_widget_text(value: object, limit: int = MAX_WIDGET_TEXT) -> str:
47
+ text = str(value)
48
+ return text if len(text) <= limit else text[:limit] + "\n[display truncated]"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class CompletionEntry:
53
+ name: str
54
+ description: str
55
+ display_name: str = ""
56
+
57
+
58
+ class ComposerTextArea(TextArea):
59
+ """Text area that reserves bare Enter for prompt submission."""
60
+
61
+ class Submitted(Message):
62
+ def __init__(self, value: str) -> None:
63
+ super().__init__()
64
+ self.value = value
65
+
66
+ def __init__(self, history: list[str], **kwargs: Any) -> None:
67
+ super().__init__(**kwargs)
68
+ self.input_history = history
69
+ self.history_index = len(history)
70
+
71
+ def submit(self) -> None:
72
+ value = self.text.strip()
73
+ if value:
74
+ self.text = ""
75
+ self.post_message(self.Submitted(value))
76
+
77
+ async def _on_key(self, event: events.Key) -> None:
78
+ completion_active = bool(getattr(self.parent, "completion_active", False))
79
+ if completion_active and event.key in {
80
+ "up",
81
+ "down",
82
+ "tab",
83
+ "enter",
84
+ "space",
85
+ "escape",
86
+ }:
87
+ event.prevent_default()
88
+ return
89
+ if event.key == "enter":
90
+ self.submit()
91
+ event.stop()
92
+ event.prevent_default()
93
+ return
94
+ row, _ = self.cursor_location
95
+ if event.key == "up" and self.input_history and row == 0:
96
+ self.history_index = max(self.history_index - 1, 0)
97
+ self.text = self.input_history[self.history_index]
98
+ self.move_cursor((len(self.document.lines) - 1, len(self.document.lines[-1])))
99
+ event.stop()
100
+ event.prevent_default()
101
+ return
102
+ if event.key == "down" and self.input_history and row == len(self.document.lines) - 1:
103
+ self.history_index = min(self.history_index + 1, len(self.input_history))
104
+ self.text = (
105
+ self.input_history[self.history_index]
106
+ if self.history_index < len(self.input_history)
107
+ else ""
108
+ )
109
+ event.stop()
110
+ event.prevent_default()
111
+ return
112
+ await super()._on_key(event)
113
+
114
+
115
+ class ChatInput(Vertical):
116
+ """Multiline dcode-style composer with slash and `@file` completion."""
117
+
118
+ DEFAULT_CSS = """
119
+ ChatInput {
120
+ height: auto;
121
+ max-height: 16;
122
+ border: solid $primary;
123
+ background: $surface;
124
+ }
125
+ ChatInput TextArea {
126
+ height: auto;
127
+ min-height: 3;
128
+ max-height: 10;
129
+ border: none;
130
+ background: transparent;
131
+ }
132
+ ChatInput OptionList {
133
+ height: auto;
134
+ max-height: 7;
135
+ display: none;
136
+ border-top: solid $primary;
137
+ }
138
+ ChatInput.-completing OptionList {
139
+ display: block;
140
+ }
141
+ """
142
+
143
+ class Submitted(Message):
144
+ def __init__(self, value: str) -> None:
145
+ super().__init__()
146
+ self.value = value
147
+ self.mode = "normal"
148
+
149
+ def __init__(
150
+ self,
151
+ cwd: str | Path | None = None,
152
+ history_file: Path | None = None,
153
+ image_tracker: Any | None = None,
154
+ **kwargs: Any,
155
+ ) -> None:
156
+ del image_tracker
157
+ super().__init__(**kwargs)
158
+ self.cwd = Path(cwd or ".").resolve()
159
+ self.history_file = history_file or paths().history
160
+ try:
161
+ self.history = [
162
+ value
163
+ for line in self.history_file.read_text(encoding="utf-8").splitlines()
164
+ if line
165
+ for value in [json.loads(line)]
166
+ if isinstance(value, str)
167
+ ][-500:]
168
+ except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
169
+ self.history = []
170
+ self.commands: list[CompletionEntry] = []
171
+ self._matches: list[CompletionEntry] = []
172
+ self._completion_kind: str | None = None
173
+ self._selected_index = 0
174
+ self.last_changed_at = 0.0
175
+ self._typing_changed = asyncio.Event()
176
+
177
+ @property
178
+ def completion_active(self) -> bool:
179
+ return bool(self._matches)
180
+
181
+ def compose(self) -> ComposeResult:
182
+ yield ComposerTextArea(self.history, id="chat-input", soft_wrap=True)
183
+ yield OptionList(id="completions")
184
+
185
+ def focus(self, scroll_visible: bool = True) -> ChatInput:
186
+ self.query_one(TextArea).focus(scroll_visible)
187
+ return self
188
+
189
+ def update_slash_commands(self, commands: Sequence[Any]) -> None:
190
+ self.commands = [
191
+ CompletionEntry(
192
+ name=str(item.name),
193
+ description=str(item.description),
194
+ display_name=str(getattr(item, "display_name", "") or ""),
195
+ )
196
+ for item in commands
197
+ ]
198
+
199
+ def set_cwd(self, cwd: str | Path) -> None:
200
+ self.cwd = Path(cwd).resolve()
201
+
202
+ def _current_token(self) -> str:
203
+ text = self.query_one(TextArea).text
204
+ return re.split(r"\s", text)[-1] if text else ""
205
+
206
+ def _file_entries(self, token: str) -> list[CompletionEntry]:
207
+ query = token.removeprefix("@").lower()
208
+ entries: list[CompletionEntry] = []
209
+ for path in self.cwd.rglob("*"):
210
+ if len(entries) >= 100:
211
+ break
212
+ if not path.is_file() or any(
213
+ part in {".git", "node_modules", "__pycache__", ".venv"}
214
+ for part in path.relative_to(self.cwd).parts
215
+ ):
216
+ continue
217
+ relative = path.relative_to(self.cwd).as_posix()
218
+ if query in relative.lower():
219
+ entries.append(CompletionEntry(f"@{relative}", "File", f"@{relative} [file]"))
220
+ return sorted(entries, key=lambda item: (len(item.name), item.name.lower()))
221
+
222
+ def _refresh_completions(self) -> None:
223
+ token = self._current_token()
224
+ if token.startswith("/"):
225
+ query = token.lower()
226
+ self._completion_kind = "slash"
227
+ self._matches = [
228
+ item
229
+ for item in self.commands
230
+ if query in item.name.lower() or query in item.description.lower()
231
+ ][:50]
232
+ elif token.startswith("@"):
233
+ self._completion_kind = "file"
234
+ self._matches = self._file_entries(token)
235
+ else:
236
+ self._completion_kind = None
237
+ self._matches = []
238
+ self._selected_index = 0
239
+ options = self.query_one(OptionList)
240
+ options.clear_options()
241
+ options.add_options(
242
+ Option(
243
+ literal_text(f"{item.display_name or item.name} {item.description}"),
244
+ id=str(index),
245
+ )
246
+ for index, item in enumerate(self._matches)
247
+ )
248
+ options.highlighted = 0 if self._matches else None
249
+ self.set_class(bool(self._matches), "-completing")
250
+
251
+ @on(TextArea.Changed)
252
+ def changed(self) -> None:
253
+ self.last_changed_at = monotonic()
254
+ self._typing_changed.set()
255
+ self._refresh_completions()
256
+
257
+ async def wait_until_typing_idle(self, idle_for: float = 0.6, max_wait: float = 5.0) -> None:
258
+ """Avoid letting approval shortcuts consume prompt text being typed."""
259
+ try:
260
+ async with asyncio.timeout(max_wait):
261
+ while self.query_one(TextArea).has_focus:
262
+ remaining = idle_for - (monotonic() - self.last_changed_at)
263
+ if remaining <= 0:
264
+ return
265
+ self._typing_changed.clear()
266
+ try:
267
+ async with asyncio.timeout(remaining):
268
+ await self._typing_changed.wait()
269
+ except TimeoutError:
270
+ return
271
+ except TimeoutError:
272
+ return
273
+
274
+ @on(OptionList.OptionSelected)
275
+ def complete(self, event: OptionList.OptionSelected) -> None:
276
+ self._apply_completion(int(str(event.option.id)))
277
+
278
+ def _dismiss_completion(self) -> None:
279
+ self._matches = []
280
+ self._completion_kind = None
281
+ self._selected_index = 0
282
+ self.query_one(OptionList).clear_options()
283
+ self.set_class(False, "-completing")
284
+
285
+ def _apply_completion(self, index: int, *, trailing_space: bool = True) -> None:
286
+ if not 0 <= index < len(self._matches):
287
+ return
288
+ area = self.query_one(TextArea)
289
+ text = area.text
290
+ token = self._current_token()
291
+ suffix = " " if trailing_space else ""
292
+ area.text = text[: len(text) - len(token)] + self._matches[index].name + suffix
293
+ area.move_cursor((len(area.document.lines) - 1, len(area.document.lines[-1])))
294
+ self._dismiss_completion()
295
+ area.focus()
296
+
297
+ async def on_key(self, event: events.Key) -> None:
298
+ if not self._matches:
299
+ return
300
+ if event.key in {"up", "down"}:
301
+ delta = -1 if event.key == "up" else 1
302
+ self._selected_index = (self._selected_index + delta) % len(self._matches)
303
+ self.query_one(OptionList).highlighted = self._selected_index
304
+ elif event.key == "escape":
305
+ self._dismiss_completion()
306
+ elif event.key == "enter":
307
+ kind = self._completion_kind
308
+ self._apply_completion(
309
+ self._selected_index,
310
+ trailing_space=kind != "slash",
311
+ )
312
+ if kind == "slash":
313
+ self.query_one(ComposerTextArea).submit()
314
+ elif event.key == "tab" or (event.key == "space" and self._completion_kind == "slash"):
315
+ self._apply_completion(self._selected_index)
316
+ elif event.key == "space":
317
+ self.query_one(ComposerTextArea).insert(" ")
318
+ else:
319
+ return
320
+ event.stop()
321
+ event.prevent_default()
322
+
323
+ @on(ComposerTextArea.Submitted)
324
+ def text_submitted(self, event: ComposerTextArea.Submitted) -> None:
325
+ self.history.append(event.value)
326
+ self.history[:] = self.history[-500:]
327
+ area = self.query_one(ComposerTextArea)
328
+ area.history_index = len(self.history)
329
+ try:
330
+ self.history_file.parent.mkdir(parents=True, exist_ok=True)
331
+ with self.history_file.open("a", encoding="utf-8") as handle:
332
+ handle.write(json.dumps(event.value) + "\n")
333
+ if self.history_file.exists() and self.history_file.stat().st_size > 2_000_000:
334
+ self.history_file.write_text(
335
+ "\n".join(json.dumps(value) for value in self.history) + "\n",
336
+ encoding="utf-8",
337
+ )
338
+ if self.history_file.exists() and self.history_file.parent.exists():
339
+ self.history_file.parent.chmod(stat.S_IRWXU)
340
+ self.history_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
341
+ except OSError:
342
+ pass
343
+ self.post_message(self.Submitted(event.value))
344
+
345
+
346
+ class UserMessage(Static):
347
+ DEFAULT_CSS = """
348
+ UserMessage {
349
+ height: auto;
350
+ padding: 1;
351
+ margin-bottom: 1;
352
+ background: $primary 12%;
353
+ border-left: wide $primary;
354
+ }
355
+ """
356
+
357
+ def __init__(self, content: str) -> None:
358
+ super().__init__(Content(content))
359
+
360
+
361
+ class AssistantMessage(Vertical):
362
+ DEFAULT_CSS = """
363
+ AssistantMessage { height: auto; padding: 0 1; margin-bottom: 1; }
364
+ AssistantMessage Markdown { padding: 0; }
365
+ """
366
+
367
+ def __init__(self, content: str = "") -> None:
368
+ super().__init__()
369
+ self.content = bounded_widget_text(content)
370
+ self._rendered_content = self.content
371
+ self._flush_timer: Timer | None = None
372
+
373
+ def compose(self) -> ComposeResult:
374
+ yield Markdown(self.content)
375
+
376
+ async def append_content(self, text: str) -> None:
377
+ self.content = bounded_widget_text(self.content + text)
378
+ if self._flush_timer is None:
379
+ self._flush_timer = self.set_timer(0.05, self._flush)
380
+
381
+ async def _flush(self) -> None:
382
+ self._flush_timer = None
383
+ if self.content != self._rendered_content and self.is_mounted:
384
+ self._rendered_content = self.content
385
+ await self.query_one(Markdown).update(self.content)
386
+
387
+ async def stop_stream(self) -> None:
388
+ if self._flush_timer is not None:
389
+ self._flush_timer.stop()
390
+ self._flush_timer = None
391
+ await self._flush()
392
+
393
+
394
+ class ReasoningMessage(Vertical):
395
+ DEFAULT_CSS = """
396
+ ReasoningMessage { height: auto; color: $text-muted; padding: 0 1; }
397
+ ReasoningMessage #reasoning { display: block; }
398
+ ReasoningMessage.-collapsed #reasoning { display: none; }
399
+ """
400
+ collapsed = var(False, toggle_class="-collapsed")
401
+
402
+ def __init__(self, content: str = "") -> None:
403
+ super().__init__()
404
+ self.content = bounded_widget_text(content)
405
+
406
+ def compose(self) -> ComposeResult:
407
+ yield Static("Reasoning [click to collapse]", classes="reasoning-header")
408
+ yield Static(literal_text(self.content), id="reasoning")
409
+
410
+ @on(events.Click)
411
+ def toggle(self) -> None:
412
+ self.collapsed = not self.collapsed
413
+
414
+ async def append_content(self, text: str) -> None:
415
+ self.content = bounded_widget_text(self.content + text)
416
+ self.query_one("#reasoning", Static).update(literal_text(self.content))
417
+
418
+ async def stop_stream(self) -> None:
419
+ self.collapsed = True
420
+
421
+
422
+ def _tool_path(args: dict[str, Any]) -> str:
423
+ return str(args.get("file_path") or args.get("path") or "")
424
+
425
+
426
+ def _tool_label(tool_name: str, args: dict[str, Any]) -> str:
427
+ name = canonical_tool_name(tool_name)
428
+ if name == "execute":
429
+ command = str(args.get("command") or "")
430
+ return f"$ {command}" if command else "Run shell command"
431
+ if name == "read_file":
432
+ return f"Read {_tool_path(args)}"
433
+ if name == "write_file":
434
+ return f"Write {_tool_path(args)}"
435
+ if name == "edit_file":
436
+ return f"Edit {_tool_path(args)}"
437
+ if name == "delete":
438
+ return f"Delete {_tool_path(args)}"
439
+ if name == "glob":
440
+ return f"Glob {args.get('pattern') or ''}"
441
+ if name == "grep":
442
+ suffix = f" in {args['path']}" if args.get("path") else ""
443
+ return f"Grep {args.get('pattern') or ''}{suffix}"
444
+ if name == "read_binary_file":
445
+ return f"Read binary {_tool_path(args)}"
446
+ if name == "write_binary_file":
447
+ return f"Write binary {_tool_path(args)}"
448
+ if name == "ask_user_question":
449
+ return "Ask user"
450
+ return tool_name
451
+
452
+
453
+ def _display_output(value: object) -> str:
454
+ if isinstance(value, str):
455
+ return value
456
+ if isinstance(value, dict):
457
+ if value.get("error"):
458
+ return str(value["error"])
459
+ if "result" in value:
460
+ result = value["result"]
461
+ return result if isinstance(result, str) else json.dumps(result, default=str, indent=2)
462
+ return json.dumps(value, default=str, indent=2)
463
+
464
+
465
+ def _compact_command(command: str, limit: int = 120) -> str:
466
+ """Keep shell rows to one visual line until explicitly expanded."""
467
+ line = " ".join(command.split())
468
+ return line if len(line) <= limit else line[: limit - 1] + "…"
469
+
470
+
471
+ _TOOL_ACTIVITY = {
472
+ "execute": ("Running", "Ran", "shell command"),
473
+ "read_file": ("Reading", "Read", "file"),
474
+ "read_binary_file": ("Reading", "Read", "binary file"),
475
+ "write_file": ("Writing", "Wrote", "file"),
476
+ "write_binary_file": ("Writing", "Wrote", "binary file"),
477
+ "edit_file": ("Editing", "Edited", "file"),
478
+ "delete": ("Deleting", "Deleted", "file"),
479
+ "glob": ("Searching", "Searched", "path"),
480
+ "grep": ("Searching", "Searched", "file"),
481
+ }
482
+
483
+
484
+ def format_work_duration(elapsed_seconds: float) -> str:
485
+ """Format elapsed work using the CopperPilot desktop client's duration contract."""
486
+ total_seconds = max(1, round(max(0.0, elapsed_seconds)))
487
+ hours, remainder = divmod(total_seconds, 3600)
488
+ minutes, seconds = divmod(remainder, 60)
489
+ if hours:
490
+ label = f"{hours} {'hr' if hours == 1 else 'hrs'}"
491
+ return label if not minutes else f"{label} {minutes} {'min' if minutes == 1 else 'mins'}"
492
+ if minutes:
493
+ label = f"{minutes} {'min' if minutes == 1 else 'mins'}"
494
+ return label if not seconds else f"{label} {seconds}s"
495
+ return f"{total_seconds}s"
496
+
497
+
498
+ class WorkRunGroup(Vertical):
499
+ """Electron-style live work window that disappears into a duration label."""
500
+
501
+ DEFAULT_CSS = """
502
+ WorkRunGroup { height: auto; margin-bottom: 1; }
503
+ WorkRunGroup .work-run-header { height: 1; color: $text-muted; padding: 0 1; }
504
+ WorkRunGroup .work-run-body { height: auto; }
505
+ WorkRunGroup.-complete .work-run-body { display: none; }
506
+ WorkRunGroup.-failed .work-run-body { display: block; }
507
+ """
508
+
509
+ def __init__(self, *, timed: bool = True) -> None:
510
+ super().__init__()
511
+ self.units: list[Widget] = []
512
+ self.started_at = monotonic() if timed else None
513
+ self.finished_at: float | None = None
514
+ self.failed = False
515
+ self._timer: Timer | None = None
516
+
517
+ def compose(self) -> ComposeResult:
518
+ yield Static(self._label(), classes="work-run-header")
519
+ yield Vertical(classes="work-run-body")
520
+
521
+ def on_mount(self) -> None:
522
+ self._refresh_header()
523
+ if self.started_at is not None:
524
+ self._timer = self.set_interval(1.0, self._refresh_header)
525
+
526
+ async def add_unit(self, widget: Widget) -> None:
527
+ self.units.append(widget)
528
+ widget.add_class("work-run-unit")
529
+ await self.query_one(".work-run-body", Vertical).mount(widget)
530
+ self._refresh_visibility()
531
+
532
+ def touch_unit(self, widget: Widget) -> None:
533
+ """Move a reused live summary to the latest position in the work window."""
534
+ if widget not in self.units:
535
+ return
536
+ self.units.remove(widget)
537
+ self.units.append(widget)
538
+ body = self.query_one(".work-run-body", Vertical)
539
+ if body.children and body.children[-1] is not widget:
540
+ body.move_child(widget, after=body.children[-1])
541
+ self._refresh_visibility()
542
+
543
+ def mark_failed(self) -> None:
544
+ self.failed = True
545
+ self.add_class("-failed")
546
+ self._refresh_visibility()
547
+
548
+ def finalize(self) -> None:
549
+ if self.finished_at is not None:
550
+ return
551
+ self.finished_at = monotonic() if self.started_at is not None else None
552
+ if self._timer is not None:
553
+ self._timer.stop()
554
+ self._timer = None
555
+ self.add_class("-complete")
556
+ self._refresh_header()
557
+ self._refresh_visibility()
558
+
559
+ def _refresh_header(self) -> None:
560
+ if not self.is_mounted:
561
+ return
562
+ self.query_one(".work-run-header", Static).update(literal_text(self._label()))
563
+
564
+ def _label(self) -> str:
565
+ label = "Worked"
566
+ if self.started_at is not None:
567
+ end = self.finished_at if self.finished_at is not None else monotonic()
568
+ label = f"Worked for {format_work_duration(end - self.started_at)}"
569
+ return label
570
+
571
+ def _refresh_visibility(self) -> None:
572
+ recent = set(self.units[-3:])
573
+ for unit in self.units:
574
+ failed_tool_group = isinstance(unit, ToolGroupSummary) and unit.has_failures
575
+ unit.display = unit in recent or (self.failed and failed_tool_group)
576
+
577
+
578
+ class ToolGroupSummary(Vertical):
579
+ """Dcode-style aggregate that folds consecutive successful tool calls."""
580
+
581
+ DEFAULT_CSS = """
582
+ ToolGroupSummary { height: auto; margin-bottom: 1; }
583
+ ToolGroupSummary .tool-group-summary { height: 1; color: $text-muted; padding: 0 1; }
584
+ ToolGroupSummary.-live .tool-group-summary { color: $tool; }
585
+ ToolGroupSummary > ToolCallMessage { display: none; }
586
+ ToolGroupSummary.-expanded > ToolCallMessage { display: block; }
587
+ ToolGroupSummary > ToolCallMessage.-status-error,
588
+ ToolGroupSummary > ToolCallMessage.-status-rejected { display: block; }
589
+ """
590
+
591
+ def __init__(self) -> None:
592
+ super().__init__()
593
+ self.tools: list[ToolCallMessage] = []
594
+ self.finalized = False
595
+ self.expanded = False
596
+ self.work_run: WorkRunGroup | None = None
597
+
598
+ def compose(self) -> ComposeResult:
599
+ yield Static("", classes="tool-group-summary")
600
+
601
+ async def add_tool(self, tool: ToolCallMessage) -> None:
602
+ self.tools.append(tool)
603
+ tool.group = self
604
+ await self.mount(tool)
605
+ self.refresh_summary()
606
+
607
+ @on(events.Click, ".tool-group-summary")
608
+ def toggle(self) -> None:
609
+ if not self.finalized or any(tool.status != "success" for tool in self.tools):
610
+ return
611
+ self.expanded = not self.expanded
612
+ self.set_class(self.expanded, "-expanded")
613
+ self.refresh_summary()
614
+
615
+ def finalize(self) -> None:
616
+ self.finalized = True
617
+ self.refresh_summary()
618
+
619
+ @property
620
+ def has_failures(self) -> bool:
621
+ return any(tool.status in {"error", "rejected"} for tool in self.tools)
622
+
623
+ def refresh_summary(self) -> None:
624
+ if not self.is_mounted:
625
+ return
626
+ running = [tool for tool in self.tools if tool.status in {"pending", "running", "approval"}]
627
+ failed = [tool for tool in self.tools if tool.status in {"error", "rejected"}]
628
+ succeeded = [tool for tool in self.tools if tool.status == "success"]
629
+ self.set_class(bool(running), "-live")
630
+
631
+ if failed:
632
+ self.query_one(".tool-group-summary", Static).display = bool(succeeded or running)
633
+ text = self._summary(succeeded, completed=True)
634
+ if running:
635
+ live = self._summary(running, completed=False)
636
+ text = f"{text}, {live.lower()}" if text else live
637
+ elif running:
638
+ self.query_one(".tool-group-summary", Static).display = True
639
+ text = self._summary(running, completed=False) + "…"
640
+ else:
641
+ self.query_one(".tool-group-summary", Static).display = True
642
+ disclosure = "▾" if self.expanded else "▸"
643
+ text = f"{disclosure} {self._summary(succeeded, completed=True)}"
644
+ self.query_one(".tool-group-summary", Static).update(literal_text(text))
645
+
646
+ @staticmethod
647
+ def _summary(tools: list[ToolCallMessage], *, completed: bool) -> str:
648
+ counts: dict[str, int] = {}
649
+ order: list[str] = []
650
+ for tool in tools:
651
+ if tool.canonical_name not in counts:
652
+ counts[tool.canonical_name] = 0
653
+ order.append(tool.canonical_name)
654
+ counts[tool.canonical_name] += 1
655
+ parts: list[str] = []
656
+ for name in order:
657
+ present, past, noun = _TOOL_ACTIVITY.get(name, ("Running", "Ran", "tool"))
658
+ count = counts[name]
659
+ verb = past if completed else present
660
+ parts.append(f"{verb} {count} {noun}{'' if count == 1 else 's'}")
661
+ return ", ".join(parts) or ("Completed work" if completed else "Working")
662
+
663
+
664
+ class ToolCallMessage(Vertical):
665
+ """Pinned dcode-style tool lifecycle row for hosted tool calls."""
666
+
667
+ DEFAULT_CSS = """
668
+ ToolCallMessage {
669
+ height: auto;
670
+ padding: 0 1 0 0;
671
+ margin-bottom: 1;
672
+ background: transparent;
673
+ border-left: wide $tool;
674
+ }
675
+ ToolCallMessage .tool-name {
676
+ color: $tool;
677
+ text-style: bold;
678
+ margin-left: 1;
679
+ }
680
+ ToolCallMessage .tool-status {
681
+ color: $warning;
682
+ margin-left: 3;
683
+ }
684
+ ToolCallMessage .tool-output {
685
+ color: $text-muted;
686
+ margin-left: 3;
687
+ height: auto;
688
+ }
689
+ ToolCallMessage .tool-output-hint {
690
+ color: $text-muted;
691
+ margin-left: 3;
692
+ }
693
+ ToolCallMessage.-status-success {
694
+ border-left: wide $success;
695
+ background: $success 8%;
696
+ }
697
+ ToolCallMessage.-status-error {
698
+ border-left: wide $error;
699
+ background: $error 10%;
700
+ }
701
+ ToolCallMessage.-status-rejected {
702
+ border-left: wide $warning;
703
+ background: $warning 8%;
704
+ }
705
+ """
706
+
707
+ def __init__(
708
+ self,
709
+ tool_name: str,
710
+ args: dict[str, Any] | None = None,
711
+ *,
712
+ workspace: Path | None = None,
713
+ ) -> None:
714
+ super().__init__()
715
+ self.tool_name = tool_name
716
+ self.canonical_name = canonical_tool_name(tool_name)
717
+ self.args = args or {}
718
+ self.workspace = workspace
719
+ self.status = "pending"
720
+ self.output = ""
721
+ self.expanded = False
722
+ self._started: float | None = None
723
+ self._timer: Timer | None = None
724
+ self.group: ToolGroupSummary | None = None
725
+ self._before_content = self._capture_before()
726
+
727
+ def compose(self) -> ComposeResult:
728
+ yield Static(literal_text(self._label()), classes="tool-name")
729
+ yield Static("", classes="tool-status")
730
+ yield Static("", classes="tool-output")
731
+ yield Static("", classes="tool-output-hint")
732
+
733
+ def _label(self) -> str:
734
+ if self.canonical_name == "execute":
735
+ command = str(self.args.get("command") or "")
736
+ if self.expanded:
737
+ return f"$ {command}" if command else "Run shell command"
738
+ return f"$ {_compact_command(command)}" if command else "Run shell command"
739
+ return _tool_label(self.tool_name, self.args)
740
+
741
+ async def on_mount(self) -> None:
742
+ if self.status == "running" and self._timer is None:
743
+ self._timer = self.set_interval(0.25, self._refresh_display)
744
+ self._refresh_display()
745
+
746
+ @on(events.Click)
747
+ def toggle_output(self) -> None:
748
+ if self._has_hidden_output() or self._has_hidden_arguments():
749
+ self.expanded = not self.expanded
750
+ self._refresh_display()
751
+
752
+ def set_running(self) -> None:
753
+ self.status = "running"
754
+ self._started = monotonic()
755
+ if self.is_mounted and self._timer is None:
756
+ self._timer = self.set_interval(0.25, self._refresh_display)
757
+ self._refresh_display()
758
+
759
+ def set_awaiting_approval(self) -> None:
760
+ self.status = "approval"
761
+ self._stop_timer()
762
+ self._refresh_display()
763
+
764
+ def set_success(self, result: object = "") -> None:
765
+ self._finish("success", self._mutation_diff() or _display_output(result))
766
+
767
+ def set_error(self, error: object) -> None:
768
+ if self.status == "rejected":
769
+ return
770
+ self.expanded = True
771
+ self._finish("error", _display_output(error))
772
+
773
+ def set_rejected(self, reason: str | None = None) -> None:
774
+ if self.status == "rejected" and self.output:
775
+ return
776
+ self.expanded = True
777
+ self._finish("rejected", reason or "Rejected by user")
778
+
779
+ def _finish(self, status: str, output: str) -> None:
780
+ self.status = status
781
+ self.output = bounded_widget_text(output)
782
+ self._stop_timer()
783
+ self._refresh_display()
784
+
785
+ def _stop_timer(self) -> None:
786
+ if self._timer is not None:
787
+ self._timer.stop()
788
+ self._timer = None
789
+
790
+ def _preview(self) -> str:
791
+ if self.expanded:
792
+ return self.output
793
+ lines = self.output.splitlines()
794
+ shown = "\n".join(lines[:4])
795
+ return shown[:400]
796
+
797
+ def _has_hidden_output(self) -> bool:
798
+ return len(self.output) > 400 or len(self.output.splitlines()) > 4
799
+
800
+ def _has_hidden_arguments(self) -> bool:
801
+ command = str(self.args.get("command") or "")
802
+ return self.canonical_name == "execute" and (
803
+ len(command) > 120 or len(command.splitlines()) > 1
804
+ )
805
+
806
+ def _capture_before(self) -> str | None:
807
+ path_value = _tool_path(self.args)
808
+ if (
809
+ self.workspace is None
810
+ or not path_value
811
+ or self.canonical_name not in {"write_file", "edit_file", "delete"}
812
+ or _sensitive_path(path_value)
813
+ ):
814
+ return None
815
+ root = self.workspace.resolve()
816
+ candidate = Path(path_value)
817
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
818
+ try:
819
+ resolved.relative_to(root)
820
+ return resolved.read_text(encoding="utf-8") if resolved.exists() else ""
821
+ except (OSError, UnicodeError, ValueError):
822
+ return None
823
+
824
+ def _mutation_diff(self) -> str:
825
+ if self._before_content is None:
826
+ return ""
827
+ after = self._before_content
828
+ if self.canonical_name == "write_file":
829
+ after = str(self.args.get("content") or "")
830
+ elif self.canonical_name == "edit_file":
831
+ old = str(self.args.get("old_string") or "")
832
+ new = str(self.args.get("new_string") or "")
833
+ if not old or old not in after:
834
+ return ""
835
+ after = after.replace(old, new, -1 if self.args.get("replace_all") else 1)
836
+ elif self.canonical_name == "delete":
837
+ after = ""
838
+ else:
839
+ return ""
840
+ path = _tool_path(self.args)
841
+ return "\n".join(
842
+ unified_diff(
843
+ self._before_content.splitlines(),
844
+ after.splitlines(),
845
+ fromfile=path,
846
+ tofile=path if self.canonical_name != "delete" else "/dev/null",
847
+ lineterm="",
848
+ )
849
+ )
850
+
851
+ def _refresh_display(self) -> None:
852
+ if not self.is_mounted:
853
+ return
854
+ for name in ("success", "error", "rejected"):
855
+ self.set_class(self.status == name, f"-status-{name}")
856
+ elapsed = monotonic() - self._started if self._started is not None else 0
857
+ statuses = {
858
+ "pending": "",
859
+ "approval": "Awaiting approval",
860
+ "running": f"Running…{f' ({elapsed:.0f}s)' if elapsed >= 10 else ''}",
861
+ "success": "Success",
862
+ "error": "Error",
863
+ "rejected": "Rejected",
864
+ }
865
+ status = statuses.get(self.status, self.status)
866
+ self.query_one(".tool-name", Static).update(literal_text(self._label()))
867
+ self.query_one(".tool-status", Static).update(literal_text(status))
868
+ self.query_one(".tool-status", Static).display = bool(status)
869
+ preview = self._preview()
870
+ self.query_one(".tool-output", Static).update(literal_text(preview))
871
+ self.query_one(".tool-output", Static).display = bool(preview)
872
+ hint = ""
873
+ if self._has_hidden_output() or self._has_hidden_arguments():
874
+ hint = "Click to collapse" if self.expanded else "Click to expand"
875
+ self.query_one(".tool-output-hint", Static).update(literal_text(hint))
876
+ self.query_one(".tool-output-hint", Static).display = bool(hint)
877
+ if self.group is not None:
878
+ self.group.refresh_summary()
879
+
880
+
881
+ @dataclass(frozen=True, slots=True)
882
+ class ApprovalDecision:
883
+ """Decision returned by the tool approval screen."""
884
+
885
+ type: str
886
+ reason: str | None = None
887
+
888
+
889
+ def _sensitive_path(path: str) -> bool:
890
+ parts = {part.lower() for part in Path(path).parts}
891
+ name = Path(path).name.lower()
892
+ return bool(parts & {".git", ".ssh", ".copperpilot"}) or name == ".env"
893
+
894
+
895
+ def _approval_preview(request: ApprovalRequest, workspace: Path) -> str:
896
+ name = request.canonical_name or canonical_tool_name(request.tool_name)
897
+ args = request.arguments
898
+ path_value = _tool_path(args)
899
+ if name == "execute":
900
+ return str(args.get("command") or "")
901
+ if _sensitive_path(path_value):
902
+ return f"File: {path_value}\n\nContents hidden — file may contain credentials"
903
+ if name in {"write_file", "write_binary_file"}:
904
+ content = args.get("content", "")
905
+ if name == "write_binary_file":
906
+ content = "[base64 binary content omitted]"
907
+ return f"File: {path_value}\n\n{content}"
908
+ if name == "edit_file":
909
+ before = str(args.get("old_string") or "")
910
+ after = str(args.get("new_string") or "")
911
+ resolved = Path(path_value)
912
+ if not resolved.is_absolute():
913
+ resolved = workspace / resolved
914
+ try:
915
+ current = resolved.read_text(encoding="utf-8")
916
+ except (OSError, UnicodeError):
917
+ current = ""
918
+ if before and before in current:
919
+ updated = current.replace(
920
+ before,
921
+ after,
922
+ -1 if args.get("replace_all") else 1,
923
+ )
924
+ old_lines = current.splitlines()
925
+ new_lines = updated.splitlines()
926
+ else:
927
+ old_lines = before.splitlines()
928
+ new_lines = after.splitlines()
929
+ diff = "\n".join(
930
+ unified_diff(old_lines, new_lines, fromfile=path_value, tofile=path_value, lineterm="")
931
+ )
932
+ return diff or f"File: {path_value}\n\nNo changes to display"
933
+ if name == "delete":
934
+ resolved = Path(path_value)
935
+ if not resolved.is_absolute():
936
+ resolved = workspace / resolved
937
+ try:
938
+ content = resolved.read_text(encoding="utf-8")
939
+ except (OSError, UnicodeError):
940
+ return f"Delete: {path_value}"
941
+ removed = "\n".join(
942
+ unified_diff(
943
+ content.splitlines(),
944
+ [],
945
+ fromfile=path_value,
946
+ tofile="/dev/null",
947
+ lineterm="",
948
+ )
949
+ )
950
+ return removed
951
+ return json.dumps(args, default=str, indent=2)
952
+
953
+
954
+ def _unicode_warning(arguments: dict[str, Any]) -> str:
955
+ text = json.dumps(arguments, default=str, ensure_ascii=False)
956
+ suspicious = [
957
+ character
958
+ for character in text
959
+ if ord(character) in {*range(0x202A, 0x202F), *range(0x2066, 0x206A)}
960
+ ]
961
+ return "Warning: hidden bidirectional Unicode detected." if suspicious else ""
962
+
963
+
964
+ class ApprovalMenu(Container):
965
+ """Inline dcode-style approval menu driven by a CopperPilot approval request."""
966
+
967
+ can_focus = True
968
+ can_focus_children = False
969
+ BINDINGS = [
970
+ Binding("up", "move_up", "Up", show=False),
971
+ Binding("k", "move_up", "Up", show=False),
972
+ Binding("down", "move_down", "Down", show=False),
973
+ Binding("j", "move_down", "Down", show=False),
974
+ Binding("enter", "select", "Select", show=False),
975
+ Binding("1", "select_position(0)", "Approve", show=False),
976
+ Binding("2", "select_position(1)", "Auto", show=False),
977
+ Binding("3", "select_position(2)", "Reject", show=False),
978
+ Binding("y", "approve", "Approve", show=False),
979
+ Binding("a", "auto", "Auto", show=False),
980
+ Binding("n", "reject", "Reject", show=False),
981
+ Binding("escape", "reject", "Reject", show=False),
982
+ Binding("tab", "reason", "Reject with feedback", show=False),
983
+ ]
984
+ DEFAULT_CSS = """
985
+ ApprovalMenu {
986
+ height: auto;
987
+ margin: 1 0;
988
+ padding: 0 1;
989
+ background: $surface;
990
+ border: solid $warning;
991
+ }
992
+ ApprovalMenu .approval-title { height: auto; color: $warning; text-style: bold; }
993
+ ApprovalMenu .approval-preview { height: auto; max-height: 10; color: $text-muted; }
994
+ ApprovalMenu .approval-option { height: 1; padding: 0 1; }
995
+ ApprovalMenu .approval-option-selected { background: $primary; text-style: bold; }
996
+ ApprovalMenu .approval-help { height: auto; color: $text-muted; text-style: italic; }
997
+ ApprovalMenu .approval-reason-input {
998
+ display: none; height: 3; margin-top: 1; border: solid $warning;
999
+ }
1000
+ """
1001
+
1002
+ def __init__(self, request: ApprovalRequest, workspace: Path) -> None:
1003
+ super().__init__()
1004
+ self.request = request
1005
+ self.workspace = workspace
1006
+ self._selected = 0
1007
+ self._options: list[tuple[str, str]] = [
1008
+ ("Approve (y)", "approve"),
1009
+ ("Enable Auto (a)", "auto"),
1010
+ ("Reject (n)", "reject"),
1011
+ ]
1012
+ self._option_widgets: list[Static] = []
1013
+ self._reason_active = False
1014
+ self._future: asyncio.Future[ApprovalDecision] = asyncio.get_running_loop().create_future()
1015
+
1016
+ def compose(self) -> ComposeResult:
1017
+ yield Static(
1018
+ literal_text(
1019
+ f">>> {canonical_tool_name(self.request.tool_name)} Requires Approval <<<"
1020
+ ),
1021
+ classes="approval-title",
1022
+ )
1023
+ warning = _unicode_warning(self.request.arguments)
1024
+ if warning:
1025
+ yield Static(literal_text(warning), classes="approval-warning")
1026
+ with VerticalScroll(classes="approval-preview"):
1027
+ yield Static(
1028
+ literal_text(_approval_preview(self.request, self.workspace), limit=32 * 1024)
1029
+ )
1030
+ for index, (_label, _decision) in enumerate(self._options):
1031
+ widget = Static("", id=f"approval-option-{index}", classes="approval-option")
1032
+ self._option_widgets.append(widget)
1033
+ yield widget
1034
+ yield Input(
1035
+ placeholder="Reason (Enter to submit, Esc to cancel)",
1036
+ classes="approval-reason-input",
1037
+ select_on_focus=False,
1038
+ )
1039
+ yield Static("", classes="approval-help")
1040
+
1041
+ def on_mount(self) -> None:
1042
+ self._refresh_options()
1043
+ self.focus()
1044
+
1045
+ async def wait(self) -> ApprovalDecision:
1046
+ return await self._future
1047
+
1048
+ def _refresh_options(self) -> None:
1049
+ for index, ((label, _decision), widget) in enumerate(
1050
+ zip(self._options, self._option_widgets, strict=True)
1051
+ ):
1052
+ widget.update(f"{'›' if index == self._selected else ' '} {index + 1}. {label}")
1053
+ widget.set_class(index == self._selected, "approval-option-selected")
1054
+ help_text = (
1055
+ "Enter submit • Esc cancel • leave blank to reject"
1056
+ if self._reason_active
1057
+ else "↑/↓ or j/k navigate • Enter select • y/a/n quick keys • Tab reject with feedback"
1058
+ )
1059
+ self.query_one(".approval-help", Static).update(help_text)
1060
+
1061
+ def action_move_up(self) -> None:
1062
+ if not self._reason_active:
1063
+ self._selected = (self._selected - 1) % len(self._options)
1064
+ self._refresh_options()
1065
+
1066
+ def action_move_down(self) -> None:
1067
+ if not self._reason_active:
1068
+ self._selected = (self._selected + 1) % len(self._options)
1069
+ self._refresh_options()
1070
+
1071
+ def action_select(self) -> None:
1072
+ if self._reason_active:
1073
+ self._submit_reason(self.query_one(Input).value)
1074
+ else:
1075
+ self._decide(self._options[self._selected][1])
1076
+
1077
+ def action_select_position(self, position: int) -> None:
1078
+ if not self._reason_active and 0 <= position < len(self._options):
1079
+ self._decide(self._options[position][1])
1080
+
1081
+ @on(events.Click, ".approval-option")
1082
+ def clicked_option(self, event: events.Click) -> None:
1083
+ if event.widget is None:
1084
+ return
1085
+ identifier = event.widget.id or ""
1086
+ try:
1087
+ position = int(identifier.rsplit("-", 1)[-1])
1088
+ except ValueError:
1089
+ return
1090
+ self.action_select_position(position)
1091
+
1092
+ def action_approve(self) -> None:
1093
+ if not self._reason_active:
1094
+ self._decide("approve")
1095
+
1096
+ def action_auto(self) -> None:
1097
+ if not self._reason_active:
1098
+ self._decide("auto")
1099
+
1100
+ def action_reject(self) -> None:
1101
+ if self._reason_active:
1102
+ self._exit_reason()
1103
+ else:
1104
+ self._decide("reject")
1105
+
1106
+ def action_reason(self) -> None:
1107
+ if self._reason_active:
1108
+ return
1109
+ self._selected = 2
1110
+ self._reason_active = True
1111
+ field = self.query_one(Input)
1112
+ field.display = True
1113
+ self._refresh_options()
1114
+ field.focus()
1115
+
1116
+ @on(Input.Submitted)
1117
+ def submitted_reason(self, event: Input.Submitted) -> None:
1118
+ if event.input is self.query_one(Input) and self._reason_active:
1119
+ event.stop()
1120
+ self._submit_reason(event.value)
1121
+
1122
+ def _submit_reason(self, value: str) -> None:
1123
+ self._reason_active = False
1124
+ self._decide("reject", value.strip() or None)
1125
+
1126
+ def _exit_reason(self) -> None:
1127
+ self._reason_active = False
1128
+ self.query_one(Input).display = False
1129
+ self._refresh_options()
1130
+ self.focus()
1131
+
1132
+ def _decide(self, decision: str, reason: str | None = None) -> None:
1133
+ if not self._future.done():
1134
+ self._future.set_result(ApprovalDecision(decision, reason))
1135
+ self.display = False
1136
+
1137
+ def on_blur(self) -> None:
1138
+ if not self._reason_active:
1139
+ self.call_after_refresh(self.focus)
1140
+
1141
+
1142
+ class ErrorMessage(Static):
1143
+ DEFAULT_CSS = """
1144
+ ErrorMessage { height: auto; color: $error; border-left: wide $error; padding: 0 1; }
1145
+ """
1146
+
1147
+ def __init__(self, content: str) -> None:
1148
+ super().__init__(literal_text(content))
1149
+
1150
+
1151
+ class ThreadSelectorScreen(ModalScreen[str | None]):
1152
+ """Folder-scoped thread picker with an explicit all-folders toggle."""
1153
+
1154
+ DEFAULT_CSS = """
1155
+ ThreadSelectorScreen { align: center middle; }
1156
+ ThreadSelectorScreen #thread-box {
1157
+ width: 90%;
1158
+ max-width: 120;
1159
+ height: 80%;
1160
+ border: solid $primary;
1161
+ background: $surface;
1162
+ padding: 1 2;
1163
+ }
1164
+ ThreadSelectorScreen #thread-box > Horizontal { height: auto; }
1165
+ ThreadSelectorScreen OptionList { height: 1fr; }
1166
+ """
1167
+
1168
+ def __init__(self, current_thread: str | None = None, *, filter_cwd: str | None = None) -> None:
1169
+ super().__init__()
1170
+ self.current_thread = current_thread
1171
+ self.filter_cwd = filter_cwd
1172
+ self.threads: list[ThreadInfo] = []
1173
+
1174
+ def compose(self) -> ComposeResult:
1175
+ with Vertical(id="thread-box"):
1176
+ yield Label("Previous chats")
1177
+ with Horizontal():
1178
+ yield Input(placeholder="Filter chats", id="thread-filter")
1179
+ yield Checkbox("All folders", id="all-folders")
1180
+ yield OptionList(id="threads")
1181
+
1182
+ async def on_mount(self) -> None:
1183
+ await self._load()
1184
+
1185
+ async def _load(self) -> None:
1186
+ all_folders = self.query_one("#all-folders", Checkbox).value
1187
+ self.threads = await list_threads(
1188
+ limit=200,
1189
+ cwd=None if all_folders else self.filter_cwd,
1190
+ include_message_count=True,
1191
+ sort_by="updated",
1192
+ )
1193
+ self._render_threads()
1194
+
1195
+ def _render_threads(self) -> None:
1196
+ query = self.query_one("#thread-filter", Input).value.lower()
1197
+ options = self.query_one("#threads", OptionList)
1198
+ options.clear_options()
1199
+ for row in self.threads:
1200
+ prompt = str(row.get("initial_prompt") or "")
1201
+ identifier = row["thread_id"]
1202
+ label = (
1203
+ f"{'* ' if identifier == self.current_thread else ''}"
1204
+ f"{identifier[:10]} {row.get('message_count', 0)} msgs "
1205
+ f"{row.get('updated_at') or ''} {prompt}"
1206
+ )
1207
+ if query in label.lower():
1208
+ options.add_option(Option(literal_text(label), id=identifier))
1209
+
1210
+ @on(Input.Changed)
1211
+ def filtered(self) -> None:
1212
+ self._render_threads()
1213
+
1214
+ @on(Checkbox.Changed)
1215
+ async def scope_changed(self) -> None:
1216
+ await self._load()
1217
+
1218
+ @on(OptionList.OptionSelected)
1219
+ def selected(self, event: OptionList.OptionSelected) -> None:
1220
+ self.dismiss(str(event.option.id))
1221
+
1222
+ async def on_key(self, event: events.Key) -> None:
1223
+ if event.key == "escape":
1224
+ self.dismiss(None)