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,2070 @@
1
+ """Main chat screen: streams a conversation against the configured gateway,
2
+ dispatching tool calls through permissions + sandbox, persisting to a Session
3
+ after every turn."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import subprocess
10
+ import time
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+ from typing import ClassVar
14
+
15
+ from textual import work
16
+ from textual.app import ComposeResult, SuspendNotSupported
17
+ from textual.binding import BindingType
18
+ from textual.containers import Vertical
19
+ from textual.screen import Screen
20
+
21
+ from pcli.agent.activity import ActivityTracker, format_subagent_activity
22
+ from pcli.agent.compaction import maybe_compact
23
+ from pcli.agent.context_pruning import extract_purpose, prune_old_tool_results
24
+ from pcli.agent.loop import AgentLoop, ToolResultEvent
25
+ from pcli.agent.prompt import PLAN_MODE_REINFORCEMENT, build_system_prompt
26
+ from pcli.agent.runtime import (
27
+ build_agent_runtime,
28
+ build_permission_manager,
29
+ effective_max_tool_iterations,
30
+ record_tool_invocation,
31
+ )
32
+ from pcli.browser.session import BrowserSession
33
+ from pcli.config.settings import Settings, get_settings, remove_config_keys, update_config_file
34
+ from pcli.cost.context import (
35
+ ContextLimitTable,
36
+ compute_max_response_tokens,
37
+ current_context_usage,
38
+ looks_like_context_ceiling,
39
+ set_model_context_limit,
40
+ )
41
+ from pcli.cost.pricing_table import ModelPricing, PricingTable
42
+ from pcli.cost.tracker import CostTracker, cost_budget_reason
43
+ from pcli.llm.client import GatewayClient
44
+ from pcli.llm.errors import GatewayError
45
+ from pcli.llm.models import ChatMessage, ToolCall, Usage
46
+ from pcli.memory.extraction import extract_memory
47
+ from pcli.memory.models import render_memory_list, render_memory_section
48
+ from pcli.memory.store import clear_memory, read_memory, remove_entry
49
+ from pcli.permissions.guardrails import update_guardrails_fs_allowed_roots, update_guardrails_limits
50
+ from pcli.permissions.manager import AskCallback
51
+ from pcli.sandbox.base import Sandbox, SandboxSecurityError
52
+ from pcli.sandbox.subprocess_backend import RestrictedSubprocessSandbox
53
+ from pcli.session.export import export_session
54
+ from pcli.session.models import Message, Session, ToolInvocation
55
+ from pcli.session.store import SessionStore
56
+ from pcli.tools.artifacts import SessionArtifactStore
57
+ from pcli.tools.base import AskQuestionCallback, ToolContext
58
+ from pcli.tools.registry import ToolRegistry
59
+ from pcli.tools.toolbox.manager import ToolboxDiscoveryError, ToolboxManager
60
+ from pcli.tui.screens.ask_question_modal import ask_question_via_modal
61
+ from pcli.tui.screens.permission_modal import ask_via_modal
62
+ from pcli.tui.screens.subagent_activity_modal import SubagentActivityModal
63
+ from pcli.tui.shell_passthrough import run_passthrough_command
64
+ from pcli.tui.widgets.chat_input import ChatInput
65
+ from pcli.tui.widgets.command_suggestions import CommandSuggestions
66
+ from pcli.tui.widgets.message_view import MessageView
67
+ from pcli.tui.widgets.status_bar import StatusBar
68
+ from pcli.tui.widgets.status_pane import StatusPane
69
+
70
+ # Used for local-api-mode sessions: always $0, regardless of pricing.toml or
71
+ # the builtin table — a local model's name could otherwise coincidentally
72
+ # match a paid pattern there (e.g. "llama-3*") and show a fake nonzero cost.
73
+ _FREE_PRICING_TABLE = PricingTable(entries={}, default=ModelPricing())
74
+
75
+ # How long a second Escape press has to land after the first to count as a
76
+ # "confirm cancel" double-press (see ChatScreen.action_cancel_turn).
77
+ _ESCAPE_DOUBLE_PRESS_WINDOW_S = 0.6
78
+
79
+ # Shared between /subagent and the Ctrl+G live-activity panel (action_
80
+ # show_subagent_activity) - both need to say the same thing when there's
81
+ # nothing to show.
82
+ _NO_SUBAGENT_RUNNING_MESSAGE = "No subagent is currently running."
83
+
84
+ # A real debugged case: the model's response got cut off by the token limit
85
+ # mid-task (e.g. "Let me implement X:" with no tool call following, because
86
+ # there was no room left to make one) - previously indistinguishable from a
87
+ # deliberate, complete stop, so the turn just silently ended with genuinely
88
+ # unfinished work and the user had to notice and type "continue" themselves.
89
+ # _run_one_turn now does that automatically (see TurnCompleteEvent.
90
+ # response_truncated), capped at this many consecutive attempts so a model
91
+ # that's stuck hitting the limit every single time doesn't loop forever.
92
+ _MAX_CONSECUTIVE_AUTO_CONTINUES = 3
93
+ _AUTO_CONTINUE_MESSAGE = "Continue."
94
+
95
+ # Static reference shown by /help — kept as one literal string (not built from
96
+ # the _handle_command dispatch table) since the wording needs full sentences,
97
+ # not just the bare command list already in ChatInput's placeholder text.
98
+ _HELP_TEXT = """\
99
+ # Commands
100
+
101
+ - **/help** — show this help.
102
+ - **/sessions** — browse, switch, or import previous sessions.
103
+ - **/export [path]** — export the current session (default: pcli's data \
104
+ directory).
105
+ - **/models [name]** — switch models. With no argument, lists models \
106
+ available from the configured gateway and lets you pick one.
107
+ - **/compact** — manually summarize the conversation so far to free up \
108
+ context space (also happens automatically as the context fills up).
109
+ - **/timeout [seconds]** — view or set the per-request timeout to the \
110
+ gateway.
111
+ - **/temperature [value|off]** — view or set the sampling temperature sent \
112
+ with each request; `off` clears it so no temperature field is sent at all \
113
+ (gateway/model default applies).
114
+ - **/budget [amount|off]** — view or set a hard cap on this session's total \
115
+ spend; the agent loop stops itself (with a clear notice) once spend reaches \
116
+ it, rather than only reporting cost after the fact. `off` clears it (no cap).
117
+ - **/context-limit [tokens]** — view or set the context window pcli assumes \
118
+ for the current model (used for the context-usage display and \
119
+ auto-compaction).
120
+ - **/max-tool-iterations [n]** — view or set how many tool-call round-trips \
121
+ a single turn can make before it's cut off (ignored — always uncapped — in \
122
+ local-api mode).
123
+ - **/artifact-threshold [chars]** — view or set the tool-output length \
124
+ beyond which results are archived out of the live conversation and \
125
+ retrieved later via fetch_artifact.
126
+ - **/max-tool-calls-per-turn [n]** — view or set the guardrail cap on tool \
127
+ calls within a single turn (0 = unlimited; ignored — always unlimited — in \
128
+ local-api mode).
129
+ - **/max-tool-calls-per-minute [n]** — view or set the guardrail cap on \
130
+ tool calls per minute, across the whole session (0 = unlimited; ignored \
131
+ — always unlimited — in local-api mode).
132
+ - **/allowed-roots [add|remove] [path]** — view or edit the filesystem \
133
+ guardrail's allowed_roots list (default: just the working directory). A \
134
+ path outside every allowed_roots entry is denied outright, before any \
135
+ permission prompt — this is how to widen what read_file/write_file/\
136
+ edit_file/list_dir/etc. can reach, not a prompt you can approve your way \
137
+ past.
138
+ - **/prune-tool-results [off|on|n]** — view, toggle, or set how many \
139
+ recent turns' tool results stay verbatim before older ones are shrunk to \
140
+ a short placeholder (archived, retrievable via fetch_artifact) to save \
141
+ context — no LLM call involved, runs every turn.
142
+ - **/max-response-tokens [off|on|margin]** — view, toggle, or set the \
143
+ dynamic max_tokens cap sent with each request, leaving `margin` tokens of \
144
+ headroom below the model's context limit so a single response can't \
145
+ consume the entire remaining window by itself.
146
+ - **/rename [name]** — view or set the current session's title (shown in \
147
+ /sessions).
148
+ - **/theme [name]** — view the available Textual themes (built-in and \
149
+ pcli's own vim-* ones) and which is active, or switch live and remember \
150
+ it for next time.
151
+ - **/plan** — enter plan mode: the agent can only use read-only/exploration \
152
+ tools (no writes, edits, or shell commands) until you exit.
153
+ - **/build** — exit plan mode, restoring full tool access.
154
+ - **/toolbox** — discover, list, or remove toolbox tools (CLI programs/\
155
+ scripts wrapped as callable tools): `/toolbox discover NAME [path]`, \
156
+ `/toolbox list`, `/toolbox remove NAME`.
157
+ - **/subagent** — show the currently-running subagent's task and its full \
158
+ tool-call history so far, plus any question it's currently waiting on an \
159
+ answer to. Nothing to show once it finishes (only its final text and \
160
+ tool-call count come back to the main conversation).
161
+ - **/memory [forget <id>|clear]** — view pcli's global, cross-session \
162
+ memory of you (nature of work, preferences, conversation style, common \
163
+ asks — injected into every session's system prompt), remove one entry, or \
164
+ clear it all. Grows automatically from what you explicitly ask to be \
165
+ remembered and, when a session gets long enough to auto-compact, from \
166
+ what pcli notices worth keeping.
167
+
168
+ # Shell passthrough
169
+
170
+ - **!command** — run a shell command directly (bypasses the model), showing \
171
+ its output.
172
+ - **!!command** — same, but hides the output.
173
+ - **!!!command** — hand off a real interactive terminal to the command \
174
+ (passwords, REPLs, editors, ssh — anything needing a real TTY).
175
+
176
+ # Input box
177
+
178
+ - **Enter** sends your message; **Ctrl+J** or **Alt+Enter** inserts a \
179
+ newline for a multi-line message. The box grows to fit what you type (up \
180
+ to 10 lines) and shrinks back down; a paste never grows it.
181
+ - **Up** / **Down** recall previously-sent messages, while the current \
182
+ draft has no newline in it.
183
+ - Typing **/** shows matching slash commands with a one-line description; \
184
+ **Up** / **Down** moves the highlight, **Tab** or **Enter** accepts it \
185
+ (without sending), and **Escape** dismisses the list.
186
+ """
187
+
188
+ # Drives ChatInput's slash-command autocomplete (tui/widgets/command_
189
+ # suggestions.py) - a separate, terser list from _HELP_TEXT above (one-liners
190
+ # for a dropdown vs. full sentences for /help), so update both when adding,
191
+ # removing, or renaming a command. Order here is purely the order suggestions
192
+ # are listed in when several match (e.g. typing bare "/"), not otherwise
193
+ # significant - kept roughly matching _HELP_TEXT's own order for ease of
194
+ # cross-checking the two lists against each other.
195
+ _SLASH_COMMANDS: list[tuple[str, str]] = [
196
+ ("help", "Show the full command reference."),
197
+ ("sessions", "Browse, switch, or import previous sessions."),
198
+ ("export", "Export the current session."),
199
+ ("models", "Switch models, or list what's available."),
200
+ ("compact", "Summarize the conversation so far to free up context."),
201
+ ("timeout", "View or set the per-request gateway timeout."),
202
+ ("temperature", "View or set the sampling temperature."),
203
+ ("budget", "View or set a hard cap on this session's total spend."),
204
+ ("context-limit", "View or set the assumed context window for this model."),
205
+ ("max-tool-iterations", "View or set the per-turn tool-call iteration cap."),
206
+ ("artifact-threshold", "View or set the tool-output archiving threshold."),
207
+ ("max-tool-calls-per-turn", "View or set the guardrail cap on tool calls per turn."),
208
+ ("max-tool-calls-per-minute", "View or set the guardrail cap on tool calls per minute."),
209
+ ("allowed-roots", "View or edit which filesystem paths the agent can reach."),
210
+ ("prune-tool-results", "View, toggle, or set old tool-result pruning."),
211
+ ("max-response-tokens", "View, toggle, or set the dynamic response-length cap."),
212
+ ("rename", "View or set the current session's title."),
213
+ ("theme", "View or switch the TUI's color theme."),
214
+ ("plan", "Enter plan mode (read-only tools only)."),
215
+ ("build", "Exit plan mode, restoring full tool access."),
216
+ ("toolbox", "Discover, list, or remove toolbox tools."),
217
+ ("subagent", "Show the currently-running subagent's task and history."),
218
+ ("memory", "View, forget, or clear pcli's memory of you."),
219
+ ]
220
+
221
+ logger = logging.getLogger(__name__)
222
+
223
+
224
+ class ChatScreen(Screen):
225
+ # No custom quit binding needed: Textual's own App already binds ctrl+q
226
+ # to quit (with priority=True, so nothing here could override it even if
227
+ # we wanted to), and ctrl+c is reserved by Textual itself as a
228
+ # "press ctrl+q to quit" hint (App.action_help_quit) rather than quitting
229
+ # directly — deliberately, to avoid killing the app on a reflexive
230
+ # ctrl+c. A previous ("ctrl+c", "quit", "Quit") entry here never actually
231
+ # fired (confirmed: Textual's system-level binding for the same key
232
+ # always wins over a Screen-level one) and just misled anyone reading it.
233
+ BINDINGS: ClassVar[list[BindingType]] = [
234
+ ("escape", "cancel_turn", "Cancel turn (press twice)"),
235
+ ("ctrl+g", "show_subagent_activity", "Subagent activity"),
236
+ ]
237
+
238
+ def __init__(
239
+ self,
240
+ settings: Settings | None = None,
241
+ *,
242
+ session: Session | None = None,
243
+ store: SessionStore | None = None,
244
+ ) -> None:
245
+ super().__init__()
246
+ self._settings = settings or get_settings()
247
+ self._store = store or SessionStore()
248
+ self._client: GatewayClient | None = None
249
+ self._agent_loop: AgentLoop | None = None
250
+ self._permission_manager = build_permission_manager(self._settings)
251
+ self._sandbox: Sandbox | None = None
252
+ self._tool_registry: ToolRegistry | None = None
253
+ self._toolbox_manager: ToolboxManager | None = None
254
+ self._browser_session: BrowserSession | None = None
255
+ self._current_ask: AskCallback | None = None
256
+ self._current_ask_question: AskQuestionCallback | None = None
257
+ self._cwd = Path.cwd()
258
+ self._activity = ActivityTracker()
259
+ # Lets a message submitted while a turn is already running be
260
+ # queued and processed right after, instead of either being ignored
261
+ # or (the bug this fixes) silently cancelling the in-flight turn —
262
+ # see _stream_response/on_input_submitted.
263
+ self._turn_in_progress = False
264
+ # Raw text of user messages submitted while _turn_in_progress - kept
265
+ # out of session.messages (and, in the UI, shown via
266
+ # MessageView.add_queued_user_message rather than add_message) until
267
+ # the active turn actually finishes, so they can't land ahead of
268
+ # that turn's own assistant reply in the history, or hijack
269
+ # MessageView's in-flight streaming target - see on_chat_input_
270
+ # submitted/_stream_response for where this is populated/flushed.
271
+ self._queued_followups: list[str] = []
272
+ # Unrelated to the queue above - set only by the auto-continue-
273
+ # after-truncation path at the very end of a turn that's already
274
+ # finished (see _run_one_turn's response_truncated handling), purely
275
+ # to make _stream_response's loop run one more time.
276
+ self._has_queued_followup = False
277
+ self._last_escape_at = 0.0
278
+ self._plan_mode = False
279
+ self._consecutive_truncations = 0
280
+ """How many turns in a row ended because the response was cut off by
281
+ the token limit (TurnCompleteEvent.response_truncated), not a
282
+ deliberate stop - see _run_one_turn's own handling. Reset whenever a
283
+ turn completes without truncation, or the user sends a real message
284
+ of their own (see on_chat_input_submitted)."""
285
+
286
+ if session is not None:
287
+ self._session = session
288
+ else:
289
+ self._session = self._store.new_session(
290
+ model=self._settings.default_model,
291
+ gateway_base_url=self._settings.gateway_base_url,
292
+ working_dir=str(self._cwd),
293
+ )
294
+ extra_sections = []
295
+ if self._settings.memory_enabled:
296
+ memory_section = render_memory_section(read_memory().entries)
297
+ if memory_section:
298
+ extra_sections.append(memory_section)
299
+ self._session.messages.append(
300
+ Message(
301
+ role="system",
302
+ content=build_system_prompt(extra_sections=extra_sections or None),
303
+ )
304
+ )
305
+
306
+ pricing_table = _FREE_PRICING_TABLE if self._settings.is_local_api() else None
307
+ self._cost_tracker = CostTracker(self._session, pricing_table=pricing_table)
308
+ self._context_limit_table = ContextLimitTable.load()
309
+ # Set when the startup probe in _maybe_detect_context_limit comes up
310
+ # empty - worth one retry after the model has actually produced a
311
+ # response, since a local gateway (LM Studio, in particular) only
312
+ # reports a model's real loaded context size once it's actually
313
+ # resident, and JIT-loads on first inference. Consumed (reset False)
314
+ # by the one retry attempt in _run_one_turn, so a backend that
315
+ # genuinely can't be probed (a hosted API) doesn't get re-hit every
316
+ # turn for the rest of the session.
317
+ self._context_limit_retry_pending = False
318
+ self._artifact_store = SessionArtifactStore(self._store, self._session.id)
319
+
320
+ def compose(self) -> ComposeResult:
321
+ with Vertical():
322
+ yield StatusPane(id="status-pane")
323
+ yield MessageView(id="message-view")
324
+ yield StatusBar(id="status-bar")
325
+ yield CommandSuggestions(id="command-suggestions")
326
+ yield ChatInput(
327
+ placeholder="Ask pcli... (/help for all commands — Enter to send, "
328
+ "Ctrl+J for a newline)",
329
+ id="input-box",
330
+ commands=_SLASH_COMMANDS,
331
+ )
332
+
333
+ def on_chat_input_suggestions_changed(self, event: ChatInput.SuggestionsChanged) -> None:
334
+ self.query_one(CommandSuggestions).update_suggestions(event.matches, event.index)
335
+
336
+ def _refresh_cost_display(self, status_bar: StatusBar) -> None:
337
+ status_bar.session_cost_usd = self._session.cost.session_total_usd
338
+ status_bar.total_tokens = self._session.cost.total_tokens
339
+
340
+ def _refresh_context_display(self, status_bar: StatusBar, usage: Usage) -> None:
341
+ # Deliberately driven by the parent loop's OWN usage events only (never
342
+ # a subagent's, even though subagent usage also lands in
343
+ # session.cost.turns for billing) — a subagent's isolated, much
344
+ # smaller context isn't the conversation's actual context size.
345
+ model = self._settings.default_model or self._session.model
346
+ status_bar.context_used_tokens = usage.total_tokens
347
+ status_bar.context_limit_tokens = self._context_limit_table.lookup(model)
348
+
349
+ def _refresh_todo_pane(self) -> None:
350
+ self.query_one(StatusPane).todos = list(self._session.todos)
351
+
352
+ def _effective_max_tool_iterations(self) -> int | None:
353
+ return effective_max_tool_iterations(self._settings)
354
+
355
+ def _on_activity_changed(self) -> None:
356
+ status_bar = self.query_one(StatusBar)
357
+ sub = self._activity.subagent
358
+ status_bar.subagent_task = sub.task if sub else None
359
+ status_bar.subagent_tool_calls = sub.tool_calls if sub else 0
360
+ status_bar.subagent_last_tool = sub.last_tool if sub else None
361
+
362
+ async def on_mount(self) -> None:
363
+ # Cheap housekeeping: clears out empty sessions left over from a
364
+ # previous run (see SessionStore.prune_empty_sessions) so /sessions
365
+ # doesn't accumulate clutter from every launch that never sent a
366
+ # message. Excludes the just-started/resumed session itself, which
367
+ # may still legitimately have zero messages at this exact point.
368
+ self._store.prune_empty_sessions(exclude_session_ids=[self._session.id])
369
+ self.query_one(ChatInput).focus()
370
+ status_bar = self.query_one(StatusBar)
371
+ status_bar.model = self._session.model or self._settings.default_model
372
+ self._refresh_cost_display(status_bar)
373
+ context_usage = current_context_usage(self._session, limit_table=self._context_limit_table)
374
+ status_bar.context_used_tokens = context_usage.used_tokens
375
+ status_bar.context_limit_tokens = context_usage.limit_tokens
376
+ self._activity.subscribe(self._on_activity_changed)
377
+ self._refresh_todo_pane()
378
+
379
+ self._replay_message_history()
380
+
381
+ message_view = self.query_one(MessageView)
382
+ if self._session.todos:
383
+ from pcli.tools.builtin.todo_tool import render_todos
384
+
385
+ message_view.add_message(
386
+ "system", f"Resuming with existing todos:\n{render_todos(self._session.todos)}"
387
+ )
388
+
389
+ if self._session.decisions:
390
+ from pcli.tools.builtin.decision_tool import render_decisions
391
+
392
+ message_view.add_message(
393
+ "system",
394
+ f"Resuming with {len(self._session.decisions)} recorded decision(s):\n"
395
+ f"{render_decisions(self._session.decisions)}",
396
+ )
397
+
398
+ if not self._settings.is_configured():
399
+ message_view.add_message(
400
+ "system",
401
+ "Gateway not configured. Set `PCLI_GATEWAY_URL` (and `PCLI_GATEWAY_API_KEY` if "
402
+ "your gateway requires auth) or edit the config file, then restart pcli.",
403
+ )
404
+ return
405
+
406
+ if self._settings.is_local_api():
407
+ message_view.add_message(
408
+ "system",
409
+ "Local-API mode active for this gateway: max_tool_iterations and the "
410
+ "guardrails' max_tool_calls_per_turn/per_minute are uncapped, and cost is "
411
+ "forced to $0.",
412
+ )
413
+
414
+ try:
415
+ runtime = await build_agent_runtime(self._settings, self._cwd)
416
+ except Exception as exc: # noqa: BLE001 - surface startup failure, don't crash
417
+ message_view.add_message("system", f"Startup failed: {exc}")
418
+ return
419
+ self._sandbox = runtime.sandbox
420
+ status_bar.sandbox_backend = self._sandbox.name
421
+ self._tool_registry = runtime.tool_registry
422
+ self._toolbox_manager = runtime.toolbox_manager
423
+ self._browser_session = runtime.browser_session
424
+ if runtime.toolbox_tools_loaded:
425
+ message_view.add_message(
426
+ "system",
427
+ f"Loaded {runtime.toolbox_tools_loaded} previously-discovered toolbox tool(s).",
428
+ )
429
+ if runtime.agent_tools_loaded:
430
+ message_view.add_message(
431
+ "system",
432
+ f"Loaded {runtime.agent_tools_loaded} previously-registered agent tool(s).",
433
+ )
434
+
435
+ self._client = runtime.client
436
+ await self._maybe_detect_context_limit(message_view)
437
+ self._agent_loop = AgentLoop(
438
+ self._client,
439
+ model=self._settings.default_model or None,
440
+ tool_registry=self._tool_registry,
441
+ permission_manager=self._permission_manager,
442
+ tool_context_factory=self._make_tool_context,
443
+ max_tool_iterations=self._effective_max_tool_iterations(),
444
+ artifact_threshold_chars=self._settings.artifact_threshold_chars,
445
+ temperature=self._settings.default_temperature,
446
+ )
447
+
448
+ async def _maybe_detect_context_limit(
449
+ self, message_view: MessageView, *, is_retry: bool = False
450
+ ) -> None:
451
+ """Best-effort: if pcli has no context-window entry for this model
452
+ yet, or the entry it has came from a previous auto-detect run
453
+ (ContextLimitTable.should_attempt_detection — a manual
454
+ /context-limit correction or a built-in default is never re-probed
455
+ or overwritten, only a prior auto-detected value), ask the gateway
456
+ directly (GatewayClient.detect_context_limit / cost/context_detect.py).
457
+ Several backends (LM Studio, Ollama, a LiteLLM proxy, a raw
458
+ llama.cpp server, and — for free, via the standard /models
459
+ response — OpenRouter/vLLM) expose this; hosted-only gateways
460
+ (OpenAI, Anthropic, ...) don't, in which case the notice below
461
+ points at /context-limit instead. Re-probing an already-auto-detected
462
+ model on every startup matters for local gateways specifically: LM
463
+ Studio/Ollama let you reload the same model with a different context
464
+ length, so a value pcli auto-detected once can silently go stale in
465
+ a way a hosted API's fixed limit or a human's manual override never
466
+ does — see a real debugged case in cost/context.py's
467
+ looks_like_context_ceiling docstring. Wrapped defensively so a probe
468
+ failure/timeout never blocks startup — this is a nice-to-have, not
469
+ a requirement for the rest of on_mount to complete.
470
+
471
+ `is_retry=True` is the one-shot retry from _run_one_turn's "usage"
472
+ handling, after the model has actually produced a response - a
473
+ gateway that JIT-loads (LM Studio) may not report a model's real
474
+ loaded context size until then, even though should_attempt_detection
475
+ said this model was worth probing. It only changes whether a second
476
+ failure re-arms _context_limit_retry_pending: the initial call sets
477
+ it so a retry gets scheduled, but the retry itself must not, or a
478
+ backend that can never be probed (a hosted API) would get re-hit
479
+ every single turn for the rest of the session instead of once."""
480
+ if not self._settings.context_limit_auto_detect_enabled:
481
+ return
482
+ model = self._settings.default_model or self._session.model
483
+ if not model or not self._context_limit_table.should_attempt_detection(model):
484
+ return
485
+ try:
486
+ limit = await self._client.detect_context_limit(model)
487
+ except Exception: # noqa: BLE001 - never let a probe failure block startup
488
+ limit = None
489
+
490
+ if limit:
491
+ set_model_context_limit(model, limit, auto_detected=True)
492
+ self._context_limit_table = ContextLimitTable.load()
493
+ self.query_one(StatusBar).context_limit_tokens = self._context_limit_table.lookup(model)
494
+ message_view.add_message(
495
+ "system", f"Auto-detected context limit for '{model}': {limit:,} tokens."
496
+ )
497
+ else:
498
+ if not is_retry:
499
+ self._context_limit_retry_pending = True
500
+ assumed = self._context_limit_table.lookup(model)
501
+ message_view.add_message(
502
+ "system",
503
+ f"Couldn't auto-detect a context limit for '{model}' — pcli is assuming "
504
+ f"{assumed:,} tokens. If that's wrong, set it with /context-limit <tokens>.",
505
+ )
506
+
507
+ def _effective_tool_registry(self) -> ToolRegistry | None:
508
+ """self._tool_registry filtered to plan_mode_safe tools while plan
509
+ mode is active, recomputed fresh each time (rather than cached)
510
+ since self._tool_registry itself can grow after startup, e.g. via
511
+ register_toolbox_tool/register_agent_tool)."""
512
+ if self._tool_registry is None:
513
+ return None
514
+ if self._plan_mode:
515
+ return self._tool_registry.filtered(lambda t: t.plan_mode_safe)
516
+ return self._tool_registry
517
+
518
+ def _make_tool_context(self) -> ToolContext:
519
+ assert self._sandbox is not None
520
+ return ToolContext(
521
+ sandbox=self._sandbox,
522
+ guardrails=self._permission_manager.guardrails,
523
+ cwd=self._cwd,
524
+ gateway_client=self._client,
525
+ model=self._settings.default_model or None,
526
+ tool_registry=self._effective_tool_registry(),
527
+ permission_manager=self._permission_manager,
528
+ ask=self._current_ask,
529
+ ask_question=self._current_ask_question,
530
+ brave_search_api_key=self._settings.brave_search_api_key,
531
+ memory_enabled=self._settings.memory_enabled,
532
+ memory_max_entries=self._settings.memory_max_entries,
533
+ max_tool_iterations=self._effective_max_tool_iterations(),
534
+ subagent_max_iterations=self._settings.subagent_max_iterations,
535
+ max_response_tokens=self._agent_loop.max_response_tokens if self._agent_loop else None,
536
+ temperature=self._agent_loop.temperature if self._agent_loop else None,
537
+ session=self._session,
538
+ max_session_cost_usd=self._settings.max_session_cost_usd,
539
+ artifact_store=self._artifact_store,
540
+ activity=self._activity,
541
+ toolbox_manager=self._toolbox_manager,
542
+ plan_mode=self._plan_mode,
543
+ browser_session=self._browser_session,
544
+ )
545
+
546
+ async def on_unmount(self) -> None:
547
+ if self._client is not None:
548
+ await self._client.aclose()
549
+ if isinstance(self._sandbox, RestrictedSubprocessSandbox):
550
+ await self._sandbox.kill_all_background_jobs()
551
+ if self._browser_session is not None:
552
+ await self._browser_session.close()
553
+
554
+ def action_cancel_turn(self) -> None:
555
+ """Esc+Esc: cancels the in-flight turn (the streaming reply and/or
556
+ whatever tool is currently executing as part of it). A no-op with no
557
+ turn running, so idly pressing Escape does nothing. Requires two
558
+ presses within _ESCAPE_DOUBLE_PRESS_WINDOW_S so a single reflexive
559
+ Escape (e.g. dismissing a thought, or a stray keypress) can't
560
+ accidentally kill real work — the first press just shows a hint."""
561
+ if not self._turn_in_progress:
562
+ return
563
+
564
+ now = time.monotonic()
565
+ if now - self._last_escape_at <= _ESCAPE_DOUBLE_PRESS_WINDOW_S:
566
+ self._last_escape_at = 0.0
567
+ message_view = self.query_one(MessageView)
568
+ # _stream_response's while-loop (see its docstring) is what
569
+ # cancel_group actually kills. If a follow-up was queued right
570
+ # before this, _has_queued_followup would stay stuck True with
571
+ # no live loop left to consult it, and a LATER, unrelated turn's
572
+ # loop-check would spuriously run an extra empty-input
573
+ # _run_one_turn() — clearing it here is required, not optional.
574
+ dropped_count = len(self._queued_followups)
575
+ self._queued_followups = []
576
+ self._has_queued_followup = False
577
+ self.workers.cancel_group(self, "agent-turn")
578
+ note = "Turn cancelled."
579
+ if dropped_count:
580
+ plural = "s" if dropped_count != 1 else ""
581
+ note += f" {dropped_count} queued follow-up message{plural} not sent."
582
+ message_view.add_message("system", note)
583
+ else:
584
+ self._last_escape_at = now
585
+ # A toast, not a transcript message - purely instructional, no
586
+ # value in a permanent scrollback record (unlike "Turn
587
+ # cancelled." above, a real conversational event).
588
+ self.notify("Press Esc again to cancel the current turn.")
589
+
590
+ def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
591
+ text = event.value.strip()
592
+ text = event.chat_input.consume_pending_paste(text)
593
+ if not text:
594
+ return
595
+ event.chat_input.text = ""
596
+ event.chat_input.add_to_history(text)
597
+
598
+ if text.startswith("!!!"):
599
+ self._run_interactive_shell(text[3:].strip())
600
+ return
601
+
602
+ if text.startswith("!"):
603
+ self._run_shell_passthrough(text)
604
+ return
605
+
606
+ if text.startswith("/"):
607
+ self._handle_command(text)
608
+ return
609
+
610
+ if self._agent_loop is None:
611
+ return
612
+ message_view = self.query_one(MessageView)
613
+ self._consecutive_truncations = 0 # a real message means a fresh direction
614
+ if self._turn_in_progress:
615
+ # Queue it rather than starting a second _stream_response worker
616
+ # (which, on the same exclusive group, would cancel the one
617
+ # already running instead of running alongside or after it).
618
+ # Deliberately NOT appended to session.messages and NOT shown via
619
+ # add_message here - either would happen out of order relative
620
+ # to the in-flight turn's own assistant reply (session.messages
621
+ # would see this new user message before that reply, since
622
+ # turn_complete hasn't fired yet) and add_message would hijack
623
+ # MessageView's in-flight streaming target mid-response. Shown
624
+ # via the non-disruptive add_queued_user_message instead;
625
+ # _stream_response folds it into session.messages once the
626
+ # active turn actually finishes.
627
+ self._queued_followups.append(text)
628
+ message_view.add_queued_user_message(text)
629
+ else:
630
+ self._session.messages.append(Message(role="user", content=text))
631
+ message_view.add_message("user", text)
632
+ self._stream_response()
633
+
634
+ def _run_interactive_shell(self, command: str) -> None:
635
+ """Runs `!!!cmd` with a real terminal handed to it (passwords,
636
+ REPLs, editors, ssh — anything that needs an actual TTY, which the
637
+ pipe-based !/!! passthrough below can't provide since Textual
638
+ already owns pcli's own stdin). Deliberately a plain sync method,
639
+ not a @work worker: App.suspend() is itself a synchronous context
640
+ manager (Textual stops reading/writing the terminal entirely for
641
+ its duration), so there's nothing useful the event loop could do
642
+ concurrently anyway — blocking here is the correct behavior, not a
643
+ workaround."""
644
+ message_view = self.query_one(MessageView)
645
+ if not command:
646
+ # A toast, not a transcript message - a usage hint, same "glance
647
+ # and maybe retype" reasoning as /timeout above.
648
+ self.notify(
649
+ "Usage: !!!<command> to run a command with a real interactive terminal",
650
+ severity="warning",
651
+ )
652
+ return
653
+
654
+ message_view.add_message("shell", f"→ Handing off terminal to: {command}")
655
+ try:
656
+ with self.app.suspend():
657
+ exit_code = subprocess.run(
658
+ command, shell=True, cwd=str(self._cwd), check=False
659
+ ).returncode
660
+ except SuspendNotSupported:
661
+ self.notify(
662
+ "Interactive shell handoff isn't supported in this terminal environment.",
663
+ severity="warning",
664
+ )
665
+ return
666
+ message_view.add_message(
667
+ "shell", f"$ {command} (ran interactively)\n[exit_code={exit_code}]"
668
+ )
669
+
670
+ @work(exclusive=False)
671
+ async def _run_shell_passthrough(self, raw: str) -> None:
672
+ """Runs a raw shell command the user typed directly (`!cmd`, or
673
+ `!!cmd` to hide the result). Deliberately bypasses the LLM, the
674
+ sandbox, permissions, and session/artifact recording entirely — this
675
+ never touches self._session or gets sent to the model in any way."""
676
+ message_view = self.query_one(MessageView)
677
+ quiet = raw.startswith("!!")
678
+ command = raw[2:].strip() if quiet else raw[1:].strip()
679
+ if not command:
680
+ # A toast, not a transcript message - same "glance and maybe
681
+ # retype" reasoning as /timeout above.
682
+ self.notify(
683
+ "Usage: !<command> to run a shell command (!!<command> to hide the result)",
684
+ severity="warning",
685
+ )
686
+ return
687
+
688
+ result = await run_passthrough_command(command, cwd=self._cwd)
689
+
690
+ if quiet:
691
+ message_view.add_message("shell", f"$ {command}\n(output hidden)")
692
+ return
693
+
694
+ output = result.stdout
695
+ if result.stderr:
696
+ output += f"\n--- stderr ---\n{result.stderr}"
697
+ footer = f"\n[exit_code={result.exit_code}]"
698
+ if result.timed_out:
699
+ footer += " (timed out)"
700
+ message_view.add_message("shell", f"$ {command}\n{output}{footer}")
701
+
702
+ def _handle_command(self, text: str) -> None:
703
+ command, _, rest = text[1:].partition(" ")
704
+ rest = rest.strip()
705
+
706
+ if command == "sessions":
707
+ from pcli.tui.screens.sessions import SessionListScreen
708
+
709
+ self.app.push_screen(
710
+ SessionListScreen(self._store, exclude_session_id=self._session.id)
711
+ )
712
+ elif command == "export":
713
+ self._export_current(rest or None)
714
+ elif command == "toolbox":
715
+ self._handle_toolbox_command(rest)
716
+ elif command == "models":
717
+ self._handle_models_command(rest or None)
718
+ elif command == "compact":
719
+ self._manual_compact()
720
+ elif command == "timeout":
721
+ self._handle_timeout_command(rest or None)
722
+ elif command == "temperature":
723
+ self._handle_temperature_command(rest or None)
724
+ elif command == "budget":
725
+ self._handle_budget_command(rest or None)
726
+ elif command == "context-limit":
727
+ self._handle_context_limit_command(rest or None)
728
+ elif command == "max-tool-iterations":
729
+ self._handle_max_tool_iterations_command(rest or None)
730
+ elif command == "artifact-threshold":
731
+ self._handle_artifact_threshold_command(rest or None)
732
+ elif command == "max-tool-calls-per-turn":
733
+ self._handle_guardrail_rate_limit_command(
734
+ rest or None,
735
+ attr_name="max_tool_calls_per_turn",
736
+ config_key="max_tool_calls_per_turn",
737
+ command_name="max-tool-calls-per-turn",
738
+ window="turn",
739
+ )
740
+ elif command == "max-tool-calls-per-minute":
741
+ self._handle_guardrail_rate_limit_command(
742
+ rest or None,
743
+ attr_name="max_tool_calls_per_minute",
744
+ config_key="max_tool_calls_per_minute",
745
+ command_name="max-tool-calls-per-minute",
746
+ window="tool call",
747
+ )
748
+ elif command == "allowed-roots":
749
+ self._handle_allowed_roots_command(rest)
750
+ elif command == "prune-tool-results":
751
+ self._handle_prune_tool_results_command(rest or None)
752
+ elif command == "subagent":
753
+ self._handle_subagent_command()
754
+ elif command == "memory":
755
+ self._handle_memory_command(rest)
756
+ elif command == "max-response-tokens":
757
+ self._handle_max_response_tokens_command(rest or None)
758
+ elif command == "rename":
759
+ self._handle_rename_command(rest or None)
760
+ elif command == "theme":
761
+ self._handle_theme_command(rest or None)
762
+ elif command == "plan":
763
+ self._set_plan_mode(True)
764
+ elif command == "build":
765
+ self._set_plan_mode(False)
766
+ elif command == "help":
767
+ self._handle_help_command()
768
+ else:
769
+ # A toast, not a transcript message - a mistyped command isn't
770
+ # worth a permanent scrollback record.
771
+ self.notify(f"Unknown command: /{command}", severity="warning")
772
+
773
+ def _handle_timeout_command(self, arg: str | None) -> None:
774
+ """`/timeout [seconds]` — GatewayClient reads request_timeout_s fresh
775
+ on every request (see llm/client.py's per-request timeout override),
776
+ so changing it here takes effect on the very next gateway call, no
777
+ restart needed. Persisted the same way /models persists a
778
+ selection, so it's remembered next time too. A toast, not a
779
+ transcript message - see the "Split system notices" design note:
780
+ this is exactly the "glance and maybe retype" class of confirmation,
781
+ trivially re-checked by running /timeout again with no argument."""
782
+ if not arg:
783
+ current = self._settings.request_timeout_s
784
+ effective = self._settings.effective_request_timeout_s
785
+ note = f" (effective: {effective:g}s — floored for local-api)" if effective != current else ""
786
+ self.notify(f"request_timeout_s is currently {current:g}s{note}. Usage: /timeout <seconds>")
787
+ return
788
+
789
+ try:
790
+ seconds = float(arg)
791
+ except ValueError:
792
+ self.notify(f"'{arg}' isn't a valid number of seconds.", severity="warning")
793
+ return
794
+ if seconds <= 0:
795
+ self.notify("request_timeout_s must be greater than 0.", severity="warning")
796
+ return
797
+
798
+ self._settings.request_timeout_s = seconds
799
+ update_config_file(request_timeout_s=seconds)
800
+ self.notify(f"request_timeout_s set to {seconds:g}s — takes effect on the next gateway request.")
801
+
802
+ def _handle_temperature_command(self, arg: str | None) -> None:
803
+ """`/temperature [value|off]` — sets the sampling temperature sent
804
+ with each request (AgentLoop.set_temperature -> GatewayClient.
805
+ chat_stream's temperature param), taking effect on the very next
806
+ turn. `off` clears it back to "unset" (no temperature field sent at
807
+ all, so the gateway/model's own default applies) — this needs
808
+ remove_config_keys, not update_config_file, since update_config_file
809
+ deliberately skips writing a None value rather than persisting the
810
+ removal. A toast, not a transcript message - same "glance and
811
+ maybe retype" reasoning as /timeout above."""
812
+ if not arg:
813
+ current = self._settings.default_temperature
814
+ text = f"{current:g}" if current is not None else "unset (gateway/model default)"
815
+ self.notify(f"default_temperature is currently {text}. Usage: /temperature <value>|off")
816
+ return
817
+
818
+ if arg == "off":
819
+ self._settings.default_temperature = None
820
+ remove_config_keys("default_temperature")
821
+ if self._agent_loop is not None:
822
+ self._agent_loop.set_temperature(None)
823
+ self.notify("default_temperature cleared — gateway/model default applies.")
824
+ return
825
+
826
+ try:
827
+ value = float(arg)
828
+ except ValueError:
829
+ self.notify(f"'{arg}' isn't a valid number, or 'off'.", severity="warning")
830
+ return
831
+ if value < 0:
832
+ self.notify("Temperature must be 0 or greater.", severity="warning")
833
+ return
834
+
835
+ self._settings.default_temperature = value
836
+ update_config_file(default_temperature=value)
837
+ if self._agent_loop is not None:
838
+ self._agent_loop.set_temperature(value)
839
+ self.notify(f"default_temperature set to {value:g} — takes effect on the next turn.")
840
+
841
+ def _handle_budget_command(self, arg: str | None) -> None:
842
+ """`/budget [amount|off]` — views or sets Settings.max_session_cost_usd,
843
+ a hard cap on this session's total spend (agent/loop.py's
844
+ AgentLoop.run_turn consults it, via cost/tracker.py's
845
+ cost_budget_reason, at the top of every internal LLM round-trip - see
846
+ that function's own docstring). Same persistence shape as /temperature:
847
+ `off` needs remove_config_keys, not update_config_file, since the
848
+ latter deliberately skips writing a None value rather than persisting
849
+ a removal. Takes effect on the very next round-trip, no restart - the
850
+ budget_check closure reads self._settings fresh each time it's
851
+ called. A toast, not a transcript message - same "glance and maybe
852
+ retype" reasoning as /timeout above."""
853
+ spent = self._session.cost.session_total_usd
854
+ if not arg:
855
+ current = self._settings.max_session_cost_usd
856
+ text = f"${current:.2f}" if current is not None else "unset (no cap)"
857
+ self.notify(
858
+ f"max_session_cost_usd is currently {text} (spent so far: ${spent:.4f}). "
859
+ "Usage: /budget <amount>|off"
860
+ )
861
+ return
862
+
863
+ if arg == "off":
864
+ self._settings.max_session_cost_usd = None
865
+ remove_config_keys("max_session_cost_usd")
866
+ self.notify("max_session_cost_usd cleared — no cap.")
867
+ return
868
+
869
+ try:
870
+ value = float(arg)
871
+ except ValueError:
872
+ self.notify(f"'{arg}' isn't a valid number, or 'off'.", severity="warning")
873
+ return
874
+ if value <= 0:
875
+ self.notify(
876
+ "Budget must be greater than 0 (use /budget off to clear it).", severity="warning"
877
+ )
878
+ return
879
+
880
+ self._settings.max_session_cost_usd = value
881
+ update_config_file(max_session_cost_usd=value)
882
+ self.notify(
883
+ f"max_session_cost_usd set to ${value:.2f} (spent so far: ${spent:.4f}) — takes "
884
+ "effect on the next round-trip."
885
+ )
886
+
887
+ def _handle_context_limit_command(self, arg: str | None) -> None:
888
+ """`/context-limit [tokens]` — sets or shows the context-window size
889
+ pcli assumes for the current model (ContextLimitTable, cost/context.py).
890
+ Wrong by default for any model without a built-in or user-configured
891
+ entry (silently falls back to a generic 128000-token guess), which
892
+ disables auto-compaction for a model with a much smaller real
893
+ window — see the context-ceiling notice in _run_one_turn, which
894
+ points here. Persists to context_limits.toml and reloads the table
895
+ immediately, so it takes effect without a restart. A toast, not a
896
+ transcript message - same "glance and maybe retype" reasoning as
897
+ /timeout above."""
898
+ model = self._session.model or self._settings.default_model
899
+ if not model:
900
+ self.notify("No model configured to set a context limit for.", severity="warning")
901
+ return
902
+
903
+ if not arg:
904
+ current = self._context_limit_table.lookup(model)
905
+ self.notify(
906
+ f"Assumed context limit for '{model}': {current:,} tokens. Usage: "
907
+ "/context-limit <tokens>"
908
+ )
909
+ return
910
+
911
+ try:
912
+ limit = int(arg)
913
+ except ValueError:
914
+ self.notify(f"'{arg}' isn't a valid number of tokens.", severity="warning")
915
+ return
916
+ if limit <= 0:
917
+ self.notify("Context limit must be greater than 0.", severity="warning")
918
+ return
919
+
920
+ set_model_context_limit(model, limit)
921
+ self._context_limit_table = ContextLimitTable.load()
922
+ self.notify(f"Context limit for '{model}' set to {limit:,} tokens.")
923
+
924
+ def _handle_max_tool_iterations_command(self, arg: str | None) -> None:
925
+ """`/max-tool-iterations [n]` — caps how many tool-call round-trips
926
+ a single turn can make before it's cut off (see AgentLoop.run_turn's
927
+ iteration guardrail). Ignored in local-api mode, which always runs
928
+ uncapped (_effective_max_tool_iterations returns None there) —
929
+ still saved for whenever local-api mode is off. A toast, not a
930
+ transcript message - same "glance and maybe retype" reasoning as
931
+ /timeout above."""
932
+ if not arg:
933
+ current = self._settings.max_tool_iterations
934
+ note = " (currently uncapped: local-api mode)" if self._settings.is_local_api() else ""
935
+ self.notify(
936
+ f"max_tool_iterations is currently {current}{note}. Usage: "
937
+ "/max-tool-iterations <n>"
938
+ )
939
+ return
940
+
941
+ try:
942
+ iterations = int(arg)
943
+ except ValueError:
944
+ self.notify(f"'{arg}' isn't a valid number of iterations.", severity="warning")
945
+ return
946
+ if iterations <= 0:
947
+ self.notify("max_tool_iterations must be greater than 0.", severity="warning")
948
+ return
949
+
950
+ self._settings.max_tool_iterations = iterations
951
+ update_config_file(max_tool_iterations=iterations)
952
+ if self._agent_loop is not None:
953
+ self._agent_loop.set_max_tool_iterations(self._effective_max_tool_iterations())
954
+ note = (
955
+ " This session is in local-api mode, so it stays uncapped until that changes."
956
+ if self._settings.is_local_api()
957
+ else " Takes effect on the next turn."
958
+ )
959
+ self.notify(f"max_tool_iterations set to {iterations}.{note}")
960
+
961
+ def _handle_artifact_threshold_command(self, arg: str | None) -> None:
962
+ """`/artifact-threshold [chars]` — tool results longer than this are
963
+ truncated out of the live conversation and archived to the artifact
964
+ library, retrievable via fetch_artifact (see AgentLoop._archive_if_large
965
+ and Settings.artifact_threshold_chars). Persisted the same way
966
+ /timeout persists request_timeout_s. A toast, not a transcript
967
+ message - same "glance and maybe retype" reasoning as /timeout
968
+ above."""
969
+ if not arg:
970
+ current = self._settings.artifact_threshold_chars
971
+ self.notify(
972
+ f"artifact_threshold_chars is currently {current:,}. Usage: "
973
+ "/artifact-threshold <chars>"
974
+ )
975
+ return
976
+
977
+ try:
978
+ threshold = int(arg)
979
+ except ValueError:
980
+ self.notify(f"'{arg}' isn't a valid number of characters.", severity="warning")
981
+ return
982
+ if threshold <= 0:
983
+ self.notify("artifact_threshold_chars must be greater than 0.", severity="warning")
984
+ return
985
+
986
+ self._settings.artifact_threshold_chars = threshold
987
+ update_config_file(artifact_threshold_chars=threshold)
988
+ if self._agent_loop is not None:
989
+ self._agent_loop.set_artifact_threshold_chars(threshold)
990
+ self.notify(f"artifact_threshold_chars set to {threshold:,}. Takes effect on the next tool result.")
991
+
992
+ def _handle_guardrail_rate_limit_command(
993
+ self, arg: str | None, *, attr_name: str, config_key: str, command_name: str, window: str
994
+ ) -> None:
995
+ """Shared by `/max-tool-calls-per-turn` and `/max-tool-calls-per-minute`
996
+ — both view/set the same-shaped guardrails.toml [limits] key
997
+ (`GuardrailsConfig`, `src/pcli/permissions/guardrails.py`), where 0
998
+ means unlimited (see AgentLoop.run_turn's per-turn check and
999
+ PermissionManager._within_rate_limit's per-minute check).
1000
+
1001
+ In local-api mode, ChatScreen.__init__ already force-copies both of
1002
+ these to 0 (unlimited) for the live guardrails instance — a new
1003
+ value is still persisted here for whenever local-api mode is off,
1004
+ but isn't applied live, so the response says as much rather than
1005
+ implying it silently took effect. A toast, not a transcript
1006
+ message - same "glance and maybe retype" reasoning as /timeout
1007
+ above."""
1008
+ guardrails = self._permission_manager.guardrails
1009
+ if not arg:
1010
+ current = getattr(guardrails, attr_name)
1011
+ if self._settings.is_local_api():
1012
+ note = " (currently forced unlimited: local-api mode)"
1013
+ elif current <= 0:
1014
+ note = " (0 = unlimited)"
1015
+ else:
1016
+ note = ""
1017
+ self.notify(
1018
+ f"{config_key} is currently {current}{note}. Usage: /{command_name} <n> (0 = unlimited)"
1019
+ )
1020
+ return
1021
+
1022
+ try:
1023
+ value = int(arg)
1024
+ except ValueError:
1025
+ self.notify(f"'{arg}' isn't a valid number.", severity="warning")
1026
+ return
1027
+ if value < 0:
1028
+ self.notify(f"{config_key} must be 0 or greater (0 means unlimited).", severity="warning")
1029
+ return
1030
+
1031
+ update_guardrails_limits(**{config_key: value})
1032
+ if self._settings.is_local_api():
1033
+ note = (
1034
+ " This session is in local-api mode, so it stays unlimited until that changes."
1035
+ )
1036
+ else:
1037
+ setattr(guardrails, attr_name, value)
1038
+ note = f" Takes effect on the next {window}."
1039
+ self.notify(f"{config_key} set to {value}.{note}")
1040
+
1041
+ def _handle_allowed_roots_command(self, rest: str) -> None:
1042
+ """`/allowed-roots [add|remove] <path>` — view or live-edit
1043
+ guardrails.toml's [fs] allowed_roots list (permissions/
1044
+ guardrails.py's evaluate_path, gating read_file/write_file/
1045
+ edit_file/list_dir/glob_search/grep/diff_files/apply_patch/
1046
+ download_file). A path outside every allowed_roots entry is a hard
1047
+ guardrail deny — checked before, and never overridable by, a
1048
+ permission prompt (see the system prompt's "Respecting
1049
+ guardrails") — this command is the actual, intended way to widen
1050
+ what the agent can reach, not something to route around via a
1051
+ prompt. Mirrors _handle_guardrail_rate_limit_command's persist-
1052
+ then-hot-reload shape, but for a list-valued [fs] key instead of a
1053
+ scalar [limits] one. A toast, not a transcript message - same
1054
+ "glance and maybe retype" reasoning as /timeout above."""
1055
+ guardrails = self._permission_manager.guardrails
1056
+ sub_command, _, arg = rest.partition(" ")
1057
+ sub_command = sub_command.strip().lower()
1058
+ arg = arg.strip()
1059
+
1060
+ if not sub_command:
1061
+ roots = "\n".join(f"- {root}" for root in guardrails.fs_allowed_roots)
1062
+ self.notify(
1063
+ f"Current allowed_roots:\n{roots}\n\nUsage: /allowed-roots add <path> | "
1064
+ "/allowed-roots remove <path>"
1065
+ )
1066
+ return
1067
+
1068
+ if sub_command == "add":
1069
+ if not arg:
1070
+ self.notify("Usage: /allowed-roots add <path>", severity="warning")
1071
+ return
1072
+ if arg in guardrails.fs_allowed_roots:
1073
+ self.notify(f"'{arg}' is already in allowed_roots.", severity="warning")
1074
+ return
1075
+ new_roots = [*guardrails.fs_allowed_roots, arg]
1076
+ update_guardrails_fs_allowed_roots(new_roots)
1077
+ guardrails.fs_allowed_roots = new_roots
1078
+ self.notify(f"Added '{arg}' to allowed_roots. Takes effect immediately.")
1079
+ return
1080
+
1081
+ if sub_command == "remove":
1082
+ if not arg:
1083
+ self.notify("Usage: /allowed-roots remove <path>", severity="warning")
1084
+ return
1085
+ if arg not in guardrails.fs_allowed_roots:
1086
+ self.notify(f"'{arg}' isn't in allowed_roots.", severity="warning")
1087
+ return
1088
+ if len(guardrails.fs_allowed_roots) == 1:
1089
+ self.notify(
1090
+ "Refusing to remove the last allowed_roots entry — the agent needs at "
1091
+ "least one, or every filesystem tool call would be denied.",
1092
+ severity="warning",
1093
+ )
1094
+ return
1095
+ new_roots = [root for root in guardrails.fs_allowed_roots if root != arg]
1096
+ update_guardrails_fs_allowed_roots(new_roots)
1097
+ guardrails.fs_allowed_roots = new_roots
1098
+ self.notify(f"Removed '{arg}' from allowed_roots. Takes effect immediately.")
1099
+ return
1100
+
1101
+ self.notify(
1102
+ f"Unknown /allowed-roots subcommand: '{sub_command}'. Use /allowed-roots, "
1103
+ "/allowed-roots add <path>, or /allowed-roots remove <path>.",
1104
+ severity="warning",
1105
+ )
1106
+
1107
+ def _handle_subagent_command(self) -> None:
1108
+ """`/subagent` — a one-off snapshot of the currently-running
1109
+ subagent's task and its full tool-call history so far (name +
1110
+ arguments), plus any question it's currently blocked on, logged as a
1111
+ system message in the chat transcript. The status bar's second line
1112
+ only has room for a one-line summary (last tool name); Ctrl+G
1113
+ (action_show_subagent_activity) opens the same detail as a
1114
+ live-updating panel instead of a static snapshot, for anyone
1115
+ actively watching rather than checking in once. The "nothing
1116
+ running" case is a toast - trivially re-checked by rerunning the
1117
+ command; the activity dump itself stays in the transcript since
1118
+ it's genuine reference content worth scrolling back to."""
1119
+ sub = self._activity.subagent
1120
+ if sub is None:
1121
+ self.notify(_NO_SUBAGENT_RUNNING_MESSAGE)
1122
+ return
1123
+ self.query_one(MessageView).add_message("system", format_subagent_activity(sub))
1124
+
1125
+ def _handle_memory_command(self, rest: str) -> None:
1126
+ """`/memory` — lists pcli's global, cross-session memory of this
1127
+ user (nature of work, preferences, conversation style, common asks -
1128
+ see memory/models.py); `/memory forget <id>` removes one entry by
1129
+ its short id (last 4 chars, as shown in the listing, same
1130
+ abbreviation SessionListScreen uses for session ids); `/memory
1131
+ clear` wipes it. This is the transparency/control surface for
1132
+ memory/extraction.py's autonomous derivation - what got remembered
1133
+ should always be visible and correctable, never a silent background
1134
+ process. The no-arg listing stays in the transcript (reference
1135
+ content worth scrolling back to, same as /toolbox list); every
1136
+ other branch here is a toast - a quick confirmation or error,
1137
+ trivially re-checked by rerunning /memory."""
1138
+ sub_command, _, arg = rest.partition(" ")
1139
+ sub_command = sub_command.strip().lower()
1140
+ arg = arg.strip()
1141
+
1142
+ if not sub_command:
1143
+ self.query_one(MessageView).add_message(
1144
+ "system", render_memory_list(read_memory().entries)
1145
+ )
1146
+ return
1147
+
1148
+ if sub_command == "clear":
1149
+ clear_memory()
1150
+ self.notify("Cleared all memory entries.")
1151
+ return
1152
+
1153
+ if sub_command == "forget":
1154
+ if not arg:
1155
+ self.notify("Usage: /memory forget <id>", severity="warning")
1156
+ return
1157
+ matches = [e for e in read_memory().entries if e.id.endswith(arg)]
1158
+ if not matches:
1159
+ self.notify(f"No memory entry found matching '{arg}'.", severity="warning")
1160
+ return
1161
+ if len(matches) > 1:
1162
+ self.notify(
1163
+ f"'{arg}' matches more than one entry — use a longer id.",
1164
+ severity="warning",
1165
+ )
1166
+ return
1167
+ remove_entry(matches[0].id)
1168
+ self.notify(f"Forgot: {matches[0].content}")
1169
+ return
1170
+
1171
+ self.notify(
1172
+ f"Unknown /memory subcommand: '{sub_command}'. Use /memory, /memory forget "
1173
+ "<id>, or /memory clear.",
1174
+ severity="warning",
1175
+ )
1176
+
1177
+ def action_show_subagent_activity(self) -> None:
1178
+ """Ctrl+G — opens a live-updating view of the current subagent's
1179
+ task/tool-call history (SubagentActivityModal), or just reports
1180
+ there's nothing running yet, same message /subagent gives (also a
1181
+ toast, same reasoning)."""
1182
+ if self._activity.subagent is None:
1183
+ self.notify(_NO_SUBAGENT_RUNNING_MESSAGE)
1184
+ return
1185
+ self.app.push_screen(SubagentActivityModal(self._activity))
1186
+
1187
+ def _handle_prune_tool_results_command(self, arg: str | None) -> None:
1188
+ """`/prune-tool-results [off|on|<n>]` — view/toggle/set the
1189
+ no-LLM-call tool-result pruning pass (see
1190
+ agent/context_pruning.py's prune_old_tool_results, run from
1191
+ _run_one_turn). No-arg reports the current enabled state and
1192
+ keep_recent_turns. `off`/`on` toggles prune_tool_results_enabled.
1193
+ A positive integer sets prune_tool_results_keep_recent_turns and
1194
+ implicitly re-enables it. Persisted to config.toml the same way
1195
+ /timeout persists request_timeout_s; applied live since
1196
+ _run_one_turn reads these settings fresh every turn. A toast, not a
1197
+ transcript message - same "glance and maybe retype" reasoning as
1198
+ /timeout above."""
1199
+ if not arg:
1200
+ state = "enabled" if self._settings.prune_tool_results_enabled else "disabled"
1201
+ self.notify(
1202
+ f"prune_tool_results is {state}, keeping the most recent "
1203
+ f"{self._settings.prune_tool_results_keep_recent_turns} turn(s) verbatim. "
1204
+ "Usage: /prune-tool-results off|on|<n>"
1205
+ )
1206
+ return
1207
+
1208
+ if arg == "off":
1209
+ self._settings.prune_tool_results_enabled = False
1210
+ update_config_file(prune_tool_results_enabled=False)
1211
+ self.notify("prune_tool_results disabled.")
1212
+ return
1213
+
1214
+ if arg == "on":
1215
+ self._settings.prune_tool_results_enabled = True
1216
+ update_config_file(prune_tool_results_enabled=True)
1217
+ self.notify("prune_tool_results enabled.")
1218
+ return
1219
+
1220
+ try:
1221
+ keep_recent_turns = int(arg)
1222
+ except ValueError:
1223
+ self.notify(f"'{arg}' isn't 'off', 'on', or a valid number.", severity="warning")
1224
+ return
1225
+ if keep_recent_turns <= 0:
1226
+ self.notify(
1227
+ "keep_recent_turns must be greater than 0 (use /prune-tool-results off to "
1228
+ "disable pruning entirely).",
1229
+ severity="warning",
1230
+ )
1231
+ return
1232
+
1233
+ self._settings.prune_tool_results_enabled = True
1234
+ self._settings.prune_tool_results_keep_recent_turns = keep_recent_turns
1235
+ update_config_file(
1236
+ prune_tool_results_enabled=True,
1237
+ prune_tool_results_keep_recent_turns=keep_recent_turns,
1238
+ )
1239
+ self.notify(
1240
+ f"prune_tool_results_keep_recent_turns set to {keep_recent_turns}. "
1241
+ "Takes effect on the next turn."
1242
+ )
1243
+
1244
+ def _handle_max_response_tokens_command(self, arg: str | None) -> None:
1245
+ """`/max-response-tokens [off|on|<n>]` — view/toggle/set the dynamic
1246
+ per-request max_tokens cap (compute_max_response_tokens,
1247
+ cost/context.py), computed fresh before every turn in
1248
+ _run_one_turn so a single response can't consume the entire
1249
+ remaining context window by itself — see that function's docstring
1250
+ for the real session this fixes. No-arg reports the enabled state,
1251
+ the configured safety margin, and what the cap would currently
1252
+ compute to. `off`/`on` toggles max_response_tokens_enabled. A
1253
+ positive integer sets max_response_tokens_safety_margin (tokens of
1254
+ headroom reserved below the model's context limit) and implicitly
1255
+ re-enables it. Persisted to config.toml, applied live. A toast, not
1256
+ a transcript message - same "glance and maybe retype" reasoning as
1257
+ /timeout above."""
1258
+ if not arg:
1259
+ state = "enabled" if self._settings.max_response_tokens_enabled else "disabled"
1260
+ current = compute_max_response_tokens(
1261
+ self._session,
1262
+ limit_table=self._context_limit_table,
1263
+ safety_margin=self._settings.max_response_tokens_safety_margin,
1264
+ )
1265
+ current_text = f"{current:,} tokens" if current is not None else "no cap (not yet computable)"
1266
+ self.notify(
1267
+ f"max_response_tokens is {state}, safety margin "
1268
+ f"{self._settings.max_response_tokens_safety_margin:,} tokens. Would currently "
1269
+ f"send max_tokens={current_text}. Usage: /max-response-tokens off|on|<margin>"
1270
+ )
1271
+ return
1272
+
1273
+ if arg == "off":
1274
+ self._settings.max_response_tokens_enabled = False
1275
+ update_config_file(max_response_tokens_enabled=False)
1276
+ self.notify("max_response_tokens disabled.")
1277
+ return
1278
+
1279
+ if arg == "on":
1280
+ self._settings.max_response_tokens_enabled = True
1281
+ update_config_file(max_response_tokens_enabled=True)
1282
+ self.notify("max_response_tokens enabled.")
1283
+ return
1284
+
1285
+ try:
1286
+ safety_margin = int(arg)
1287
+ except ValueError:
1288
+ self.notify(f"'{arg}' isn't 'off', 'on', or a valid number.", severity="warning")
1289
+ return
1290
+ if safety_margin <= 0:
1291
+ self.notify(
1292
+ "safety margin must be greater than 0 (use /max-response-tokens off to "
1293
+ "disable the cap entirely).",
1294
+ severity="warning",
1295
+ )
1296
+ return
1297
+
1298
+ self._settings.max_response_tokens_enabled = True
1299
+ self._settings.max_response_tokens_safety_margin = safety_margin
1300
+ update_config_file(
1301
+ max_response_tokens_enabled=True,
1302
+ max_response_tokens_safety_margin=safety_margin,
1303
+ )
1304
+ self.notify(
1305
+ f"max_response_tokens_safety_margin set to {safety_margin:,}. Takes effect on the "
1306
+ "next turn."
1307
+ )
1308
+
1309
+ def _handle_rename_command(self, arg: str | None) -> None:
1310
+ """`/rename [name]` — sets Session.title, which derive_title() (used
1311
+ by the /sessions list) prefers over the auto-derived first-message
1312
+ snippet. No-arg shows the current title. A toast, not a transcript
1313
+ message - same "glance and maybe retype" reasoning as /timeout
1314
+ above."""
1315
+ if not arg:
1316
+ self.notify(f"Current session title: '{self._session.derive_title()}'. Usage: /rename <name>")
1317
+ return
1318
+
1319
+ self._session.title = arg
1320
+ self._store.save(self._session)
1321
+ self.notify(f"Session renamed to '{arg}'.")
1322
+
1323
+ def _handle_theme_command(self, arg: str | None) -> None:
1324
+ """`/theme [name]` — App.theme is the actual switch (Textual repaints
1325
+ every CSS design-token-based style live, no restart needed); this
1326
+ just persists the choice the same way /rename persists a title, so
1327
+ it's remembered next time (see PcliApp.on_mount, which applies
1328
+ settings.ui_theme at startup). No-arg lists every registered theme
1329
+ (Textual's own builtins plus pcli's vim-* ones from tui/themes.py),
1330
+ marking the active one — also the fallback shown for an unknown
1331
+ name, rather than silently doing nothing. The set-confirmation is a
1332
+ toast (same "glance and maybe retype" reasoning as /timeout above);
1333
+ the listing branches stay in the transcript since they dump the
1334
+ full theme list, same reasoning as /memory's/`/toolbox list`'s own
1335
+ listing output."""
1336
+ message_view = self.query_one(MessageView)
1337
+ available = sorted(self.app.available_themes)
1338
+ current = self.app.theme
1339
+
1340
+ if arg and arg in self.app.available_themes:
1341
+ self.app.theme = arg
1342
+ self._settings.ui_theme = arg
1343
+ update_config_file(ui_theme=arg)
1344
+ self.notify(f"Theme set to '{arg}'.")
1345
+ return
1346
+
1347
+ listing = "\n".join(f"- `{name}`{' (active)' if name == current else ''}" for name in available)
1348
+ prefix = f"Unknown theme '{arg}'.\n\n" if arg else ""
1349
+ message_view.add_message(
1350
+ "system", f"{prefix}**Available themes:**\n{listing}\n\nUsage: /theme <name>"
1351
+ )
1352
+
1353
+ def _set_plan_mode(self, enabled: bool) -> None:
1354
+ """`/plan` (enter) / `/build` (exit) — restricts the agent to
1355
+ plan_mode_safe tools (read/explore only, no writes/edits/shell). The
1356
+ registry swap is the primary mechanism (the model never even sees a
1357
+ disallowed tool); AgentLoop's dispatch-time backstop and the
1358
+ per-turn prompt reinforcement (agent/prompt.py's
1359
+ PLAN_MODE_REINFORCEMENT, shared with headless.py's own plan-mode
1360
+ support) are the additional layers on top, not the boundary itself."""
1361
+ message_view = self.query_one(MessageView)
1362
+ if enabled == self._plan_mode:
1363
+ message_view.add_message(
1364
+ "system", f"Already in {'plan' if enabled else 'build'} mode."
1365
+ )
1366
+ return
1367
+
1368
+ self._plan_mode = enabled
1369
+ self.query_one(StatusBar).plan_mode = enabled
1370
+ if self._agent_loop is not None:
1371
+ self._agent_loop.set_tool_registry(self._effective_tool_registry())
1372
+
1373
+ if enabled:
1374
+ message_view.add_message(
1375
+ "system",
1376
+ "Plan mode enabled: only read-only/exploration tools are available. "
1377
+ "Use /build to exit and allow writes/edits/shell commands again.",
1378
+ )
1379
+ else:
1380
+ message_view.add_message("system", "Build mode restored: all tools are available again.")
1381
+
1382
+ def _handle_help_command(self) -> None:
1383
+ """`/help` — a static reference of every slash command and shell
1384
+ prefix, rendered as Markdown (message_view._render_message already
1385
+ runs every system message through rich.markdown.Markdown)."""
1386
+ message_view = self.query_one(MessageView)
1387
+ message_view.add_message("system", _HELP_TEXT)
1388
+
1389
+ def _handle_toolbox_command(self, rest: str) -> None:
1390
+ sub, _, arg = rest.partition(" ")
1391
+ arg = arg.strip()
1392
+
1393
+ if sub == "discover" and arg:
1394
+ # /toolbox discover <name> [path] - a second token registers a
1395
+ # self-authored script directly, bypassing PATH lookup (same
1396
+ # path= parameter register_toolbox_tool gives the LLM itself).
1397
+ name, _, path = arg.partition(" ")
1398
+ self._toolbox_discover(name, path.strip() or None)
1399
+ elif sub == "list":
1400
+ self._toolbox_list()
1401
+ elif sub == "remove" and arg:
1402
+ self._toolbox_remove(arg)
1403
+ else:
1404
+ self.notify(
1405
+ "Usage: /toolbox discover <name> [path] | /toolbox list | /toolbox remove <name>",
1406
+ severity="warning",
1407
+ )
1408
+
1409
+ @work(exclusive=True)
1410
+ async def _toolbox_discover(self, name: str, path: str | None = None) -> None:
1411
+ """Discovery's own failure and success summary stay in the
1412
+ transcript (genuine, potentially detailed reference content); the
1413
+ "isn't available" guard and the "Discovering..." progress ping are
1414
+ toasts - the ping is superseded within moments by one of the two
1415
+ transcript outcomes above, so keeping it around permanently would
1416
+ just be a stray duplicate."""
1417
+ message_view = self.query_one(MessageView)
1418
+ if self._toolbox_manager is None:
1419
+ self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
1420
+ return
1421
+ self.notify(f"Discovering '{name}'...")
1422
+ try:
1423
+ summary = await self._toolbox_manager.discover(
1424
+ name, gateway_client=self._client, model=self._settings.default_model or None, path=path
1425
+ )
1426
+ except (ToolboxDiscoveryError, GatewayError, SandboxSecurityError) as exc:
1427
+ # discover() calls the gateway to synthesize tool schemas when
1428
+ # there's no curated plugin (toolbox/manager.py) - that call can
1429
+ # raise GatewayError same as any other, and it previously wasn't
1430
+ # caught here at all, crashing this worker silently.
1431
+ message_view.add_message("system", f"Discovery failed: {exc}")
1432
+ return
1433
+ message_view.add_message("system", summary)
1434
+
1435
+ if self._tool_registry is not None:
1436
+ loaded = await self._toolbox_manager.load_all()
1437
+ self._tool_registry.merge(loaded)
1438
+
1439
+ @work(exclusive=True)
1440
+ async def _toolbox_list(self) -> None:
1441
+ if self._toolbox_manager is None:
1442
+ self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
1443
+ return
1444
+ entries = self._toolbox_manager.list_discovered()
1445
+ if not entries:
1446
+ self.notify("No software discovered yet. Try /toolbox discover <name>.")
1447
+ return
1448
+ lines = [
1449
+ f"- **{name}** [{entry['source']}] {entry.get('version', '?')} - "
1450
+ f"{entry.get('tool_count', 0)} tool(s)"
1451
+ for name, entry in entries.items()
1452
+ ]
1453
+ self.query_one(MessageView).add_message("system", "\n".join(lines))
1454
+
1455
+ @work(exclusive=True)
1456
+ async def _toolbox_remove(self, name: str) -> None:
1457
+ if self._toolbox_manager is None:
1458
+ self.notify("Toolbox isn't available (gateway/sandbox not set up).", severity="warning")
1459
+ return
1460
+ self._toolbox_manager.remove(name)
1461
+ self.notify(f"Removed '{name}' from the toolbox.")
1462
+
1463
+ @work(exclusive=True)
1464
+ async def _handle_models_command(self, arg: str | None) -> None:
1465
+ """"Failed to list models" stays in the transcript as a real,
1466
+ investigable failure; everything else here is a toast - a quick
1467
+ confirmation or a state the user can just retry."""
1468
+ if arg:
1469
+ self._set_model(arg)
1470
+ self.notify(f"Model set to '{arg}'.")
1471
+ return
1472
+
1473
+ if not self._settings.gateway_base_url:
1474
+ self.notify(
1475
+ "No gateway URL configured. Set PCLI_GATEWAY_URL and restart pcli.",
1476
+ severity="warning",
1477
+ )
1478
+ return
1479
+
1480
+ client = self._client
1481
+ owns_client = client is None
1482
+ if client is None:
1483
+ client = GatewayClient(self._settings)
1484
+
1485
+ try:
1486
+ models = await client.list_models()
1487
+ except GatewayError as exc:
1488
+ self.query_one(MessageView).add_message("system", f"Failed to list models: {exc.message}")
1489
+ return
1490
+ finally:
1491
+ if owns_client:
1492
+ await client.aclose()
1493
+
1494
+ if not models:
1495
+ self.notify("Gateway returned no models.", severity="warning")
1496
+ return
1497
+
1498
+ from pcli.tui.screens.models import ModelListScreen
1499
+
1500
+ current = self._settings.default_model or self._session.model or None
1501
+ selected = await self.app.push_screen_wait(ModelListScreen(models, current))
1502
+ if selected:
1503
+ self._set_model(selected)
1504
+ self.notify(f"Model set to '{selected}'.")
1505
+
1506
+ def _set_model(self, model: str) -> None:
1507
+ self._settings.default_model = model
1508
+ self._session.model = model
1509
+ if self._agent_loop is not None:
1510
+ self._agent_loop.set_model(model)
1511
+ self.query_one(StatusBar).model = model
1512
+ # Remembered for next time so a bare `pcli` picks it up.
1513
+ update_config_file(default_model=model)
1514
+
1515
+ @work(exclusive=True)
1516
+ async def _export_current(self, out_path_arg: str | None) -> None:
1517
+ """A toast, not a transcript message - same "glance and maybe
1518
+ retype" reasoning as /timeout above; the exported path is right
1519
+ there in the notification and the file itself is the durable
1520
+ record, not this confirmation."""
1521
+ from pcli.config.paths import data_dir
1522
+
1523
+ out_path = (
1524
+ Path(out_path_arg).expanduser()
1525
+ if out_path_arg
1526
+ else data_dir() / "exports" / f"{self._session.id}.pcli-session.json"
1527
+ )
1528
+ export_session(self._session, out_path, store=self._store)
1529
+ self.notify(f"Exported session to {out_path}")
1530
+
1531
+ def _record_tool_invocation(self, event: ToolResultEvent) -> None:
1532
+ """Delegates to agent/runtime.py's record_tool_invocation, shared
1533
+ with headless/scheduled runs (run_headless_task) so every caller
1534
+ records identically - this method used to duplicate that logic
1535
+ TUI-only."""
1536
+ record_tool_invocation(
1537
+ self._session, event, audit_enabled=self._settings.audit_mode_enabled
1538
+ )
1539
+
1540
+ def _effective_compaction_model(self) -> str | None:
1541
+ """The model compaction summarization and memory extraction should
1542
+ use - settings.compaction_model if configured (a deliberately
1543
+ cheaper/smaller model for these mechanical, lower-stakes background
1544
+ calls), falling back to the same default_model/session.model chain
1545
+ every other call site here uses when it isn't."""
1546
+ return (
1547
+ self._settings.compaction_model
1548
+ or self._settings.default_model
1549
+ or self._session.model
1550
+ or None
1551
+ )
1552
+
1553
+ def _show_decision_notice(self, event: ToolResultEvent) -> None:
1554
+ """record_decision results are shown as a distinct, always-visible
1555
+ message (not the generic collapsed-by-default tool Collapsible) —
1556
+ the whole point of the decision log is that it's immediately
1557
+ scannable, not tucked away."""
1558
+ self._render_decision_notice(event.tool_call.function.arguments)
1559
+
1560
+ def _render_decision_notice(self, arguments_json: str) -> None:
1561
+ """Shared with _replay_message_history, which reconstructs this same
1562
+ notice for a reloaded session from the original record_decision
1563
+ call's arguments rather than re-deriving it some other way."""
1564
+ try:
1565
+ arguments = json.loads(arguments_json or "{}")
1566
+ except json.JSONDecodeError:
1567
+ arguments = {}
1568
+ decision = arguments.get("decision", "") if isinstance(arguments, dict) else ""
1569
+ rationale = arguments.get("rationale", "") if isinstance(arguments, dict) else ""
1570
+ self.query_one(MessageView).add_message("decision", f"**{decision}**\n\n{rationale}")
1571
+
1572
+ def _replay_message_history(self) -> None:
1573
+ """Rebuilds the visible transcript from session.messages using the
1574
+ same rendering helpers a live turn uses (add_tool_call/
1575
+ add_tool_result/_render_decision_notice) — a real reported gap:
1576
+ the previous reload logic only ever showed user/assistant .content
1577
+ and a flat, hard-truncated-to-500-chars line per tool result, so a
1578
+ tool-call-only assistant turn (content=None, tool_calls set) vanished
1579
+ entirely, tool results lost their syntax-highlighted/collapsible
1580
+ rendering, and a mid-conversation system message (notably a
1581
+ compaction summary, which replaces old turns in session.messages
1582
+ itself) wasn't shown at all. Purely cosmetic either way —
1583
+ session.messages itself (what's actually resent to the LLM) is
1584
+ untouched regardless of how it's displayed here.
1585
+
1586
+ One real, unavoidable gap: whether a given past tool call actually
1587
+ errored isn't retained on Message itself (only in
1588
+ Session.tool_invocations, which - unlike session.messages - is never
1589
+ pruned/compacted, so it can't be reliably correlated back to a
1590
+ specific reconstructed message once either has happened). Every
1591
+ reconstructed tool result therefore renders as if it succeeded, even
1592
+ if the original call actually failed."""
1593
+ message_view = self.query_one(MessageView)
1594
+ messages = self._session.messages
1595
+ skip_leading_system_prompt = bool(messages) and messages[0].role == "system"
1596
+ pending_calls: dict[str, ToolCall] = {}
1597
+
1598
+ for index, message in enumerate(messages):
1599
+ if index == 0 and skip_leading_system_prompt:
1600
+ continue
1601
+ if message.role in ("system", "user") and message.content:
1602
+ message_view.add_message(message.role, message.content)
1603
+ elif message.role == "assistant":
1604
+ if message.content:
1605
+ message_view.add_message("assistant", message.content)
1606
+ for call in message.tool_calls or []:
1607
+ pending_calls[call.id] = call
1608
+ purpose = extract_purpose(call.function.arguments)
1609
+ message_view.add_tool_call(
1610
+ call.function.name, call.function.arguments, purpose=purpose
1611
+ )
1612
+ elif message.role == "tool" and message.content:
1613
+ originating_call = pending_calls.pop(message.tool_call_id or "", None)
1614
+ if message.name == "record_decision" and originating_call is not None:
1615
+ self._render_decision_notice(originating_call.function.arguments)
1616
+ else:
1617
+ message_view.add_tool_result(message.name or "tool", message.content, is_error=False)
1618
+
1619
+ async def _run_compaction(self, reason: str) -> None:
1620
+ """Summarizes and archives the oldest turns of session.messages (see
1621
+ agent/compaction.py) — either after an "auto" trigger from
1622
+ _stream_response, or a "manual" /compact command. Plain async method
1623
+ (not @work) so _stream_response can just await it directly while
1624
+ already inside its own worker; /compact reaches it via the small
1625
+ @work-wrapped _manual_compact below, same pattern as every other
1626
+ synchronously-dispatched command in this file. The "isn't
1627
+ available"/"still working"/"nothing to compact yet" guards below
1628
+ are toasts - trivially re-checked by rerunning /compact; a real
1629
+ failure and the actual success notice both stay in the transcript
1630
+ as genuine, investigable records."""
1631
+ message_view = self.query_one(MessageView)
1632
+ status_bar = self.query_one(StatusBar)
1633
+ if self._client is None or self._agent_loop is None:
1634
+ if reason == "manual":
1635
+ self.notify(
1636
+ "Compaction isn't available (gateway/sandbox not set up).",
1637
+ severity="warning",
1638
+ )
1639
+ return
1640
+
1641
+ if reason == "manual" and self._turn_in_progress:
1642
+ # maybe_compact slices/replaces session.messages directly —
1643
+ # running it concurrently with an active turn (which also reads
1644
+ # and appends to that same list) could corrupt the conversation.
1645
+ # The "auto" trigger is never at risk of this: it only ever runs
1646
+ # sequentially, awaited from inside _run_one_turn itself, after
1647
+ # that turn has already finished.
1648
+ self.notify(
1649
+ "Still working on the current turn — try /compact again once it's done.",
1650
+ severity="warning",
1651
+ )
1652
+ return
1653
+
1654
+ configured_keep_recent_turns = self._settings.auto_compact_keep_recent_turns
1655
+ tightened_to: int | None = None
1656
+ status_bar.busy = True
1657
+ try:
1658
+ # Regression guard: this used to only cover the maybe_compact
1659
+ # call(s) below, not the bookkeeping/memory-extraction that
1660
+ # follows a successful one - "Working..." would disappear the
1661
+ # instant summarization finished, then reappear once memory
1662
+ # extraction's own (invisible) gateway call finally completed,
1663
+ # looking exactly like pcli had silently stalled in between.
1664
+ # Covering the whole function in one busy=True/False window
1665
+ # fixes that: the indicator now stays lit for everything this
1666
+ # method actually does, not just its first step.
1667
+ try:
1668
+ result = await maybe_compact(
1669
+ self._session,
1670
+ gateway_client=self._client,
1671
+ model=self._effective_compaction_model(),
1672
+ artifact_store=self._artifact_store,
1673
+ keep_recent_turns=configured_keep_recent_turns,
1674
+ )
1675
+ if result is None:
1676
+ # The configured recent-turns window can itself be
1677
+ # what's filling context - e.g. several truncation/
1678
+ # auto-continue retries (each its own turn boundary,
1679
+ # see compaction.py's turn_boundaries) concentrated
1680
+ # inside the protected window, with everything older
1681
+ # already compacted away by an earlier pass. Only retry
1682
+ # with a smaller window when there's genuine pressure
1683
+ # to free room - a small/fresh session at low usage
1684
+ # should still just report "nothing to compact" rather
1685
+ # than having its only turns eaten unnecessarily.
1686
+ usage = current_context_usage(
1687
+ self._session, limit_table=self._context_limit_table
1688
+ )
1689
+ if usage.fraction >= self._settings.auto_compact_threshold:
1690
+ for smaller in range(configured_keep_recent_turns - 1, -1, -1):
1691
+ result = await maybe_compact(
1692
+ self._session,
1693
+ gateway_client=self._client,
1694
+ model=self._effective_compaction_model(),
1695
+ artifact_store=self._artifact_store,
1696
+ keep_recent_turns=smaller,
1697
+ )
1698
+ if result is not None:
1699
+ tightened_to = smaller
1700
+ break
1701
+ except GatewayError as exc:
1702
+ # Previously uncaught here: maybe_compact's one
1703
+ # summarization call failing (e.g. a timeout) would crash
1704
+ # straight out of this worker with nothing shown to the
1705
+ # user - the turn that triggered auto-compaction had
1706
+ # already completed and saved successfully by this point,
1707
+ # so this is a notice, not a lost turn, but it still needs
1708
+ # to be visible (context usage just silently won't have
1709
+ # shrunk).
1710
+ logger.exception("Gateway error during compaction: %s", exc.message)
1711
+ message_view.add_message("system", f"Compaction failed: {exc.message}")
1712
+ return
1713
+
1714
+ if result is None:
1715
+ if reason == "manual":
1716
+ self.notify("Nothing to compact yet.")
1717
+ return
1718
+
1719
+ # Reuses ToolInvocation.full_result_ref purely so export_session
1720
+ # (which only bundles blobs it finds referenced there) carries
1721
+ # this artifact along too — no real tool call happened.
1722
+ self._session.tool_invocations.append(
1723
+ ToolInvocation(
1724
+ tool_name="_compaction",
1725
+ arguments={},
1726
+ status="ok",
1727
+ result_summary=f"Compacted {result.messages_compacted} message(s).",
1728
+ full_result_ref=SessionArtifactStore.blob_name_for(result.artifact_id),
1729
+ )
1730
+ )
1731
+ # Real spend, so it counts toward cost — but tagged
1732
+ # source="compaction" so current_context_usage/
1733
+ # compute_max_response_tokens (which look for the last *main*-
1734
+ # conversation entry, not just the literal last one) aren't
1735
+ # misled into thinking the main conversation is however big
1736
+ # this one summarization call's own prompt happened to be. Also
1737
+ # deliberately not fed into _refresh_context_display, for the
1738
+ # same reason subagent usage isn't - the status bar
1739
+ # self-corrects on the next real turn's usage report.
1740
+ self._cost_tracker.record_turn(
1741
+ self._effective_compaction_model() or "",
1742
+ result.usage,
1743
+ source="compaction",
1744
+ )
1745
+ self._refresh_cost_display(status_bar)
1746
+ tightened_note = (
1747
+ f" (kept only the last {tightened_to} recent turn(s) verbatim instead of the "
1748
+ f"usual {configured_keep_recent_turns} — context was still full at that setting)"
1749
+ if tightened_to is not None
1750
+ else ""
1751
+ )
1752
+ message_view.add_message(
1753
+ "system",
1754
+ f"Compacted {result.messages_compacted} earlier message(s) to reduce context "
1755
+ f"usage (archived as artifact_id='{result.artifact_id}'){tightened_note}.",
1756
+ )
1757
+ self._store.save(self._session)
1758
+
1759
+ if self._settings.memory_enabled:
1760
+ await self._extract_memory_from(result.artifact_id, status_bar)
1761
+ finally:
1762
+ status_bar.busy = False
1763
+
1764
+ async def _extract_memory_from(self, artifact_id: str, status_bar: StatusBar) -> None:
1765
+ """Reviews the exact transcript maybe_compact just archived for
1766
+ anything durable/cross-session-worthy (memory/extraction.py) -
1767
+ piggybacked on compaction rather than a separate trigger, since a
1768
+ session substantial enough to need compacting is also substantial
1769
+ enough to be worth learning from, and this reuses the transcript
1770
+ already sitting in the artifact store instead of re-deriving it.
1771
+ Best-effort: a failure here is logged and otherwise invisible - it
1772
+ never touched the turn that triggered compaction, and losing one
1773
+ extraction pass isn't worth interrupting the user over."""
1774
+ transcript = self._artifact_store.get(artifact_id)
1775
+ if not transcript:
1776
+ return
1777
+ model = self._effective_compaction_model()
1778
+ try:
1779
+ extraction_ctx = replace(self._make_tool_context(), model=model)
1780
+ usages = await extract_memory(transcript, extraction_ctx)
1781
+ except GatewayError as exc:
1782
+ logger.exception("Gateway error during memory extraction: %s", exc.message)
1783
+ return
1784
+ if not usages:
1785
+ return
1786
+ for usage in usages:
1787
+ self._cost_tracker.record_turn(model or "", usage, source="memory")
1788
+ self._refresh_cost_display(status_bar)
1789
+ self._store.save(self._session)
1790
+
1791
+ @work(exclusive=True)
1792
+ async def _manual_compact(self) -> None:
1793
+ await self._run_compaction("manual")
1794
+
1795
+ @work(exclusive=True, group="agent-turn")
1796
+ async def _stream_response(self) -> None:
1797
+ """Runs turns back-to-back for as long as new user input gets
1798
+ queued while the previous one was still in flight (see
1799
+ on_input_submitted) — matches how opencode/Claude Code let you
1800
+ queue follow-up messages instead of either blocking input or
1801
+ cancelling the in-flight turn.
1802
+
1803
+ Given its own dedicated worker group so no other exclusive worker
1804
+ on this screen (compaction, /export, /toolbox, ...) can ever cancel
1805
+ it: they used to all share Textual's default group, and Textual
1806
+ cancels every other worker in a group whenever a new exclusive one
1807
+ in that same group starts — so submitting a second chat message (or
1808
+ any other command) while a turn was running silently killed it
1809
+ instead of queueing or running after."""
1810
+ self._turn_in_progress = True
1811
+ try:
1812
+ while True:
1813
+ await self._run_one_turn()
1814
+ if self._queued_followups:
1815
+ # The turn that just finished has already appended its
1816
+ # own assistant reply to session.messages (turn_complete,
1817
+ # inside _run_one_turn) - only now is it safe to append
1818
+ # what the user typed while that was still in flight, so
1819
+ # it lands after that reply instead of before it.
1820
+ for text in self._queued_followups:
1821
+ self._session.messages.append(Message(role="user", content=text))
1822
+ self._queued_followups = []
1823
+ self._store.save(self._session)
1824
+ self._has_queued_followup = False
1825
+ continue
1826
+ if not self._has_queued_followup:
1827
+ break
1828
+ self._has_queued_followup = False
1829
+ finally:
1830
+ self._turn_in_progress = False
1831
+
1832
+ async def _prune_and_maybe_auto_compact(self) -> None:
1833
+ """Cheap, mechanical, no-LLM-call pruning first (so compaction's own
1834
+ summarization call has less bulk to work with by the time its
1835
+ threshold is reached), then a real auto-compaction pass if context
1836
+ is still over auto_compact_threshold afterward. Factored out so the
1837
+ exact same recovery can run from _run_one_turn's GatewayError
1838
+ handler, not just its normal end-of-turn path — see that call site
1839
+ for why: a turn that fails outright because context was already
1840
+ full previously skipped this entirely (an early `return` inside the
1841
+ except block), so a session over threshold stayed over threshold and
1842
+ every subsequent turn failed the exact same way, with no compaction
1843
+ ever attempted despite the status bar having shown the overflow."""
1844
+ if self._settings.prune_tool_results_enabled:
1845
+ pruned_count = prune_old_tool_results(
1846
+ self._session,
1847
+ keep_recent_turns=self._settings.prune_tool_results_keep_recent_turns,
1848
+ artifact_store=self._artifact_store,
1849
+ )
1850
+ if pruned_count:
1851
+ self._store.save(self._session)
1852
+
1853
+ if self._settings.auto_compact_enabled:
1854
+ usage = current_context_usage(self._session, limit_table=self._context_limit_table)
1855
+ if usage.fraction >= self._settings.auto_compact_threshold:
1856
+ await self._run_compaction("auto")
1857
+
1858
+ async def _run_one_turn(self) -> None:
1859
+ assert self._agent_loop is not None
1860
+ message_view = self.query_one(MessageView)
1861
+ status_bar = self.query_one(StatusBar)
1862
+ message_view.add_message("assistant", "")
1863
+
1864
+ async def ask(tool_name: str, arguments: dict, risk_description: str):
1865
+ return await ask_via_modal(self.app, tool_name, arguments, risk_description)
1866
+
1867
+ async def ask_question(question: str, options: list[str] | None) -> str:
1868
+ return await ask_question_via_modal(self.app, question, options)
1869
+
1870
+ self._current_ask = ask
1871
+ self._current_ask_question = ask_question
1872
+ status_bar.busy = True
1873
+ status_bar.main_tool_calls = 0
1874
+ status_bar.main_tool_calls_limit = self._permission_manager.guardrails.max_tool_calls_per_turn
1875
+
1876
+ # Reasoning models stream their chain-of-thought under a channel
1877
+ # separate from the actual reply (see llm/streaming.py) — buffered
1878
+ # here and flushed as a collapsed "Thinking" panel whenever real
1879
+ # content or a tool call follows, rather than shown live: a model
1880
+ # can spend its *entire* response reasoning without ever producing
1881
+ # visible text or a tool call, which previously looked exactly like
1882
+ # pcli doing nothing at all. had_any_content tracks that case so a
1883
+ # clear notice can be shown instead of a silently empty turn.
1884
+ reasoning_parts: list[str] = []
1885
+ had_any_content = False
1886
+ had_any_reasoning = False
1887
+ response_truncated = False
1888
+
1889
+ def flush_reasoning() -> None:
1890
+ nonlocal had_any_reasoning
1891
+ if reasoning_parts:
1892
+ had_any_reasoning = True
1893
+ message_view.add_reasoning("".join(reasoning_parts))
1894
+ reasoning_parts.clear()
1895
+
1896
+ if self._settings.max_response_tokens_enabled:
1897
+ self._agent_loop.set_max_response_tokens(
1898
+ compute_max_response_tokens(
1899
+ self._session,
1900
+ limit_table=self._context_limit_table,
1901
+ safety_margin=self._settings.max_response_tokens_safety_margin,
1902
+ )
1903
+ )
1904
+ else:
1905
+ self._agent_loop.set_max_response_tokens(None)
1906
+
1907
+ try:
1908
+ chat_messages = [m.to_chat_message() for m in self._session.messages]
1909
+ if self._plan_mode:
1910
+ chat_messages.append(ChatMessage(role="system", content=PLAN_MODE_REINFORCEMENT))
1911
+ async for chunk in self._agent_loop.run_turn(
1912
+ chat_messages,
1913
+ ask=ask,
1914
+ budget_check=lambda: cost_budget_reason(
1915
+ self._session, self._settings.max_session_cost_usd
1916
+ ),
1917
+ ):
1918
+ if chunk.kind == "text_delta":
1919
+ had_any_content = True
1920
+ flush_reasoning()
1921
+ message_view.append_to_last(chunk.text)
1922
+ elif chunk.kind == "reasoning_delta":
1923
+ reasoning_parts.append(chunk.text)
1924
+ elif chunk.kind == "usage":
1925
+ self._cost_tracker.record_turn(
1926
+ self._settings.default_model or self._session.model, chunk.usage
1927
+ )
1928
+ self._refresh_cost_display(status_bar)
1929
+ self._refresh_context_display(status_bar, chunk.usage)
1930
+ if self._context_limit_retry_pending:
1931
+ # The model just produced a real response, so a
1932
+ # local gateway that JIT-loads (LM Studio) must have
1933
+ # it loaded now, even if it didn't at startup - one
1934
+ # retry, not a per-turn habit (see the flag's own
1935
+ # comment in __init__).
1936
+ self._context_limit_retry_pending = False
1937
+ await self._maybe_detect_context_limit(message_view, is_retry=True)
1938
+ elif chunk.kind == "tool_start":
1939
+ had_any_content = True
1940
+ flush_reasoning()
1941
+ message_view.finish_streaming()
1942
+ purpose = extract_purpose(chunk.tool_call.function.arguments)
1943
+ message_view.add_tool_call(
1944
+ chunk.tool_call.function.name,
1945
+ chunk.tool_call.function.arguments,
1946
+ purpose=purpose,
1947
+ )
1948
+ message_view.finish_streaming()
1949
+ elif chunk.kind == "tool_result":
1950
+ self._record_tool_invocation(chunk)
1951
+ status_bar.main_tool_calls += 1
1952
+ if chunk.tool_call.function.name == "write_todos":
1953
+ self._refresh_todo_pane()
1954
+ # source="subagent": real spend from a nested AgentLoop
1955
+ # (spawn_subagent/explore_*/etc.), not the main
1956
+ # conversation - see the "compaction" note above for why
1957
+ # this tag matters, not just that it counts toward cost.
1958
+ for extra in chunk.extra_usage:
1959
+ self._cost_tracker.record_turn(
1960
+ self._settings.default_model or self._session.model,
1961
+ extra,
1962
+ source="subagent",
1963
+ )
1964
+ if chunk.extra_usage:
1965
+ self._refresh_cost_display(status_bar)
1966
+ if chunk.tool_call.function.name == "record_decision" and not chunk.is_error:
1967
+ self._show_decision_notice(chunk)
1968
+ else:
1969
+ message_view.add_tool_result(
1970
+ chunk.tool_call.function.name, chunk.output, is_error=chunk.is_error
1971
+ )
1972
+ message_view.finish_streaming()
1973
+ elif chunk.kind == "turn_complete":
1974
+ self._session.messages.extend(
1975
+ Message.from_chat_message(m) for m in chunk.new_messages
1976
+ )
1977
+ response_truncated = chunk.response_truncated
1978
+ except GatewayError as exc:
1979
+ # Previously the only trace of this was a message that vanished
1980
+ # the moment the TUI closed — nothing was logged, and nothing
1981
+ # was saved, so a session that failed on its very first turn
1982
+ # left literally no record anywhere (not even the user's own
1983
+ # message). Log it (with traceback) and save whatever's already
1984
+ # in self._session.messages (system prompt, prior successful
1985
+ # turns, and the user message that triggered this attempt) so a
1986
+ # failed turn is actually debuggable afterward.
1987
+ logger.exception("Gateway error during turn: %s", exc.message)
1988
+ flush_reasoning()
1989
+ message_view.finish_streaming()
1990
+ message_view.add_message(
1991
+ "system",
1992
+ f"Gateway error: {exc.message}\n\n"
1993
+ "**Note:** the message you just sent is still part of this session and will "
1994
+ "be resent as context on your next turn - a failed attempt doesn't discard "
1995
+ "it, it isn't silently skipped.",
1996
+ )
1997
+ self._store.save(self._session)
1998
+ # A GatewayError this far into a turn is often exactly a context-
1999
+ # length overflow (see llm/client.py's _http_status_hint) - the
2000
+ # status bar may well have shown context usage crossing 100% on
2001
+ # an earlier "usage" chunk of this same turn, right before the
2002
+ # next internal call got rejected for being too large. Without
2003
+ # this, the oversized history that caused the failure would
2004
+ # never get compacted (the normal end-of-turn call below is
2005
+ # never reached on this path), so every subsequent turn would
2006
+ # fail the exact same way. Safe to attempt unconditionally: it's
2007
+ # a no-op unless context is actually still over threshold, and
2008
+ # _run_compaction already handles its own GatewayError quietly.
2009
+ await self._prune_and_maybe_auto_compact()
2010
+ return
2011
+ finally:
2012
+ status_bar.busy = False
2013
+ flush_reasoning()
2014
+ message_view.finish_streaming()
2015
+ if not had_any_content:
2016
+ # Not an error - the model responded, just with nothing visible
2017
+ # (e.g. it used its whole response budget on reasoning above and
2018
+ # never got to an actual answer or tool call). Previously this
2019
+ # looked identical to pcli having silently failed to do anything.
2020
+ suffix = ' (see "Thinking" above)' if had_any_reasoning else ""
2021
+ note = f"The model didn't produce a reply or tool call this turn{suffix}."
2022
+ if looks_like_context_ceiling(self._session):
2023
+ # A real debugged case: pcli's assumed context limit for this
2024
+ # model was wrong (silently falling back to a generic 128k
2025
+ # guess), so the fraction-based auto-compact trigger never
2026
+ # fired even though the session had actually exhausted the
2027
+ # model's real, much smaller window. total_tokens plateauing
2028
+ # near its highest-ever value for this session, turn after
2029
+ # turn, is the tell — see looks_like_context_ceiling.
2030
+ assumed = self._context_limit_table.lookup(self._session.model)
2031
+ note += (
2032
+ f" This looks like it may have hit the model's real context limit — pcli "
2033
+ f"is currently assuming {assumed:,} tokens for '{self._session.model}', "
2034
+ "which may be wrong. Try /context-limit <tokens> to correct it (so "
2035
+ "auto-compaction can kick in), or /compact to free up space now."
2036
+ )
2037
+ else:
2038
+ note += " Try again, or ask something more focused."
2039
+ message_view.add_message("system", note)
2040
+ self._store.save(self._session)
2041
+
2042
+ await self._prune_and_maybe_auto_compact()
2043
+
2044
+ if response_truncated:
2045
+ # Deliberately after pruning/auto-compact above, not before: if
2046
+ # context was tight enough to truncate this response in the
2047
+ # first place, freeing some up first gives the retry an actual
2048
+ # chance of finishing instead of just hitting the same wall.
2049
+ self._consecutive_truncations += 1
2050
+ if self._consecutive_truncations <= _MAX_CONSECUTIVE_AUTO_CONTINUES:
2051
+ message_view.add_message(
2052
+ "system",
2053
+ "Response was cut off by the token limit before finishing — continuing "
2054
+ f"automatically ({self._consecutive_truncations}/{_MAX_CONSECUTIVE_AUTO_CONTINUES}).",
2055
+ )
2056
+ self._session.messages.append(Message(role="user", content=_AUTO_CONTINUE_MESSAGE))
2057
+ message_view.add_message("user", _AUTO_CONTINUE_MESSAGE)
2058
+ self._store.save(self._session)
2059
+ self._has_queued_followup = True
2060
+ else:
2061
+ message_view.add_message(
2062
+ "system",
2063
+ f"Response was cut off by the token limit {_MAX_CONSECUTIVE_AUTO_CONTINUES} "
2064
+ "times in a row — stopping automatic continuation rather than risk a runaway "
2065
+ "loop. Try /max-response-tokens to raise the response-length cap, or just say "
2066
+ '"continue" to keep going manually.',
2067
+ )
2068
+ self._consecutive_truncations = 0
2069
+ else:
2070
+ self._consecutive_truncations = 0