zettcode-commands 0.4.2__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.
@@ -0,0 +1,646 @@
1
+ """The slash commands that act on the application, written as ordinary plugins.
2
+
3
+ ``/resume``, ``/model`` and the rest used to be methods of the shell. They are plugins now, for
4
+ the reason the shell's rows and the agent's extensions are: the shell asks the loaded plugins for what to show and
5
+ does not know which of it is its own, and a plugin that wants a command of this kind finds the way already laid.
6
+
7
+ A command that acts on the application is handed the shell through the context it runs with
8
+ (:meth:`~zettcode.plugin_api.commands.CommandContext.require_host`), typed as the narrow
9
+ :class:`~zettcode.plugin_api.commands.CommandHost` and not as the shell itself, so a plugin of anybody's can do what these
10
+ do without importing it. The words a command's completion menu offers are asked for before any command runs, so they
11
+ read the shell through :class:`~zettcode.plugin_api.host.HostLink` instead, which the shell attaches itself to.
12
+
13
+ What stays in the shell is what cannot be written against that interface yet: the commands that preview as they are
14
+ walked (``/theme``, ``/pet``) and the panels that edit a form of their own (``/config``, ``/statusline``,
15
+ ``/self-compact``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+ import shlex
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from zettcode.app.errors import COMMAND_ERRORS
28
+ from zettcode.app.plugins.groups import require_tui
29
+ from zettcode.config import ZettCodeConfig
30
+ from zettcode.engine.plugins import applied, catalog
31
+ from zettcode.engine.plugins import set_many as switch_plugins
32
+ from zettcode.engine.plugins.commands import PlainCommandContext, TurnCommandResult
33
+ from zettcode.engine.plugins.context import PluginContext
34
+ from zettcode.paths import DEFAULT_COMMAND_HISTORY
35
+ from zettcode.plugin_api.commands import (
36
+ CommandContext,
37
+ CommandHost,
38
+ CommandResult,
39
+ Subcommand,
40
+ subcommand_words,
41
+ )
42
+ from zettcode.plugin_api.host import HOST_LINK, HostLink
43
+ from zettcode.plugin_api.plugin import TuiPlugin
44
+ from zettcode.tui import Widget
45
+
46
+ #: Rows the picker panels take from the bottom of the screen: the title, the list, and the footer hint. The
47
+ #: handler owns the number; nothing in the framework reads it.
48
+ PICKER_ROWS = 14
49
+
50
+ #: Rows the ``/resume`` panel asks for: the picker, plus the two the boxed search field costs, so a screen tall
51
+ #: enough for them shows as many sessions as any other picker. A shorter screen caps the panel to its height, and
52
+ #: the list gives those two rows up with the box rather than being cut.
53
+ RESUME_ROWS = PICKER_ROWS + 2
54
+
55
+ #: Sessions the ``/resume`` panel lists.
56
+ RESUME_LIMIT = 20
57
+
58
+ #: One-line note per shell-permission level, shown next to its name in the menu. They say what happens to a command,
59
+ #: because the names alone do not: `safe` in particular is a promise about what a command can do, not about what it
60
+ #: can read.
61
+ PERMISSION_NOTES = {
62
+ "deny": "no shell commands run; the model is told and carries on without",
63
+ "ask": "each command waits for your answer (the default)",
64
+ "safe": "every command runs in a sandbox: writes only the workspace and /tmp, no network",
65
+ "full": "every command runs with your own access, without asking",
66
+ }
67
+
68
+ #: One-line note per reasoning level, shown next to its name in the menu.
69
+ EFFORT_NOTES = {
70
+ "off": "no deliberate reasoning",
71
+ "minimal": "a token of thought, fastest",
72
+ "low": "quick answers",
73
+ "medium": "the default balance",
74
+ "high": "more thought on hard problems",
75
+ "xhigh": "a long think on hard problems",
76
+ "max": "the highest budget the provider takes",
77
+ "ultra": "above max; a provider maps it down when it must",
78
+ }
79
+
80
+
81
+ def _panel(page: Widget, *, rows: int = PICKER_ROWS) -> Widget:
82
+ """Wrap one picker in the bottom panel these commands share."""
83
+ from zettcode.app.ui.widgets import bottom_panel
84
+
85
+ return bottom_panel(page, rows=rows)
86
+
87
+
88
+ def _switched(written: dict[str, bool]) -> str:
89
+ """Return the switches a panel wrote, as one line: ``a, b will be off; c will be on``."""
90
+ on = [name for name, enabled in written.items() if enabled]
91
+ off = [name for name, enabled in written.items() if not enabled]
92
+ return "; ".join(f"{', '.join(names)} will be {state}" for names, state in ((on, "on"), (off, "off")) if names)
93
+
94
+
95
+ # -- the session ----------------------------------------------------------------------------------
96
+
97
+
98
+ class SessionCommands(TuiPlugin):
99
+ """``/resume``, ``/continue``, ``/fork`` and ``/title``: the session you are in and the ones before it."""
100
+
101
+ name = "session-commands"
102
+ description = "resume, continue, fork and title"
103
+
104
+ def activate(self, ctx: PluginContext) -> None:
105
+ """Register the four commands."""
106
+ tui = require_tui(ctx)
107
+ tui.register_command("resume", "resume a session: /resume [id]", self.resume)
108
+ tui.register_command("continue", "carry on a request that was cut short", self.continue_)
109
+ tui.register_command("fork", "carry this conversation into a new session", self.fork)
110
+ tui.register_command("title", "name this session: /title <name>", self.title, when_busy="now")
111
+
112
+ async def resume(self, context: CommandContext) -> CommandResult:
113
+ """Open the stored sessions with what each holds, or resume the one named in the argument.
114
+
115
+ The picker is a widget, so the command needs the shell; the agent owns the stored sessions and the
116
+ totals beside them. The same call opens the session `--resume` names on the command line. A store whose
117
+ totals cannot be read still lists its sessions, only without the figures.
118
+ """
119
+ host = context.require_host()
120
+ if context.argument:
121
+ await host.resume_session(context.argument)
122
+ return CommandResult(notification=f"resumed session {context.argument[:8]}", relayout=True)
123
+ from zettcode.app.ui.widgets import SessionsPage
124
+
125
+ sessions = await host.client.sessions.list(limit=RESUME_LIMIT)
126
+ if not sessions:
127
+ return CommandResult(message="No persisted sessions.")
128
+ try:
129
+ found = await host.client.session_stats([session.id for session in sessions])
130
+ stats = {item.id: item for item in found}
131
+ except Exception as error: # noqa: BLE001 - the figures are a courtesy; a list without them is still a list
132
+ context.ui.notice(f"Session totals unavailable: {error}")
133
+ stats = None
134
+ page = SessionsPage(sessions, stats=stats, on_select=host.select_session, on_cancel=host.close_page)
135
+ return CommandResult(widget=_panel(page, rows=RESUME_ROWS))
136
+
137
+ async def continue_(self, context: CommandContext) -> CommandResult:
138
+ """Carry on the request that was interrupted \u2014 stopped, cut off, failed \u2014 from where it stopped.
139
+
140
+ ``/resume`` opens an earlier *session*; this picks up the *request* that did not finish in this one. Having
141
+ nothing to continue is not an error: the reader is told why (the last request finished; the model declined
142
+ and would again; the context window is full), which is the useful part of asking.
143
+ """
144
+ host = context.require_host()
145
+ host.agent.require_session()
146
+ await host.run_continue()
147
+ return CommandResult()
148
+
149
+ async def fork(self, context: CommandContext) -> CommandResult:
150
+ """Copy what this conversation carries into a new session and continue there.
151
+
152
+ The copy lands in a session of its own so the reader can take a different direction without losing the
153
+ conversation that got them here, which stays where it is: nothing about the session being left is written.
154
+ """
155
+ # The line the fork leaves in the conversation is the report: it names the session, and both commands that
156
+ # reopen it. A toast above the composer would be the same news twice, in less detail.
157
+ await context.require_host().fork_session()
158
+ return CommandResult(relayout=True)
159
+
160
+ async def title(self, context: CommandContext) -> CommandResult:
161
+ """Rename the active session, or report the name it already has.
162
+
163
+ The name lives in the session metadata beside the title the agent summarizes, so `/resume` shows it and a
164
+ later automatic naming step leaves it alone.
165
+ """
166
+ host = context.require_host()
167
+ if not context.argument:
168
+ current = host.client.session.title
169
+ if current is None:
170
+ return CommandResult(message="This session has no title yet. Use `/title <name>`.")
171
+ return CommandResult(message=f"Title: **{current}**")
172
+ try:
173
+ renamed = await host.rename_session(context.argument)
174
+ except COMMAND_ERRORS as error:
175
+ return CommandResult(message=str(error))
176
+ return CommandResult(notification=f"renamed to {renamed}", relayout=True)
177
+
178
+
179
+ # -- the model ------------------------------------------------------------------------------------
180
+
181
+
182
+ def model_words(host: CommandHost | None) -> tuple[Subcommand, ...]:
183
+ """Offer the configured models by the name ``/model`` accepts.
184
+
185
+ A name two entries share is left out (``/model`` refuses it as ambiguous), and so is one with a space in it,
186
+ which would not stay one word. Before there is a shell there is nothing to offer.
187
+ """
188
+ if host is None:
189
+ return ()
190
+ models = host.client.models
191
+ shown = [model.display_model or model.model for model in models]
192
+ return tuple(
193
+ Subcommand(name, model.model)
194
+ for name, model in zip(shown, models, strict=True)
195
+ if shown.count(name) == 1 and not any(character.isspace() for character in name)
196
+ )
197
+
198
+
199
+ def permission_words(host: CommandHost | None) -> tuple[Subcommand, ...]:
200
+ """Offer the four levels strictest first, each with what it does to a command and a mark on the one in force."""
201
+ if host is None:
202
+ return ()
203
+ return subcommand_words(host.client.permissions, PERMISSION_NOTES, host.client.permission)
204
+
205
+
206
+ class ModelCommands(TuiPlugin):
207
+ """``/model``, ``/effort`` and ``/permission``: what the next request runs on and how far it may go."""
208
+
209
+ name = "model-commands"
210
+ description = "model, effort and permission"
211
+
212
+ def activate(self, ctx: PluginContext) -> None:
213
+ """Register the commands; the ones that are a choice among words read the shell for those words."""
214
+ tui = require_tui(ctx)
215
+ # A container built by hand has no link to follow; the menus then offer no words, and the commands still run.
216
+ link = ctx.find(HOST_LINK, HostLink) or HostLink()
217
+ tui.register_command("model", "choose a model", self.model, subcommands=lambda: model_words(link.host))
218
+ tui.register_command("effort", "how much the model reasons: /effort high", self.effort, when_busy="now")
219
+ tui.register_command(
220
+ "permission",
221
+ "how much of the shell may run unasked: /permission safe",
222
+ self.permission,
223
+ when_busy="now",
224
+ subcommands=lambda: permission_words(link.host),
225
+ )
226
+
227
+ async def model(self, context: CommandContext) -> CommandResult:
228
+ """Open the model picker, or switch directly to a named model.
229
+
230
+ The picker is a widget, so the command needs the shell: the agent owns the models, the shell owns the
231
+ widget that chooses between them. A machine with no model has nothing to pick, so the command points at
232
+ the one that adds the first entry instead of opening an empty list.
233
+ """
234
+ host = context.require_host()
235
+ if host.client.active_model is None:
236
+ return CommandResult(message="No model is configured yet \u2014 `/config` adds the first one.")
237
+ if context.argument:
238
+ host.change_model(context.argument)
239
+ return CommandResult(relayout=True)
240
+ from zettcode.app.ui.widgets import ModelPage
241
+
242
+ page = ModelPage(host.client, on_select=host.select_model, on_cancel=host.close_page)
243
+ return CommandResult(widget=_panel(page))
244
+
245
+ async def effort(self, context: CommandContext) -> CommandResult:
246
+ """Switch straight to a named level, or open the panel with a knob on a rail.
247
+
248
+ The levels are the runtime's own, so the panel lists what the provider adapters can actually send rather
249
+ than a hand-written list. The arrow keys only slide the knob; Enter applies the level under it, which is
250
+ when the header plays its gauge and the transcript says so. Escape applies nothing. A machine with no
251
+ model has no levels to list — the panel would be a rail with nothing on it — so the command says where the
252
+ model goes instead.
253
+ """
254
+ host = context.require_host()
255
+ if host.client.active_model is None:
256
+ return CommandResult(
257
+ message="No model is configured yet \u2014 `/config` adds one; `/effort` sets the level it reasons at."
258
+ )
259
+ if context.argument:
260
+ try:
261
+ host.change_effort(context.argument)
262
+ except ValueError as error:
263
+ return CommandResult(message=str(error))
264
+ return CommandResult(relayout=True)
265
+
266
+ from zettcode.app.ui.widgets import EFFORT_ROWS, EffortPage, bottom_panel
267
+
268
+ def select(level: str) -> None:
269
+ # The page goes first: the header's gauge is for a reader looking at the header, and it is skipped
270
+ # while a command holds the shell busy.
271
+ host.close_page()
272
+ try:
273
+ host.change_effort(level)
274
+ except ValueError as error:
275
+ host.transcript.error(str(error))
276
+
277
+ page = EffortPage(
278
+ host.client.efforts,
279
+ host.client.effort,
280
+ notes=EFFORT_NOTES,
281
+ on_select=select,
282
+ on_cancel=host.close_page,
283
+ )
284
+ return CommandResult(widget=bottom_panel(page, rows=EFFORT_ROWS))
285
+
286
+ async def permission(self, context: CommandContext) -> CommandResult:
287
+ """Set a named level straight away, or open the menu of the levels.
288
+
289
+ The menu lists the four levels strictest first, and each says what happens to a command rather than what
290
+ the level is called. The level governs the shell tool only; the file and MCP tools are not behind a prompt
291
+ in this application, so nobody should read ``deny`` as a read-only mode for the workspace.
292
+ """
293
+ host = context.require_host()
294
+ if context.argument:
295
+ try:
296
+ host.change_permission(context.argument)
297
+ except ValueError as error:
298
+ return CommandResult(message=str(error))
299
+ return CommandResult(relayout=True)
300
+ host.offer("/permission")
301
+ return CommandResult()
302
+
303
+
304
+ # -- asking, and leaving ----------------------------------------------------------------------------
305
+
306
+
307
+ class SideQuestion(TuiPlugin):
308
+ """``/btw``: a question that does not join the conversation."""
309
+
310
+ name = "side-question-command"
311
+ description = "btw"
312
+
313
+ def activate(self, ctx: PluginContext) -> None:
314
+ """Register ``/btw``; it is the one command worth taking mid-reply, so it queues."""
315
+ tui = require_tui(ctx)
316
+ tui.register_command(
317
+ "btw",
318
+ "ask something that will not join the conversation: /btw <question>",
319
+ self.btw,
320
+ # How a reader asks about what is on screen while it is still arriving.
321
+ when_busy="queue",
322
+ )
323
+
324
+ async def btw(self, context: CommandContext) -> CommandResult:
325
+ """Ask a side question: answered here, and kept out of the conversation.
326
+
327
+ The answer is shown in the transcript like any other, but the store records the exchange as one the model
328
+ is never given back, and the tools are the reading ones \u2014 see :mod:`zettcode.engine.dialogue.side`.
329
+ Declaring itself queue-worthy is what lets the command be typed during a reply: the answer then waits its
330
+ turn like any other request.
331
+
332
+ A session another process has open takes none: the store records the exchange, which is a write, and the
333
+ question is refused before it is echoed rather than shown and then failed.
334
+ """
335
+ host = context.require_host()
336
+ host.agent.require_session()
337
+ await host.run_side_question(context.argument)
338
+ return CommandResult()
339
+
340
+
341
+ class HelpCommand(TuiPlugin):
342
+ """``/help``: the commands and the keys, whoever provides them."""
343
+
344
+ name = "help-command"
345
+ description = "help"
346
+
347
+ def activate(self, ctx: PluginContext) -> None:
348
+ """Register ``/help``; it only shows something, so it is safe beside a running request."""
349
+ tui = require_tui(ctx)
350
+ tui.register_command("help", "show the commands and the keys", self.help, when_busy="now")
351
+
352
+ async def help(self, context: CommandContext) -> CommandResult:
353
+ """Describe every command the shell knows, whoever provides it."""
354
+ from zettcode.app.ui.widgets import help_text
355
+
356
+ return CommandResult(message=help_text(context.require_host().commands))
357
+
358
+
359
+ class BashCommand(TuiPlugin):
360
+ """``/bash``: a full-screen command line over the workspace.
361
+
362
+ ``/bash`` opens it empty and ``/bash <command>`` opens it already running that command. It is the reader's
363
+ own shell run by the reader's own hand, so it asks no permission and shows nothing to the model; what it prints
364
+ stays on its screen and is gone when the page closes.
365
+
366
+ Args:
367
+ history_file: Where the commands typed in the panel are kept; ``~/.zettcode/command_history`` by default.
368
+ """
369
+
370
+ name = "bash-command"
371
+ description = "a shell panel"
372
+
373
+ def __init__(self, history_file: Path | None = None) -> None:
374
+ self.history_file = history_file
375
+
376
+ def activate(self, ctx: PluginContext) -> None:
377
+ """Register ``/bash``; it neither reads nor writes the session, so it may open beside a request."""
378
+ tui = require_tui(ctx)
379
+ tui.register_command("bash", "run commands in a full-screen shell: /bash [command]", self.bash, when_busy="now")
380
+
381
+ async def bash(self, context: CommandContext) -> CommandResult:
382
+ """Open the panel in the workspace, with the history of the reader's shells for Up and Tab."""
383
+ from zettcode.app.commandline import CommandHistory, CommandRunner
384
+ from zettcode.app.ui.widgets import BashPage
385
+
386
+ host = context.require_host()
387
+ history = CommandHistory(self.history_file or DEFAULT_COMMAND_HISTORY)
388
+ # A shell history can be megabytes; reading it is not the event loop's to wait for.
389
+ entries = await asyncio.to_thread(history.load)
390
+ runner = CommandRunner(host.agent.runtime.config.workspace)
391
+ page = BashPage(runner, history, entries, on_close=host.close_page, initial=context.argument)
392
+ return CommandResult(widget=page)
393
+
394
+
395
+ class CodeReview(TuiPlugin):
396
+ """``/code-review``: ask for a review of the uncommitted changes, in this conversation."""
397
+
398
+ name = "code-review-command"
399
+ description = "code review"
400
+
401
+ #: What the model is asked, in pieces: a preset for the turn, not a report about it. The reader gets the review
402
+ #: as an answer they could have asked for by typing all of this themselves.
403
+ INTRO = "Review the uncommitted changes in this workspace."
404
+ COLLECT = (
405
+ "Collect them first: `git status --short` for what changed, `git diff HEAD` for the patch, and the contents "
406
+ "of any untracked file that belongs to the change."
407
+ )
408
+ #: The reviewer is a session of its own, at the shell level of the reader who asked — never above it, since
409
+ #: nobody here approved anything. `{spawn}` is the whole line to run, so the model copies it rather than
410
+ #: reassembling an alias.
411
+ DAEMON = (
412
+ "Have it read by a reviewer of its own, so this conversation keeps its context. `{command} list` shows the "
413
+ "daemons running on this machine. If one is already there for this workspace — `code-review` is the name "
414
+ "this command gives one — reuse it rather than starting another: it has read the earlier rounds and "
415
+ "already knows this change, which makes it the better reviewer. Send it what you need with "
416
+ '`send_message(to="<session id>", message=...)` — the request as you would put it, and that the change '
417
+ "is the uncommitted diff this workspace holds, which it can collect itself. Only when there is none, run\n"
418
+ "\n"
419
+ " {spawn}\n"
420
+ "\n"
421
+ "and take the session id it prints; it is recorded under the name `code-review`, so the next review in "
422
+ "this workspace finds it.\n"
423
+ "\n"
424
+ "Say in that message, every round, that it must answer: send its findings back to you with `send_message`, "
425
+ "and if it has nothing to change, say that in a word. A reviewer that goes quiet leaves the change "
426
+ "waiting, and a review that found nothing cannot be told apart from one that never happened.\n"
427
+ "\n"
428
+ "Keep the reviewer running while you work on what it found: make the changes here, then send it the new "
429
+ "state of the change for another look, and go round again until it has nothing left to say. A reviewer "
430
+ "stopped after the first round cannot tell you whether the fix worked. If a round brings no answer at all, "
431
+ "send the request once more; if that also goes unanswered, read what it did say: its conversation is "
432
+ "`~/.zettcode/sessions/*/<session id>/data.jsonl`, and the last assistant message in it is its review — "
433
+ "report that here, then stop it with `{command} kill <session id>` so that it is not left running. If a "
434
+ "daemon cannot be started here, say why and do the review yourself."
435
+ )
436
+ #: A reviewer is told plainly what it is: the instruction is the safeguard, and the reader is told the same in
437
+ #: the guide, since a session at `full` could change things if it chose to.
438
+ NOT_YOURS = (
439
+ "Tell the reviewer plainly that it reads and does not change: no edits, no writes, no commits, no command "
440
+ "that modifies the workspace. The changes are yours to make, and you keep making them, round after round, "
441
+ "until nothing it found is left."
442
+ )
443
+ #: Under `deny` there is no shell for a daemon to collect a diff with, and the session that asked has none
444
+ #: either: the review is a reading one.
445
+ ALONE = (
446
+ "This session runs no shell commands, so neither the diff nor a daemon to read it can be gathered: review "
447
+ "the files themselves with the reading tools, and say which ones you read."
448
+ )
449
+ SHAPE = (
450
+ "In the review, name concrete problems with `file:line` — correctness, error handling, tests, naming — say "
451
+ "what you would change and why, and do not restate the diff.\n"
452
+ "\n"
453
+ "What to pay attention to: {focus}"
454
+ )
455
+
456
+ #: The shell levels a reviewer may be started at. `ask` has no meaning in a process nobody sits at, so it is
457
+ #: started `safe`: sandboxed, which is still more than a reader at `ask` has granted.
458
+ LEVELS = {"full": "full", "safe": "safe", "ask": "safe"}
459
+
460
+ def activate(self, ctx: PluginContext) -> None:
461
+ """Offer it to every front end: what it answers with is a turn, not a page."""
462
+ ctx.commands.register(
463
+ "/code-review",
464
+ "review the uncommitted changes: /code-review [what to look for]",
465
+ self.review,
466
+ when_busy="queue",
467
+ )
468
+
469
+ def review(self, context: PlainCommandContext) -> TurnCommandResult:
470
+ """Run the review as one turn, with what the reader typed as its focus.
471
+
472
+ The command adds no work of its own: it writes the brief, hands it to `host.prompt`, and the ordinary turn
473
+ does the rest — the echo in the transcript, the session, the streaming, the line it ends on. Nothing is
474
+ gathered here, so the model decides what the review needs and how to get it.
475
+
476
+ The transcript shows the command the reader typed, not the brief it stands for: the brief is what the model
477
+ reads, and `shown` is what the conversation keeps.
478
+ """
479
+ argument = context.argument.strip()
480
+ focus = argument or "anything that would make this change wrong or hard to keep"
481
+ shown = f"/code-review {argument}" if argument else "/code-review"
482
+ return TurnCommandResult(prompt=self.brief(level=context.level, focus=focus), shown=shown)
483
+
484
+ def brief(self, *, level: str, focus: str) -> str:
485
+ """Return what the model reads, for a reader whose shell level is ``level``.
486
+
487
+ The reviewer is started at the reader's own level (`ask` becoming `safe`, which is what a level that asks
488
+ means in a process nobody can answer in), and not at all under `deny`.
489
+ """
490
+ command = self.command()
491
+ parts = [self.INTRO, self.COLLECT]
492
+ if level in self.LEVELS:
493
+ spawn = f"{command} spawn --name code-review --permission {self.LEVELS[level]}"
494
+ parts.append(self.DAEMON.format(command=command, spawn=spawn))
495
+ parts.append(self.NOT_YOURS)
496
+ else:
497
+ parts.append(self.ALONE)
498
+ parts.append(self.SHAPE.format(focus=focus))
499
+ return "\n\n".join(parts)
500
+
501
+ @staticmethod
502
+ def command() -> str:
503
+ """Return the command that runs this installation's daemon, quoted for the shell that will run it.
504
+
505
+ `python -m zettcode.daemon` and the interpreter that is running this terminal: a checkout whose console
506
+ script is not on the PATH, or a virtualenv nothing else can see, still works. The quoting follows the
507
+ platform's shell, because a Windows path with a space cannot be passed the way POSIX quotes it.
508
+ """
509
+ argv = [sys.executable, "-m", "zettcode.daemon"]
510
+ return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv)
511
+
512
+
513
+ class PluginCommand(TuiPlugin):
514
+ """``/plugins``: what loaded, what failed, and which plugins the next start will leave out."""
515
+
516
+ name = "plugins-command"
517
+ description = "plugin news and switches"
518
+
519
+ def activate(self, ctx: PluginContext) -> None:
520
+ """Register ``/plugins``."""
521
+ tui = require_tui(ctx)
522
+
523
+ async def handler(context: CommandContext) -> CommandResult:
524
+ return await self.plugins(context)
525
+
526
+ tui.register_command(
527
+ "plugins",
528
+ "switch the plugins on or off for the next start: /plugins [disable|enable] <name>",
529
+ handler,
530
+ when_busy="now",
531
+ )
532
+
533
+ async def plugins(self, context: CommandContext) -> CommandResult:
534
+ """Open the page of switches, or record one switch the next start will honour.
535
+
536
+ Without an argument this is a page: the program's own plugins first, then the installed ones, each with a
537
+ switch. Enter writes every change down in one go and Escape writes nothing, because the switch is not a
538
+ setting this session reads again — what it decides is what the *next* start does.
539
+
540
+ The settings are read from the runtime now rather than from the activation: a switch this session already wrote
541
+ is a new configuration object, and a page that opened with the old one would show the switch where it was.
542
+ """
543
+ config = context.require_host().agent.runtime.config
544
+ argument = context.argument.strip()
545
+ if not argument:
546
+ return CommandResult(widget=self._page(context, config))
547
+
548
+ verb, _, name = argument.partition(" ")
549
+ name = name.strip()
550
+ if verb not in ("disable", "enable") or not name:
551
+ raise ValueError("Usage: /plugins [disable|enable] <name>")
552
+ written, problems = self._write(context, config, {name: verb == "enable"})
553
+ if problems:
554
+ raise ValueError(problems[0])
555
+ state = "off" if verb == "disable" else "on"
556
+ return CommandResult(
557
+ notification=f"{name} will be {state} from the next start",
558
+ message=f"`{name}` is {state} for the next start; this session keeps the plugins it loaded.",
559
+ )
560
+
561
+ def _page(self, context: CommandContext, config: ZettCodeConfig) -> Widget:
562
+ """Return the page of switches, which writes what it can and closes itself when Enter is pressed."""
563
+ from zettcode.app.ui.widgets import PluginRow, PluginsPage
564
+
565
+ entries = catalog(config, context.require_host().agent.runtime.plugins)
566
+
567
+ def apply(changes: dict[str, bool]) -> None:
568
+ host = context.require_host()
569
+ if not changes:
570
+ host.close_page()
571
+ return
572
+ written, problems = self._write(context, config, changes)
573
+ for problem in problems:
574
+ host.transcript.error(problem)
575
+ if problems:
576
+ return
577
+ host.close_page()
578
+ said = _switched(written)
579
+ if said:
580
+ host.transcript.notice(f"{said} from the next start; this session keeps the plugins it loaded.")
581
+
582
+ return PluginsPage(
583
+ [
584
+ PluginRow(
585
+ entry.name,
586
+ entry.origin,
587
+ entry.builtin,
588
+ entry.enabled,
589
+ entry.failure,
590
+ entry.setting,
591
+ entry.switchable,
592
+ entry.why,
593
+ entry.note,
594
+ )
595
+ for entry in entries
596
+ ],
597
+ on_apply=apply,
598
+ on_cancel=context.require_host().close_page,
599
+ )
600
+
601
+ def _write(
602
+ self, context: CommandContext, config: ZettCodeConfig, changes: dict[str, bool]
603
+ ) -> tuple[dict[str, bool], list[str]]:
604
+ """Write every switch down; return the ones written and the message for each that could not be.
605
+
606
+ A plugin compiled into the program is switched by a configuration key, and the running configuration takes the
607
+ new value too, so the next `/plugins` shows what was chosen even though this process keeps what it loaded.
608
+ """
609
+ runtime = context.require_host().agent.runtime
610
+ try:
611
+ written, keys, problems = switch_plugins(config, changes, bundle=runtime.plugins)
612
+ except ValueError as error:
613
+ # The configuration file could not be written.
614
+ return {}, [str(error)]
615
+ for setting, enabled in keys:
616
+ runtime.config = applied(runtime.config, setting, enabled)
617
+ from zettcode.engine.plugins.switches import applied_installed
618
+
619
+ installed = {name: enabled for name, enabled in written.items() if name not in runtime.plugins.builtins}
620
+ if installed:
621
+ runtime.config = applied_installed(runtime.config, installed)
622
+ return written, problems
623
+
624
+
625
+ class ShellControl(TuiPlugin):
626
+ """``/clear``, ``/quit`` and ``/exit``."""
627
+
628
+ name = "shell-control"
629
+ description = "clear and quit"
630
+
631
+ def activate(self, ctx: PluginContext) -> None:
632
+ """Register the three commands; leaving does not wait for the answer."""
633
+ tui = require_tui(ctx)
634
+ tui.register_command("clear", "clear the transcript", self.clear)
635
+ tui.register_command("quit", "exit", self.quit, when_busy="now")
636
+ tui.register_command("exit", "exit, same as /quit", self.quit, when_busy="now")
637
+
638
+ async def clear(self, context: CommandContext) -> CommandResult:
639
+ """Clear visible conversation entries."""
640
+ context.require_host().transcript.clear()
641
+ return CommandResult()
642
+
643
+ async def quit(self, context: CommandContext) -> CommandResult:
644
+ """Exit the terminal application."""
645
+ context.require_host().quit()
646
+ return CommandResult()