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/host_protocol.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Wire format and rendezvous for session hosts.
|
|
2
|
+
|
|
3
|
+
A session host is a headless pcode process that owns one conversation: the
|
|
4
|
+
agent, its tools, and the journal. Terminals attach to it over a Unix socket,
|
|
5
|
+
send prompts, and render the same `pcode.runtime` events a local turn yields.
|
|
6
|
+
Closing a terminal only detaches it; the host keeps working.
|
|
7
|
+
|
|
8
|
+
Messages are one JSON object per line. Every running host leaves
|
|
9
|
+
`<id>.sock` and `<id>.json` in `host_dir()`; the JSON is what `/switch` lists
|
|
10
|
+
without connecting to anything. A host that stopped itself for being idle with
|
|
11
|
+
a turn nobody has seen leaves its entry in `stopped/<session id>.json`, so
|
|
12
|
+
`/switch` still lists the session and resumes it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pcode.runtime import (
|
|
23
|
+
CacheBust,
|
|
24
|
+
ChildPlan,
|
|
25
|
+
ChildText,
|
|
26
|
+
CommandOutput,
|
|
27
|
+
EditCompleted,
|
|
28
|
+
EditPreview,
|
|
29
|
+
JobFinished,
|
|
30
|
+
Message,
|
|
31
|
+
PlanPreview,
|
|
32
|
+
PlanUpdated,
|
|
33
|
+
RunStatus,
|
|
34
|
+
TextDelta,
|
|
35
|
+
Thinking,
|
|
36
|
+
ThinkingDelta,
|
|
37
|
+
ToolStarted,
|
|
38
|
+
ToolSummary,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Bumped on any incompatible message change. An editable install lets a host
|
|
42
|
+
# outlive the code that started it, so a mismatch is refused, not guessed at.
|
|
43
|
+
PROTOCOL = 2
|
|
44
|
+
|
|
45
|
+
# asyncio's default line limit is 64 KiB, and one tool result or an attach
|
|
46
|
+
# snapshot of a long conversation is far larger than that.
|
|
47
|
+
LINE_LIMIT = 256 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
# macOS caps a Unix socket path (sun_path) at 104 bytes including the NUL.
|
|
50
|
+
SOCKET_PATH_MAX = 103
|
|
51
|
+
|
|
52
|
+
EVENT_TYPES = {
|
|
53
|
+
cls.__name__: cls
|
|
54
|
+
for cls in (
|
|
55
|
+
Message,
|
|
56
|
+
ToolStarted,
|
|
57
|
+
ToolSummary,
|
|
58
|
+
JobFinished,
|
|
59
|
+
TextDelta,
|
|
60
|
+
ThinkingDelta,
|
|
61
|
+
Thinking,
|
|
62
|
+
RunStatus,
|
|
63
|
+
CacheBust,
|
|
64
|
+
PlanUpdated,
|
|
65
|
+
PlanPreview,
|
|
66
|
+
ChildPlan,
|
|
67
|
+
ChildText,
|
|
68
|
+
CommandOutput,
|
|
69
|
+
EditCompleted,
|
|
70
|
+
EditPreview,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def encode_event(event) -> dict:
|
|
76
|
+
name = type(event).__name__
|
|
77
|
+
if name not in EVENT_TYPES:
|
|
78
|
+
raise TypeError(f"{name} is not a pcode runtime event")
|
|
79
|
+
# Nested, not spread beside "kind" the way the journal writes events:
|
|
80
|
+
# EditPreview has a `kind` field of its own that would overwrite it.
|
|
81
|
+
return {"kind": name, "fields": asdict(event)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode_event(data: dict):
|
|
85
|
+
cls = EVENT_TYPES[data["kind"]]
|
|
86
|
+
values = data["fields"]
|
|
87
|
+
# Fields a newer host added are dropped rather than rejected.
|
|
88
|
+
return cls(**{f.name: values[f.name] for f in fields(cls) if f.name in values})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def dumps(message: dict) -> bytes:
|
|
92
|
+
return (json.dumps(message, ensure_ascii=False) + "\n").encode("utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def read_message(reader: asyncio.StreamReader) -> dict | None:
|
|
96
|
+
"""The next message, or None once the peer has gone."""
|
|
97
|
+
try:
|
|
98
|
+
line = await reader.readline()
|
|
99
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
100
|
+
return None
|
|
101
|
+
if not line:
|
|
102
|
+
return None
|
|
103
|
+
message = json.loads(line)
|
|
104
|
+
if not isinstance(message, dict):
|
|
105
|
+
raise ValueError("Session host message is not an object.")
|
|
106
|
+
return message
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def host_dir() -> Path:
|
|
110
|
+
if value := os.environ.get("PCODE_HOST_DIR"):
|
|
111
|
+
return Path(value).expanduser()
|
|
112
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
113
|
+
return state / "pcode" / "hosts"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def socket_path(identity: str, directory: Path | None = None) -> Path:
|
|
117
|
+
path = (directory or host_dir()) / f"{identity}.sock"
|
|
118
|
+
if len(os.fsencode(path)) > SOCKET_PATH_MAX:
|
|
119
|
+
raise OSError(
|
|
120
|
+
f"Socket path {path} is longer than a Unix socket allows; "
|
|
121
|
+
"set PCODE_HOST_DIR to a shorter directory."
|
|
122
|
+
)
|
|
123
|
+
return path
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class HostEntry:
|
|
128
|
+
"""What a running host says about itself, read without connecting to it."""
|
|
129
|
+
|
|
130
|
+
id: str
|
|
131
|
+
pid: int
|
|
132
|
+
model: str
|
|
133
|
+
workspace: str
|
|
134
|
+
protocol: int = PROTOCOL
|
|
135
|
+
session_id: str = ""
|
|
136
|
+
# "starting", "idle", or "working"; "stopped" for one `list_stopped` remembers.
|
|
137
|
+
state: str = "starting"
|
|
138
|
+
# The first prompt names the conversation; the last one says what it is doing.
|
|
139
|
+
title: str = ""
|
|
140
|
+
last_prompt: str = ""
|
|
141
|
+
started: float = field(default_factory=time.time)
|
|
142
|
+
updated: float = field(default_factory=time.time)
|
|
143
|
+
log: str = ""
|
|
144
|
+
# Finished turns, so a watcher polling every few seconds cannot miss a
|
|
145
|
+
# short one; and how the last of them ended.
|
|
146
|
+
turns: int = 0
|
|
147
|
+
outcome: str = ""
|
|
148
|
+
# Terminals showing it now, and whether a turn finished with none watching.
|
|
149
|
+
attached: int = 0
|
|
150
|
+
unseen: bool = False
|
|
151
|
+
# `code_fingerprint()` when the host started, to spot hosts on older code.
|
|
152
|
+
code: str = ""
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def socket(self) -> Path:
|
|
156
|
+
return socket_path(self.id)
|
|
157
|
+
|
|
158
|
+
def label(self) -> str:
|
|
159
|
+
return self.title or "(no prompt yet)"
|
|
160
|
+
|
|
161
|
+
def stale(self, current: str | None = None) -> bool:
|
|
162
|
+
"""Running code older than what is installed now (an editable install moves).
|
|
163
|
+
|
|
164
|
+
A host with no fingerprint predates fingerprints, so it is older too.
|
|
165
|
+
"""
|
|
166
|
+
return self.code != (current or code_fingerprint())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def code_fingerprint() -> str:
|
|
170
|
+
"""Changes whenever pcode's source on disk does: the newest source mtime."""
|
|
171
|
+
package = Path(__file__).parent
|
|
172
|
+
newest = max((path.stat().st_mtime_ns for path in package.rglob("*.py")), default=0)
|
|
173
|
+
return str(newest)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def claim(name: str, directory: Path | None = None) -> bool:
|
|
177
|
+
"""True for exactly one caller per `name`, across processes (one notification per turn)."""
|
|
178
|
+
directory = directory or host_dir()
|
|
179
|
+
try:
|
|
180
|
+
os.close(os.open(directory / f"{name}.claim", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
|
181
|
+
except FileExistsError:
|
|
182
|
+
return False
|
|
183
|
+
except OSError:
|
|
184
|
+
return False
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write(entry: HostEntry, target: Path) -> None:
|
|
189
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
190
|
+
temporary = target.with_suffix(".json.tmp")
|
|
191
|
+
fd = os.open(temporary, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
|
|
192
|
+
with os.fdopen(fd, "w") as file:
|
|
193
|
+
json.dump(asdict(entry), file)
|
|
194
|
+
os.replace(temporary, target)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read(path: Path) -> HostEntry | None:
|
|
198
|
+
try:
|
|
199
|
+
data = json.loads(path.read_text())
|
|
200
|
+
return HostEntry(**{f.name: data[f.name] for f in fields(HostEntry) if f.name in data})
|
|
201
|
+
except (OSError, ValueError, TypeError):
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def write_entry(entry: HostEntry, directory: Path | None = None) -> None:
|
|
206
|
+
entry.updated = time.time()
|
|
207
|
+
_write(entry, (directory or host_dir()) / f"{entry.id}.json")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def remove_entry(identity: str, directory: Path | None = None) -> None:
|
|
211
|
+
directory = directory or host_dir()
|
|
212
|
+
paths = [directory / f"{identity}{suffix}" for suffix in (".json", ".sock", ".json.tmp")]
|
|
213
|
+
for path in [*paths, *directory.glob(f"{identity}-*.claim")]:
|
|
214
|
+
try:
|
|
215
|
+
path.unlink()
|
|
216
|
+
except FileNotFoundError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _alive(pid: int) -> bool:
|
|
221
|
+
try:
|
|
222
|
+
os.kill(pid, 0)
|
|
223
|
+
except ProcessLookupError:
|
|
224
|
+
return False
|
|
225
|
+
except PermissionError:
|
|
226
|
+
return True
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def list_hosts(directory: Path | None = None) -> list[HostEntry]:
|
|
231
|
+
"""Running hosts, most recently active first; a dead host's files are removed."""
|
|
232
|
+
directory = directory or host_dir()
|
|
233
|
+
entries = []
|
|
234
|
+
if not directory.is_dir():
|
|
235
|
+
return entries
|
|
236
|
+
for path in directory.glob("*.json"):
|
|
237
|
+
if (entry := _read(path)) is None:
|
|
238
|
+
continue
|
|
239
|
+
if not _alive(entry.pid):
|
|
240
|
+
remove_entry(entry.id, directory)
|
|
241
|
+
continue
|
|
242
|
+
entries.append(entry)
|
|
243
|
+
return sorted(entries, key=lambda entry: entry.updated, reverse=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _stopped_path(session_id: str, directory: Path | None) -> Path:
|
|
247
|
+
return (directory or host_dir()) / "stopped" / f"{session_id}.json"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def remember_stopped(entry: HostEntry, directory: Path | None = None) -> None:
|
|
251
|
+
"""Keep a stopped host's unseen result listed until the session is opened again."""
|
|
252
|
+
if not entry.session_id:
|
|
253
|
+
return
|
|
254
|
+
stopped = HostEntry(**{**asdict(entry), "state": "stopped", "attached": 0})
|
|
255
|
+
stopped.updated = entry.updated # When it finished, not when it stopped.
|
|
256
|
+
_write(stopped, _stopped_path(entry.session_id, directory))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def forget_stopped(session_id: str, directory: Path | None = None) -> None:
|
|
260
|
+
_stopped_path(session_id, directory).unlink(missing_ok=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def list_stopped(directory: Path | None = None) -> list[HostEntry]:
|
|
264
|
+
"""Sessions whose host stopped before anyone saw their last turn, newest first.
|
|
265
|
+
|
|
266
|
+
One whose session runs in a host again is left out; that host forgets it.
|
|
267
|
+
"""
|
|
268
|
+
directory = directory or host_dir()
|
|
269
|
+
running = {entry.session_id for entry in list_hosts(directory)}
|
|
270
|
+
entries = []
|
|
271
|
+
for path in (directory / "stopped").glob("*.json"):
|
|
272
|
+
if (entry := _read(path)) is not None and entry.session_id not in running:
|
|
273
|
+
entries.append(entry)
|
|
274
|
+
return sorted(entries, key=lambda entry: entry.updated, reverse=True)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def find_host(selector: str, directory: Path | None = None) -> HostEntry:
|
|
278
|
+
"""A running host by ID or session ID prefix."""
|
|
279
|
+
matches = [
|
|
280
|
+
entry
|
|
281
|
+
for entry in list_hosts(directory)
|
|
282
|
+
if entry.id.startswith(selector)
|
|
283
|
+
or (entry.session_id and entry.session_id.startswith(selector))
|
|
284
|
+
]
|
|
285
|
+
if len(matches) != 1:
|
|
286
|
+
raise LookupError(
|
|
287
|
+
f"No running session host matches {selector!r}."
|
|
288
|
+
if not matches
|
|
289
|
+
else f"{selector!r} matches {len(matches)} session hosts; use more characters."
|
|
290
|
+
)
|
|
291
|
+
return matches[0]
|
pcode/host_ui.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The `/switch` chooser over running session hosts, and stopped ones nobody has seen."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.application import Application
|
|
7
|
+
from prompt_toolkit.document import Document
|
|
8
|
+
from prompt_toolkit.filters import Always, has_focus
|
|
9
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
10
|
+
from prompt_toolkit.layout import Layout
|
|
11
|
+
from prompt_toolkit.layout.containers import HSplit
|
|
12
|
+
from prompt_toolkit.widgets import Label, TextArea
|
|
13
|
+
|
|
14
|
+
from pcode.frame import Dialog
|
|
15
|
+
from pcode.host_protocol import HostEntry, code_fingerprint
|
|
16
|
+
from pcode.popup_ui import (
|
|
17
|
+
bind_list_paging,
|
|
18
|
+
fit_width,
|
|
19
|
+
popup_container,
|
|
20
|
+
popup_mouse,
|
|
21
|
+
popup_style,
|
|
22
|
+
steer_list_from_query,
|
|
23
|
+
)
|
|
24
|
+
from pcode.prefix_keys import PrefixKeys
|
|
25
|
+
|
|
26
|
+
_TITLE_WIDTH = 40
|
|
27
|
+
STATES = {"working": "● working", "idle": "○ idle ", "starting": "◌ starting"}
|
|
28
|
+
NEW = "✓ new "
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unseen(entry: HostEntry) -> bool:
|
|
32
|
+
"""Finished while no terminal was showing it, and not looked at since."""
|
|
33
|
+
return entry.unseen and entry.state != "working"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ordered(entries: list[HostEntry]) -> list[HostEntry]:
|
|
37
|
+
"""Finished-and-unseen first, then working, then the rest; newest activity first in each."""
|
|
38
|
+
|
|
39
|
+
def rank(entry: HostEntry) -> tuple[int, float]:
|
|
40
|
+
group = 0 if unseen(entry) else 1 if entry.state == "working" else 2
|
|
41
|
+
return group, -entry.updated
|
|
42
|
+
|
|
43
|
+
return sorted(entries, key=rank)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def age(seconds: float) -> str:
|
|
47
|
+
if seconds < 60:
|
|
48
|
+
return "just now"
|
|
49
|
+
if seconds < 3600:
|
|
50
|
+
return f"{int(seconds // 60)}m ago"
|
|
51
|
+
if seconds < 86400:
|
|
52
|
+
return f"{int(seconds // 3600)}h ago"
|
|
53
|
+
return f"{int(seconds // 86400)}d ago"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def host_row(
|
|
57
|
+
entry: HostEntry, current: str | None, now: float | None = None, *, code: str | None = None
|
|
58
|
+
) -> str:
|
|
59
|
+
"""One line per host; `code` is the installed fingerprint, to flag hosts on older code."""
|
|
60
|
+
now = time.time() if now is None else now
|
|
61
|
+
title = " ".join(entry.label().split())
|
|
62
|
+
if len(title) > _TITLE_WIDTH:
|
|
63
|
+
title = title[: _TITLE_WIDTH - 1] + "…"
|
|
64
|
+
marker = "▸" if entry.id == current else " "
|
|
65
|
+
state = NEW if unseen(entry) else STATES.get(entry.state, entry.state)
|
|
66
|
+
where = Path(entry.workspace).name
|
|
67
|
+
if entry.state == "stopped":
|
|
68
|
+
old = " · stopped" # Enter starts it again on the code installed now.
|
|
69
|
+
else:
|
|
70
|
+
old = " · old code" if code is not None and entry.stale(code) else ""
|
|
71
|
+
return f"{marker} {state} {title:<{_TITLE_WIDTH}} {where} · {age(now - entry.updated)}{old}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def status_rows(entry: HostEntry, now: float | None = None, *, code: str | None = None):
|
|
75
|
+
"""`/status` rows about the host itself, read from its status file."""
|
|
76
|
+
now = time.time() if now is None else now
|
|
77
|
+
terminals = f"{entry.attached} terminal{'s' if entry.attached != 1 else ''} attached"
|
|
78
|
+
rows = [("Host", f"{entry.id} · pid {entry.pid} · started {age(now - entry.started)}")]
|
|
79
|
+
rows.append(("Attached", terminals))
|
|
80
|
+
if entry.log:
|
|
81
|
+
rows.append(("Host log", entry.log))
|
|
82
|
+
if entry.stale(code):
|
|
83
|
+
rows.append(("Host code", "older than the pcode installed now; /restart updates it"))
|
|
84
|
+
return rows
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hosts_dialog(
|
|
88
|
+
entries: list[HostEntry],
|
|
89
|
+
*,
|
|
90
|
+
current: str | None,
|
|
91
|
+
input=None,
|
|
92
|
+
output=None,
|
|
93
|
+
style=None,
|
|
94
|
+
key_prefix: str | None = None,
|
|
95
|
+
):
|
|
96
|
+
"""Returns ("attach", id), ("stop", id), ("new", None), or None when cancelled.
|
|
97
|
+
|
|
98
|
+
For a stopped entry "attach" means resume it, and "stop" forget it.
|
|
99
|
+
"""
|
|
100
|
+
entries = ordered(entries)
|
|
101
|
+
code = code_fingerprint()
|
|
102
|
+
visible: list[HostEntry] = []
|
|
103
|
+
query = TextArea(height=1, prompt="Search: ", multiline=False)
|
|
104
|
+
choices = TextArea(
|
|
105
|
+
read_only=True,
|
|
106
|
+
wrap_lines=False,
|
|
107
|
+
scrollbar=True,
|
|
108
|
+
# Fit every row, not just the filtered ones, so searching does not resize the box.
|
|
109
|
+
width=fit_width([host_row(entry, current, code=code) for entry in entries]),
|
|
110
|
+
)
|
|
111
|
+
choices.window.cursorline = Always()
|
|
112
|
+
status = [""]
|
|
113
|
+
armed: list[str | None] = [None]
|
|
114
|
+
bindings = KeyBindings()
|
|
115
|
+
|
|
116
|
+
def selected() -> HostEntry | None:
|
|
117
|
+
row = choices.document.cursor_position_row
|
|
118
|
+
return visible[row] if row < len(visible) else None
|
|
119
|
+
|
|
120
|
+
def refresh():
|
|
121
|
+
previous = selected()
|
|
122
|
+
term = query.text.casefold()
|
|
123
|
+
visible[:] = [
|
|
124
|
+
entry
|
|
125
|
+
for entry in entries
|
|
126
|
+
if term in f"{entry.title} {entry.last_prompt} {entry.workspace} {entry.id}".casefold()
|
|
127
|
+
]
|
|
128
|
+
index = next((i for i, entry in enumerate(visible) if entry is previous), 0)
|
|
129
|
+
lines = [host_row(entry, current, code=code) for entry in visible]
|
|
130
|
+
position = sum(len(line) + 1 for line in lines[:index])
|
|
131
|
+
choices.buffer.set_document(
|
|
132
|
+
Document("\n".join(lines) or "No matching sessions.", position), bypass_readonly=True
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
query.buffer.on_text_changed += lambda _: refresh()
|
|
136
|
+
steer_list_from_query(bindings, query, choices)
|
|
137
|
+
bind_list_paging(bindings, choices, has_focus(choices) | has_focus(query))
|
|
138
|
+
|
|
139
|
+
@bindings.add("enter", eager=True)
|
|
140
|
+
def accept(event):
|
|
141
|
+
entry = selected()
|
|
142
|
+
if entry is not None:
|
|
143
|
+
event.app.exit(result=("attach", entry.id))
|
|
144
|
+
|
|
145
|
+
shortcuts = PrefixKeys(key_prefix)
|
|
146
|
+
shortcuts.set_help(
|
|
147
|
+
lambda: [
|
|
148
|
+
("Type", "Search in the search field"),
|
|
149
|
+
("↑/↓", "Select session"),
|
|
150
|
+
("PgUp/PgDn", "Page"),
|
|
151
|
+
("Ctrl+U/D", "Half page"),
|
|
152
|
+
("Enter", "Switch session"),
|
|
153
|
+
("Tab/Shift+Tab", "Switch search / list"),
|
|
154
|
+
("Delete", "Stop (or forget a stopped) session (twice; list focused)"),
|
|
155
|
+
("Esc", "Clear search when focused, otherwise cancel"),
|
|
156
|
+
("Ctrl+C", "Cancel"),
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@shortcuts.add("f", "Search")
|
|
161
|
+
def search(event):
|
|
162
|
+
event.app.layout.focus(query)
|
|
163
|
+
|
|
164
|
+
@shortcuts.add("n", "New session")
|
|
165
|
+
def new(event):
|
|
166
|
+
event.app.exit(result=("new", None))
|
|
167
|
+
|
|
168
|
+
@shortcuts.add("x", "Stop (twice)")
|
|
169
|
+
@bindings.add("delete", filter=has_focus(choices))
|
|
170
|
+
def stop(event):
|
|
171
|
+
entry = selected()
|
|
172
|
+
if entry is None:
|
|
173
|
+
return
|
|
174
|
+
if armed[0] == entry.id:
|
|
175
|
+
event.app.exit(result=("stop", entry.id))
|
|
176
|
+
return
|
|
177
|
+
armed[0] = entry.id
|
|
178
|
+
status[0] = (
|
|
179
|
+
f"Press {shortcuts.label('x')} again to remove {entry.label()[:40]} from this list. "
|
|
180
|
+
"/resume still opens it."
|
|
181
|
+
if entry.state == "stopped"
|
|
182
|
+
else f"Press {shortcuts.label('x')} again to stop {entry.id} ({entry.label()[:40]}). "
|
|
183
|
+
"Its turn is cancelled."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@bindings.add("escape", eager=True)
|
|
187
|
+
def escape(event):
|
|
188
|
+
if event.app.layout.has_focus(query) and query.text:
|
|
189
|
+
query.text = ""
|
|
190
|
+
else:
|
|
191
|
+
event.app.exit(result=None)
|
|
192
|
+
|
|
193
|
+
@bindings.add("c-c")
|
|
194
|
+
def cancel(event):
|
|
195
|
+
event.app.exit(result=None)
|
|
196
|
+
|
|
197
|
+
running = sum(entry.state != "stopped" for entry in entries)
|
|
198
|
+
working = sum(entry.state == "working" for entry in entries)
|
|
199
|
+
fresh = sum(unseen(entry) for entry in entries)
|
|
200
|
+
dialog = Dialog(
|
|
201
|
+
title="Sessions",
|
|
202
|
+
body=HSplit(
|
|
203
|
+
[
|
|
204
|
+
Label(
|
|
205
|
+
f"{running} running · {working} working · {fresh} new · ▸ this terminal",
|
|
206
|
+
dont_extend_height=True,
|
|
207
|
+
),
|
|
208
|
+
query,
|
|
209
|
+
choices,
|
|
210
|
+
Label(lambda: status[0], dont_extend_height=True),
|
|
211
|
+
Label(shortcuts.summary),
|
|
212
|
+
],
|
|
213
|
+
padding=1,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
refresh()
|
|
217
|
+
return Application(
|
|
218
|
+
layout=Layout(popup_container(dialog, shortcuts), focused_element=query),
|
|
219
|
+
key_bindings=shortcuts.key_bindings(bindings),
|
|
220
|
+
full_screen=True,
|
|
221
|
+
mouse_support=popup_mouse(shortcuts),
|
|
222
|
+
input=input,
|
|
223
|
+
output=output,
|
|
224
|
+
style=popup_style(style),
|
|
225
|
+
)
|
pcode/input_keys.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Normalize explicit terminal newline chords before vi sees Escape.
|
|
2
|
+
|
|
3
|
+
prompt_toolkit 3.0.53 does not decode CSI-u Ctrl+J/Shift+Enter and maps
|
|
4
|
+
xterm's modified Shift+Enter to ControlM (submit). Ctrl+/, the help key, gets
|
|
5
|
+
the same treatment so it still reaches pcode as Ctrl+_ in those modes. Keep this narrow: we do
|
|
6
|
+
not enable the Kitty keyboard protocol or claim general CSI-u support.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
|
10
|
+
from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE
|
|
11
|
+
from prompt_toolkit.keys import Keys
|
|
12
|
+
|
|
13
|
+
_NEWLINE_SEQUENCES = (
|
|
14
|
+
"\x1b[106;5u", # CSI-u Ctrl+J
|
|
15
|
+
"\x1b[27;5;106~", # xterm modifyOtherKeys Ctrl+J
|
|
16
|
+
"\x1b[13;2u", # CSI-u Shift+Enter
|
|
17
|
+
"\x1b[27;2;13~", # xterm modifyOtherKeys Shift+Enter
|
|
18
|
+
)
|
|
19
|
+
_HELP_SEQUENCES = (
|
|
20
|
+
"\x1b[47;5u", # CSI-u Ctrl+/
|
|
21
|
+
"\x1b[27;5;47~", # xterm modifyOtherKeys Ctrl+/
|
|
22
|
+
)
|
|
23
|
+
_ALIASES = {
|
|
24
|
+
**dict.fromkeys(_NEWLINE_SEQUENCES, Keys.ControlJ),
|
|
25
|
+
**dict.fromkeys(_HELP_SEQUENCES, Keys.ControlUnderscore),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def configure_newline_keys() -> None:
|
|
30
|
+
"""Register process-wide VT100 aliases, including for pipe-input tests."""
|
|
31
|
+
if any(ANSI_SEQUENCES.get(sequence) != key for sequence, key in _ALIASES.items()):
|
|
32
|
+
ANSI_SEQUENCES.update(_ALIASES)
|
|
33
|
+
# The parser caches prefixes globally; an earlier prompt may already
|
|
34
|
+
# have classified these sequences as unknown. Recompute after updates.
|
|
35
|
+
_IS_PREFIX_OF_LONGER_MATCH_CACHE.clear()
|