pcode 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 (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/app.py ADDED
@@ -0,0 +1,3165 @@
1
+ """Compose the terminal with either the offline preview or a real Coder agent."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import importlib.metadata
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ from collections.abc import Callable
12
+ from contextlib import ExitStack, aclosing, asynccontextmanager, nullcontext
13
+ from pathlib import Path
14
+
15
+ from prompt_toolkit.application import get_app
16
+ from prompt_toolkit.styles import DynamicStyle
17
+ from rich.cells import cell_len
18
+ from rich.console import Console
19
+ from rich.rule import Rule
20
+ from rich.text import Text
21
+
22
+ from pcode.appearance import AppearanceWatch
23
+ from pcode.cli import ask, restore_stdin
24
+ from pcode.commands import Command, CommandRegistry
25
+ from pcode.completion import SHELLS as COMPLETION_SHELLS
26
+ from pcode.completion import complete_with
27
+ from pcode.config import USAGE as CONFIG_USAGE
28
+ from pcode.config import config_argument_descriptions, config_arguments, configure
29
+ from pcode.controller import (
30
+ MODEL_COMMANDS,
31
+ TERMINAL_COMMANDS,
32
+ SessionController,
33
+ delivered_job,
34
+ mcp_enable,
35
+ meridian_thinking_note,
36
+ )
37
+ from pcode.error_report import error_message
38
+ from pcode.preferences import (
39
+ ANTHROPIC_THINKS_BY_DEFAULT,
40
+ SETTINGS,
41
+ SYNTAX_THEMES,
42
+ THINKING_KEYS,
43
+ THINKING_MODES,
44
+ apply_effort,
45
+ apply_thinking,
46
+ effort_for,
47
+ hints_preference,
48
+ load_preferences,
49
+ parse_height,
50
+ preference_count,
51
+ save_preferences,
52
+ thinking_mode_preference,
53
+ thinking_settings,
54
+ tool_rows_preference,
55
+ )
56
+ from pcode.prefix_keys import PrefixKeys, shortcut_label
57
+ from pcode.runtime import (
58
+ EditCompleted,
59
+ Message,
60
+ PlanUpdated,
61
+ PreviewRuntime,
62
+ ToolSummary,
63
+ )
64
+ from pcode.shell_mode import shell_command
65
+ from pcode.startup_input import StartupInput
66
+ from pcode.stream_display import PrintedReply, present_events, present_stream_event
67
+ from pcode.terminal_notify import TabProgress, TabTitle
68
+ from pcode.theme import THEMES, replay_pending_input
69
+ from pcode.tool_display import plain
70
+ from pcode.ui import (
71
+ WATCHED_PREFIX,
72
+ Activity,
73
+ TerminalOutput,
74
+ Transcript,
75
+ create_prompt,
76
+ suspended_editor,
77
+ )
78
+ from pcode.worktree import (
79
+ SESSION_WORKTREE_PREFIX,
80
+ WORKTREES_DIR,
81
+ leave_worktree,
82
+ session_scope,
83
+ )
84
+
85
+
86
+ def location_label(workspace: Path, branch: str) -> str:
87
+ """Compact `path@branch` for the footer.
88
+
89
+ A session worktree repeats itself three times (`…/.worktrees/pcode-abc123`
90
+ plus branch `pcode-abc123`), so collapse it to the repository it belongs to
91
+ and let the branch name the checkout: `~/p/pcode@abc123`.
92
+ """
93
+ path, label = workspace, branch
94
+ if branch and path.name == branch and path.parent.name == WORKTREES_DIR:
95
+ path = path.parent.parent
96
+ label = branch.removeprefix(SESSION_WORKTREE_PREFIX) or branch
97
+ try:
98
+ relative = path.relative_to(Path.home())
99
+ directory = "~" if relative == Path(".") else f"~/{relative}"
100
+ except ValueError:
101
+ directory = str(path)
102
+ if not label or label == path.name:
103
+ return directory
104
+ return f"{directory}@{label}"
105
+
106
+
107
+ BRANCH_POLL_SECONDS = 30
108
+ """Safety net for a checkout made outside this session; turns refresh it directly."""
109
+
110
+ # Queue modes for a turn a session host started without this terminal asking:
111
+ # another terminal's, steering that arrived too late, or the one running at
112
+ # attach. "follow-quiet" is one whose prompt is already in scrollback.
113
+
114
+ HOST_POLL_SECONDS = 2
115
+ """How often an attached terminal looks at the other hosts, for the footer and notices."""
116
+
117
+
118
+ class _PopupSuperseded(Exception):
119
+ """Another popup opened after this command was queued."""
120
+
121
+
122
+ def _controller_attribute(name: str) -> property:
123
+ """A session attribute the terminal reads and sets on its controller."""
124
+ return property(
125
+ lambda self: getattr(self.controller, name),
126
+ lambda self, value: setattr(self.controller, name, value),
127
+ )
128
+
129
+
130
+ class PreviewApp:
131
+ def __init__(
132
+ self,
133
+ theme: str | None = None,
134
+ console: Console | None = None,
135
+ *,
136
+ model: str | None = None,
137
+ workspace: Path | None = None,
138
+ runtime=None,
139
+ saved_session=None,
140
+ save: bool = False,
141
+ session_dir: Path | None = None,
142
+ resume: bool = False,
143
+ initial_prompt: str | None = None,
144
+ session_id: str | None = None,
145
+ host=None,
146
+ ) -> None:
147
+ self.activity = Activity(
148
+ show_tasks=load_preferences().get("show_tasks", "on") == "on",
149
+ autohide_tasks=load_preferences().get("autohide_tasks", "off") == "on",
150
+ attach_tasks=load_preferences().get("attach_tasks", SETTINGS["attach_tasks"].default)
151
+ == "on",
152
+ task_style=load_preferences().get("task_style", SETTINGS["task_style"].default),
153
+ tool_glyphs=load_preferences().get("tool_glyphs", SETTINGS["tool_glyphs"].default),
154
+ tool_max_rows=tool_rows_preference(),
155
+ tool_linger_seconds=preference_count("tool_linger_seconds"),
156
+ tasks_max_height=parse_height(load_preferences().get("tasks_max_height")),
157
+ tasks_min_rows=preference_count("tasks_min_rows"),
158
+ tasks_min_columns=preference_count("tasks_min_columns"),
159
+ show_hints=hints_preference(),
160
+ thinking_mode=thinking_mode_preference(),
161
+ )
162
+ self.preview = PreviewRuntime()
163
+ # The conversation itself, with this terminal as its view.
164
+ self.controller = SessionController(self, self.activity, runtime or self.preview)
165
+ self.send_mode = load_preferences().get("send_mode", "steering")
166
+ # Ctrl+S picks a mode for the next prompt only; the saved default stands.
167
+ self.send_mode_once: str | None = None
168
+ self.model = model
169
+ self.initial_prompt = initial_prompt
170
+ # Consumed by the first saved session so it shares its ID with the
171
+ # worktree created for it; later `/new` sessions get their own.
172
+ self._session_id = session_id
173
+ self.resuming = resume
174
+ self.save_sessions = save or saved_session is not None
175
+ self.workspace = (workspace or Path.cwd()).resolve()
176
+ self.branch = ""
177
+ self.session_dir = saved_session.directory.parent if saved_session else session_dir
178
+ # Where turns stream, and the editor; set once the prompt exists.
179
+ self.output: TerminalOutput | None = None
180
+ self.prompt_session = None
181
+ self._saved_session = saved_session
182
+ # A `pcode.remote.HostLaunch`: the conversation runs in a session host
183
+ # and this terminal attaches to it once its event loop is up.
184
+ self._host_launch = host
185
+ self._needs_runtime = bool(model and runtime is None and host is None)
186
+ self._startup_pending = self._needs_runtime or resume or host is not None
187
+ # Other running hosts, for the footer; refreshed while attached to one.
188
+ self.hosts: list = []
189
+ self._host_watch: Callable[[], None] | None = None
190
+ self._host_watch_task = None
191
+ self.switch_requested: str | None = None
192
+ self.restart_requested = False
193
+ self.host_stopped = False
194
+ # A command this terminal sent ended the host's session (`/worktree finish`).
195
+ self.session_over = False
196
+ # `/detach`: leave the host running on exit, which otherwise stops it.
197
+ self.detach_requested = False
198
+ # The host this terminal showed before the current one, for `/switch -`.
199
+ self.previous_host: str | None = None
200
+ # Its conversation, to resume should that host have stopped meanwhile.
201
+ self.previous_session: str | None = None
202
+ # How this terminal came to show its host ("Switched to session …").
203
+ self._attach_note: str | None = None
204
+ # Writes an escape to the terminal emulator (desktop notifications).
205
+ self._emulator: Callable[[str], None] | None = None
206
+ # The tab's progress bar, while this terminal runs its prompt.
207
+ self._progress: TabProgress | None = None
208
+ self._title: TabTitle | None = None
209
+ self._appearance: AppearanceWatch | None = None
210
+ # The in-process controller's loops, while this terminal runs them.
211
+ self._loops: list = []
212
+ agent = getattr(self.runtime, "agent", None)
213
+ if agent is not None and model:
214
+ apply_effort(agent, model, effort_for(model))
215
+ if agent is not None and model:
216
+ apply_thinking(agent, model, self.activity.thinking_mode)
217
+ self.transcript = Transcript(
218
+ console or Console(),
219
+ theme or load_preferences().get("theme", SETTINGS["theme"].default),
220
+ activity=self.activity,
221
+ )
222
+ self.running = True
223
+ self._popup_generation = 0
224
+ self._command_popup_generation: int | None = None
225
+ self.inspector_requested: str | None = None
226
+ self.diffs_requested = False
227
+ # The checkpoint last marked reviewed, for a session with no saved id to key it by.
228
+ self.reviewed_checkpoint = None
229
+ self.links_requested = False
230
+ self.copy_requested = False
231
+ # Unsaved conversations have no journal to re-read, so keep their changes.
232
+ self.edits: list[EditCompleted] = []
233
+ self.session_requested = False
234
+ self.session_info_requested = False
235
+ self.tree_requested = False
236
+ self.worker_view_requested = False
237
+ # The viewer follows answers that settle while it is open, so auto-open
238
+ # has nothing to do then.
239
+ self.aside_view_open = False
240
+ # The popup generation an auto-open `/btw` was queued in. That viewer
241
+ # opens on the answer, not the editor, since it can land mid-keystroke.
242
+ # Matched by generation rather than order: a queued command can be
243
+ # dropped (Ctrl+C) or superseded without ever reaching `read_asides`.
244
+ self._auto_open_generation: int | None = None
245
+ self.registry = CommandRegistry()
246
+ self._session_commands: set[str] = set()
247
+ for command in (
248
+ Command(
249
+ "/help",
250
+ "List commands and keyboard shortcuts",
251
+ self.help,
252
+ aliases=("/commands",),
253
+ group="App",
254
+ ),
255
+ Command(
256
+ "/config",
257
+ "Inspect or edit saved defaults: diff / get KEY / set KEY VALUE / unset KEY",
258
+ self.config,
259
+ free_arguments=True,
260
+ argument_provider=config_arguments,
261
+ argument_descriptions=config_argument_descriptions(),
262
+ group="App",
263
+ ),
264
+ Command(
265
+ "/bind",
266
+ "List, set, or reset prompt keybindings; bind KEY /command [args]",
267
+ self.bind,
268
+ free_arguments=True,
269
+ argument_completer=self.complete_bind,
270
+ group="App",
271
+ ),
272
+ Command(
273
+ "/unbind",
274
+ "Disable a prompt binding; /bind reset KEY restores its default",
275
+ self.unbind,
276
+ free_arguments=True,
277
+ group="App",
278
+ ),
279
+ Command("/quit", "Exit pcode", self.quit, aliases=("/exit",), group="App"),
280
+ Command(
281
+ "/status",
282
+ "Show model, workspace, session, and context usage",
283
+ self.status,
284
+ group="Inspect",
285
+ ),
286
+ Command(
287
+ "/tools",
288
+ "Browse tool calls and results; 'failed' or 'edits' narrows them",
289
+ self.tools,
290
+ ("failed", "edits"),
291
+ group="Inspect",
292
+ ),
293
+ Command(
294
+ "/diffs",
295
+ "Git diff of this worktree's branch, or of files edited this session",
296
+ self.diffs,
297
+ group="Inspect",
298
+ ),
299
+ Command(
300
+ "/links",
301
+ "Pick a URL from this conversation and open it in the browser",
302
+ self.links,
303
+ group="Inspect",
304
+ ),
305
+ Command(
306
+ "/copy",
307
+ "Copy the last response, or a quote or code block from it",
308
+ self.copy,
309
+ group="Inspect",
310
+ ),
311
+ Command(
312
+ "/tree",
313
+ "Browse the conversation tree and fork from any point",
314
+ self.select_tree,
315
+ group="Inspect",
316
+ ),
317
+ self.controller.registry.find("/btw"),
318
+ Command(
319
+ "/agents",
320
+ "Follow delegated agents' own output, live and read-only",
321
+ self.agents,
322
+ group="Inspect",
323
+ ),
324
+ self.controller.registry.find("/model"),
325
+ self.controller.registry.find("/effort"),
326
+ self.controller.registry.find("/mcp"),
327
+ self.controller.registry.find("/login"),
328
+ self.controller.registry.find("/logout"),
329
+ self.controller.registry.find("/extensions"),
330
+ self.controller.registry.find("/subagents"),
331
+ self.controller.registry.find("/reload"),
332
+ self.controller.registry.find("/new"),
333
+ Command(
334
+ "/resume",
335
+ "Browse and search saved sessions to resume",
336
+ self.select_session,
337
+ group="Session",
338
+ ),
339
+ self.controller.registry.find("/rename"),
340
+ Command(
341
+ "/switch",
342
+ "Switch to another running session; 'new [PROMPT]' starts one in the background",
343
+ self.switch,
344
+ ("new",),
345
+ free_arguments=True,
346
+ group="Session",
347
+ ),
348
+ Command(
349
+ "/restart",
350
+ "Restart this background session's host on the pcode installed now",
351
+ self.restart,
352
+ group="Session",
353
+ ),
354
+ Command(
355
+ "/stop",
356
+ "End this background session's host and quit, as any exit does",
357
+ self.stop_host,
358
+ group="Session",
359
+ ),
360
+ Command(
361
+ "/detach",
362
+ "Quit but leave this background session's host running",
363
+ self.detach,
364
+ group="Session",
365
+ ),
366
+ self.controller.registry.find("/compact"),
367
+ self.controller.registry.find("/autocompact"),
368
+ self.controller.registry.find("/jobs"),
369
+ self.controller.registry.find("/resend"),
370
+ self.controller.registry.find("/worktree"),
371
+ Command(
372
+ "/show-tasks",
373
+ "Tasks/Tools widget: on / off; bare toggles",
374
+ self.show_tasks,
375
+ ("on", "off"),
376
+ group="Display",
377
+ ),
378
+ Command(
379
+ "/autohide-tasks",
380
+ "Hide the Tasks/Tools widget when a turn ends: on / off; bare toggles",
381
+ self.autohide_tasks,
382
+ ("on", "off"),
383
+ group="Display",
384
+ ),
385
+ Command(
386
+ "/show-thinking",
387
+ "Where thinking shows: off / status-line / scrollback; bare cycles",
388
+ self.show_thinking,
389
+ THINKING_MODES,
390
+ group="Display",
391
+ ),
392
+ Command(
393
+ "/show-edits",
394
+ "Edit diffs and previews in scrollback: on / off; bare toggles",
395
+ self.show_edits,
396
+ ("on", "off"),
397
+ group="Display",
398
+ ),
399
+ Command(
400
+ "/show-commands",
401
+ "Shell command output in scrollback: on / off; bare toggles",
402
+ self.show_commands,
403
+ ("on", "off"),
404
+ group="Display",
405
+ ),
406
+ Command(
407
+ "/group-tools",
408
+ "Fold runs of tool calls into one scrollback line: on / off; bare toggles",
409
+ self.group_tools,
410
+ ("on", "off"),
411
+ group="Display",
412
+ ),
413
+ Command(
414
+ "/theme",
415
+ "Set the palette: dark / light / auto; bare toggles dark/light",
416
+ self.theme,
417
+ THEMES,
418
+ group="Display",
419
+ ),
420
+ Command(
421
+ "/syntax",
422
+ "Set the code, menu and prompt style for the active palette",
423
+ self.syntax,
424
+ SYNTAX_THEMES,
425
+ group="Display",
426
+ ),
427
+ Command(
428
+ "/theme-preview",
429
+ "Sample output plus every syntax style and how to select one",
430
+ self.theme_preview,
431
+ group="Display",
432
+ ),
433
+ Command(
434
+ "/redraw",
435
+ "Rebuild scrollback at the current width and display settings",
436
+ lambda _: self.transcript.regenerate(),
437
+ group="Display",
438
+ ),
439
+ ):
440
+ self.registry.register(command)
441
+ # Skills (and, once the runtime exists, extension commands) come after.
442
+ self.controller.register_skills()
443
+ self.commands_changed()
444
+
445
+ # The session's state, read and set through the controller that owns it.
446
+ runtime = _controller_attribute("runtime")
447
+ model = _controller_attribute("model")
448
+ workspace = _controller_attribute("workspace")
449
+ session_dir = _controller_attribute("session_dir")
450
+ save_sessions = _controller_attribute("save_sessions")
451
+ resuming = _controller_attribute("resuming")
452
+ pending_model = _controller_attribute("pending_model")
453
+ extensions = _controller_attribute("extensions")
454
+ asides = _controller_attribute("asides")
455
+ running = _controller_attribute("running")
456
+ _startup_pending = _controller_attribute("startup_pending")
457
+ _startup_error = _controller_attribute("startup_error")
458
+ skill_command_names = _controller_attribute("skill_command_names")
459
+ _session_id = _controller_attribute("_session_id")
460
+ _saved_session = _controller_attribute("_saved_session")
461
+ _needs_runtime = _controller_attribute("_needs_runtime")
462
+
463
+ def current_effort(self) -> str:
464
+ return self.controller.current_effort()
465
+
466
+ def adjust_effort(self, direction: int) -> None:
467
+ self.controller.adjust_effort(direction)
468
+
469
+ def switch_model(self, model: str):
470
+ return self.controller.switch_model(model)
471
+
472
+ # SessionView: what the controller shows. See pcode.controller.SessionView.
473
+
474
+ def user(self, text: str) -> None:
475
+ self.transcript.user(text)
476
+
477
+ def note(self, text: str) -> None:
478
+ self.transcript.note(text)
479
+
480
+ def retained_note(self, text: str) -> None:
481
+ self.transcript.retained_note(text)
482
+
483
+ def flash(self, text: str) -> None:
484
+ self.transcript.flash(text)
485
+
486
+ def warning(self, text: str) -> None:
487
+ self.transcript.warning(text)
488
+
489
+ def error(self, text: str, *, title: str = "Error") -> None:
490
+ self.transcript.error(text, title=title)
491
+
492
+ def cancelled(self) -> None:
493
+ self.transcript.cancelled()
494
+
495
+ def tool_result(self, event) -> None:
496
+ self.transcript.tool_result(event)
497
+
498
+ def shell_result(
499
+ self, command: str, output: str, *, failed: bool, elapsed_seconds: float
500
+ ) -> None:
501
+ self.transcript.shell_result(
502
+ command, output, failed=failed, elapsed_seconds=elapsed_seconds
503
+ )
504
+
505
+ def session_changed(self) -> None:
506
+ pass # In this process the footer reads the controller directly.
507
+
508
+ def commands_changed(self) -> None:
509
+ """Mirror the session's commands (skills and extensions come and go) in the editor's."""
510
+ owned = self.controller.registry
511
+ for name in self._session_commands - {command.name for command in owned.commands}:
512
+ self.registry.unregister(name)
513
+ for command in owned.commands:
514
+ if self.registry.find(command.name) is not command:
515
+ self.registry.replace(command)
516
+ self._session_commands = {command.name for command in owned.commands}
517
+
518
+ def conversation_reset(self, title: str) -> None:
519
+ """A new conversation: clear the screen and the live panel, and say so."""
520
+ self.activity.reset()
521
+ self.edits.clear()
522
+ self.reviewed_checkpoint = None
523
+ self.transcript.clear()
524
+ self.transcript.print(Rule(title, style="pcode.muted"))
525
+
526
+ def replay_conversation(self) -> None:
527
+ # A host that loaded its conversation after this terminal attached
528
+ # draws it now, under the note that said how it got here.
529
+ self.replay(note=self._attach_note if self.hosted else None)
530
+
531
+ def preview_reply(self, text: str) -> None:
532
+ """No model: answer from the canned preview runtime."""
533
+ self.handle(text)
534
+
535
+ def show_events(self, events) -> None:
536
+ self.transcript.events(tuple(events))
537
+
538
+ def show_branch(self) -> None:
539
+ """Redraw the conversation after /tree moved it to another branch."""
540
+ self.activity.reset()
541
+ if self.runtime.session:
542
+ self.replay()
543
+ return
544
+ from pcode.diagnostics import redact
545
+
546
+ tree = self.runtime.tree
547
+ with self.transcript.restore():
548
+ for node_id in tree.path(tree.active):
549
+ node = tree.nodes[node_id]
550
+ self.transcript.user(redact(node.prompt))
551
+ if node.response:
552
+ self.transcript.events((Message(redact(node.response)),))
553
+ self.activity.plan = tree.nodes[tree.active].plan if tree.active else []
554
+
555
+ def aside_changed(self, aside) -> None:
556
+ # The footer counts side questions, and an open viewer follows the
557
+ # answer as it streams, so both only need to know that something moved.
558
+ self.redraw()
559
+
560
+ def aside_answered(self, aside) -> None:
561
+ """Say a side answer is ready, opening it when that is the preference."""
562
+ # Queued as a command rather than opened here: the command consumer owns
563
+ # the terminal, so the viewer waits for whatever popup or command is
564
+ # already using it instead of racing it.
565
+ opening = self.auto_open_asides()
566
+ if opening:
567
+ self._auto_open_generation = self._popup_generation
568
+ self.controller.command("/btw", self._popup_generation)
569
+ on = f"{aside.label}: " if aside.label else ""
570
+ self.transcript.note(
571
+ f"Side answer ready ({on}{plain(aside.question, 60)}). "
572
+ + ("Opening it." if opening else "/btw opens it.")
573
+ )
574
+ self.redraw()
575
+
576
+ def redraw(self) -> None:
577
+ if self.output is not None:
578
+ self.output.app.invalidate()
579
+
580
+ def turn_started(self, text: str, *, echo: bool) -> None:
581
+ self.activity.tools.clear()
582
+ self.activity.forget_calls()
583
+ self.activity.cache_note = ""
584
+ if echo:
585
+ self.output.begin_turn(text)
586
+ if self._progress is not None:
587
+ self._progress.turn_started()
588
+
589
+ def turn_event(self, event) -> None:
590
+ if self._progress is not None:
591
+ self._progress.turn_event()
592
+ present_stream_event(
593
+ event,
594
+ output=self.output,
595
+ transcript=self.transcript,
596
+ activity=self.activity,
597
+ present=self.present_events,
598
+ )
599
+
600
+ def turn_retry(self, text: str) -> None:
601
+ # Separate abandoned partial text/thinking from the next attempt.
602
+ self.output.finish_thinking()
603
+ self.output.finish()
604
+ if self._progress is not None:
605
+ self._progress.turn_retry()
606
+ self.activity.plan_preview = None
607
+ self.activity.edit_previews.clear()
608
+ self.transcript.note(text)
609
+ self.output.app.invalidate()
610
+
611
+ def turn_ended(self) -> None:
612
+ if self._progress is not None:
613
+ self._progress.turn_ended()
614
+ self.activity.edit_previews.clear()
615
+ # A watched job is not the turn's; its preview stays pinned.
616
+ for key in [k for k in self.activity.command_outputs if not k.startswith(WATCHED_PREFIX)]:
617
+ del self.activity.command_outputs[key]
618
+ self.activity.plan_preview = None
619
+ self.transcript.settle_tools()
620
+ self.output.end_turn()
621
+ # Drop whatever the turn left running.
622
+ self.activity.tools.clear()
623
+ self.activity.workers.end_turn()
624
+
625
+ def finish_text(self) -> None:
626
+ if self.output is not None:
627
+ self.output.finish()
628
+
629
+ def show_output(self, event) -> None:
630
+ self.activity.command_outputs.pop(event.call_id, None)
631
+ self.activity.command_outputs[event.call_id] = event
632
+
633
+ def drop_output(self, call_id: str) -> None:
634
+ self.activity.command_outputs.pop(call_id, None)
635
+ self.redraw()
636
+
637
+ async def after_turn(self) -> None:
638
+ await self.output.flush()
639
+ if not self.running:
640
+ self.output.app.exit()
641
+ # A turn (or a shell command) is the usual reason the branch moved, so
642
+ # read it here rather than polling fast enough to catch one.
643
+ elif await asyncio.to_thread(self.refresh_branch):
644
+ self.output.app.invalidate()
645
+
646
+ async def _initialize_runtime(self) -> None:
647
+ await self.controller.initialize_runtime()
648
+
649
+ def command_started(self, tag) -> None:
650
+ self._command_popup_generation = tag
651
+
652
+ def command_finished(self) -> None:
653
+ self._command_popup_generation = None
654
+
655
+ async def choose_model(self, values, providers, current):
656
+ """The model picker; returns the choice, or None when dismissed."""
657
+ from pcode.model_ui import ModelPicker
658
+
659
+ output, session = self.output, self.prompt_session
660
+ try:
661
+ async with self.popup(output, session) as modal_input:
662
+ picker = ModelPicker(
663
+ values,
664
+ providers,
665
+ current=current,
666
+ input=modal_input,
667
+ output=session.app.output,
668
+ style=session.app.style,
669
+ )
670
+ return await picker.run()
671
+ except _PopupSuperseded:
672
+ return None
673
+
674
+ async def browse_jobs(self) -> None:
675
+ from pcode.jobs_ui import JobBrowser
676
+
677
+ output, session = self.output, self.prompt_session
678
+ try:
679
+ async with self.popup(output, session) as modal_input:
680
+ browser = JobBrowser(
681
+ self.runtime.jobs,
682
+ stop=lambda job: self.controller.stop_jobs([job.id]),
683
+ watch=lambda job: self.controller.watch_job(job.id if job else None),
684
+ watched=lambda: self.activity.watched_job,
685
+ rich_theme=self.transcript.rich_theme,
686
+ code_theme=self.transcript.code_theme,
687
+ color_system=self.transcript.console.color_system,
688
+ input=modal_input,
689
+ output=session.app.output,
690
+ style=session.app.style,
691
+ )
692
+ await browser.run()
693
+ except _PopupSuperseded:
694
+ pass
695
+
696
+ def prompt_bindings(self):
697
+ bindings = getattr(self.prompt_session, "command_bindings", None)
698
+ if bindings is None:
699
+ raise ValueError("Keybindings require an interactive prompt.")
700
+ return bindings
701
+
702
+ def bind(self, argument: str) -> None:
703
+ try:
704
+ result = self.prompt_bindings().manage(argument)
705
+ except OSError as error:
706
+ raise ValueError(f"Could not access keybindings: {error}") from None
707
+ self.transcript.note(result)
708
+ self.prompt_session.app.invalidate()
709
+
710
+ def unbind(self, argument: str) -> None:
711
+ try:
712
+ result = self.prompt_bindings().manage(argument, unbind=True)
713
+ except OSError as error:
714
+ raise ValueError(f"Could not access keybindings: {error}") from None
715
+ self.transcript.note(result)
716
+ self.prompt_session.app.invalidate()
717
+
718
+ def complete_bind(self, argument: str):
719
+ bindings = getattr(self.prompt_session, "command_bindings", None)
720
+ return bindings.complete(argument) if bindings is not None else ()
721
+
722
+ def config(self, argument: str) -> None:
723
+ args = shlex.split(argument)
724
+ try:
725
+ result = configure(args)
726
+ except OSError as error:
727
+ raise ValueError(f"Could not access global defaults: {error}") from None
728
+ # Layout-only settings apply at once rather than on the next launch.
729
+ preferences = load_preferences()
730
+ self.activity.attach_tasks = (
731
+ preferences.get("attach_tasks", SETTINGS["attach_tasks"].default) == "on"
732
+ )
733
+ self.activity.tasks_max_height = parse_height(preferences.get("tasks_max_height"))
734
+ self.activity.tasks_min_rows = preference_count("tasks_min_rows")
735
+ self.activity.tasks_min_columns = preference_count("tasks_min_columns")
736
+ self.activity.task_style = preferences.get("task_style", SETTINGS["task_style"].default)
737
+ self.activity.tool_glyphs = preferences.get("tool_glyphs", SETTINGS["tool_glyphs"].default)
738
+ self.activity.tool_max_rows = tool_rows_preference()
739
+ self.activity.tool_linger_seconds = preference_count("tool_linger_seconds")
740
+ self.activity.show_hints = hints_preference()
741
+ self.transcript.live_edits = (
742
+ preferences.get("live_edits", SETTINGS["live_edits"].default) == "on"
743
+ )
744
+ if self.transcript.output is not None:
745
+ self.transcript.output.app.invalidate()
746
+ edits = args[1:] if args[:1] == ["project"] else args
747
+ if (
748
+ len(edits) >= 2
749
+ and edits[0] in ("set", "unset")
750
+ and edits[1]
751
+ in (
752
+ "attach_tasks",
753
+ "tasks_max_height",
754
+ "tasks_min_rows",
755
+ "tasks_min_columns",
756
+ "task_style",
757
+ "tool_glyphs",
758
+ "tool_max_lines",
759
+ "tool_linger_seconds",
760
+ "show_hints",
761
+ "live_edits",
762
+ )
763
+ ):
764
+ result = result.replace("Applies on next launch.", "Layout settings apply immediately.")
765
+ self.transcript.note(result)
766
+
767
+ def set_show_tasks(self, shown: bool) -> None:
768
+ self.activity.show_tasks = shown
769
+ self.persist_defaults(show_tasks="on" if shown else "off")
770
+ if self.transcript.output is not None:
771
+ self.transcript.output.app.invalidate()
772
+
773
+ @staticmethod
774
+ def toggle_argument(command: str, argument: str, current: bool) -> bool:
775
+ """Resolve `on`/`off`, or flip `current` when the argument is empty."""
776
+ if not argument:
777
+ return not current
778
+ if argument not in ("on", "off"):
779
+ raise ValueError(f"Usage: {command} [on|off]")
780
+ return argument == "on"
781
+
782
+ def show_tasks(self, argument: str) -> None:
783
+ activity = self.activity
784
+ if argument:
785
+ shown = self.toggle_argument("/show-tasks", argument, activity.show_tasks)
786
+ # `on` means on screen now, even in a pane below the size thresholds.
787
+ activity.tasks_unhidden = shown and activity.screen_small
788
+ else:
789
+ # Bare acts on what is on screen, like Ctrl+O: in a small pane it
790
+ # overrides the size rule rather than saving the widget as off.
791
+ shown = activity.toggle_tasks()
792
+ self.set_show_tasks(shown)
793
+ state = "on" if activity.show_tasks else "off"
794
+ if activity.show_tasks and activity.tasks_too_big:
795
+ state += " (hidden in this pane: see tasks_min_rows and tasks_min_columns)"
796
+ self.transcript.flash(
797
+ f"Show tasks: {state}. Usage: /show-tasks [on|off] ({self.shortcut('o')})"
798
+ )
799
+
800
+ def autohide_tasks(self, argument: str) -> None:
801
+ enabled = self.toggle_argument("/autohide-tasks", argument, self.activity.autohide_tasks)
802
+ self.activity.autohide_tasks = enabled
803
+ if not enabled:
804
+ self.activity.tasks_autohidden = False
805
+ self.persist_defaults(autohide_tasks="on" if enabled else "off")
806
+ if self.transcript.output is not None:
807
+ self.transcript.output.app.invalidate()
808
+ state = "on" if enabled else "off"
809
+ self.transcript.flash(
810
+ f"Auto-hide tasks after each turn: {state}. Usage: /autohide-tasks [on|off]"
811
+ )
812
+
813
+ def show_edits(self, argument: str) -> None:
814
+ shown = self.toggle_argument("/show-edits", argument, self.transcript.show_edits)
815
+ self.transcript.show_edits = shown
816
+ self.persist_defaults(show_edits="on" if shown else "off")
817
+ self.transcript.regenerate()
818
+ if self.transcript.output is not None:
819
+ self.transcript.output.app.invalidate()
820
+ self.transcript.flash(
821
+ f"Show edits: {'on' if shown else 'off'}. Usage: /show-edits [on|off]"
822
+ )
823
+
824
+ def group_tools(self, argument: str) -> None:
825
+ grouped = self.toggle_argument("/group-tools", argument, self.transcript.group_tools)
826
+ self.transcript.group_tools = grouped
827
+ self.persist_defaults(group_tools="on" if grouped else "off")
828
+ self.transcript.regenerate()
829
+ if self.transcript.output is not None:
830
+ self.transcript.output.app.invalidate()
831
+ self.transcript.flash(
832
+ f"Group tools: {'on' if grouped else 'off'}. Usage: /group-tools [on|off]"
833
+ )
834
+
835
+ def set_thinking_mode(self, mode: str) -> None:
836
+ scrollback = self.activity.show_thinking
837
+ self.activity.thinking_mode = mode
838
+ self.controller.set_thinking(mode)
839
+ self.persist_defaults(show_thinking=mode)
840
+ # Only scrollback's own thinking needs the transcript rebuilt.
841
+ if self.activity.show_thinking != scrollback:
842
+ self.transcript.regenerate()
843
+ if self.transcript.output is not None:
844
+ self.transcript.output.app.invalidate()
845
+
846
+ def show_thinking(self, argument: str) -> None:
847
+ """`/show-thinking [off|status-line|scrollback]`; the bare command cycles."""
848
+ if argument and argument not in THINKING_MODES:
849
+ raise ValueError(f"Usage: /show-thinking [{'|'.join(THINKING_MODES)}]")
850
+ if not argument:
851
+ current = self.activity.thinking_mode
852
+ index = THINKING_MODES.index(current) if current in THINKING_MODES else -1
853
+ argument = THINKING_MODES[(index + 1) % len(THINKING_MODES)]
854
+ self.set_thinking_mode(argument)
855
+ lines = [
856
+ f"Thinking: {argument}. Usage: /show-thinking [{'|'.join(THINKING_MODES)}]; "
857
+ f"bare cycles ({self.shortcut('t')})"
858
+ ]
859
+ model = self.model or ""
860
+ provider = model.split(":", 1)[0]
861
+ if provider in THINKING_KEYS:
862
+ wanted = thinking_settings(model, None, argument)
863
+ display = wanted.get("anthropic_thinking", {}).get("display")
864
+ if display == "updates":
865
+ lines.append("Asks for progress updates between tool calls from the next turn.")
866
+ elif wanted:
867
+ lines.append("Asks for thinking summaries from the next turn.")
868
+ else:
869
+ lines.append("Asks for no readable thinking from the next turn.")
870
+ if provider == "anthropic":
871
+ # Only scrollback turns thinking on, and only where it was off.
872
+ if wanted and not ANTHROPIC_THINKS_BY_DEFAULT.search(model):
873
+ lines.append("This model only thinks when asked: more latency and tokens.")
874
+ elif wanted:
875
+ lines.append("An unverified OpenAI organisation is refused summaries; use off.")
876
+ if argument != "off" and model.startswith("meridian:"):
877
+ lines.append(meridian_thinking_note(*self.controller.meridian_thinking_state()))
878
+ self.transcript.flash("\n".join(lines))
879
+
880
+ @property
881
+ def next_send_mode(self) -> str:
882
+ """The mode the next prompt sends with: a send-mode shortcut pick, else the default."""
883
+ return self.send_mode_once or self.send_mode
884
+
885
+ def cycle_send_mode(self) -> None:
886
+ """Cycle the mode for the next send only; the saved default is untouched."""
887
+ from pcode.preferences import SEND_MODES
888
+
889
+ mode = SEND_MODES[(SEND_MODES.index(self.next_send_mode) + 1) % len(SEND_MODES)]
890
+ self.send_mode_once = None if mode == self.send_mode else mode
891
+
892
+ def set_show_commands(self, shown: bool) -> None:
893
+ # Reproject retained results as well as future completions.
894
+ self.transcript.command_scrollback = shown
895
+ self.persist_defaults(show_commands="on" if shown else "off")
896
+ self.transcript.regenerate()
897
+ if self.transcript.output is not None:
898
+ self.transcript.output.app.invalidate()
899
+
900
+ def show_commands(self, argument: str) -> None:
901
+ self.set_show_commands(
902
+ self.toggle_argument("/show-commands", argument, self.transcript.command_scrollback)
903
+ )
904
+ state = "on" if self.transcript.command_scrollback else "off"
905
+ self.transcript.flash(
906
+ f"Show commands: {state}. Usage: /show-commands [on|off] ({self.shortcut('g')})"
907
+ )
908
+
909
+ def shortcut(self, key: str) -> str:
910
+ """How this session's prompt spells shortcut ``key``, e.g. Ctrl+O."""
911
+ bindings = getattr(self.prompt_session, "command_bindings", None)
912
+ if bindings is not None:
913
+ return bindings.action_label(key)
914
+ shortcuts = getattr(self.prompt_session, "shortcuts", None)
915
+ return shortcuts.label(key) if shortcuts is not None else shortcut_label(key)
916
+
917
+ def persist_defaults(self, **updates: str) -> None:
918
+ try:
919
+ save_preferences(**updates)
920
+ except (OSError, ValueError):
921
+ self.transcript.warning("Could not save defaults; this selection applies only here.")
922
+
923
+ def forget_defaults(self, *keys: str) -> None:
924
+ from pcode.preferences import update_preferences
925
+
926
+ try:
927
+ update_preferences({}, remove=keys)
928
+ except (OSError, ValueError):
929
+ self.transcript.warning("Could not update defaults; this change applies only here.")
930
+
931
+ @asynccontextmanager
932
+ async def popup(self, output: TerminalOutput, session):
933
+ """Give a modal exclusive terminal ownership, superseding pending popups."""
934
+ if (
935
+ self._command_popup_generation is not None
936
+ and self._command_popup_generation != self._popup_generation
937
+ ):
938
+ raise _PopupSuperseded
939
+ await output.flush(drain=True)
940
+ try:
941
+ async with output.lock:
942
+ async with suspended_editor(session.app):
943
+ # A separate parser prevents the editor's escape-flush timer
944
+ # from stealing the modal's first Escape key.
945
+ stdin = getattr(session.app.input, "stdin", None)
946
+ # It also filters appearance replies, which arrive at any time.
947
+ modal_input = (
948
+ StartupInput(stdin, b"", self.follow_appearance)
949
+ if stdin is not None
950
+ else session.app.input
951
+ )
952
+ try:
953
+ # Include requests queued during preparation and terminal
954
+ # handoff, not just those waiting when this command began.
955
+ self._popup_generation += 1
956
+ if self._command_popup_generation is not None:
957
+ self._command_popup_generation = self._popup_generation
958
+ yield modal_input
959
+ finally:
960
+ if modal_input is not session.app.input:
961
+ modal_input.close()
962
+ finally:
963
+ # Reuse resize's transcript replay, even when resize replay is off.
964
+ # Flush only after releasing the writer lock and restoring the
965
+ # normal screen, including dismissal, cancellation, and failures.
966
+ self.transcript.regenerate()
967
+ await output.flush()
968
+
969
+ def tools(self, argument: str) -> None:
970
+ self.inspector_requested = argument
971
+
972
+ async def inspect_tools(self, output: TerminalOutput, session) -> None:
973
+ from pcode.inspection import ToolArchive
974
+ from pcode.inspector_ui import ToolInspector
975
+
976
+ failed = self.inspector_requested == "failed"
977
+ edits = self.inspector_requested == "edits"
978
+ self.inspector_requested = None
979
+ if self.hosted:
980
+ # A host's journal is re-read when a turn ends (or on an edit), so
981
+ # mid-turn the tree could still end at the previous turn, and the branch filter
982
+ # below would drop every call the running turn has made.
983
+ self.runtime.refresh()
984
+ saved = getattr(self.runtime, "session", None)
985
+ if saved is not None or hasattr(self.runtime, "inspections"):
986
+ # The browser owns a snapshot, never the archive streaming mutates.
987
+ # File indexing can then run off-thread, including saved history.
988
+ from copy import deepcopy
989
+
990
+ archive = deepcopy(getattr(self.runtime, "inspections", None) or ToolArchive())
991
+ if saved is not None:
992
+ await asyncio.to_thread(archive.update, saved.directory / "transcript.jsonl")
993
+ self.add_running_tools(archive)
994
+ else:
995
+ archive = ToolArchive()
996
+ for call in self.activity.tools.calls:
997
+ archive.event(call.event)
998
+ for change in self.edits:
999
+ archive.event(change)
1000
+ archive.settle("unknown")
1001
+ tree = getattr(self.runtime, "tree", None)
1002
+ if tree is not None:
1003
+ selected = set(tree.path(tree.active))
1004
+ archive.calls = [call for call in archive.calls if call.run_id in selected]
1005
+ async with self.popup(output, session) as modal_input:
1006
+ inspector = ToolInspector(
1007
+ archive,
1008
+ failed=failed,
1009
+ edits=edits,
1010
+ rich_theme=self.transcript.rich_theme,
1011
+ code_theme=self.transcript.code_theme,
1012
+ delta=self.transcript.delta,
1013
+ dedent=self.transcript.diff_dedent,
1014
+ color_system=self.transcript.console.color_system,
1015
+ input=modal_input,
1016
+ output=session.app.output,
1017
+ style=session.app.style,
1018
+ )
1019
+ await inspector.run()
1020
+
1021
+ def add_running_tools(self, archive) -> None:
1022
+ """The journal says what finished; the tools panel, what still runs.
1023
+
1024
+ A journal read mid-turn cannot tell a running call from an abandoned one,
1025
+ so it marks both `unknown`.
1026
+ """
1027
+ running = [call.event for call in self.activity.tools.calls if call.settled is None]
1028
+ listed = {call.call_id: call for call in archive.calls}
1029
+ for event in running:
1030
+ if (call := listed.get(event.call_id)) is None:
1031
+ archive.event(event)
1032
+ elif call.state == "unknown":
1033
+ call.state = "running"
1034
+
1035
+ def diffs(self, argument: str) -> None:
1036
+ if argument:
1037
+ raise ValueError("Usage: /diffs")
1038
+ self.diffs_requested = True
1039
+
1040
+ def links(self, argument: str) -> None:
1041
+ if argument:
1042
+ raise ValueError("Usage: /links")
1043
+ self.links_requested = True
1044
+
1045
+ def copy(self, argument: str) -> None:
1046
+ if argument:
1047
+ raise ValueError("Usage: /copy")
1048
+ self.copy_requested = True
1049
+
1050
+ async def choose_copy(self, output: TerminalOutput, session) -> None:
1051
+ from pcode.clipboard import copy as copy_to_clipboard
1052
+ from pcode.copy_ui import last_response, snippet_dialog, snippets
1053
+ from pcode.session_ui import literal
1054
+
1055
+ self.copy_requested = False
1056
+ tree = getattr(self.runtime, "tree", None)
1057
+ # Redacted like /tree's copy: what leaves pcode never carries a secret.
1058
+ choices = snippets(literal(last_response(tree))) if tree is not None else []
1059
+ if not choices:
1060
+ self.transcript.note("No response to copy yet.")
1061
+ return
1062
+ choice = choices[0]
1063
+ if len(choices) > 1:
1064
+ async with self.popup(output, session) as modal_input:
1065
+ dialog = snippet_dialog(
1066
+ choices,
1067
+ input=modal_input,
1068
+ output=session.app.output,
1069
+ style=session.app.style,
1070
+ )
1071
+ choice = await dialog.run_async()
1072
+ if choice is None:
1073
+ return
1074
+ copied, truncated = copy_to_clipboard(choice.text, session.app.output if session else None)
1075
+ name = choice.kind
1076
+ limit = " (truncated)" if truncated else ""
1077
+ if copied:
1078
+ self.transcript.note(f"Copied {name}{limit}.")
1079
+ else:
1080
+ self.transcript.error(f"Could not copy {name}.")
1081
+
1082
+ async def choose_link(self, output: TerminalOutput, session) -> None:
1083
+ from pcode.links import conversation_links, open_link
1084
+ from pcode.links_ui import links_dialog
1085
+
1086
+ self.links_requested = False
1087
+ tree = getattr(self.runtime, "tree", None)
1088
+ links = conversation_links(tree) if tree is not None and tree.nodes else []
1089
+ if not links:
1090
+ self.transcript.note("No links in this conversation yet.")
1091
+ return
1092
+ async with self.popup(output, session) as modal_input:
1093
+ dialog = links_dialog(
1094
+ links,
1095
+ message_links=conversation_links(tree, include_tools=False),
1096
+ input=modal_input,
1097
+ output=session.app.output,
1098
+ style=session.app.style,
1099
+ )
1100
+ url = await dialog.run_async()
1101
+ if url is None:
1102
+ return
1103
+ try:
1104
+ open_link(url)
1105
+ except (OSError, RuntimeError) as error:
1106
+ self.transcript.error(f"Could not open {url}: {error}")
1107
+ return
1108
+ self.transcript.note(f"Opened {url}")
1109
+
1110
+ def recorded_edits(self) -> list[EditCompleted]:
1111
+ """Prefer the saved journal on the active branch; fall back to this process."""
1112
+ from pcode.edits import change_from_record
1113
+
1114
+ saved = getattr(self.runtime, "session", None)
1115
+ if saved is None:
1116
+ return list(self.edits)
1117
+ return [
1118
+ change_from_record(record)
1119
+ for record in saved.active_records()
1120
+ if record.get("kind") == "EditCompleted"
1121
+ ]
1122
+
1123
+ def review_key(self) -> str | None:
1124
+ """The session id its review checkpoint is kept under; None for an unsaved one."""
1125
+ info = getattr(getattr(self.runtime, "session", None), "info", None)
1126
+ return getattr(info, "id", None)
1127
+
1128
+ def load_review(self):
1129
+ """The session's git review for /diffs, or None outside a git repository."""
1130
+ from pcode import git_diff
1131
+
1132
+ info = getattr(getattr(self.runtime, "session", None), "info", None)
1133
+ key = self.review_key()
1134
+ reviewed = git_diff.reviewed(self.workspace, key) if key else self.reviewed_checkpoint
1135
+ return git_diff.load_review(
1136
+ self.workspace,
1137
+ [edit.path for edit in self.recorded_edits()],
1138
+ getattr(info, "start_commit", None),
1139
+ getattr(info, "created", None),
1140
+ reviewed,
1141
+ )
1142
+
1143
+ def mark_reviewed(self, checkpoint) -> None:
1144
+ """Record a checkpoint: in git for a saved session, else for this process."""
1145
+ from pcode import git_diff
1146
+
1147
+ if (key := self.review_key()) is not None:
1148
+ git_diff.mark_reviewed(self.workspace, key, checkpoint)
1149
+ self.reviewed_checkpoint = checkpoint
1150
+
1151
+ async def browse_diffs(self, output: TerminalOutput, session) -> None:
1152
+ from pcode.edit_ui import DiffBrowser, notes_prompt, render_review
1153
+ from pcode.git_diff import GitDiffError
1154
+
1155
+ self.diffs_requested = False
1156
+ delta, dedent = self.transcript.delta, self.transcript.diff_dedent
1157
+ # The diff pane's width: the screen less its frame and scrollbar.
1158
+ width = max(1, session.app.output.get_size().columns - 4)
1159
+
1160
+ def load():
1161
+ review = self.load_review()
1162
+ return review, render_review(review, delta, width, dedent=dedent) if review else {}
1163
+
1164
+ try:
1165
+ review, rendered = await asyncio.to_thread(load)
1166
+ except GitDiffError as error:
1167
+ self.transcript.error(f"git diff unavailable: {plain(str(error), limit=160)}")
1168
+ return
1169
+ if review is None:
1170
+ self.transcript.note(
1171
+ "/diffs reviews git changes, and this workspace is not in a git repository."
1172
+ " Each file tool's edits are in /tools edits."
1173
+ )
1174
+ return
1175
+ async with self.popup(output, session) as modal_input:
1176
+ browser = DiffBrowser(
1177
+ review,
1178
+ reload=self.load_review,
1179
+ mark=self.mark_reviewed,
1180
+ code_theme=self.transcript.code_theme,
1181
+ delta=delta,
1182
+ rendered=rendered,
1183
+ width=width,
1184
+ dedent=dedent,
1185
+ input=modal_input,
1186
+ output=session.app.output,
1187
+ style=session.app.style,
1188
+ handoff=self._appearance.paused if self._appearance else nullcontext,
1189
+ )
1190
+ await browser.run()
1191
+ if prompt := notes_prompt(browser.notes):
1192
+ buffer = session.default_buffer
1193
+ draft = buffer.text.rstrip()
1194
+ buffer.text = f"{draft}\n\n{prompt}" if draft else prompt
1195
+ buffer.cursor_position = len(buffer.text)
1196
+ count = len(browser.notes)
1197
+ self.transcript.note(f"Added {count} review note{'s' * (count != 1)} to the prompt.")
1198
+
1199
+ def help(self, argument: str) -> None:
1200
+ self.transcript.help(self.registry, self.shortcut)
1201
+
1202
+ def present_events(self, events) -> None:
1203
+ present_events(events, activity=self.activity, transcript=self.transcript, edits=self.edits)
1204
+
1205
+ def theme_preview(self, argument: str) -> None:
1206
+ self.present_events(self.preview.demo())
1207
+ self.transcript.syntax_gallery()
1208
+
1209
+ def theme(self, argument: str) -> None:
1210
+ self.transcript.theme = argument or (
1211
+ "light" if self.transcript.resolved_theme == "dark" else "dark"
1212
+ )
1213
+ self.persist_defaults(theme=self.transcript.theme)
1214
+ selected = self.transcript.theme
1215
+ if selected == "auto":
1216
+ selected += f" ({self.transcript.resolved_theme})"
1217
+ self.transcript.flash(f"Theme: {selected}.")
1218
+ self.transcript.regenerate()
1219
+
1220
+ def follow_appearance(self, detected: str) -> None:
1221
+ """The terminal reported its background; repaint if `theme auto` now differs."""
1222
+ if detected == self.transcript.detected_theme:
1223
+ return
1224
+ self.transcript.detected_theme = detected
1225
+ if self.transcript.theme == "auto":
1226
+ self.transcript.regenerate()
1227
+ if self.prompt_session is not None:
1228
+ self.prompt_session.app.invalidate() # The editor's style follows too.
1229
+
1230
+ def syntax(self, argument: str) -> None:
1231
+ """Choose the Pygments style for code, the completion menu and the prompt.
1232
+
1233
+ Each palette keeps its own style, so switching to the other palette and
1234
+ back restores the style picked for it rather than the last one set.
1235
+ """
1236
+ palette = self.transcript.resolved_theme
1237
+ if argument:
1238
+ SETTINGS[f"syntax_{palette}"].validate(f"syntax_{palette}", argument)
1239
+ self.transcript.syntax_themes[palette] = argument
1240
+ self.persist_defaults(**{f"syntax_{palette}": argument})
1241
+ self.transcript.flash(f"Syntax ({palette}): {self.transcript.syntax_themes[palette]}.")
1242
+ self.transcript.regenerate()
1243
+
1244
+ def status(self, argument: str) -> None:
1245
+ """Popup in the interactive editor; plain notes wherever there is no editor."""
1246
+ if argument:
1247
+ raise ValueError("Usage: /status")
1248
+ if self.transcript.output is not None:
1249
+ self.session_info_requested = True
1250
+ return
1251
+ for label, value in self.controller.session_overview():
1252
+ self.transcript.note(f"{label}: {value}")
1253
+
1254
+ def select_session(self, argument: str) -> None:
1255
+ self.session_requested = True
1256
+
1257
+ # Session hosts: the conversation runs in another process (`pcode.host`)
1258
+ # and this terminal only renders it, so it can leave and come back.
1259
+
1260
+ @property
1261
+ def hosted(self) -> bool:
1262
+ # `is True`: a Mock runtime in tests answers every attribute.
1263
+ return getattr(self.runtime, "remote", False) is True
1264
+
1265
+ def switch(self, argument: str) -> None:
1266
+ if not self.model:
1267
+ raise ValueError("/switch needs a model; this is a local UI preview.")
1268
+ if argument.strip() == "-" and self.previous_host is None:
1269
+ raise ValueError("No previous session in this terminal yet; /switch lists them all.")
1270
+ if not self.hosted and (self.activity.busy or self.activity.queued_prompts):
1271
+ raise ValueError(
1272
+ "This session runs in this terminal, so leaving it stops its turn. "
1273
+ "Wait or cancel, then /switch."
1274
+ )
1275
+ self.switch_requested = argument.strip()
1276
+
1277
+ def stop_host(self, argument: str) -> None:
1278
+ if argument:
1279
+ raise ValueError("Usage: /stop")
1280
+ if not self.hosted:
1281
+ raise ValueError("/stop ends a session host; this session runs in this terminal.")
1282
+ # The worktree is left for this terminal to tidy once it exits, asking
1283
+ # the way a local session does; a host has nobody to ask.
1284
+ self.runtime.stop(keep_worktree=True)
1285
+ self.host_stopped = True
1286
+ self.running = False
1287
+
1288
+ def detach(self, argument: str) -> None:
1289
+ if argument:
1290
+ raise ValueError("Usage: /detach")
1291
+ if not self.hosted:
1292
+ raise ValueError("/detach leaves a session host running; this session runs here.")
1293
+ self.detach_requested = True
1294
+ self.running = False
1295
+
1296
+ def stop_host_on_exit(self) -> None:
1297
+ """Quitting (Ctrl+D, `/quit`) ends the host as `/stop` does, unless `/detach` asked."""
1298
+ if self.hosted and not (
1299
+ self.host_stopped or self.detach_requested or self.session_over or self.runtime.lost
1300
+ ):
1301
+ self.stop_host("")
1302
+
1303
+ def background_finished(self, entry) -> None:
1304
+ """A turn ended in another session: say so on the desktop.
1305
+
1306
+ Nothing is written to this transcript: another session's turn is not
1307
+ this conversation. Every finished turn is announced, watched or not,
1308
+ and once per turn however many terminals notice it.
1309
+ """
1310
+ from pcode.host_protocol import claim
1311
+ from pcode.terminal_notify import notification
1312
+
1313
+ what = {"failed": "failed", "cancelled": "was cancelled"}.get(entry.outcome, "finished")
1314
+ title = plain(entry.label(), 60)
1315
+ if self._emulator is None:
1316
+ return
1317
+ if claim(f"{entry.id}-{entry.turns}"):
1318
+ self._emulator(notification(f"pcode: {title} — {what}"))
1319
+
1320
+ def restart(self, argument: str) -> None:
1321
+ if argument:
1322
+ raise ValueError("Usage: /restart")
1323
+ if not self.hosted:
1324
+ raise ValueError("/restart restarts a session host; quit and relaunch for new code.")
1325
+ if self.activity.busy or self.activity.queued_prompts:
1326
+ raise ValueError("/restart waits for the turn. Cancel with Ctrl+C or wait, then retry.")
1327
+ self.restart_requested = True
1328
+
1329
+ async def restart_host(self) -> None:
1330
+ """Stop this host and resume its conversation in a new one, on the code now installed."""
1331
+ from pcode.remote import wait_for_exit
1332
+
1333
+ self.restart_requested = False
1334
+ runtime = self.runtime
1335
+ # Kept: the new host resumes in the same worktree.
1336
+ runtime.stop(keep_worktree=True)
1337
+ with self.activity.waiting("Stopping the session host"):
1338
+ await wait_for_exit(runtime.pid)
1339
+ if runtime.session_id:
1340
+ await self.start_host_session(
1341
+ resume=runtime.session_id, note="Restarted on the current pcode"
1342
+ )
1343
+ else:
1344
+ await self.start_host_session("")
1345
+
1346
+ async def switch_session(self, output: TerminalOutput, session) -> None:
1347
+ """`/switch`: pick a running host (or start one) and show it in this terminal."""
1348
+ from pcode.host_protocol import find_host, forget_stopped, list_hosts, list_stopped
1349
+
1350
+ argument, self.switch_requested = self.switch_requested or "", None
1351
+ previous = argument == "-"
1352
+ if previous:
1353
+ argument = self.previous_host or ""
1354
+ if argument == "new" or argument.startswith("new "):
1355
+ await self.start_host_session(argument[3:].strip())
1356
+ return
1357
+ current = getattr(self.runtime, "id", None) if self.hosted else None
1358
+ if argument:
1359
+ try:
1360
+ entry = await asyncio.to_thread(find_host, argument)
1361
+ except LookupError as error:
1362
+ # Its host stopped once idle: carry the conversation on instead.
1363
+ stopped = (
1364
+ self.previous_session
1365
+ if previous
1366
+ else await asyncio.to_thread(_stopped_session, argument, self.session_dir)
1367
+ )
1368
+ if stopped:
1369
+ forget_stopped(stopped)
1370
+ await self.open_saved_session(stopped, self.active_session_id())
1371
+ return
1372
+ self.transcript.warning(
1373
+ f"The previous session ({argument}) is no longer running; /switch lists "
1374
+ "the ones that are."
1375
+ if previous
1376
+ else str(error)
1377
+ )
1378
+ return
1379
+ if entry.id == current:
1380
+ self.transcript.note("This terminal is already showing that session.")
1381
+ return
1382
+ await self.attach_host(entry)
1383
+ return
1384
+ entries = await asyncio.to_thread(lambda: list_hosts() + list_stopped())
1385
+ if not entries:
1386
+ self.transcript.note(
1387
+ "No sessions are running in the background. /switch new [PROMPT] starts one."
1388
+ )
1389
+ return
1390
+ from pcode.host_ui import hosts_dialog
1391
+
1392
+ async with self.popup(output, session) as modal_input:
1393
+ dialog = hosts_dialog(
1394
+ entries,
1395
+ current=current,
1396
+ input=modal_input,
1397
+ output=session.app.output,
1398
+ style=session.app.style,
1399
+ )
1400
+ choice = await dialog.run_async()
1401
+ if choice is None:
1402
+ return
1403
+ action, identity = choice
1404
+ if action == "new":
1405
+ await self.start_host_session("")
1406
+ return
1407
+ entry = next(entry for entry in entries if entry.id == identity)
1408
+ if entry.state == "stopped":
1409
+ # Seen once chosen either way; a resumed host forgets it too.
1410
+ forget_stopped(entry.session_id)
1411
+ if action == "stop":
1412
+ self.transcript.note(f"Removed {entry.label()[:60]} from /switch.")
1413
+ return
1414
+ await self.open_saved_session(entry.session_id, self.active_session_id())
1415
+ elif action == "stop":
1416
+ await self.stop_other_host(entry)
1417
+ elif entry.id == current:
1418
+ self.transcript.note("This terminal is already showing that session.")
1419
+ else:
1420
+ await self.attach_host(entry)
1421
+
1422
+ def session_ended(self) -> None:
1423
+ """The host: a command from this terminal ended the session, so the host is stopping."""
1424
+ self.session_over = True
1425
+ self.running = False
1426
+ if self.prompt_session is not None and self.prompt_session.app.is_running:
1427
+ self.prompt_session.app.exit()
1428
+
1429
+ def host_closed(self) -> None:
1430
+ """The host this terminal was showing went away (stopped elsewhere, or crashed)."""
1431
+ if self.session_over:
1432
+ return # Expected: this terminal is already on its way out.
1433
+ runtime = self.runtime
1434
+ session = runtime.session_id and f" pcode --continue {runtime.session_id} resumes it."
1435
+ self.transcript.warning(
1436
+ f"The session host {runtime.id} has exited. /switch picks another;{session}"
1437
+ )
1438
+ self.activity.busy = False
1439
+ self.redraw()
1440
+
1441
+ async def stop_other_host(self, entry) -> None:
1442
+ from pcode.remote import stop_entry
1443
+
1444
+ if self.hosted and entry.id == self.runtime.id:
1445
+ self.stop_host("")
1446
+ return
1447
+ with self.activity.waiting(f"Stopping session {entry.id}"):
1448
+ await stop_entry(entry)
1449
+ self.transcript.note(f"Stopped session {entry.id} ({entry.label()[:60]}).")
1450
+
1451
+ async def attach_host(self, entry) -> None:
1452
+ from pcode.host_protocol import list_hosts
1453
+ from pcode.remote import HostError, HostLaunch
1454
+
1455
+ try:
1456
+ with self.activity.waiting(f"Connecting to session {entry.id}"):
1457
+ controller, welcome = await HostLaunch.running(entry).connect(self, self.activity)
1458
+ except HostError:
1459
+ # Picked from a list read a moment ago, and stopped since for being idle.
1460
+ gone = all(other.id != entry.id for other in list_hosts())
1461
+ if not (gone and entry.session_id):
1462
+ raise
1463
+ await self.open_saved_session(entry.session_id, self.active_session_id())
1464
+ return
1465
+ await self.adopt_controller(controller, welcome, f"Switched to session {entry.id}")
1466
+
1467
+ async def start_host_session(
1468
+ self, prompt: str = "", *, resume: str | None = None, note: str | None = None
1469
+ ) -> None:
1470
+ """Start a host for a new (or resumed) conversation beside this one.
1471
+
1472
+ A new one starts from the main checkout, so with the worktree setting on
1473
+ it gets its own worktree rather than sharing this one. With a prompt it
1474
+ is left working in the background; without, this terminal switches to it.
1475
+ """
1476
+ from pcode import worktree
1477
+ from pcode.remote import spawn_host, wait_for_host
1478
+
1479
+ base = (
1480
+ self.workspace if resume else worktree.main_checkout(self.workspace) or self.workspace
1481
+ )
1482
+ wait = self.activity.begin_wait(
1483
+ f"Resuming {resume[:8]} in a new session host" if resume else "Starting a session host"
1484
+ )
1485
+ try:
1486
+ identity, process, log = await asyncio.to_thread(
1487
+ spawn_host,
1488
+ model=self.model,
1489
+ workspace=base,
1490
+ resume=resume,
1491
+ session_dir=self.session_dir,
1492
+ )
1493
+ self.transcript.note(f"Starting session {identity}… (log: {log})")
1494
+ if prompt:
1495
+ # Its state goes nowhere: this terminal stays on its own session.
1496
+ controller, _ = await wait_for_host(identity, None, Activity(), process, log)
1497
+ else:
1498
+ controller, welcome = await wait_for_host(
1499
+ identity, self, self.activity, process, log
1500
+ )
1501
+ finally:
1502
+ self.activity.end_wait(wait)
1503
+ if prompt:
1504
+ controller.submit(prompt, "queue")
1505
+ controller.close()
1506
+ self.transcript.note(
1507
+ f"Session {identity} is working on it in the background. "
1508
+ "/switch shows it; this terminal stays here."
1509
+ )
1510
+ if self._host_watch is not None:
1511
+ self._host_watch()
1512
+ return
1513
+ what = (
1514
+ f"Resumed {resume} in session host {identity}" if resume else f"New session {identity}"
1515
+ )
1516
+ await self.adopt_controller(controller, welcome, f"{note} ({identity})" if note else what)
1517
+
1518
+ async def adopt_controller(self, controller, welcome: dict, note: str) -> None:
1519
+ """Show the host behind `controller` in this terminal, leaving the current session.
1520
+
1521
+ A session running in this process ends here (its journal keeps it for
1522
+ /resume or `pcode -c`); another host keeps running.
1523
+ """
1524
+ previous = self.controller
1525
+ if isinstance(previous, SessionController):
1526
+ if saved := getattr(previous.runtime, "session", None):
1527
+ note += f" · left {saved.info.id} (pcode --continue {saved.info.id[:8]} resumes it)"
1528
+ await self.leave_controller(previous)
1529
+ close = getattr(previous.runtime, "close", None)
1530
+ if close is not None:
1531
+ close()
1532
+ else:
1533
+ if not previous.runtime.lost:
1534
+ self.previous_host = previous.id
1535
+ self.previous_session = previous.runtime.session_id or None
1536
+ previous.close()
1537
+ self.controller = controller
1538
+ controller.on_closed = self.host_closed
1539
+ if forked := welcome.get("forked_from"):
1540
+ note += f" · continuing a copy of {forked}"
1541
+ if not welcome.get("fork_requested"):
1542
+ note += ", which was open elsewhere"
1543
+ self._attach_note = note
1544
+ self.activity.reset()
1545
+ if self._progress is not None:
1546
+ self._progress.switched()
1547
+ self.edits.clear()
1548
+ self.reviewed_checkpoint = None
1549
+ for name, value in welcome["activity"].items():
1550
+ controller.apply_field(name, value)
1551
+ self.commands_changed()
1552
+ journal = controller.runtime.session
1553
+ if journal is not None:
1554
+ end = welcome["settled_end"] if welcome["settled_end"] is not None else 0
1555
+ self.replay(journal, note=note, end=end)
1556
+ else:
1557
+ with self.transcript.restore():
1558
+ self.transcript.retained_note(note)
1559
+ await controller.start(welcome)
1560
+ if self._host_watch is not None:
1561
+ self._host_watch()
1562
+ self.redraw()
1563
+
1564
+ def select_tree(self, argument: str) -> None:
1565
+ self.tree_requested = True
1566
+
1567
+ def auto_open_asides(self) -> bool:
1568
+ """Whether a settled side answer should open the viewer by itself."""
1569
+ if self.aside_view_open:
1570
+ return False
1571
+ default = SETTINGS["btw_auto_open"].default
1572
+ return load_preferences().get("btw_auto_open", default) == "on"
1573
+
1574
+ async def read_asides(self):
1575
+ """The side-answer viewer; returns a thread to bring into the conversation, if any."""
1576
+ from pcode.aside_ui import AsideBrowser
1577
+
1578
+ output, session = self.output, self.prompt_session
1579
+ latest = self.asides.latest()
1580
+ auto = (
1581
+ self._command_popup_generation is not None
1582
+ and self._command_popup_generation == self._auto_open_generation
1583
+ )
1584
+ self.aside_view_open = True
1585
+ try:
1586
+ async with self.popup(output, session) as modal_input:
1587
+ browser = AsideBrowser(
1588
+ self.asides,
1589
+ ask=self.controller.follow_up_aside,
1590
+ check_bridge=self.controller.check_bridge,
1591
+ stop=self.controller.cancel_asides,
1592
+ selected=latest.id if latest else None,
1593
+ edit=not auto,
1594
+ rich_theme=self.transcript.rich_theme,
1595
+ code_theme=self.transcript.code_theme,
1596
+ color_system=self.transcript.console.color_system,
1597
+ input=modal_input,
1598
+ output=session.app.output,
1599
+ style=session.app.style,
1600
+ )
1601
+ return await browser.run()
1602
+ except _PopupSuperseded:
1603
+ return None
1604
+ finally:
1605
+ self.aside_view_open = False
1606
+
1607
+ def agents(self, argument: str) -> None:
1608
+ if not self.activity.workers.items:
1609
+ self.transcript.note("No agents yet. They appear once the model delegates a task.")
1610
+ return
1611
+ self.worker_view_requested = True
1612
+
1613
+ async def read_workers(self, output: TerminalOutput, session) -> None:
1614
+ from pcode.worker_ui import WorkerBrowser
1615
+
1616
+ self.worker_view_requested = False
1617
+ async with self.popup(output, session) as modal_input:
1618
+ browser = WorkerBrowser(
1619
+ self.activity.workers,
1620
+ rich_theme=self.transcript.rich_theme,
1621
+ code_theme=self.transcript.code_theme,
1622
+ color_system=self.transcript.console.color_system,
1623
+ show_thinking=self.activity.show_thinking,
1624
+ input=modal_input,
1625
+ output=session.app.output,
1626
+ style=session.app.style,
1627
+ )
1628
+ await browser.run()
1629
+
1630
+ async def choose_tree(self, output: TerminalOutput, session) -> None:
1631
+ from pcode.tree_ui import tree_dialog
1632
+
1633
+ self.tree_requested = False
1634
+ tree = getattr(self.runtime, "tree", None)
1635
+ if tree is None or not tree.nodes:
1636
+ self.transcript.note("No conversation turns yet. Send a message to start a tree.")
1637
+ return
1638
+ # Reading the tree is safe at any time; switching context is not, because
1639
+ # a running turn owns the history it would be replaced with. Browse now,
1640
+ # fork when the turn ends — or ask the branch a side question with /btw.
1641
+ navigable = not (self.activity.busy or self.activity.queued)
1642
+ async with self.popup(output, session) as modal_input:
1643
+ dialog = tree_dialog(
1644
+ tree,
1645
+ navigable=navigable,
1646
+ rich_theme=self.transcript.rich_theme,
1647
+ code_theme=self.transcript.code_theme,
1648
+ color_system=self.transcript.console.color_system,
1649
+ input=modal_input,
1650
+ output=session.app.output,
1651
+ style=session.app.style,
1652
+ )
1653
+ selection = await dialog.run_async()
1654
+ if selection is not None:
1655
+ identity, edit = selection
1656
+ draft = await self.controller.navigate_tree(identity, edit=edit)
1657
+ # A cancelled picker leaves the editor alone. Only user selection prefills it.
1658
+ if edit:
1659
+ session.default_buffer.text = draft
1660
+ session.default_buffer.cursor_position = len(draft)
1661
+
1662
+ async def choose_session(self, output: TerminalOutput, session) -> None:
1663
+ self.session_requested = False
1664
+
1665
+ def load():
1666
+ # Off the event loop: the first import of pcode.sessions pulls in the
1667
+ # agent stack, and grouping by repository runs git per workspace.
1668
+ from pcode.sessions import list_sessions
1669
+ from pcode.worktree import repo_scope
1670
+
1671
+ records = list_sessions(self.session_dir)
1672
+ scope = repo_scope(self.workspace)
1673
+ return records, any(repo_scope(Path(info.workspace)) == scope for info in records)
1674
+
1675
+ with self.activity.waiting("Loading saved sessions"):
1676
+ records, any_here = await asyncio.to_thread(load)
1677
+ from pcode.session_ui import SessionBrowser
1678
+ from pcode.sessions import session_root
1679
+
1680
+ if not any_here:
1681
+ self.transcript.note("No saved sessions for this workspace.")
1682
+ return
1683
+ active_id = self.active_session_id()
1684
+ async with self.popup(output, session) as modal_input:
1685
+ browser = SessionBrowser(
1686
+ records,
1687
+ root=self.session_dir or session_root(),
1688
+ workspace=self.workspace,
1689
+ active_id=active_id,
1690
+ rich_theme=self.transcript.rich_theme,
1691
+ code_theme=self.transcript.code_theme,
1692
+ color_system=self.transcript.console.color_system,
1693
+ input=modal_input,
1694
+ output=session.app.output,
1695
+ style=session.app.style,
1696
+ )
1697
+ identity = await browser.run()
1698
+ if identity is None:
1699
+ return
1700
+ await self.open_saved_session(identity, active_id)
1701
+
1702
+ def active_session_id(self) -> str | None:
1703
+ if self.hosted:
1704
+ return self.runtime.session_id or None
1705
+ current = getattr(self.runtime, "session", None)
1706
+ return current.info.id if current else None
1707
+
1708
+ async def open_saved_session(self, identity: str, active_id: str | None) -> None:
1709
+ """Show a saved session here: where it runs, else resumed in a host or this process."""
1710
+ from pcode.host_protocol import list_hosts
1711
+
1712
+ running = [entry for entry in list_hosts() if entry.session_id == identity]
1713
+ if identity == active_id:
1714
+ self.transcript.note("This session is already active.")
1715
+ elif running:
1716
+ # Shown where it runs: resuming the saved session would continue a copy.
1717
+ await self.attach_host(running[0])
1718
+ elif self.hosted:
1719
+ await self.start_host_session(resume=identity)
1720
+ else:
1721
+ await self.controller.resume_session(identity)
1722
+
1723
+ async def show_session_info(self, output: TerminalOutput, session) -> None:
1724
+ from pcode.session_ui import session_info_dialog
1725
+
1726
+ self.session_info_requested = False
1727
+ rows = await self.controller.query("session_overview")
1728
+ if self.hosted:
1729
+ from pcode.host_protocol import find_host
1730
+ from pcode.host_ui import status_rows
1731
+
1732
+ try:
1733
+ entry = await asyncio.to_thread(find_host, self.runtime.id)
1734
+ except LookupError:
1735
+ host = [("Host", f"{self.runtime.id} (pid {self.runtime.pid}) · not running")]
1736
+ else:
1737
+ host = status_rows(entry)
1738
+ rows = [*host, *rows]
1739
+ async with self.popup(output, session) as modal_input:
1740
+ dialog = session_info_dialog(
1741
+ rows,
1742
+ input=modal_input,
1743
+ output=session.app.output,
1744
+ style=session.app.style,
1745
+ )
1746
+ await dialog.run_async()
1747
+
1748
+ def replay(self, saved=None, *, note: str | None = None, end: int | None = None) -> None:
1749
+ """Redraw a saved conversation. `saved` names one the runtime does not hold yet.
1750
+
1751
+ `end` stops at that journal offset: a host sends the rest as it happened.
1752
+ """
1753
+ from pcode.diagnostics import redact
1754
+
1755
+ saved = saved or self.runtime.session
1756
+ self.activity.plan = saved.latest_plan()
1757
+ # Reopening never leaves tools running. Settled results belong to the
1758
+ # transcript, including hidden command payloads needed by later toggles.
1759
+ self.activity.tools.clear()
1760
+ with self.transcript.restore():
1761
+ self.transcript.retained_note(note or f"Resumed {saved.info.id}")
1762
+ if saved.forked_from:
1763
+ self.transcript.retained_note(forked_note(saved))
1764
+ for record in saved.transcript_records(end):
1765
+ kind = record["kind"]
1766
+ if kind in ("turn_started", "steering"):
1767
+ self.transcript.user(redact(record["prompt"]))
1768
+ elif kind in ("Thinking", "thinking_partial"):
1769
+ self.transcript.thinking(redact(record["text"]).rstrip("\n") + "\n\n")
1770
+ elif kind == "CacheBust":
1771
+ continue # Kept in the journal; live runs show it in the footer.
1772
+ elif kind == "EditCompleted":
1773
+ from pcode.edits import change_from_record
1774
+
1775
+ self.transcript.edit(change_from_record(record))
1776
+ elif kind in {"ToolSummary", "JobFinished"}:
1777
+ result = record.get("result")
1778
+ self.transcript.tool_result(
1779
+ ToolSummary(
1780
+ record["name"],
1781
+ redact(record["detail"]),
1782
+ record.get("failed", False),
1783
+ record.get("call_id", ""),
1784
+ record.get("elapsed_seconds"),
1785
+ redact(record.get("error", "")),
1786
+ redact(record.get("command", "")),
1787
+ result=redact(result) if isinstance(result, str) else None,
1788
+ outcome=record.get("outcome", ""),
1789
+ parent_call_id=record.get("parent_call_id", ""),
1790
+ purpose=redact(record.get("purpose", "")),
1791
+ execution=record.get("execution", ""),
1792
+ )
1793
+ )
1794
+ elif kind in ("Message", "partial"):
1795
+ self.transcript.events((Message(redact(record["markdown"])),))
1796
+ if kind == "partial":
1797
+ self.transcript.warning("[Partial output from an interrupted run]")
1798
+
1799
+ def quit(self, argument: str) -> None:
1800
+ self.running = False
1801
+
1802
+ def refresh_branch(self) -> bool:
1803
+ """Read only Git metadata; called off the UI thread, never during rendering.
1804
+
1805
+ Reports whether the branch moved, so an unchanged one costs no repaint.
1806
+ """
1807
+ previous = self.branch
1808
+ try:
1809
+ result = subprocess.run(
1810
+ ["git", "-C", str(self.workspace), "symbolic-ref", "--quiet", "--short", "HEAD"],
1811
+ capture_output=True,
1812
+ text=True,
1813
+ timeout=1,
1814
+ )
1815
+ if result.returncode == 1: # Detached HEAD: show its short commit instead.
1816
+ result = subprocess.run(
1817
+ ["git", "-C", str(self.workspace), "rev-parse", "--short", "HEAD"],
1818
+ capture_output=True,
1819
+ text=True,
1820
+ timeout=1,
1821
+ )
1822
+ self.branch = plain(result.stdout.strip(), limit=None) if result.returncode == 0 else ""
1823
+ except (OSError, subprocess.TimeoutExpired):
1824
+ self.branch = ""
1825
+ return self.branch != previous
1826
+
1827
+ def toolbar(self):
1828
+ width = get_app().output.get_size().columns
1829
+ location = plain(location_label(self.workspace, self.branch), limit=None)
1830
+ model = self.model if self.model else "preview"
1831
+ if self.pending_model:
1832
+ # The running turn keeps its model; show what the next one will use.
1833
+ model += f" → {self.pending_model}"
1834
+ if self.model:
1835
+ model += f" ({self.current_effort()})"
1836
+ # Read left to right and cut from the right: the path leads, then send
1837
+ # mode and live activity, then metadata, with the help hint last.
1838
+ once = " (once)" if self.send_mode_once else ""
1839
+ segments = [("mode", f"{self.next_send_mode}{once}")]
1840
+ if self._startup_pending:
1841
+ segments.extend([("sep", " · "), ("activity", "starting")])
1842
+ # No "working" label: the status on the editor's top border already says so.
1843
+ if self.activity.busy:
1844
+ if self.activity.queued:
1845
+ steering = self.activity.queued_modes.count("steering")
1846
+ queued = self.activity.queued - steering
1847
+ if steering:
1848
+ segments.extend([("sep", " · "), ("activity", f"{steering} steering pending")])
1849
+ if queued:
1850
+ segments.extend([("sep", " · "), ("activity", f"{queued} queued")])
1851
+ if count := self.activity.job_count:
1852
+ label = "job" if count == 1 else "jobs"
1853
+ segments.extend([("sep", " · "), ("activity", f"{count} {label}")])
1854
+ # Side questions are not "working": they neither block input nor end the
1855
+ # turn, so they get their own counter rather than the activity label.
1856
+ if running := self.asides.running:
1857
+ segments.extend([("sep", " · "), ("activity", f"{running} btw running")])
1858
+ if unread := self.asides.unread:
1859
+ segments.extend([("sep", " · "), ("activity", f"{unread} btw ready")])
1860
+ segments.extend([("sep", " · "), ("model", plain(model, limit=None))])
1861
+ context = self.controller.context_label()
1862
+ # Colorize the token counts distinctly from the " · " and "/" around them.
1863
+ parts = re.split(r"(~?\d[\d.]*[a-z]?)", context)
1864
+ segments.extend(
1865
+ ("context-value", part)
1866
+ if re.fullmatch(r"~?\d[\d.]*[a-z]?", part)
1867
+ else ("context", part)
1868
+ for part in parts
1869
+ if part
1870
+ )
1871
+ if self.activity.cache_note:
1872
+ segments.extend([("sep", " · "), ("cache", self.activity.cache_note)])
1873
+ if self.activity.show_hints:
1874
+ shortcuts = getattr(self.prompt_session, "shortcuts", None)
1875
+ if shortcuts is None:
1876
+ shortcuts = PrefixKeys()
1877
+ segments.extend([("sep", " · "), ("hint", shortcuts.summary())])
1878
+ details = "".join(value for _, value in segments)
1879
+ # The path takes any spare width; under pressure it keeps a third of
1880
+ # the row, and never so much that the send mode after it is cut.
1881
+ spare = width - cell_len(details) - 4 # The leading space and " · ".
1882
+ # One more cell than the separators for the line's own trailing "…".
1883
+ floor = min(width // 3, width - cell_len(segments[0][1]) - 5)
1884
+ path = Text(location)
1885
+ path.truncate(max(spare, floor, 1), overflow="ellipsis")
1886
+ text = Text(f" {path.plain} · {details}" if path.plain else f" {details}")
1887
+ text.truncate(width, overflow="ellipsis")
1888
+ prefix = [("text", " ")]
1889
+ if path.plain:
1890
+ prefix.extend([("location", path.plain), ("sep", " · ")])
1891
+ segments = prefix + segments
1892
+ # Slice the already cell-truncated text, preserving its ellipsis and the
1893
+ # same narrow-terminal priorities without splitting wide characters.
1894
+ fragments = []
1895
+ remaining = text.plain
1896
+ for role, value in segments:
1897
+ if not remaining:
1898
+ break
1899
+ fragments.append((f"class:bottom-toolbar.{role}", remaining[: len(value)]))
1900
+ remaining = remaining[len(value) :]
1901
+ return fragments
1902
+
1903
+ def handle(self, text: str) -> bool:
1904
+ """Handle commands/preview synchronously; return whether a live run is needed."""
1905
+ text = text.strip()
1906
+ if not text:
1907
+ return False
1908
+ if self.model and not text.startswith("/"):
1909
+ return True
1910
+ if text.startswith("/"):
1911
+ try:
1912
+ if not self.registry.dispatch(text):
1913
+ self.transcript.warning(
1914
+ "Unknown command. Type /help to see available commands."
1915
+ )
1916
+ except ValueError as error:
1917
+ self.transcript.error(str(error))
1918
+ return False
1919
+ self.transcript.user(text)
1920
+ self.present_events(self.preview.reply(text))
1921
+ return False
1922
+
1923
+ async def run_live(
1924
+ self,
1925
+ output: TerminalOutput,
1926
+ text: str,
1927
+ *,
1928
+ resend: bool = False,
1929
+ wake: bool = False,
1930
+ ) -> bool:
1931
+ """Run one turn shown through `output`: the controller's turn, this terminal its view."""
1932
+ self.output = output
1933
+ return await self.controller.run_turn(text, resend=resend, wake=wake)
1934
+
1935
+ async def run_command(self, text: str, *, idle: bool, tag) -> None:
1936
+ """Run a slash command the controller handed back to this terminal.
1937
+
1938
+ `idle` says whether the session was idle when it was sent, and `tag`
1939
+ is the popup generation it was sent in (see `popup`).
1940
+ """
1941
+ output, session = self.output, self.prompt_session
1942
+ self._command_popup_generation = tag
1943
+ try:
1944
+ self.handle(text)
1945
+ if self.worker_view_requested:
1946
+ await self.read_workers(output, session)
1947
+ if self.tree_requested:
1948
+ await self.choose_tree(output, session)
1949
+ if self.session_requested:
1950
+ await self.choose_session(output, session)
1951
+ if self.switch_requested is not None:
1952
+ await self.switch_session(output, session)
1953
+ if self.restart_requested:
1954
+ await self.restart_host()
1955
+ if self.session_info_requested:
1956
+ await self.show_session_info(output, session)
1957
+ if self.inspector_requested is not None:
1958
+ await self.inspect_tools(output, session)
1959
+ if self.diffs_requested:
1960
+ await self.browse_diffs(output, session)
1961
+ if self.links_requested:
1962
+ await self.choose_link(output, session)
1963
+ if self.copy_requested:
1964
+ await self.choose_copy(output, session)
1965
+ except _PopupSuperseded:
1966
+ pass
1967
+ finally:
1968
+ self._command_popup_generation = None
1969
+
1970
+ async def after_command(self, tag=None) -> None:
1971
+ await self.output.flush()
1972
+ if not self.running and self.prompt_session.app.is_running:
1973
+ self.prompt_session.app.exit()
1974
+
1975
+ async def run_async(self) -> None:
1976
+ # This frontend owns the terminal; suppress the framework's unsolicited banner.
1977
+ os.environ.setdefault("PYDANTIC_AI_NO_BANNER", "1")
1978
+ self.transcript.welcome(self.model, str(self.workspace))
1979
+ # Typed before a session host is attached; sent to it once it is.
1980
+ early: list[tuple[str, str, object]] | None = [] if self._host_launch else None
1981
+
1982
+ async def refresh_metadata():
1983
+ refresh = getattr(self.runtime, "refresh_context", None)
1984
+ if refresh is not None:
1985
+ try:
1986
+ await refresh()
1987
+ except Exception:
1988
+ # Optional metadata must not take down a usable editor.
1989
+ pass
1990
+ finally:
1991
+ session.app.invalidate()
1992
+
1993
+ async def initialize_host():
1994
+ nonlocal early
1995
+ launch, self._host_launch = self._host_launch, None
1996
+ if launch.process is not None:
1997
+ self.transcript.note(
1998
+ f"Starting session host {launch.id}; quitting stops it, /detach leaves "
1999
+ f"it running. Log: {launch.log}"
2000
+ )
2001
+ label = "Starting the session host" if launch.process else "Connecting to the host"
2002
+ try:
2003
+ with self.activity.waiting(label):
2004
+ controller, welcome = await launch.connect(self, self.activity)
2005
+ except Exception as error:
2006
+ self._startup_error = error
2007
+ self._startup_pending = False
2008
+ self.transcript.error(error_message(error), title="Session host failed")
2009
+ early = None
2010
+ session.app.invalidate()
2011
+ return
2012
+ note = f"Attached to session host {launch.id}"
2013
+ await self.adopt_controller(controller, welcome, note)
2014
+ waiting, early = early or [], None
2015
+ for kind, text, detail in waiting:
2016
+ if kind == "command":
2017
+ command(text, detail)
2018
+ else:
2019
+ self.controller.submit(text, detail)
2020
+
2021
+ async def initialize():
2022
+ # Replaying is a journal read of a few milliseconds, while the
2023
+ # provider stack below takes a second or two to import. Draw the
2024
+ # conversation first: waiting for a backend you have not used yet
2025
+ # to see what was already said is a wait for nothing.
2026
+ controller = self.controller
2027
+ replayed = self.resuming and self._saved_session is not None
2028
+ if replayed:
2029
+ self.replay(self._saved_session)
2030
+ controller.conversation_shown = True
2031
+ try:
2032
+ await self._initialize_runtime()
2033
+ controller.show_startup_context()
2034
+ controller.warn_without_credentials()
2035
+ await controller.warn_meridian_thinking()
2036
+ saved = getattr(self.runtime, "session", None)
2037
+ if self.model and saved:
2038
+ self.transcript.retained_note(f"Saving session: {saved.info.id}")
2039
+ if self.resuming and not replayed:
2040
+ self.replay()
2041
+ except Exception as error:
2042
+ self._startup_error = error
2043
+ self.transcript.error(error_message(error), title="Agent startup failed")
2044
+ controller.clear_queue()
2045
+ self.activity.busy = False
2046
+ else:
2047
+ session.app.create_background_task(refresh_metadata())
2048
+ controller.start_mcp_defaults()
2049
+ finally:
2050
+ self._startup_pending = False
2051
+ controller.ready.set()
2052
+ for command in controller.startup_commands:
2053
+ controller.commands.put_nowait(command)
2054
+ controller.startup_commands.clear()
2055
+ session.app.invalidate()
2056
+
2057
+ async def watch_hosts() -> None:
2058
+ """Track other sessions for notifications, and say when one finishes."""
2059
+ from pcode.host_protocol import list_hosts
2060
+
2061
+ # Finished-turn counts, not states: a turn shorter than the poll
2062
+ # interval goes working-idle-working unseen but still counts.
2063
+ turns: dict[str, int] = {}
2064
+ while self.running:
2065
+ entries = await asyncio.to_thread(list_hosts)
2066
+ current = self.runtime.id if self.hosted else None
2067
+ for entry in entries:
2068
+ seen = turns.get(entry.id)
2069
+ if entry.id != current and seen is not None and entry.turns > seen:
2070
+ self.background_finished(entry)
2071
+ turns = {entry.id: entry.turns for entry in entries}
2072
+ others = [entry for entry in entries if entry.id != current]
2073
+ key = [(e.id, e.state, e.unseen) for e in others]
2074
+ if key != [(e.id, e.state, e.unseen) for e in self.hosts]:
2075
+ self.hosts = others
2076
+ session.app.invalidate()
2077
+ await asyncio.sleep(HOST_POLL_SECONDS)
2078
+
2079
+ def start_host_watch() -> None:
2080
+ # Only once a host is involved: a plain session never polls for them.
2081
+ if self._host_watch_task is None:
2082
+ self._host_watch_task = session.app.create_background_task(watch_hosts())
2083
+
2084
+ self._host_watch = start_host_watch
2085
+
2086
+ def emulator(sequence: str) -> None:
2087
+ from pcode.terminal_notify import send
2088
+
2089
+ if load_preferences().get("desktop_notifications", "on") == "on":
2090
+ send(session.app.output, sequence)
2091
+
2092
+ self._emulator = emulator
2093
+
2094
+ local: asyncio.Queue = asyncio.Queue()
2095
+
2096
+ async def run_local_commands():
2097
+ """In a hosted session, the terminal's own commands run here, not in the host."""
2098
+ while True:
2099
+ text, tag = await local.get()
2100
+ try:
2101
+ await self.run_command(text, idle=True, tag=tag)
2102
+ except Exception as error:
2103
+ self.transcript.error(error_message(error, unexpected=f"{text} failed"))
2104
+ await self.after_command()
2105
+
2106
+ def command(text, tag):
2107
+ # A host's terminal runs its own commands itself, even before the
2108
+ # host answers; in-process they keep their place in the session's queue.
2109
+ hosted = self.hosted or early is not None
2110
+ if hosted and text.split(maxsplit=1)[0] in TERMINAL_COMMANDS:
2111
+ local.put_nowait((text, tag))
2112
+ else:
2113
+ self.controller.command(text, tag)
2114
+
2115
+ def cancel():
2116
+ """Ctrl+C. Before the host answers, it drops what was typed ahead for it."""
2117
+ if not early:
2118
+ self.cancel()
2119
+ return
2120
+ commands = [text for kind, text, _ in early if kind == "command"]
2121
+ prompts = len(early) - len(commands)
2122
+ early.clear()
2123
+ if any(mcp_enable(text) for text in commands):
2124
+ self.transcript.warning("Pending MCP enable command cancelled.")
2125
+ if any(text.split()[0] in MODEL_COMMANDS for text in commands):
2126
+ self.transcript.warning("Pending model command cancelled.")
2127
+ if prompts:
2128
+ self.transcript.note(f"Cleared {prompts} queued message(s).")
2129
+ self.activity.busy = False
2130
+
2131
+ def submit(text):
2132
+ text = text.strip()
2133
+ ready = early is None and getattr(self.controller, "ready", None)
2134
+ if (early is not None or (ready and not ready.is_set())) and text in {"/quit", "/exit"}:
2135
+ # Do not strand exit behind a command waiting for initialization.
2136
+ self.running = False
2137
+ session.app.exit()
2138
+ return
2139
+ if text.startswith("/"):
2140
+ name = text.split(maxsplit=1)[0]
2141
+ if early is not None and name not in TERMINAL_COMMANDS:
2142
+ early.append(("command", text, self._popup_generation))
2143
+ if name in MODEL_COMMANDS or mcp_enable(text):
2144
+ # As in-process: Ctrl+C now cancels it, not the draft.
2145
+ self.activity.busy = True
2146
+ else:
2147
+ command(text, self._popup_generation)
2148
+ return
2149
+ if shell_command(text) is not None:
2150
+ mode = "shell"
2151
+ elif text:
2152
+ # One send consumes a Ctrl+S pick; the saved default returns.
2153
+ mode = self.next_send_mode
2154
+ self.send_mode_once = None
2155
+ else:
2156
+ return
2157
+ if early is not None:
2158
+ early.append(("submit", text, mode))
2159
+ self.activity.busy = True
2160
+ else:
2161
+ self.controller.submit(text, mode)
2162
+
2163
+ session = create_prompt(
2164
+ self.registry,
2165
+ activity=self.activity,
2166
+ transcript=self.transcript,
2167
+ workspace=lambda: self.workspace,
2168
+ on_submit=submit,
2169
+ on_command=submit,
2170
+ on_cancel=cancel,
2171
+ on_tasks=self.set_show_tasks,
2172
+ on_thinking=self.show_thinking,
2173
+ on_commands=lambda: self.show_commands(""),
2174
+ on_effort=self.adjust_effort,
2175
+ on_send_mode=self.cycle_send_mode,
2176
+ on_model=lambda: submit("/model"),
2177
+ on_previous_session=lambda: submit("/switch -"),
2178
+ on_copy_response=lambda: submit("/copy"),
2179
+ bottom_toolbar=self.toolbar,
2180
+ # Whichever controller is on screen: /switch replaces it.
2181
+ session_title=lambda: self.controller.session_title(),
2182
+ vi_mode=load_preferences().get("editing_mode", "emacs") == "vi",
2183
+ )
2184
+ output = TerminalOutput(
2185
+ self.transcript.console,
2186
+ session.app,
2187
+ code_theme=lambda: self.transcript.code_theme,
2188
+ rich_theme=lambda: self.transcript.rich_theme,
2189
+ )
2190
+ self.transcript.output = output
2191
+ self.output = output
2192
+ self.prompt_session = session
2193
+ session.app.style = DynamicStyle(
2194
+ lambda: self.transcript.prompt_style(self.activity.task_style)
2195
+ )
2196
+ try:
2197
+ terminal = session.app.output.fileno()
2198
+ except (NotImplementedError, OSError, ValueError):
2199
+ terminal = None
2200
+ if terminal is not None:
2201
+ mode = load_preferences().get("terminal_progress", "auto")
2202
+ self._progress = TabProgress(self.activity, terminal, mode)
2203
+ # Any key here means the failed turn's red bar has been seen.
2204
+ session.app.key_processor.before_key_press += self._progress.key_pressed
2205
+ title_mode = load_preferences().get(
2206
+ "terminal_title", SETTINGS["terminal_title"].default
2207
+ )
2208
+ # Whichever controller is on screen: /switch replaces it.
2209
+ self._title = TabTitle(lambda: self.controller.session_title(), terminal, title_mode)
2210
+ self._appearance = AppearanceWatch(
2211
+ terminal, active=lambda: self.transcript.theme == "auto"
2212
+ )
2213
+
2214
+ async def watch_branch():
2215
+ """Keep the footer's branch current without a Git process every 2 s.
2216
+
2217
+ A branch moves because the session moved it, so the turn boundary
2218
+ below is the moment that matters and this loop only has to notice a
2219
+ checkout made in another terminal. Polling faster ran `git` ~1800
2220
+ times an hour and repainted the whole layout each time, for a value
2221
+ that changes once a session.
2222
+ """
2223
+ while True:
2224
+ if await asyncio.to_thread(self.refresh_branch):
2225
+ session.app.invalidate()
2226
+ await asyncio.sleep(BRANCH_POLL_SECONDS)
2227
+
2228
+ stalls = None
2229
+ if load_preferences().get("stall_log", SETTINGS["stall_log"].default) == "on":
2230
+ from pcode.stall_log import StallWatch
2231
+
2232
+ # Read from the watcher thread: plain attribute reads only.
2233
+ stalls = StallWatch(
2234
+ context=lambda: {"busy": self.activity.busy, "status": self.activity.status}
2235
+ )
2236
+
2237
+ def start():
2238
+ restore_stdin()
2239
+ replay_pending_input(session.app, self.follow_appearance)
2240
+ if stalls is not None:
2241
+ session.app.create_background_task(stalls.heartbeat())
2242
+ session.app.create_background_task(watch_branch())
2243
+ session.app.create_background_task(output.run())
2244
+ session.app.create_background_task(run_local_commands())
2245
+ if self._progress is not None:
2246
+ session.app.create_background_task(self._progress.run())
2247
+ if self._title is not None:
2248
+ session.app.create_background_task(self._title.run())
2249
+ if self._appearance is not None:
2250
+ session.app.create_background_task(self._appearance.run())
2251
+ if early is not None:
2252
+ session.app.create_background_task(initialize_host())
2253
+ else:
2254
+ self.run_controller(self.controller)
2255
+ session.app.create_background_task(initialize())
2256
+ if self.initial_prompt:
2257
+ # Queued like a typed message: it waits for the backend the same
2258
+ # way, and Ctrl+C clears it the same way.
2259
+ submit(self.initial_prompt)
2260
+
2261
+ try:
2262
+ await session.app.run_async(pre_run=start)
2263
+ # Before `leave_controller` closes the connection the stop goes out on.
2264
+ self.stop_host_on_exit()
2265
+ finally:
2266
+ if stalls is not None:
2267
+ stalls.stop()
2268
+ if self._progress is not None:
2269
+ self._progress.close()
2270
+ self._progress = None
2271
+ if self._title is not None:
2272
+ self._title.close()
2273
+ self._title = None
2274
+ if self._appearance is not None:
2275
+ self._appearance.close()
2276
+ self._appearance = None
2277
+ await self.leave_controller(self.controller)
2278
+ await output.flush(drain=True)
2279
+ self.transcript.output = None
2280
+ self.print_resume_hint()
2281
+
2282
+ def cancel(self) -> None:
2283
+ """Ctrl+C, for whichever controller this terminal is showing now."""
2284
+ self.controller.cancel()
2285
+
2286
+ def run_controller(self, controller: SessionController) -> None:
2287
+ """Start an in-process controller's loops, which this terminal owns."""
2288
+ app = self.prompt_session.app
2289
+ controller.closing = lambda: not app.is_running
2290
+ controller.interactive = True
2291
+ self._loops = [
2292
+ app.create_background_task(work)
2293
+ for work in (
2294
+ controller.watch_jobs(),
2295
+ controller.consume(),
2296
+ controller.consume_commands(),
2297
+ )
2298
+ ]
2299
+
2300
+ async def leave_controller(self, controller) -> None:
2301
+ """Stop showing `controller`: detach from a host, or end an in-process session."""
2302
+ if self.hosted and controller is self.controller:
2303
+ # Leave before the loop shuts down, so the connection closing
2304
+ # reads as this terminal detaching, not the host dying.
2305
+ controller.close()
2306
+ return
2307
+ if not isinstance(controller, SessionController):
2308
+ controller.close()
2309
+ return
2310
+ loops, self._loops = self._loops, []
2311
+ for task in loops:
2312
+ task.cancel()
2313
+ # Side questions outlive turns, not the terminal; nor does a title request.
2314
+ await controller.asides.close()
2315
+ await controller.stop_naming()
2316
+ for task in controller.tasks():
2317
+ if not task.done() and not task.cancelling():
2318
+ task.cancel()
2319
+ await asyncio.gather(*loops, *controller.tasks(), return_exceptions=True)
2320
+ if controller.extensions is not None:
2321
+ await controller.extensions.close()
2322
+ controller.extensions = None
2323
+
2324
+ def print_resume_hint(self) -> None:
2325
+ if self.hosted:
2326
+ runtime = self.runtime
2327
+ if self.host_stopped:
2328
+ line = "Stopped the session host."
2329
+ if runtime.session_id:
2330
+ line += f" Continue with: pcode --continue {runtime.session_id}"
2331
+ elif self.session_over:
2332
+ if not runtime.session_id:
2333
+ return
2334
+ line = f"Continue with: pcode --continue {runtime.session_id}"
2335
+ elif runtime.lost:
2336
+ line = f"The session host {runtime.id} had exited."
2337
+ else:
2338
+ line = (
2339
+ f"Session {runtime.id} keeps running in the background. "
2340
+ f"Reattach: pcode --attach {runtime.id}"
2341
+ )
2342
+ self.transcript.console.print(line, markup=False)
2343
+ return
2344
+ saved = getattr(self.runtime, "session", None)
2345
+ if saved is None:
2346
+ self.transcript.console.print("Session not saved; no continue command available.")
2347
+ else:
2348
+ from pcode.sessions import session_root
2349
+
2350
+ command = ["pcode", "--continue", saved.info.id]
2351
+ if saved.directory.parent.resolve() != session_root().resolve():
2352
+ command.extend(["--session-dir", str(saved.directory.parent.resolve())])
2353
+ self.transcript.console.print(f"Continue with: {shlex.join(command)}", markup=False)
2354
+
2355
+ def run(self) -> None:
2356
+ asyncio.run(_stopping_provider_processes(self.run_async()))
2357
+
2358
+ async def run_print_async(self, prompt: str, *, stdout=None) -> bool:
2359
+ """Answer one prompt without an editor: the reply to `stdout`, the rest to the transcript.
2360
+
2361
+ The transcript console is expected to be stderr, so a pipe reading
2362
+ stdout sees the reply alone. A terminal gets rendered Markdown; a pipe
2363
+ gets its source, which is what a reader downstream can work with.
2364
+ Returns whether the turn succeeded.
2365
+ """
2366
+ os.environ.setdefault("PYDANTIC_AI_NO_BANNER", "1")
2367
+ reply = PrintedReply(
2368
+ sys.stdout if stdout is None else stdout,
2369
+ transcript=self.transcript,
2370
+ present=self.present_events,
2371
+ )
2372
+ if not self.model:
2373
+ for event in self.preview.reply(prompt):
2374
+ if isinstance(event, Message):
2375
+ reply.write(event.markdown)
2376
+ return True
2377
+ # The tab looks busy from launch to exit: startup is part of the wait.
2378
+ self.activity.start_prompt(prompt)
2379
+ async with reply.tab_progress(self.activity).shown() as tab:
2380
+ try:
2381
+ await self._initialize_runtime()
2382
+ except Exception as error:
2383
+ self.transcript.error(error_message(error), title="Agent startup failed")
2384
+ return False
2385
+ if self._saved_session is not None and self._saved_session.forked_from:
2386
+ self.transcript.note(forked_note(self._saved_session))
2387
+
2388
+ def retry_notice(text: str) -> None:
2389
+ tab.turn_retry()
2390
+ self.transcript.note(text)
2391
+
2392
+ self.runtime.compaction_notice = self.transcript.note
2393
+ if hasattr(self.runtime, "retry_notice"):
2394
+ self.runtime.retry_notice = retry_notice
2395
+ if hasattr(self.runtime, "warning_notice"):
2396
+ self.runtime.warning_notice = self.transcript.warning
2397
+ try:
2398
+ async with aclosing(self.runtime.stream(prompt)) as stream:
2399
+ async for event in stream:
2400
+ tab.turn_event()
2401
+ if isinstance(event, PlanUpdated):
2402
+ self.activity.plan = event.items
2403
+ reply.event(event)
2404
+ if (job_id := delivered_job(event)) is not None:
2405
+ self.controller.report_delivered_job(job_id)
2406
+ except Exception as error:
2407
+ reply.settle()
2408
+ self.controller.report_finished_jobs()
2409
+ self.transcript.error(error_message(error), title="Agent failed")
2410
+ saved = getattr(self.runtime, "session", None)
2411
+ if saved is not None:
2412
+ self.transcript.note(f"Session and diagnostics: {saved.directory}")
2413
+ return False
2414
+ reply.settle()
2415
+ self.controller.report_finished_jobs()
2416
+ self.print_resume_hint()
2417
+ return True
2418
+
2419
+ def run_print(self, prompt: str) -> bool:
2420
+ return asyncio.run(_stopping_provider_processes(self.run_print_async(prompt)))
2421
+
2422
+
2423
+ def _profile_capture(args) -> tuple[Path, bool, bool] | None:
2424
+ """The capture this run should write: (directory, cpu tracing, memory tracing).
2425
+
2426
+ Flags win over the saved `profile` default, which exists so the everyday
2427
+ session that feels slow is captured without remembering to ask for it.
2428
+ """
2429
+ from pcode.profiling import PROFILE_MODES, new_capture
2430
+
2431
+ if args.no_profile:
2432
+ return None
2433
+ if args.profile is None:
2434
+ mode = load_preferences().get("profile", SETTINGS["profile"].default)
2435
+ if mode == "off" or mode not in PROFILE_MODES:
2436
+ return None
2437
+ return new_capture(), mode == "cpu", mode == "memory"
2438
+ # `--profile` without DIR names its own directory under the state directory.
2439
+ directory = new_capture() if args.profile is True else args.profile
2440
+ return directory, args.profile_cpu, args.profile_memory
2441
+
2442
+
2443
+ def main() -> None:
2444
+ parser = argparse.ArgumentParser(
2445
+ description="Streaming terminal with a Coder agent",
2446
+ epilog=f"Global defaults, without starting a session: pcode {CONFIG_USAGE}",
2447
+ )
2448
+ # The workspace's `.pcode/preferences.json` overlays user defaults, so it has
2449
+ # to be known before the first load_preferences() (the --theme default).
2450
+ _select_project_root(sys.argv[1:])
2451
+ parser.add_argument(
2452
+ "--version", action="version", version=f"pcode {importlib.metadata.version('pcode')}"
2453
+ )
2454
+ parser.add_argument(
2455
+ "--theme",
2456
+ choices=THEMES,
2457
+ default=load_preferences().get("theme", SETTINGS["theme"].default),
2458
+ help="Color theme (default: saved preference)",
2459
+ )
2460
+ parser.add_argument(
2461
+ "-m",
2462
+ "--model",
2463
+ help="Pydantic Agent model string; omitted = saved default or offline preview",
2464
+ )
2465
+ parser.add_argument(
2466
+ "-C", "--workspace", type=Path, help="Coder workspace (default: current directory)"
2467
+ )
2468
+ parser.add_argument(
2469
+ "--worktree",
2470
+ nargs="?",
2471
+ const=True,
2472
+ metavar="NAME",
2473
+ help="Work in a fresh .worktrees/NAME git worktree (default NAME: the session ID)",
2474
+ )
2475
+ parser.add_argument(
2476
+ "--no-worktree",
2477
+ action="store_true",
2478
+ help="Stay in the current checkout even when the worktree default is on",
2479
+ )
2480
+ # One positional list serves both the message and the `config` subcommand,
2481
+ # so an unquoted `pcode fix the bug` works and `config` needs no subparser.
2482
+ parser.add_argument(
2483
+ "prompt",
2484
+ nargs="*",
2485
+ metavar="PROMPT",
2486
+ help="Send this message first; with --print, read stdin when omitted",
2487
+ )
2488
+ parser.add_argument(
2489
+ "-p",
2490
+ "--print",
2491
+ action="store_true",
2492
+ help="Answer PROMPT without the editor: reply on stdout, tool activity on stderr",
2493
+ )
2494
+ parser.add_argument(
2495
+ "--theme-preview",
2496
+ # The flag was named --demo before it grew the style gallery; keep the
2497
+ # old spelling working for scripts and the Homebrew smoke test.
2498
+ "--demo",
2499
+ dest="theme_preview",
2500
+ action="store_true",
2501
+ help="Print an offline sample and the syntax-style gallery, then exit",
2502
+ )
2503
+ parser.add_argument("--sessions", action="store_true", help="List saved sessions and exit")
2504
+ parser.add_argument(
2505
+ "--upgrade-meridian",
2506
+ action="store_true",
2507
+ help="Install or upgrade the Meridian proxy with npm, then exit",
2508
+ )
2509
+ parser.add_argument(
2510
+ "--compact",
2511
+ action="store_true",
2512
+ help="With --sessions: drop superseded step checkpoints and report the space freed",
2513
+ )
2514
+ parser.add_argument(
2515
+ "--completions",
2516
+ choices=COMPLETION_SHELLS,
2517
+ metavar="SHELL",
2518
+ help=f"Print a shell completion script ({', '.join(COMPLETION_SHELLS)}) and exit",
2519
+ )
2520
+ parser.add_argument(
2521
+ "-c",
2522
+ "--continue",
2523
+ dest="resume",
2524
+ nargs="?",
2525
+ const="latest",
2526
+ metavar="SESSION",
2527
+ help=(
2528
+ "Continue a session ID/prefix, or a copy of it if it is open elsewhere; "
2529
+ "omit SESSION for this directory's latest"
2530
+ ),
2531
+ )
2532
+ parser.add_argument(
2533
+ "--fork",
2534
+ action="store_true",
2535
+ help="With --continue: always continue a copy, leaving the original session unchanged",
2536
+ )
2537
+ parser.add_argument(
2538
+ "--session-dir", type=Path, help="Override the private session storage directory"
2539
+ )
2540
+ parser.add_argument(
2541
+ "--no-save", action="store_true", help="Keep this live session in memory only"
2542
+ )
2543
+ parser.add_argument(
2544
+ "--host",
2545
+ action=argparse.BooleanOptionalAction,
2546
+ default=None,
2547
+ help="Run the conversation in a background session host that outlives this terminal "
2548
+ "(the default); --no-host runs it inside this terminal",
2549
+ )
2550
+ complete_with(
2551
+ parser.add_argument(
2552
+ "--attach",
2553
+ nargs="?",
2554
+ const="",
2555
+ metavar="HOST",
2556
+ help="Attach to a running session host by host or session ID prefix; "
2557
+ "omit HOST for the most recent",
2558
+ ),
2559
+ "hosts",
2560
+ )
2561
+ parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
2562
+ parser.add_argument(
2563
+ "--stop-hosts",
2564
+ choices=("all", "stale"),
2565
+ help="Stop every session host, or those running older pcode code, and exit",
2566
+ )
2567
+ parser.add_argument(
2568
+ "--email-listen",
2569
+ action="store_true",
2570
+ help="Take tasks by email from the account set up with --email-setup; "
2571
+ "each starts a sandboxed background session in its own worktree",
2572
+ )
2573
+ parser.add_argument(
2574
+ "--email-ttl",
2575
+ default="8h",
2576
+ metavar="DURATION",
2577
+ help="With --email-listen: stop listening after this long (default 8h)",
2578
+ )
2579
+ parser.add_argument(
2580
+ "--email-setup",
2581
+ nargs="?",
2582
+ const="",
2583
+ metavar="ADDRESS",
2584
+ help="Store a Gmail app password in the keychain and enable --email-listen for it",
2585
+ )
2586
+ parser.add_argument(
2587
+ "--profile",
2588
+ type=Path,
2589
+ nargs="?",
2590
+ const=True,
2591
+ metavar="DIR",
2592
+ help="Sample process-tree CPU/RSS to a new private DIR (default: the state directory)",
2593
+ )
2594
+ parser.add_argument(
2595
+ "--no-profile",
2596
+ action="store_true",
2597
+ help="Skip the capture this run even when the profile default is on",
2598
+ )
2599
+ parser.add_argument(
2600
+ "--profile-cpu",
2601
+ action="store_true",
2602
+ help="Also trace function CPU time across threads (much slower; requires --profile)",
2603
+ )
2604
+ parser.add_argument(
2605
+ "--profile-memory",
2606
+ action="store_true",
2607
+ help="Also trace Python allocations (slower; requires --profile)",
2608
+ )
2609
+ args = parser.parse_args()
2610
+ if args.completions:
2611
+ from pcode.completion import install_hint, render
2612
+
2613
+ print(render(parser, args.completions), end="")
2614
+ print(f"# Install: {install_hint(args.completions)}")
2615
+ return
2616
+ if args.upgrade_meridian:
2617
+ from pcode.meridian_setup import upgrade_meridian
2618
+
2619
+ sys.exit(upgrade_meridian())
2620
+ args.command = None
2621
+ if args.resume is not None:
2622
+ from pcode.sessions import is_session_selector
2623
+
2624
+ if not is_session_selector(args.resume):
2625
+ # SESSION is optional, so argparse would otherwise swallow the first
2626
+ # word of `pcode -c fix the bug`. Anything unlike an ID is prompt text.
2627
+ args.prompt.insert(0, args.resume)
2628
+ args.resume = "latest"
2629
+ if args.prompt and args.prompt[0] == "config":
2630
+ args.command = "config"
2631
+ args.arguments = args.prompt[1:]
2632
+ args.prompt = " ".join(args.prompt).strip() or None
2633
+ if (args.profile_cpu or args.profile_memory) and args.profile is None:
2634
+ parser.error("--profile-cpu and --profile-memory require --profile")
2635
+ if args.profile is not None and args.no_profile:
2636
+ parser.error("--profile and --no-profile are mutually exclusive")
2637
+ if args.worktree and args.no_worktree:
2638
+ parser.error("--worktree and --no-worktree are mutually exclusive")
2639
+ with ExitStack() as stack:
2640
+ capture = _profile_capture(args)
2641
+ if capture is not None:
2642
+ from pcode.profiling import profile_session, prune_captures
2643
+
2644
+ directory, cpu, memory = capture
2645
+ try:
2646
+ stack.enter_context(profile_session(directory, cpu=cpu, memory=memory))
2647
+ except (OSError, ValueError) as error:
2648
+ parser.error(
2649
+ f"Cannot start profile ({type(error).__name__}); use a new writable DIR"
2650
+ )
2651
+ # Only automatic captures are pruned, and only once this one exists,
2652
+ # so retention counts the directory the session is writing to.
2653
+ if not isinstance(args.profile, Path):
2654
+ prune_captures()
2655
+ _run_cli(args, parser)
2656
+
2657
+
2658
+ def _select_project_root(argv: list[str]) -> None:
2659
+ """Point preferences at `-C DIR` (else the cwd) ahead of full argument parsing."""
2660
+ from pcode.preferences import set_project_root
2661
+
2662
+ root = Path.cwd()
2663
+ for index, arg in enumerate(argv):
2664
+ if arg in ("-C", "--workspace") and index + 1 < len(argv):
2665
+ root = Path(argv[index + 1])
2666
+ elif arg.startswith("--workspace="):
2667
+ root = Path(arg.partition("=")[2])
2668
+ elif arg.startswith("-C") and len(arg) > 2 and not arg.startswith("--"):
2669
+ root = Path(arg[2:])
2670
+ set_project_root(root if root.is_dir() else Path.cwd())
2671
+
2672
+
2673
+ def _enter_worktree(
2674
+ workspace: Path, requested, *, always: bool = False, base: str | None = None
2675
+ ) -> tuple[Path, str | None]:
2676
+ """Create the session's worktree when asked to, returning (workspace, session id).
2677
+
2678
+ `requested` is None (use the `worktree` setting), True (unnamed), or a
2679
+ name. Session worktrees are named `pcode-<name>` so `git worktree list`
2680
+ and `git branch` show which ones pcode made; unnamed ones use the session
2681
+ ID's prefix, so `pcode -c <prefix>` finds the session. Already inside a
2682
+ linked worktree, or outside git, the workspace is left alone rather than
2683
+ nested. `always` (a remote host) makes one regardless, and fails outside
2684
+ git; `base` is the commit or branch it starts from, else the mainline.
2685
+ """
2686
+ from uuid import uuid4
2687
+
2688
+ from pcode import worktree
2689
+
2690
+ if requested is None and load_preferences().get("worktree", "off") != "on":
2691
+ return workspace, None
2692
+ if worktree.main_checkout(workspace) is None:
2693
+ if requested is None and not always:
2694
+ return workspace, None
2695
+ raise worktree.WorktreeError(
2696
+ f"--worktree needs a git repository; {workspace} is not in one."
2697
+ )
2698
+ if worktree.is_linked(workspace) and not always:
2699
+ return workspace, None
2700
+ identity = str(uuid4())
2701
+ name = requested if isinstance(requested, str) else identity[:8]
2702
+ created = worktree.create(workspace, SESSION_WORKTREE_PREFIX + name, base)
2703
+ try:
2704
+ worktree.run_setup(created, stream=sys.stderr)
2705
+ except worktree.WorktreeError:
2706
+ worktree.remove(created, force=True)
2707
+ raise
2708
+ print(f"worktree: {created.path} (branch {created.branch})", file=sys.stderr)
2709
+ return created.path, identity
2710
+
2711
+
2712
+ def _leave_worktree_on_exit(app, ask=input, stream=None) -> None:
2713
+ """`leave_worktree` for the process exit: notes go to stderr, and a deleted
2714
+ session is dropped from the runtime so nothing writes to it afterwards."""
2715
+ stream = stream or sys.stderr
2716
+ session = getattr(app.runtime, "session", None)
2717
+ deleted = leave_worktree(
2718
+ app.workspace, session, ask=ask, notify=lambda text: print(text, file=stream)
2719
+ )
2720
+ if deleted:
2721
+ app.runtime.session = None
2722
+
2723
+
2724
+ async def _stopping_provider_processes(main):
2725
+ """Run `main`, then stop the `claude:` CLI processes this event loop started."""
2726
+ from pcode.claude_sdk import shutdown
2727
+
2728
+ try:
2729
+ return await main
2730
+ finally:
2731
+ await shutdown()
2732
+
2733
+
2734
+ def forked_note(saved) -> str:
2735
+ """Say where a copied session came from, and that the two share a workspace."""
2736
+ note = f"Continuing a copy of session {saved.forked_from}"
2737
+ note += "." if saved.fork_requested else ", which is open in another process."
2738
+ active = saved.tree.nodes.get(saved.tree.active) if saved.tree.active else None
2739
+ if active is not None and active.status == "interrupted":
2740
+ note += " Its running turn was copied up to its last safe step; /resend carries it on."
2741
+ return note + f" Both sessions work in {saved.info.workspace}."
2742
+
2743
+
2744
+ def _resume_workspace(info, requested: Path | None) -> Path:
2745
+ """Where to continue a session whose own workspace may have been deleted.
2746
+
2747
+ A session worktree is removed from outside the session that owns it -- a
2748
+ merge from a sibling session, `/worktree clean`, `git worktree prune` --
2749
+ and the conversation is still worth resuming afterwards. Prefer an explicit
2750
+ `-C DIR`, already checked to be the same repository, then the checkout the
2751
+ worktree was made from. Refusing outright would strand the session on a
2752
+ path nothing can recreate.
2753
+ """
2754
+ from pcode.sessions import SessionError
2755
+
2756
+ workspace = Path(info.workspace)
2757
+ if workspace.is_dir():
2758
+ return workspace
2759
+ for candidate in (requested, Path(info.project) if info.project else None):
2760
+ if candidate is not None and candidate.is_dir():
2761
+ print(
2762
+ f"workspace: {workspace} no longer exists; continuing in {candidate}",
2763
+ file=sys.stderr,
2764
+ )
2765
+ return candidate
2766
+ raise SessionError(
2767
+ f"The session's workspace {workspace} no longer exists, and neither does its "
2768
+ f"project checkout. Continue it elsewhere with `pcode -C DIR --continue {info.id}`."
2769
+ )
2770
+
2771
+
2772
+ def _stopped_session(selector: str, root: Path | None = None) -> str | None:
2773
+ """The saved session `--attach SELECTOR` means when no running host matches it.
2774
+
2775
+ A stopped host's ID still finds its session while /switch lists it; any
2776
+ session ID prefix finds a saved session.
2777
+ """
2778
+ from pcode.host_protocol import list_hosts, list_stopped
2779
+ from pcode.sessions import SessionError, resolve_session
2780
+
2781
+ if any(
2782
+ entry.id.startswith(selector) or entry.session_id.startswith(selector)
2783
+ for entry in list_hosts()
2784
+ ):
2785
+ return None # One host, or an ambiguous prefix find_host reports.
2786
+ stopped = {entry.session_id for entry in list_stopped() if entry.id.startswith(selector)}
2787
+ if len(stopped) == 1:
2788
+ return stopped.pop()
2789
+ if selector == "latest":
2790
+ return None # A word to resolve_session, not an ID.
2791
+ try:
2792
+ return resolve_session(selector, root).name
2793
+ except SessionError:
2794
+ return None
2795
+
2796
+
2797
+ def _pick_host(selector: str, workspace: Path):
2798
+ """`--attach [HOST]`: by prefix, else the latest in this repository, else the latest."""
2799
+ from pcode.host_protocol import find_host, list_hosts
2800
+ from pcode.worktree import repo_scope
2801
+
2802
+ if selector:
2803
+ return find_host(selector)
2804
+ entries = list_hosts()
2805
+ if not entries:
2806
+ raise LookupError("No session hosts are running; start one with pcode.")
2807
+ scope = repo_scope(workspace)
2808
+ here = [entry for entry in entries if repo_scope(Path(entry.workspace)) == scope]
2809
+ return (here or entries)[0]
2810
+
2811
+
2812
+ def _running_host(selector: str, session_dir: Path | None, workspace: Path):
2813
+ """The host already running this session, if any.
2814
+
2815
+ Continuing a session that is open elsewhere makes a copy of it, which is
2816
+ right for one open in another terminal's own process, but a host exists to
2817
+ be attached to: going there keeps one conversation instead of two.
2818
+ """
2819
+ from pcode.host_protocol import list_hosts
2820
+ from pcode.sessions import SessionError, resolve_session
2821
+
2822
+ try:
2823
+ identity = resolve_session(selector, session_dir, workspace).name
2824
+ except SessionError:
2825
+ return None # The usual resume path reports it.
2826
+ return next((entry for entry in list_hosts() if entry.session_id == identity), None)
2827
+
2828
+
2829
+ def _run_hosted(args: argparse.Namespace) -> None:
2830
+ """Run this terminal against a session host: a new one, or one already running.
2831
+
2832
+ The host is started from here so it inherits this terminal's environment;
2833
+ the terminal then only renders it. Trust is asked here too, since the host
2834
+ has nobody to ask. The host makes the worktree, as the process that works in it.
2835
+ """
2836
+ from pcode.remote import HostLaunch, spawn_host
2837
+
2838
+ if args.attach is not None:
2839
+ entry = _pick_host(args.attach, args.workspace or Path.cwd())
2840
+ launch, model, workspace = HostLaunch.running(entry), entry.model, Path(entry.workspace)
2841
+ else:
2842
+ from pcode.preferences import rejected_project_keys, set_project_root
2843
+ from pcode.project_trust import prompt_trust
2844
+ from pcode.sessions import SessionError, read_info, resolve_session
2845
+
2846
+ workspace = (args.workspace or Path.cwd()).resolve()
2847
+ if not workspace.is_dir():
2848
+ raise SessionError(f"Workspace {workspace} is not an existing directory.")
2849
+ set_project_root(workspace)
2850
+ if rejected := rejected_project_keys():
2851
+ print(
2852
+ f"pcode: ignoring user-only settings in .pcode/preferences.json: "
2853
+ f"{', '.join(rejected)} (set them with `pcode config set`)",
2854
+ file=sys.stderr,
2855
+ )
2856
+ prompt_trust(workspace, ask=ask)
2857
+ model = args.model or load_preferences().get("model")
2858
+ resume = None
2859
+ if args.resume:
2860
+ # One already running in a host was routed to --attach by the caller,
2861
+ # unless --fork asked for a copy of it.
2862
+ path = resolve_session(args.resume, args.session_dir, workspace)
2863
+ resume, model = path.name, read_info(path).model
2864
+ if not model:
2865
+ raise ValueError("A session host needs a model: pass -m or set a default with /model.")
2866
+ identity, process, log = spawn_host(
2867
+ model=model,
2868
+ workspace=workspace,
2869
+ resume=resume,
2870
+ session_dir=args.session_dir,
2871
+ no_save=args.no_save,
2872
+ worktree=args.worktree,
2873
+ no_worktree=args.no_worktree,
2874
+ fork=args.fork,
2875
+ )
2876
+ launch = HostLaunch(identity, process, log)
2877
+ app = PreviewApp(
2878
+ theme=args.theme,
2879
+ model=model,
2880
+ workspace=workspace,
2881
+ session_dir=args.session_dir,
2882
+ initial_prompt=args.prompt,
2883
+ host=launch,
2884
+ )
2885
+ try:
2886
+ app.run()
2887
+ finally:
2888
+ if app.hosted:
2889
+ app.runtime.close()
2890
+ if app.hosted and app.host_stopped:
2891
+ _tidy_stopped_host(app)
2892
+
2893
+
2894
+ def _print_hosted(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
2895
+ """`--attach --print`: one message or command for a running host, without the editor."""
2896
+ from pcode.remote import HostError
2897
+ from pcode.remote_print import print_to_host
2898
+ from pcode.rpc import RemoteError
2899
+
2900
+ try:
2901
+ entry = _pick_host(args.attach, args.workspace or Path.cwd())
2902
+ except LookupError as error:
2903
+ parser.exit(2, f"{error}\n")
2904
+ # Stdout carries the reply alone, as for a local --print.
2905
+ app = PreviewApp(theme=args.theme, console=Console(stderr=True))
2906
+ try:
2907
+ ok = asyncio.run(
2908
+ print_to_host(entry, args.prompt, transcript=app.transcript, present=app.present_events)
2909
+ )
2910
+ except KeyboardInterrupt:
2911
+ parser.exit(130) # It has said what it left running.
2912
+ # ValueError: a malformed line from the host (OSError covers timeouts and resets).
2913
+ except (HostError, RemoteError, OSError, ValueError) as error:
2914
+ parser.exit(2, error_message(error) + "\n")
2915
+ if not ok:
2916
+ parser.exit(1)
2917
+
2918
+
2919
+ def _email(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
2920
+ """`--email-setup` and `--email-listen` (`pcode.email_remote`)."""
2921
+ from pcode.email_remote import cli
2922
+ from pcode.email_remote.mailbox import SetupError
2923
+
2924
+ if args.email_setup is not None and args.email_listen:
2925
+ parser.error("--email-setup and --email-listen are separate steps")
2926
+ if args.prompt or args.print or args.attach is not None or args.resume:
2927
+ parser.error("--email-listen and --email-setup take no prompt or session options")
2928
+ try:
2929
+ if args.email_setup is not None:
2930
+ status = cli.setup(args.email_setup or None)
2931
+ else:
2932
+ status = cli.listen(args.workspace or Path.cwd(), ttl=args.email_ttl, model=args.model)
2933
+ except (SetupError, ValueError, OSError) as error:
2934
+ parser.exit(2, f"pcode: {error_message(error)}\n")
2935
+ except KeyboardInterrupt:
2936
+ parser.exit(130)
2937
+ sys.exit(status)
2938
+
2939
+
2940
+ def _tidy_stopped_host(app) -> None:
2941
+ """After `/stop`: the host kept its worktree so this terminal can ask, as a local exit does."""
2942
+ from pcode.remote import wait_for_exit_sync
2943
+ from pcode.sessions import SavedSession, SessionError
2944
+
2945
+ runtime = app.runtime
2946
+ wait_for_exit_sync(runtime.pid)
2947
+ saved = None
2948
+ if runtime.session_id:
2949
+ try:
2950
+ saved = SavedSession.open(runtime.session_id, app.session_dir)
2951
+ except SessionError:
2952
+ saved = None # Open elsewhere again, or gone: leave the session to them.
2953
+ try:
2954
+ leave_worktree(
2955
+ app.workspace, saved, ask=ask, notify=lambda text: print(text, file=sys.stderr)
2956
+ )
2957
+ finally:
2958
+ if saved is not None:
2959
+ saved.close()
2960
+
2961
+
2962
+ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
2963
+ if args.command == "config":
2964
+ try:
2965
+ print(configure(args.arguments))
2966
+ except (OSError, ValueError) as error:
2967
+ parser.exit(2, f"{error}\n")
2968
+ return
2969
+ if args.email_setup is not None or args.email_listen:
2970
+ _email(args, parser)
2971
+ return
2972
+ if args.resume and (args.no_save or args.theme_preview):
2973
+ parser.error("--continue cannot be combined with --no-save or --theme-preview")
2974
+ if args.fork and not args.resume:
2975
+ parser.error("--fork needs --continue")
2976
+ if args.fork and args.attach is not None:
2977
+ parser.error("--fork cannot be combined with --attach")
2978
+ if args.compact and not args.sessions:
2979
+ parser.error("--compact applies to --sessions")
2980
+ if args.print:
2981
+ if args.theme_preview or args.sessions:
2982
+ parser.error("--print cannot be combined with --theme-preview or --sessions")
2983
+ if args.prompt is None:
2984
+ if sys.stdin.isatty():
2985
+ parser.error("--print needs a PROMPT argument or text on stdin")
2986
+ args.prompt = sys.stdin.read()
2987
+ if not args.prompt.strip():
2988
+ parser.error("--print needs a non-empty prompt")
2989
+ if args.sessions:
2990
+ from pcode.sessions import compact_snapshots, list_sessions, session_root
2991
+
2992
+ console = Transcript(Console(), args.theme)
2993
+ records = list_sessions(args.session_dir)
2994
+ if not records:
2995
+ console.note("No saved sessions.")
2996
+ # Rows are plain so the listing can be piped; only the summary is a note.
2997
+ root = args.session_dir or session_root()
2998
+ freed = 0
2999
+ for info in records:
3000
+ line = f"{info.id} {info.status} {info.model} {info.workspace}"
3001
+ if args.compact:
3002
+ before, after = compact_snapshots(root / info.id)
3003
+ freed += before - after
3004
+ line += f" {(before - after) / 1_000_000:.0f} MB freed"
3005
+ console.print(Text(line))
3006
+ if args.compact:
3007
+ console.note(f"Reclaimed {freed / 1_000_000_000:.2f} GB. Open sessions were skipped.")
3008
+ return
3009
+ if args.theme_preview:
3010
+ # --theme-preview never constructs a provider, even when -m is also supplied.
3011
+ app = PreviewApp(theme=args.theme)
3012
+ app.transcript.welcome()
3013
+ # There is no mutable panel in the non-interactive sample.
3014
+ app.transcript.events(app.preview.demo(), show_tools=True)
3015
+ app.transcript.syntax_gallery()
3016
+ return
3017
+ if args.hosts or args.stop_hosts:
3018
+ from pcode.host_protocol import code_fingerprint, list_hosts
3019
+ from pcode.host_ui import host_row, ordered
3020
+
3021
+ code = code_fingerprint()
3022
+ entries = ordered(list_hosts())
3023
+ if args.stop_hosts:
3024
+ from pcode.remote import stop_entry
3025
+
3026
+ for entry in entries:
3027
+ if args.stop_hosts == "all" or entry.stale(code):
3028
+ asyncio.run(stop_entry(entry))
3029
+ print(f"Stopped {entry.id} {entry.label()}")
3030
+ return
3031
+ for entry in entries:
3032
+ print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
3033
+ if not entries:
3034
+ print("No session hosts are running.")
3035
+ return
3036
+ if (
3037
+ args.attach
3038
+ and not args.resume
3039
+ and (stopped := _stopped_session(args.attach, args.session_dir))
3040
+ ):
3041
+ # Its host stopped once idle; the conversation carries on as --continue would.
3042
+ args.attach, args.resume = None, stopped
3043
+ if args.print and args.attach is not None:
3044
+ _print_hosted(args, parser)
3045
+ return
3046
+ # Every interactive session with a model runs in a background host unless
3047
+ # asked not to; the canned preview (no model) has nothing to host.
3048
+ has_model = bool(args.model or args.resume or load_preferences().get("model"))
3049
+ hosted = args.attach is not None or (
3050
+ has_model
3051
+ and (
3052
+ args.host
3053
+ if args.host is not None
3054
+ else load_preferences().get("session_host", SETTINGS["session_host"].default) == "on"
3055
+ )
3056
+ )
3057
+ if args.resume and not args.fork and args.attach is None and not args.print:
3058
+ running = _running_host(args.resume, args.session_dir, args.workspace or Path.cwd())
3059
+ if running is not None:
3060
+ print(
3061
+ f"pcode: session {running.session_id[:8]} is running in background host "
3062
+ f"{running.id}; attaching to it.",
3063
+ file=sys.stderr,
3064
+ )
3065
+ args.attach, hosted = running.id, True
3066
+ if hosted and not args.print and not args.theme_preview:
3067
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
3068
+ parser.error("attaching to a session host needs a terminal")
3069
+ try:
3070
+ _run_hosted(args)
3071
+ except (LookupError, OSError, ValueError) as error:
3072
+ parser.exit(2, error_message(error) + "\n")
3073
+ return
3074
+ if not args.print and (not sys.stdin.isatty() or not sys.stdout.isatty()):
3075
+ parser.error("interactive mode needs a terminal; use --print PROMPT to answer without one")
3076
+ saved = None
3077
+ app = None
3078
+ try:
3079
+ if args.resume:
3080
+ from pcode.sessions import SavedSession, SessionError
3081
+
3082
+ saved = SavedSession.open(
3083
+ args.resume,
3084
+ args.session_dir,
3085
+ args.workspace or Path.cwd(),
3086
+ fork_if_open=True,
3087
+ fork=args.fork,
3088
+ )
3089
+ try:
3090
+ if args.model and args.model != saved.info.model:
3091
+ raise SessionError(
3092
+ "Cannot change models when resuming; start a new session instead."
3093
+ )
3094
+ if args.workspace and str(args.workspace.resolve()) != saved.info.workspace:
3095
+ # Another worktree of the same repository is fine: the session
3096
+ # goes back to its own directory. Another repository is not.
3097
+ from pcode.worktree import repo_scope
3098
+
3099
+ if repo_scope(args.workspace) != session_scope(saved.info):
3100
+ raise SessionError(
3101
+ "Workspace differs from the saved session; refusing cross-repo resume."
3102
+ )
3103
+ args.workspace = _resume_workspace(saved.info, args.workspace)
3104
+ except BaseException:
3105
+ saved.abandon()
3106
+ saved = None # Already closed; the `finally` below must not close it again.
3107
+ raise
3108
+ args.model = saved.info.model
3109
+ if not args.resume and not args.model:
3110
+ args.model = load_preferences().get("model")
3111
+ workspace = args.workspace or Path.cwd()
3112
+ if not workspace.is_dir():
3113
+ from pcode.sessions import SessionError
3114
+
3115
+ raise SessionError(f"Workspace {workspace} is not an existing directory.")
3116
+ from pcode.preferences import rejected_project_keys, set_project_root
3117
+
3118
+ set_project_root(workspace)
3119
+ if rejected := rejected_project_keys():
3120
+ print(
3121
+ f"pcode: ignoring user-only settings in .pcode/preferences.json: "
3122
+ f"{', '.join(rejected)} (set them with `pcode config set`)",
3123
+ file=sys.stderr,
3124
+ )
3125
+ from pcode.project_trust import prompt_trust
3126
+
3127
+ # Before the worktree, whose setup script is one of the things being
3128
+ # trusted. --print has no one to ask, so untrusted code is skipped.
3129
+ prompt_trust(workspace, ask=None if args.print else ask)
3130
+ session_id = None
3131
+ if not args.resume and not args.no_worktree and not args.theme_preview:
3132
+ workspace, session_id = _enter_worktree(workspace, args.worktree)
3133
+ app = PreviewApp(
3134
+ theme=args.theme,
3135
+ model=args.model,
3136
+ workspace=workspace,
3137
+ saved_session=saved,
3138
+ save=not args.no_save,
3139
+ session_dir=args.session_dir,
3140
+ resume=bool(args.resume),
3141
+ initial_prompt=args.prompt,
3142
+ session_id=session_id,
3143
+ # Keep stdout for the reply alone when printing.
3144
+ console=Console(stderr=True) if args.print else None,
3145
+ )
3146
+ if args.print:
3147
+ ok = app.run_print(args.prompt)
3148
+ _leave_worktree_on_exit(app, ask=None)
3149
+ if not ok:
3150
+ parser.exit(1)
3151
+ else:
3152
+ app.run()
3153
+ _leave_worktree_on_exit(app)
3154
+ except Exception as error:
3155
+ parser.exit(2, error_message(error) + "\n")
3156
+ finally:
3157
+ if app is not None and app.model and hasattr(app.runtime, "close"):
3158
+ app.runtime.close()
3159
+ if saved is not None:
3160
+ # A copy made for a resume that never started is removed, not listed.
3161
+ saved.close() if app is not None else saved.abandon()
3162
+
3163
+
3164
+ if __name__ == "__main__":
3165
+ main()