pcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/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()