pcli-agent 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 (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,328 @@
1
+ """Scrollable conversation history widget with streaming append support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any
8
+
9
+ from rich.console import Group, RenderableType
10
+ from rich.markdown import Markdown
11
+ from rich.syntax import Syntax
12
+ from rich.text import Text
13
+ from textual.containers import VerticalScroll
14
+ from textual.widgets import Collapsible, Static
15
+
16
+ _ROLE_LABELS = {
17
+ "user": "You",
18
+ "assistant": "pcli",
19
+ "system": "System",
20
+ "tool": "Tool",
21
+ "shell": "Shell",
22
+ "decision": "Decision",
23
+ }
24
+
25
+ # Caps how often a streaming message repaints. Re-rendering on every single
26
+ # token (unthrottled) is more redraws/sec than some terminals (e.g. VTE-based
27
+ # ones on Linux) can cleanly keep up with, and manifests as flicker/ghosted
28
+ # duplicate frames. 20fps is smooth to read and gentle on any terminal.
29
+ _MIN_REFRESH_INTERVAL_S = 1 / 20
30
+
31
+ # Tool name -> the argument holding its "payload" (real, unescaped multi-line
32
+ # text worth syntax-highlighting on its own) rather than a short scalar like
33
+ # a path. edit_file's replace mode (old_string+new_string) is handled
34
+ # separately below since it has two such arguments; this same entry also
35
+ # covers edit_file's insert mode (insert_after_line+new_string has no
36
+ # old_string, so it falls through to here) - delete mode (no big text
37
+ # payload at all) falls further through to the generic pretty-JSON path,
38
+ # which is fine for it. Anything not listed here just gets its whole
39
+ # arguments dict pretty-printed as indented JSON instead.
40
+ _CODE_ARG_BY_TOOL = {
41
+ "write_file": "content",
42
+ "run_shell": "command",
43
+ "run_shell_background": "command",
44
+ "edit_file": "new_string",
45
+ }
46
+
47
+ # A tool call rendered larger than this (in characters of its formatted body)
48
+ # is collapsed by default, same threshold spirit as tool *results* — small,
49
+ # common calls (read_file, grep, list_dir, ...) stay visible inline, only
50
+ # genuinely large payloads (a big write_file/edit_file, a long script) get
51
+ # tucked behind a click.
52
+ _LARGE_TOOL_CALL_THRESHOLD_CHARS = 500
53
+
54
+ # Roles whose output can pile up in the transcript and pollute the view -
55
+ # real reported complaint: /help, /memory, /subagent, a long !command all
56
+ # just print permanently with no way to reclaim the space. "user"/
57
+ # "assistant" are never wrapped (primary conversation content — "assistant"
58
+ # also streams into self._current via append_to_last/_flush, which needs a
59
+ # plain Static it can .update() in place). "decision" is deliberately
60
+ # excluded too - see test_chat_screen_decisions.py: a recorded decision is
61
+ # meant to stay always-visible, not tucked behind a click, unlike ordinary
62
+ # system notices/slash-command output.
63
+ _COLLAPSIBLE_MESSAGE_ROLES = frozenset({"system", "shell"})
64
+
65
+ # Below this, a message in one of the roles above still renders as a plain,
66
+ # permanent line, same as before - short confirmations ("Model set to
67
+ # 'x'.") don't need a collapse affordance. At or above it, it's substantial
68
+ # enough to be worth hiding once read - still expanded by default (the user
69
+ # just asked to see this; only the *option* to collapse it away is new),
70
+ # unlike tool calls/results/reasoning above which default to collapsed.
71
+ _COLLAPSIBLE_MESSAGE_THRESHOLD_CHARS = 300
72
+
73
+
74
+ def _format_tool_output(output: str) -> RenderableType:
75
+ """Pretty-prints/syntax-highlights JSON tool output; otherwise renders
76
+ verbatim as monospace text (not Markdown — tool output routinely contains
77
+ underscores/asterisks/etc. that Markdown would misinterpret)."""
78
+ stripped = output.strip()
79
+ if stripped[:1] in "{[":
80
+ parsed: Any = None
81
+ try:
82
+ parsed = json.loads(stripped)
83
+ except (json.JSONDecodeError, ValueError):
84
+ pass
85
+ if parsed is not None:
86
+ pretty = json.dumps(parsed, indent=2, ensure_ascii=False)
87
+ return Syntax(pretty, "json", word_wrap=True, background_color="default")
88
+ return Text(output, no_wrap=False, overflow="fold")
89
+
90
+
91
+ def _guess_lexer(path: str | None, code: str, default: str = "text") -> str:
92
+ if path:
93
+ try:
94
+ return Syntax.guess_lexer(path, code=code)
95
+ except Exception: # noqa: BLE001, S110 - lexer guessing is cosmetic, never fatal
96
+ pass
97
+ return default
98
+
99
+
100
+ def _format_tool_call_body(tool_name: str, arguments: dict[str, Any]) -> tuple[RenderableType, int]:
101
+ """Returns (renderable, char_count) — char_count drives the collapse
102
+ threshold in add_tool_call, measured from the actual formatted content
103
+ (code/JSON), not the raw arguments JSON string, so it reflects what the
104
+ user would actually see."""
105
+ path = arguments.get("path") if isinstance(arguments.get("path"), str) else None
106
+
107
+ if tool_name == "edit_file" and "old_string" in arguments and "new_string" in arguments:
108
+ remaining = {k: v for k, v in arguments.items() if k not in ("old_string", "new_string")}
109
+ old_code = str(arguments["old_string"])
110
+ new_code = str(arguments["new_string"])
111
+ lexer = _guess_lexer(path, old_code)
112
+ parts: list[RenderableType] = []
113
+ if remaining:
114
+ parts.append(Text(_format_remaining_args(remaining), style="dim"))
115
+ parts.append(Text("- old_string", style="bold red"))
116
+ parts.append(Syntax(old_code, lexer, word_wrap=True, background_color="default"))
117
+ parts.append(Text("+ new_string", style="bold green"))
118
+ parts.append(Syntax(new_code, lexer, word_wrap=True, background_color="default"))
119
+ return Group(*parts), len(old_code) + len(new_code)
120
+
121
+ code_arg = _CODE_ARG_BY_TOOL.get(tool_name)
122
+ if code_arg and code_arg in arguments and isinstance(arguments[code_arg], str):
123
+ remaining = {k: v for k, v in arguments.items() if k != code_arg}
124
+ code = arguments[code_arg]
125
+ default_lexer = "bash" if tool_name.startswith("run_shell") else "text"
126
+ lexer = _guess_lexer(path, code, default=default_lexer)
127
+ parts = []
128
+ if remaining:
129
+ parts.append(Text(_format_remaining_args(remaining), style="dim"))
130
+ parts.append(Syntax(code, lexer, word_wrap=True, background_color="default"))
131
+ return Group(*parts), len(code)
132
+
133
+ if not arguments:
134
+ return Text("(no arguments)", style="dim"), 0
135
+ pretty = json.dumps(arguments, indent=2, ensure_ascii=False)
136
+ return Syntax(pretty, "json", word_wrap=True, background_color="default"), len(pretty)
137
+
138
+
139
+ def _format_remaining_args(remaining: dict[str, Any]) -> str:
140
+ return ", ".join(f"{key}={value!r}" for key, value in remaining.items())
141
+
142
+
143
+ def _message_title(role: str, text: str) -> str:
144
+ """Collapsible title for a long system/shell message - mirrors
145
+ add_tool_result's "{name} — {char count} char(s)" convention. A shell
146
+ message's first line is "$ <command>" (see _run_shell_command below);
147
+ surfacing the command itself in the title is more useful than the
148
+ generic role label alone, same reasoning as showing the tool name for a
149
+ tool result."""
150
+ label = _ROLE_LABELS.get(role, role)
151
+ first_line = text.strip().splitlines()[0] if text.strip() else ""
152
+ if role == "shell" and first_line.startswith("$ "):
153
+ preview = first_line[2:]
154
+ if len(preview) > 60:
155
+ preview = preview[:57] + "..."
156
+ return f"{label}: {preview} — {len(text):,} char(s)"
157
+ return f"{label} — {len(text):,} char(s)"
158
+
159
+
160
+ class MessageView(VerticalScroll):
161
+ def __init__(self, **kwargs) -> None:
162
+ super().__init__(**kwargs)
163
+ self._current: Static | None = None
164
+ self._current_role: str = "assistant"
165
+ self._current_text = ""
166
+ self._last_refresh = 0.0
167
+ self._refresh_pending = False
168
+
169
+ def _scroll_end_if_at_bottom(self, was_at_bottom: bool) -> None:
170
+ """Auto-scroll is "stick to bottom", not "force to bottom": if the
171
+ user had already scrolled up (e.g. to read an expanded "Thinking" or
172
+ tool-result panel while a turn keeps streaming below it), further
173
+ content must not drag them back down and fight that scroll. Only
174
+ resumes once they scroll back to the bottom themselves. Callers must
175
+ capture is_vertical_scroll_end *before* mounting/updating content —
176
+ doing it after would always read as "not at bottom" once the
177
+ content just grew."""
178
+ if was_at_bottom:
179
+ self.scroll_end(animate=False)
180
+
181
+ def add_message(self, role: str, text: str = "") -> Static:
182
+ was_at_bottom = self.is_vertical_scroll_end
183
+ widget = Static(classes=f"message message-{role}")
184
+ widget.update(self._render_message(role, text))
185
+ if role in _COLLAPSIBLE_MESSAGE_ROLES and len(text) >= _COLLAPSIBLE_MESSAGE_THRESHOLD_CHARS:
186
+ self.mount(
187
+ Collapsible(
188
+ widget,
189
+ title=_message_title(role, text),
190
+ collapsed=False,
191
+ classes=f"message-collapsible message-collapsible-{role}",
192
+ )
193
+ )
194
+ else:
195
+ self.mount(widget)
196
+ self._current = widget
197
+ self._current_role = role
198
+ self._current_text = text
199
+ self._scroll_end_if_at_bottom(was_at_bottom)
200
+ self._last_refresh = time.monotonic()
201
+ return widget
202
+
203
+ def add_queued_user_message(self, text: str) -> Static:
204
+ """Shows a user message submitted while a turn is still in progress
205
+ (ChatScreen._turn_in_progress) — deliberately does NOT reassign
206
+ self._current/_current_role/_current_text the way add_message does.
207
+ Those track whatever widget append_to_last is currently streaming
208
+ text into; calling add_message here (a real prior bug) would hijack
209
+ that target mid-stream, silently redirecting the rest of the
210
+ in-flight turn's own reply (or reasoning, or a tool-call preview)
211
+ into this new user message's widget instead of its own. The queued
212
+ message is only folded into session.messages once the active turn
213
+ actually finishes (see ChatScreen._stream_response), so this widget
214
+ is purely a "received, will send once ready" visual - styled via
215
+ message-queued rather than a plain message-user."""
216
+ was_at_bottom = self.is_vertical_scroll_end
217
+ widget = Static(classes="message message-user message-queued")
218
+ widget.update(self._render_message("user", text))
219
+ self.mount(widget)
220
+ self._scroll_end_if_at_bottom(was_at_bottom)
221
+ return widget
222
+
223
+ def append_to_last(self, fragment: str) -> None:
224
+ if self._current is None:
225
+ self.add_message("assistant", fragment)
226
+ return
227
+ self._current_text += fragment
228
+ if time.monotonic() - self._last_refresh >= _MIN_REFRESH_INTERVAL_S:
229
+ self._flush()
230
+ elif not self._refresh_pending:
231
+ self._refresh_pending = True
232
+ self.set_timer(_MIN_REFRESH_INTERVAL_S, self._flush)
233
+
234
+ def _flush(self) -> None:
235
+ self._refresh_pending = False
236
+ self._last_refresh = time.monotonic()
237
+ if self._current is None:
238
+ return
239
+ was_at_bottom = self.is_vertical_scroll_end
240
+ self._current.update(self._render_message(self._current_role, self._current_text))
241
+ self._scroll_end_if_at_bottom(was_at_bottom)
242
+
243
+ def add_tool_call(self, tool_name: str, arguments_json: str, *, purpose: str | None = None) -> None:
244
+ """The "→ tool_name(...)" preview shown as soon as a call is
245
+ dispatched, before its result is known. Known code-bearing arguments
246
+ (write_file's content, run_shell's command, edit_file's old/new
247
+ strings) are syntax-highlighted with real line breaks instead of the
248
+ raw, single-line-escaped JSON string; anything else falls back to
249
+ pretty-printed JSON. Large calls collapse behind a click, mirroring
250
+ add_tool_result — small/common calls (read_file, grep, ...) stay
251
+ visible inline."""
252
+ was_at_bottom = self.is_vertical_scroll_end
253
+ try:
254
+ arguments = json.loads(arguments_json or "{}")
255
+ except (json.JSONDecodeError, ValueError):
256
+ arguments = None
257
+ if not isinstance(arguments, dict):
258
+ # Malformed/non-object arguments (shouldn't normally happen -
259
+ # AgentLoop's own dispatch would separately reject these) - fall
260
+ # back to the raw string rather than crashing the render.
261
+ self.add_message("tool", f"→ {tool_name}({arguments_json})")
262
+ self._scroll_end_if_at_bottom(was_at_bottom)
263
+ return
264
+
265
+ # "purpose" is shown on its own line below, not mixed into the
266
+ # argument listing/highlighting.
267
+ arguments = {k: v for k, v in arguments.items() if k != "purpose"}
268
+ body_renderable, char_count = _format_tool_call_body(tool_name, arguments)
269
+
270
+ if char_count > _LARGE_TOOL_CALL_THRESHOLD_CHARS:
271
+ parts: list[RenderableType] = []
272
+ if purpose:
273
+ parts.append(Text(purpose, style="italic"))
274
+ parts.append(body_renderable)
275
+ title = f"→ {tool_name}(...) — {char_count:,} char(s)"
276
+ body = Static(Group(*parts), classes="tool-result-body")
277
+ collapsible = Collapsible(body, title=title, collapsed=True, classes="tool-call-collapsible")
278
+ self.mount(collapsible)
279
+ else:
280
+ parts = [Text(f"→ {tool_name}", style="bold")]
281
+ if purpose:
282
+ parts.append(Text(purpose, style="italic"))
283
+ parts.append(body_renderable)
284
+ widget = Static(Group(*parts), classes="message message-tool")
285
+ self.mount(widget)
286
+ self._scroll_end_if_at_bottom(was_at_bottom)
287
+
288
+ def add_tool_result(self, tool_name: str, output: str, *, is_error: bool) -> None:
289
+ """Tool results render as a collapsed-by-default Collapsible (full
290
+ content available on demand) rather than a fixed-length preview —
291
+ the previous view-layer truncation hid content that was often
292
+ already fully available in memory at this point. Formatting is
293
+ handled by _format_tool_output; this never touches self._current
294
+ (no ongoing streaming state to track for a tool result)."""
295
+ was_at_bottom = self.is_vertical_scroll_end
296
+ status_icon = "✗" if is_error else "✓"
297
+ title = f"{status_icon} {tool_name} — {len(output):,} char(s)"
298
+ body = Static(_format_tool_output(output), classes="tool-result-body")
299
+ classes = "tool-result-collapsible" + (" error" if is_error else "")
300
+ collapsible = Collapsible(body, title=title, collapsed=True, classes=classes)
301
+ self.mount(collapsible)
302
+ self._scroll_end_if_at_bottom(was_at_bottom)
303
+
304
+ def add_reasoning(self, text: str) -> None:
305
+ """A reasoning/"thinking" model's chain-of-thought (delta.reasoning_content
306
+ — see llm/streaming.py), rendered collapsed by default: it's the
307
+ model's internal monologue, not its actual answer, and can run to
308
+ thousands of tokens. One-shot render like add_tool_result (never
309
+ streamed token-by-token into the DOM) — this is secondary content,
310
+ and the "busy" spinner already covers the in-progress case."""
311
+ if not text.strip():
312
+ return
313
+ was_at_bottom = self.is_vertical_scroll_end
314
+ title = f"\U0001f9e0 Thinking — {len(text):,} char(s)"
315
+ body = Static(Text(text, no_wrap=False, overflow="fold"), classes="tool-result-body")
316
+ collapsible = Collapsible(body, title=title, collapsed=True, classes="reasoning-collapsible")
317
+ self.mount(collapsible)
318
+ self._scroll_end_if_at_bottom(was_at_bottom)
319
+
320
+ def finish_streaming(self) -> None:
321
+ self._flush() # ensure the last throttled fragment(s) are actually shown
322
+ self._current = None
323
+ self._current_text = ""
324
+
325
+ @staticmethod
326
+ def _render_message(role: str, text: str) -> Group:
327
+ label = _ROLE_LABELS.get(role, role)
328
+ return Group(Text(label, style="bold"), Markdown(text or ""))
@@ -0,0 +1,99 @@
1
+ """Input subclass making paste (however it reaches the app) respect
2
+ expand_full_paste, instead of Textual's built-in always-first-line-only
3
+ behavior.
4
+
5
+ Two independent paths can deliver a paste, and both needed fixing:
6
+
7
+ 1. **Terminal-intercepted paste.** Most terminals (Windows Terminal, xterm,
8
+ GNOME Terminal, ...) intercept Shift+Insert (and middle-click, and
9
+ Ctrl+Shift+V) themselves and deliver the clipboard through the
10
+ already-enabled bracketed-paste ANSI channel — Textual turns that into an
11
+ `events.Paste` message, which `Input._on_paste` handles by taking only
12
+ `event.text.splitlines()[0]`. This is the path that fires in practice on
13
+ most setups, and overriding `_on_paste` here is what actually matters.
14
+ 2. **Literal keystroke.** Any terminal that instead passes Shift+Insert
15
+ through as a plain keystroke leaves Textual with nothing bound to it by
16
+ default (Input.BINDINGS has ctrl+v, not shift+insert) — plus Textual's
17
+ own ctrl+v (Input.action_paste) only reads text copied *within* the app
18
+ (App.clipboard's docstring says explicitly it doesn't track the OS
19
+ clipboard), which can't help here either. The explicit shift+insert
20
+ binding below reads the OS clipboard directly via pyperclip as a
21
+ fallback for this case.
22
+
23
+ Both paths funnel into the same `_apply_pasted_text`. By default
24
+ (`expand_full_paste=False`, e.g. the sessions-import path field) only the
25
+ first line is kept, matching Textual's own prior behavior — appropriate for
26
+ fields that are conceptually single-value. With `expand_full_paste=True`
27
+ (the main chat input), the *whole* paste is kept: since Input can only ever
28
+ display one line, a multi-line paste is shown as a compact "[Pasted N
29
+ lines]" placeholder while composing, and the real full text is substituted
30
+ back in at submit time via `consume_pending_paste()` — the placeholder is a
31
+ display affordance only, never the actual message content.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import ClassVar
37
+
38
+ import pyperclip
39
+ from textual import events
40
+ from textual.binding import Binding, BindingType
41
+ from textual.widgets import Input
42
+
43
+ from pcli.tui.widgets.paste_marker import PendingPaste, decide_paste
44
+
45
+
46
+ class PasteInput(Input):
47
+ BINDINGS: ClassVar[list[BindingType]] = [
48
+ Binding("shift+insert", "paste_from_os_clipboard", "Paste", show=False),
49
+ ]
50
+
51
+ def __init__(self, *args, expand_full_paste: bool = False, **kwargs) -> None:
52
+ super().__init__(*args, **kwargs)
53
+ self._expand_full_paste = expand_full_paste
54
+ self._pending_paste = PendingPaste()
55
+
56
+ def _on_paste(self, event: events.Paste) -> None:
57
+ """Overrides Input's own bracketed-paste handler — see module
58
+ docstring: this is the path that fires for most terminals'
59
+ Shift+Insert (and middle-click, Ctrl+Shift+V, ...).
60
+
61
+ Textual dispatches "_on_<event>"-named handlers from *every* class
62
+ in the MRO independently (see MessagePump._get_dispatch_methods) —
63
+ this is cooperative layering, not standard method-override
64
+ semantics, so merely defining _on_paste here does not stop
65
+ Input._on_paste from *also* running right after (which would
66
+ insert its own first-line text a second time, right after ours).
67
+ event.prevent_default() is what actually suppresses it — the
68
+ dispatch loop checks message._no_default_action before walking
69
+ further up the MRO."""
70
+ if event.text:
71
+ self._apply_pasted_text(event.text)
72
+ event.prevent_default()
73
+ event.stop()
74
+
75
+ def action_paste_from_os_clipboard(self) -> None:
76
+ """Explicit shift+insert binding, for terminals that pass it
77
+ through as a literal keystroke instead of intercepting it
78
+ themselves (in which case _on_paste above never fires)."""
79
+ try:
80
+ text = pyperclip.paste()
81
+ except pyperclip.PyperclipException as exc:
82
+ self.notify(f"Couldn't read the system clipboard: {exc}", severity="error")
83
+ return
84
+ if text:
85
+ self._apply_pasted_text(text)
86
+
87
+ def _apply_pasted_text(self, text: str) -> None:
88
+ decision = decide_paste(text, expand_full_paste=self._expand_full_paste)
89
+ if decision is None:
90
+ return
91
+ if decision.pending_marker is not None:
92
+ self._pending_paste.set(decision.pending_marker, decision.pending_full_text)
93
+ start, end = self.selection
94
+ self.replace(decision.text_to_insert, start, end)
95
+
96
+ def consume_pending_paste(self, text: str) -> str:
97
+ """Expands a still-present placeholder marker back to the full
98
+ clipboard text it stands in for — see PendingPaste.consume."""
99
+ return self._pending_paste.consume(text)
@@ -0,0 +1,69 @@
1
+ """Shared "what should a paste look like in the input box" decision, used by
2
+ both PasteInput (Input-based) and ChatInput (TextArea-based) — the two
3
+ widgets differ in how they actually insert text (Input.replace() takes flat
4
+ character offsets; TextArea.replace() takes row/col Locations), but the
5
+ placeholder/expansion behavior itself is one implementation, not two
6
+ diverging copies.
7
+
8
+ By default (expand_full_paste=False, e.g. the sessions-import path field)
9
+ only the first line is kept, matching Textual's own Input._on_paste
10
+ behavior — appropriate for fields that are conceptually single-value. With
11
+ expand_full_paste=True (the main chat input), the *whole* paste is kept: a
12
+ multi-line paste is shown as a compact "[Pasted N lines]" placeholder while
13
+ composing (never the raw multi-line text — this is what keeps a large
14
+ paste from growing an auto-growing input box), and the real full text is
15
+ substituted back in at submit time via PendingPaste.consume().
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+
23
+ @dataclass
24
+ class PasteDecision:
25
+ text_to_insert: str
26
+ pending_full_text: str | None = None
27
+ pending_marker: str | None = None
28
+
29
+
30
+ def decide_paste(text: str, *, expand_full_paste: bool) -> PasteDecision | None:
31
+ """Returns None for an empty clipboard/paste (nothing to insert)."""
32
+ lines = text.splitlines()
33
+ if not lines:
34
+ return None
35
+
36
+ if not expand_full_paste or len(lines) <= 1:
37
+ line = lines[0]
38
+ if not line:
39
+ return None
40
+ return PasteDecision(text_to_insert=line)
41
+
42
+ marker = f"[Pasted {len(lines)} lines]"
43
+ return PasteDecision(text_to_insert=marker, pending_full_text=text, pending_marker=marker)
44
+
45
+
46
+ class PendingPaste:
47
+ """Tracks a still-composing placeholder marker and the full text it
48
+ stands in for, until consume() expands it back at submit time (or the
49
+ marker gets edited away, in which case consume() is a no-op)."""
50
+
51
+ def __init__(self) -> None:
52
+ self._text: str | None = None
53
+ self._marker: str | None = None
54
+
55
+ def set(self, marker: str, full_text: str) -> None:
56
+ self._marker = marker
57
+ self._text = full_text
58
+
59
+ def consume(self, text: str) -> str:
60
+ """Expands a still-present placeholder marker back to the full
61
+ text it stands in for. Always clears the pending state — a
62
+ submitted turn resets it either way, so an edited-away or
63
+ already-used marker can't leak into a later, unrelated paste."""
64
+ pending_text, marker = self._text, self._marker
65
+ self._text = None
66
+ self._marker = None
67
+ if pending_text is not None and marker is not None and marker in text:
68
+ return text.replace(marker, pending_text)
69
+ return text
@@ -0,0 +1,133 @@
1
+ """Live cost/context/model/sandbox status line. Grows to a second line only
2
+ while a subagent is running (see spawn_subagent) — collapses back to one
3
+ line once it finishes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from textual.reactive import reactive
8
+ from textual.widgets import Static
9
+
10
+ from pcli.util.text import truncate
11
+
12
+ # Classic braille "circling" spinner frames.
13
+ _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
14
+ # Ticked only while busy, and well under the ~20fps ceiling that keeps
15
+ # repaints smooth on slower (e.g. VTE-based Linux) terminal renderers.
16
+ _SPINNER_INTERVAL_S = 0.1
17
+
18
+
19
+ def format_token_count(n: int) -> str:
20
+ if n >= 1_000_000:
21
+ return f"{n / 1_000_000:.1f}m"
22
+ if n >= 1_000:
23
+ return f"{n / 1_000:.1f}k"
24
+ return str(n)
25
+
26
+
27
+ _MODEL_NAME_MAX_LEN = 40
28
+
29
+
30
+ def format_model_name(model: str) -> str:
31
+ """Shortens an overly long model identifier for the status bar -
32
+ notably a full local file path, which a raw llama-server instance with
33
+ no --alias configured reports as its own "model" id (unlike LM Studio's
34
+ short, friendly names) - e.g. "E:\\...\\Mistral-Nemo-Instruct-2407-
35
+ Q4_K_M.gguf" down to "Mistral-Nemo-Instruct-2407-Q4_K_M". Only the
36
+ display is shortened; the underlying model string used for actual
37
+ requests is untouched. Falls back to plain truncation for anything
38
+ else still too long to fit the line."""
39
+ name = model
40
+ if "/" in name or "\\" in name:
41
+ name = name.replace("\\", "/").rsplit("/", 1)[-1]
42
+ if name.lower().endswith(".gguf"):
43
+ name = name[: -len(".gguf")]
44
+ return truncate(name, _MODEL_NAME_MAX_LEN)
45
+
46
+
47
+ class StatusBar(Static):
48
+ model: reactive[str] = reactive("")
49
+ session_cost_usd: reactive[float] = reactive(0.0)
50
+ total_tokens: reactive[int] = reactive(0)
51
+ context_used_tokens: reactive[int] = reactive(0)
52
+ context_limit_tokens: reactive[int] = reactive(0)
53
+ sandbox_backend: reactive[str] = reactive("n/a")
54
+ # True for the whole duration the agent is working on a turn (waiting on
55
+ # the LLM, streaming its reply, running tool calls, ...) — cleared only
56
+ # once the response has been fully printed.
57
+ busy: reactive[bool] = reactive(False)
58
+ # The main agent's own top-level tool-call count for the current turn -
59
+ # reset to 0 at the start of each turn (see ChatScreen._run_one_turn),
60
+ # incremented on each of its own tool_result chunks. Deliberately
61
+ # separate from subagent_tool_calls below: a subagent's internal calls
62
+ # are its own isolated count, not part of this one (spawn_subagent
63
+ # itself counts here as a single call).
64
+ main_tool_calls: reactive[int] = reactive(0)
65
+ # guardrails.max_tool_calls_per_turn as of the start of the current turn
66
+ # (re-read fresh each turn, since it's live-editable via /max-tool-calls
67
+ # -per-turn) - the denominator for the "n/N" display below. 0 means
68
+ # unlimited (same convention the guardrail itself uses, e.g. local-api
69
+ # mode), in which case only "n" is shown - there's no N to divide by.
70
+ main_tool_calls_limit: reactive[int] = reactive(0)
71
+ # Second line, shown only while non-None: a running subagent's progress.
72
+ subagent_task: reactive[str | None] = reactive(None)
73
+ subagent_tool_calls: reactive[int] = reactive(0)
74
+ subagent_last_tool: reactive[str | None] = reactive(None)
75
+ # True while /plan is active (see ChatScreen) — read/explore tools only.
76
+ plan_mode: reactive[bool] = reactive(False)
77
+
78
+ def __init__(self, *args, **kwargs) -> None:
79
+ super().__init__(*args, **kwargs)
80
+ self._spinner_index = 0
81
+
82
+ def on_mount(self) -> None:
83
+ self.set_interval(_SPINNER_INTERVAL_S, self._advance_spinner)
84
+ self._sync_height()
85
+
86
+ def watch_subagent_task(self, _task: str | None) -> None:
87
+ self._sync_height()
88
+
89
+ def _sync_height(self) -> None:
90
+ self.styles.height = 2 if self.subagent_task is not None else 1
91
+
92
+ def _advance_spinner(self) -> None:
93
+ if not self.busy:
94
+ return
95
+ self._spinner_index = (self._spinner_index + 1) % len(_SPINNER_FRAMES)
96
+ self.refresh()
97
+
98
+ def _render_line1(self) -> str:
99
+ context_part = ""
100
+ if self.context_limit_tokens:
101
+ pct = 100 * self.context_used_tokens / self.context_limit_tokens
102
+ context_part = (
103
+ f"ctx: {format_token_count(self.context_used_tokens)}/"
104
+ f"{format_token_count(self.context_limit_tokens)} ({pct:.0f}%) "
105
+ )
106
+ spinner_part = f"{_SPINNER_FRAMES[self._spinner_index]} Working... " if self.busy else ""
107
+ plan_part = "[PLAN MODE] " if self.plan_mode else ""
108
+ tools_part = (
109
+ f"{self.main_tool_calls}/{self.main_tool_calls_limit}"
110
+ if self.main_tool_calls_limit > 0
111
+ else str(self.main_tool_calls)
112
+ )
113
+ return (
114
+ f"{plan_part}"
115
+ f"{spinner_part}"
116
+ f"model: {format_model_name(self.model) if self.model else '-'} "
117
+ f"cost: ${self.session_cost_usd:.4f} "
118
+ f"{context_part}"
119
+ f"tokens: {format_token_count(self.total_tokens)} "
120
+ f"tools: {tools_part} "
121
+ f"sandbox: {self.sandbox_backend}"
122
+ )
123
+
124
+ def render(self) -> str:
125
+ line1 = self._render_line1()
126
+ if self.subagent_task is None:
127
+ return line1
128
+ detail = f", last: {self.subagent_last_tool}" if self.subagent_last_tool else ""
129
+ line2 = (
130
+ f"⟳ Subagent: {truncate(self.subagent_task, 60)} "
131
+ f"— {self.subagent_tool_calls} tool call(s){detail}"
132
+ )
133
+ return f"{line1}\n{line2}"