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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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()
|