-
Notifications
You must be signed in to change notification settings - Fork 135
Expand file tree
/
Copy pathapp.py
More file actions
1572 lines (1409 loc) · 61.1 KB
/
Copy pathapp.py
File metadata and controls
1572 lines (1409 loc) · 61.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Textual ``App`` subclass that hosts the Claw Codex TUI.
The app owns everything that must outlive a single screen push: the
``Session`` / ``Conversation``, the provider instance, the tool registry,
the tool context, the :class:`AppState`, and the
:class:`AgentBridge` that shuttles events between the agent-loop worker
thread and the UI.
Phase 1 boots the :class:`REPLScreen` on mount and delegates user
submissions to :class:`AgentBridge.submit`. Permission requests from
tools land here as :class:`PermissionRequested` messages, which the
screen then materialises as a modal.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from src.services.bash_mode import BashModeOutcome
from textual.app import App
from src import __version__ as CLAW_VERSION
from src.agent import Session
from src.tool_system.context import ToolContext
from src.tool_system.defaults import build_default_registry
from src.tool_system.registry import ToolRegistry
from .a11y import Announcer, describe_status
from .agent_bridge import AgentBridge
from .commands import (
CommandDispatchResult,
CommandSuggestion,
build_command_suggestions,
build_command_words,
dispatch_local_command,
dispatch_registry_command,
)
from .history_store import HistoryStore # noqa: F401 (re-exported for tests)
from .messages import CancelRequested
from .screens.cost_threshold import CostThresholdScreen
from .screens.diff_dialog import DiffDialogScreen, FileDiff
from .screens.effort_picker import EffortPickerScreen
from .screens.exit_flow import ExitFlowScreen
from .screens.history_search import HistoryEntry, HistorySearchScreen
from .screens.idle_return import IdleReturnScreen
from .screens.mcp_dialogs import McpListScreen, McpServer
from .screens.message_selector import MessageSelectorScreen, TranscriptMessage
from .screens.model_picker import ModelPickerScreen
from .screens.repl import REPLScreen
from .screens.theme_picker import ThemePickerScreen
from .state import AppState
from .terminal_chrome import (
clear_terminal_title,
disable_focus_events,
enable_focus_events,
ring_bell,
set_tab_status,
set_terminal_title,
)
from .theme import (
get_palette,
list_theme_names,
resolve_auto_theme,
textual_css_overrides,
)
from .widgets.transcript_view import Transcript
def _flatten_message_text(content: Any) -> str:
"""Normalise ``Message.content`` (string or block list) to text."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
kind = item.get("type")
if kind in (None, "text"):
parts.append(str(item.get("text") or ""))
elif kind == "tool_use":
parts.append(f"[tool:{item.get('name') or ''}]")
else:
parts.append("")
return "\n".join(p for p in parts if p).strip()
return str(content)
class ClawCodexTUI(App):
"""Top-level Textual application for Claw Codex."""
TITLE = "ClawCodex"
SUB_TITLE = "interactive terminal"
BINDINGS = [
("ctrl+c", "cancel_or_quit", "Cancel / Quit"),
("ctrl+d", "quit", "Quit"),
]
def __init__(
self,
*,
provider,
provider_name: str,
workspace_root: Path,
tool_registry: ToolRegistry | None = None,
tool_context: ToolContext | None = None,
session: Session | None = None,
max_turns: int = 20,
stream: bool = True,
theme_name: str | None = None,
) -> None:
super().__init__()
self.provider = provider
self.provider_name = provider_name
self.workspace_root = Path(workspace_root)
self.max_turns = max_turns
self.stream = stream
self.model = getattr(provider, "model", "unknown")
self.session = session or Session.create(provider_name, self.model or "")
self.tool_registry = tool_registry or build_default_registry(provider=provider)
self.tool_context = tool_context or self._build_default_tool_context()
# Theme is resolved once on boot; ``/theme`` can switch it
# live via :meth:`apply_theme`.
self.palette = get_palette(theme_name or self._resolve_theme_name())
self.app_state = AppState(
model=self.model or "",
provider=provider_name,
)
self._repl_screen: REPLScreen | None = None
self._command_context: Any | None = None
# P0-6 Option B: guards one-time global registration of builtins +
# user-invocable skills (see _ensure_command_context).
self._skills_registered: bool = False
# Reactive AppState store backing interactive commands like
# /permissions. Created once on first command-context build and
# held here so the chosen mode persists across invocations.
self._app_state_store: Any | None = None
# Transcript renderables captured at exit time so entry points
# can dump them back to the main terminal scrollback after the
# alt-screen tears down. Mirrors the TS ink behaviour where the
# conversation the user saw stays on-screen after ``/exit``.
self.exit_snapshot: list[Any] = []
# Persistent prompt history used by the PromptInput (↑/↓) and
# the /history slash-command dialog. The store is append-only
# per turn and auto-rotates past ``max_entries``.
self.history_store = HistoryStore()
self._theme_name = (theme_name or self._resolve_theme_name())
# Screen-reader announcer. The :class:`LiveRegion` widget is
# bound in :meth:`on_mount` once the REPL screen is composed.
self.announcer = Announcer(self)
self._agent_bridge = AgentBridge(
post_message=self._post_to_screen,
session=self.session,
provider=self.provider,
tool_registry=self.tool_registry,
tool_context=self.tool_context,
app_state=self.app_state,
run_worker=self.run_worker,
max_turns=self.max_turns,
stream=self.stream,
)
# The base CSS for the REPL; Phase 1 uses Textual's default theme
# variables ($primary, $surface, …) — palette overrides sit in
# ``textual_css_overrides`` and are appended at class build time.
CSS = ""
def _resolve_theme_name(self) -> str:
try:
from src.config import load_config
cfg = load_config() or {}
return cfg.get("theme") or "dark"
except Exception:
return "dark"
# ---- lifecycle ----
def on_mount(self) -> None:
# Apply palette-derived CSS on top of the component defaults so
# the chrome picks up the correct background / foreground even
# when Textual's internal theme doesn't cover every slot.
try:
self.stylesheet.add_source(
textual_css_overrides(self.palette),
path="palette-overrides",
)
self.stylesheet.parse()
except Exception:
pass
self._repl_screen = REPLScreen(
version=CLAW_VERSION,
provider=self.provider_name,
model=self.model,
workspace_root=self.workspace_root,
words_provider=self._slash_command_words,
suggestions_provider=self._slash_command_suggestions,
# Pass the live BaseProvider so the status line's advisor
# segment can call ``decide_advisor_mode(provider, ...)``
# and show the correct mode label (server/client/inactive).
provider_instance=self.provider,
)
self.push_screen(self._repl_screen)
# Terminal chrome: set a descriptive title, enable DEC 1004
# focus reporting, and mark the tab idle. The app-state
# observer below keeps title + tab status in sync with agent
# activity.
self._last_thinking: bool = self.app_state.is_thinking
self._sync_terminal_title()
set_tab_status("idle")
try:
enable_focus_events()
except Exception:
pass
self._state_unsub = self.app_state.subscribe(self._on_state_change)
# C8 → C6 → C7 boot chain, strictly sequential: the security
# gates (trust → external includes → bypass acceptance; TS
# interactiveHelpers order) must resolve BEFORE the .mcp.json
# approval prompts — a user who hasn't trusted the folder yet
# must not be asked to enable its MCP servers first.
self.call_after_refresh(self._run_startup_chain)
# ---- C8 startup security gates ---------------------------------------
def _run_startup_chain(self) -> None:
self._startup_gate_trust()
def _startup_gate_trust(self) -> None:
try:
from src.services.startup_gates import check_trust_accepted
needed = not check_trust_accepted()
except Exception:
# Fail CLOSED: a consent gate that errors must ask, not
# silently wave the folder through.
needed = True
if not needed:
self._startup_gate_includes()
return
from src.tui.screens.startup_gates import TrustFolderScreen
try:
from src.services.startup_gates import collect_trust_warnings
warnings = collect_trust_warnings()
except Exception:
warnings = []
self.push_screen(
TrustFolderScreen(str(self.workspace_root), warnings),
callback=self._on_trust_choice,
)
def _on_trust_choice(self, choice: str | None) -> None:
if choice != "trust":
# TS TrustDialog "No, exit" → gracefulShutdownSync(1).
self.exit(return_code=1)
return
persisted = False
try:
from src.services.startup_gates import record_trust_accepted
persisted = record_trust_accepted()
except Exception:
persisted = False
# The tool context was built before the dialog ran (untrusted seed);
# propagate acceptance so hooks stop being trust-skipped (#275).
# record_trust_accepted already synced the bootstrap flag for any
# contexts constructed after this point.
self.tool_context.workspace_trusted = True
if not persisted and self._repl_screen is not None:
self._repl_screen.transcript.append_system(
"Folder trusted for this session only — could not write "
"the global config; you'll be asked again next launch.",
style="muted",
)
self._startup_gate_includes()
def _startup_gate_includes(self) -> None:
try:
from src.services.startup_gates import (
get_external_includes_state,
)
state = get_external_includes_state()
except Exception:
state = "declined" # fail safe: no prompt, no external load
if state != "unset":
self._startup_gate_bypass()
return
self.run_worker(
self._detect_external_includes(),
exclusive=False,
name="c8-external-includes",
)
async def _detect_external_includes(self) -> None:
try:
from src.services.startup_gates import list_external_includes
externals = await list_external_includes()
except Exception:
externals = []
if not externals:
# Nothing external to approve — the question never arises
# (TS shows the dialog only when externals exist).
self._startup_gate_bypass()
return
from src.tui.screens.startup_gates import ExternalIncludesScreen
self.push_screen(
ExternalIncludesScreen(externals),
callback=self._on_includes_choice,
)
def _on_includes_choice(self, choice: str | None) -> None:
approved = choice == "yes"
persisted = False
try:
from src.services.startup_gates import (
record_external_includes_choice,
)
persisted = record_external_includes_choice(approved)
except Exception:
persisted = False
if self._repl_screen is not None:
transcript = self._repl_screen.transcript
if not persisted:
transcript.append_system(
"Could not save the external-imports choice — "
"you'll be asked again next launch.",
style="muted",
)
elif approved:
transcript.append_system(
"External CLAUDE.md imports enabled for this project.",
style="muted",
)
else:
transcript.append_system(
"External CLAUDE.md imports disabled for this project.",
style="muted",
)
self._startup_gate_bypass()
def _startup_gate_bypass(self) -> None:
mode = getattr(
getattr(self.tool_context, "permission_context", None),
"mode",
"default",
)
if mode != "bypassPermissions":
self._finish_startup_gates()
return
try:
from src.services.startup_gates import (
has_skip_dangerous_mode_permission_prompt,
)
already_accepted = has_skip_dangerous_mode_permission_prompt()
except Exception:
already_accepted = False
if already_accepted:
self._finish_startup_gates()
return
from src.tui.screens.startup_gates import BypassPermissionsScreen
self.push_screen(
BypassPermissionsScreen(), callback=self._on_bypass_choice
)
def _on_bypass_choice(self, choice: str | None) -> None:
if choice == "accept":
persisted = False
try:
from src.services.startup_gates import record_bypass_accepted
persisted = record_bypass_accepted()
except Exception:
persisted = False
if not persisted and self._repl_screen is not None:
self._repl_screen.transcript.append_system(
"Could not persist the bypass acceptance — you'll be "
"asked again next launch.",
style="muted",
)
self._finish_startup_gates()
return
# TS: explicit "No, exit" → exit 1; Esc (or any non-answer
# dismissal) → exit 0.
self.exit(return_code=1 if choice == "decline" else 0)
def _finish_startup_gates(self) -> None:
# C6: surface ignored/malformed config files as startup rows
# (TS StatusNotices / InvalidSettingsDialog family — Python's
# loader silently falls back to {}, so warn honestly instead).
self._show_config_warnings()
# C7: prompt for any pending .mcp.json servers (TS
# handleMcpjsonServerApprovals).
self._run_mcp_approvals()
# ---- C7 .mcp.json server approvals ----------------------------------
# cwd note: approvals deliberately key off the process cwd (the
# default of every mcp_approval call), NOT self.workspace_root —
# enumeration and enforcement in services/mcp/config both resolve
# from the process cwd, and splitting the two would persist
# approvals to a file enforcement never reads.
def _run_mcp_approvals(self) -> None:
try:
from src.services.mcp_approval import list_pending_mcpjson_servers
pending = list_pending_mcpjson_servers()
except Exception:
return
if pending:
self._prompt_next_mcp_approval(list(pending))
def _prompt_next_mcp_approval(self, queue: list[str]) -> None:
from src.services.mcp_approval import get_mcpjson_server_status
from src.tui.screens.mcp_approval import McpApprovalScreen
while queue:
name = queue.pop(0)
try:
still_pending = get_mcpjson_server_status(name) == "pending"
except Exception:
still_pending = False
if not still_pending:
# An earlier answer (enable_all, or a normalization-equal
# name) already decided this one — don't re-ask.
continue
self.push_screen(
McpApprovalScreen(name),
callback=lambda choice, _n=name, _q=queue: (
self._on_mcp_approval(_n, choice, _q)
),
)
return
def _on_mcp_approval(
self, name: str, choice: str | None, queue: list[str]
) -> None:
transcript = (
self._repl_screen.transcript if self._repl_screen else None
)
if choice:
persisted = False
try:
from src.services.mcp_approval import record_mcpjson_choice
persisted = record_mcpjson_choice(name, choice)
except Exception:
persisted = False
if not persisted:
if transcript is not None:
transcript.append_system(
f"Could not save the choice for MCP server "
f"'{name}' (settings file not writable) — it "
"stays pending.",
style="muted",
)
self._prompt_next_mcp_approval(queue)
return
if transcript is not None:
if choice == "enable":
transcript.append_system(
f"MCP server '{name}' enabled.", style="muted"
)
elif choice == "enable_all":
transcript.append_system(
"All .mcp.json servers from this project enabled.",
style="muted",
)
elif choice == "disable":
transcript.append_system(
f"MCP server '{name}' disabled.", style="muted"
)
else:
transcript.append_system(
f"MCP server '{name}' left pending — you'll be asked "
"again next launch.",
style="muted",
)
if choice == "enable_all":
queue.clear() # everything else is now approved too
self._prompt_next_mcp_approval(queue)
def _show_config_warnings(self) -> None:
if self._repl_screen is None:
return
try:
from src.services.config_health import (
collect_config_warnings,
collect_rule_warnings,
)
warnings = collect_config_warnings(str(self.workspace_root))
rule_warnings = collect_rule_warnings(str(self.workspace_root))
transcript = self._repl_screen.transcript
for warning in warnings:
transcript.append_system(
f"⚠ {warning.message()}", style="warning"
)
for text in rule_warnings:
transcript.append_system(f"⚠ {text}", style="warning")
if warnings or rule_warnings:
transcript.append_system(
"Run /doctor for details.", style="muted"
)
except Exception:
# A health-check failure must never crash app mount (the
# call_after_refresh callback would take the app down).
return
def on_unmount(self) -> None:
# Best-effort cleanup so we don't leave stale chrome on the host.
try:
self._state_unsub() # type: ignore[attr-defined]
except Exception:
pass
try:
set_tab_status(None)
clear_terminal_title()
disable_focus_events()
except Exception:
pass
# Fallback capture in case ``exit()`` wasn't the path out (e.g.
# Ctrl+C / SIGTERM). Entry points will print whatever landed
# here to the host shell after the alt-screen exits.
if not self.exit_snapshot:
self._capture_exit_snapshot()
# ---- exit / snapshot ----------------------------------------------
def _capture_exit_snapshot(self) -> None:
"""Collect the transcript's renderables into :attr:`exit_snapshot`.
Called from :meth:`exit` and (as a fallback) :meth:`on_unmount`
so no matter which shutdown path fires we preserve what the
user saw. Failures are swallowed — a blank snapshot is fine,
but raising would mask a normal exit.
"""
if self.exit_snapshot or self._repl_screen is None:
return
try:
self.exit_snapshot = list(self._repl_screen.transcript.snapshot())
except Exception:
self.exit_snapshot = []
def exit(self, result=None, return_code=0, message=None): # type: ignore[override]
"""Capture transcript before handing control back to Textual.
Overriding ``exit()`` lets the entry-point reprint the
conversation to the host terminal once the alt-screen unwinds,
matching the TS ink reference's non-fullscreen UX where
`/exit` leaves the printed text intact in scrollback.
"""
self._capture_exit_snapshot()
return super().exit(result, return_code=return_code, message=message)
def _on_state_change(self) -> None:
"""React to :class:`AppState` changes to refresh terminal chrome.
Title reflects the active verb; tab status flips between
``busy`` (agent thinking) and ``idle`` (prompt ready); a
terminal bell rings on the idle→thinking→idle edge to
announce turn completion, matching the TS reference's
idle-notification.
"""
thinking = self.app_state.is_thinking
if thinking != self._last_thinking:
set_tab_status("busy" if thinking else "idle")
if not thinking:
# Turn completed — poke the host so the user notices
# even when they tabbed away.
try:
ring_bell()
except Exception:
pass
self.announcer.announce(
describe_status("idle"), level="polite", notify=False
)
else:
self.announcer.announce(
describe_status("busy", verb=self.app_state.verb),
level="polite",
notify=False,
)
self._last_thinking = thinking
self._sync_terminal_title()
def _sync_terminal_title(self) -> None:
try:
state = self.app_state
verb = state.verb if state.is_thinking else "Ready"
title = f"ClawCodex — {state.model or self.provider_name}: {verb}"
set_terminal_title(title)
except Exception:
pass
# ---- bindings ----
def action_cancel_or_quit(self) -> None:
# Phase 1 keeps Ctrl+C as exit. Real cancellation (interrupt the
# in-flight agent loop) lands in Phase 2 alongside the cost /
# idle dialogs.
self.exit()
# ---- local command dispatcher ----
def handle_local_slash_command(self, text: str, transcript: Transcript) -> bool:
"""Return ``True`` if the command was handled without hitting the agent.
The dispatcher tries the local built-ins first (``/exit``,
``/help``, …), then falls through to the shared
:mod:`src.command_system` registry. Commands that produce a
prompt (``/init``) forward the prompt back to the agent bridge.
"""
result = dispatch_local_command(
text,
session=self.session,
workspace_root=self.workspace_root,
tool_registry=self.tool_registry,
)
if result.handled:
self._apply_command_result(result, transcript)
return True
# Fall through to the async command registry. We run it via the
# asyncio loop that Textual already runs on.
async def _run() -> CommandDispatchResult:
return await dispatch_registry_command(
text,
command_context=self._ensure_command_context(),
)
# Schedule the async work on the Textual loop; if it comes back
# handled we emit the appropriate UI response.
self.run_worker(self._dispatch_registry_async(text, transcript), exclusive=False, name="slash-cmd")
return True
async def _dispatch_registry_async(self, text: str, transcript: Transcript) -> None:
result = await dispatch_registry_command(
text,
command_context=self._ensure_command_context(),
)
if not result.handled:
# Unknown command — show the raw text as a user prompt so
# the agent can react to it, matching legacy REPL behavior.
transcript.append_user(text)
self.submit_to_agent(text)
return
self._apply_command_result(result, transcript)
def _apply_command_result(
self,
result: CommandDispatchResult,
transcript: Transcript,
) -> None:
if result.error:
transcript.append_system(result.error, style="error")
return
if result.open_dialog:
self._open_phase2_dialog(result.open_dialog, transcript)
return
if result.system_text == "__exit__":
self._confirm_exit(transcript)
return
if result.system_text == "__clear__":
transcript.clear_transcript()
self._agent_bridge.reset_advisor_dedup()
# C3a: the context-% segment must not keep showing the
# pre-clear context (TS shows nothing for an empty list).
self.app_state.last_turn_input_tokens = 0
return
if result.system_text == "__thinking__":
# C3b /thinking: flip the bridge's session override. First use
# DISABLES (auto → off, matching the TS toggle's "respond
# without extended thinking" default action), then alternates.
bridge = self._agent_bridge
new_value = bridge.extended_thinking is False
if new_value:
# Honest enable (review M4): an explicit True bypasses the
# query layer's model gate and a non-Anthropic provider
# ignores it silently — refuse instead of lying.
try:
from src.providers.anthropic_provider import (
AnthropicProvider,
)
from src.providers.minimax_provider import MinimaxProvider
from src.query.query import (
_model_supports_extended_thinking,
)
provider_obj = bridge._provider
supported = isinstance(
provider_obj, (AnthropicProvider, MinimaxProvider)
) and _model_supports_extended_thinking(
getattr(provider_obj, "model", None)
)
except Exception:
supported = False
if not supported:
transcript.append_system(
"Extended thinking is not supported by the current "
"model/provider — leaving it disabled.",
style="muted",
)
return
bridge.extended_thinking = new_value
transcript.append_system(
"Extended thinking enabled for this session."
if new_value
else "Extended thinking disabled for this session — "
"the model will respond without extended thinking.",
style="muted",
)
return
if result.compact and result.system_text:
transcript.append_compact_boundary(result.system_text)
return
if result.system_text:
transcript.append_system(result.system_text, style="muted")
if result.prompt_text:
transcript.append_user(f"(from slash command) {result.prompt_text[:80]}…")
self.submit_to_agent(result.prompt_text)
# ---- C4 bash-mode (`!` prefix) --------------------------------------
# ---- C9 `#` memory-append shortcut -----------------------------------
def run_memory_shortcut(self, note: str, transcript: Transcript) -> None:
# History first, like bash-mode does for `!` — a saved (or
# cancelled) note must stay reachable via up-arrow.
try:
self.history_store.append(f"#{note}")
except Exception:
pass
self.run_worker(
self._memory_shortcut_flow(note, transcript),
exclusive=False,
name="c9-memory-shortcut",
)
async def _memory_shortcut_flow(
self, note: str, transcript: Transcript
) -> None:
try:
from src.command_system.memory_command import build_memory_options
options = await build_memory_options(str(self.workspace_root))
except Exception:
options = []
if not options:
transcript.append_system(
"Could not enumerate memory files — note not saved.",
style="muted",
)
self._insert_into_prompt(f"#{note}")
return
from src.tui.screens.memory_save import MemorySaveScreen
self.push_screen(
MemorySaveScreen(note, options),
callback=lambda path, _n=note, _t=transcript: (
self._on_memory_target(_n, path, _t)
),
)
def _on_memory_target(
self, note: str, path: str | None, transcript: Transcript
) -> None:
from src.services.memory_append import (
append_memory_note,
pick_saving_message,
)
if path is None:
# Verbatim TS cancel string (memory.tsx:65); the note goes
# back into the prompt so nothing typed is lost.
transcript.append_system("Cancelled memory editing", style="muted")
self._insert_into_prompt(f"#{note}")
return
ok = False
try:
ok = append_memory_note(path, note)
except Exception:
ok = False
if not ok:
transcript.append_system(
f"Could not write {path} — memory not saved.", style="muted"
)
self._insert_into_prompt(f"#{note}")
return
# TS UserMemoryInputMessage: the `#`-styled note row plus a
# random acknowledgement line.
transcript.append_system(f"# {note.strip()}", style="muted")
transcript.append_system(pick_saving_message(), style="muted")
def run_bash_mode(self, command: str, transcript: Transcript) -> None:
"""Execute a user-typed ``!command`` directly (no agent turn).
Divergences from TS (documented; see services/bash_mode):
sequential only (a second ``!`` while one runs is refused — TS
queues), and no live progress streaming / ESC cancel yet (the
echo row shows "running…" until completion; cancel is a
follow-up). If the agent starts mid-flight, the conversation
texts DEFER via the bridge and land after the run (review B1).
"""
command = (command or "").strip()
if not command:
transcript.append_system("Usage: !<shell command>", style="muted")
return
# History first: TS records queued/refused commands too (m11).
self.history_store.append(f"!{command}")
if getattr(self, "_bash_inflight", False):
transcript.append_system(
"A bash command is already running — wait for it to finish.",
style="muted",
)
return
if self._agent_bridge.busy:
transcript.append_system(
"Agent is working — bash mode is unavailable until idle.",
style="muted",
)
return
self._bash_inflight = True
row = transcript.append_bash_running(command)
def _work() -> None:
from src.services.bash_mode import run_bash_mode_command
try:
outcome = run_bash_mode_command(command, self.tool_context)
except Exception as exc: # service contract: never raises
try:
self.call_from_thread(
self._bash_mode_error, command, str(exc), transcript
)
except Exception:
pass
return
try:
self.call_from_thread(
self._finish_bash_mode, outcome, row, transcript
)
except Exception:
# App shutting down mid-flight; nothing to render.
self._bash_inflight = False
self.run_worker(
_work, thread=True, exit_on_error=False, group="bash-mode"
)
def _bash_mode_error(
self, command: str, error: str, transcript: Transcript
) -> None:
self._bash_inflight = False
transcript.append_system(f"!{command}: {error}", style="error")
def _finish_bash_mode(
self,
outcome: "BashModeOutcome",
row: Any,
transcript: Transcript,
) -> None:
self._bash_inflight = False
transcript.finish_bash_io(
row,
command=outcome.command,
stdout=outcome.stdout,
stderr=outcome.stderr,
exit_code=outcome.exit_code,
ok=outcome.ok,
)
# TS parity: both messages enter the CONVERSATION (the model sees
# them next turn) — via the bridge, which defers while a run is
# in flight so the texts can never interleave a tool_use/result
# pair (review B1); no agent turn fires (shouldQuery: false).
self._agent_bridge.append_user_texts(outcome.conversation_texts)
# ---- Phase 2 dialog dispatcher -------------------------------------
def _open_phase2_dialog(self, name: str, transcript: Transcript) -> None:
"""Push the modal screen for ``name`` from the slash command.
``name`` is one of the values produced by
:func:`dispatch_local_command`; unknown names degrade to a
muted system message.
"""
if name == "model":
self._open_model_picker(transcript)
elif name == "effort":
self._open_effort_picker(transcript)
elif name == "history":
self._open_history_search(transcript)
elif name == "cost":
self._open_cost_threshold(transcript)
elif name == "idle":
self._open_idle_return(transcript)
elif name == "theme":
self._open_theme_picker(transcript)
elif name == "diff":
self._open_diff_dialog(transcript)
elif name == "mcp":
self._open_mcp_list(transcript)
elif name in ("rewind", "messages"):
self._open_message_selector(transcript)
elif name == "tasks":
self._open_tasks_dialog(transcript)
elif name == "workflows":
self._open_workflows_dialog(transcript)
elif name == "resume":
self._open_resume_picker(transcript)
elif name == "search" or name.startswith("search:"):
self._open_global_search(
transcript, name.partition(":")[2] if ":" in name else ""
)
elif name == "quickopen":
self._open_quick_open(transcript)
elif name == "doctor":
from src.tui.screens.doctor import DoctorScreen
self.push_screen(
DoctorScreen(
app_state=self.app_state,
workspace_root=self.workspace_root,
)
)
else:
transcript.append_system(f"Dialog '{name}' not available.", style="muted")
# ---- C5 workspace search dialogs -----------------------------------
def _insert_into_prompt(self, insertion: str | None) -> None:
if not insertion:
return
if self._repl_screen is not None:
self._repl_screen.prompt_input.append_value(insertion)
def _open_global_search(
self, transcript: Transcript, initial_query: str = ""
) -> None:
from src.tui.screens.workspace_search import GlobalSearchScreen
self.push_screen(
GlobalSearchScreen(
cwd=str(self.workspace_root), initial_query=initial_query
),
callback=self._insert_into_prompt,
)
def _open_quick_open(self, transcript: Transcript) -> None:
from src.tui.screens.workspace_search import QuickOpenScreen
self.push_screen(
QuickOpenScreen(cwd=str(self.workspace_root)),
callback=self._insert_into_prompt,
)
def _open_resume_picker(self, transcript: Transcript) -> None:
"""C2: list persisted sessions; selection swaps the live session.
Refuses while the agent is busy (the bridge would also refuse —
this just gives the honest message before pushing a modal).
"""
if self._agent_bridge.busy:
transcript.append_system(
"Cannot resume while the agent is working — try again when idle.",
style="muted",
)
return
from src.bootstrap.state import get_session_id
from src.services.session_storage import SessionStorage
from src.tui.screens.resume_conversation import (
ResumeConversation,
build_resume_entries,
)
try:
metas = SessionStorage.list_sessions()
except Exception:
metas = []
entries, hidden = build_resume_entries(
metas, exclude_session_id=str(get_session_id())
)
def _on_selected(session_id: str | None) -> None:
if not session_id:
return