zettcode-app 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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Widgets the ZettCode shell composes, one component per module.
|
|
2
|
+
|
|
3
|
+
These are the application's own pieces — the transcript view, the composer's
|
|
4
|
+
command completion, the model picker, the session list, the approval prompt,
|
|
5
|
+
the root, and the banner — as opposed to ``zettcode.tui``, which holds the
|
|
6
|
+
reusable framework they are built from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
#: Where each public name lives; read by :func:`__getattr__` on first use.
|
|
15
|
+
_EXPORTS = {
|
|
16
|
+
"ApprovalChoice": ".approval_page",
|
|
17
|
+
"ApprovalPage": ".approval_page",
|
|
18
|
+
"AskUserPage": ".ask_page",
|
|
19
|
+
"BashPage": ".bash_page",
|
|
20
|
+
"CommandCompleter": ".completer",
|
|
21
|
+
"Composer": ".composer",
|
|
22
|
+
"LOCK_CAPTIONS": ".composer",
|
|
23
|
+
"UPDATING_CAPTIONS": ".composer",
|
|
24
|
+
"DocumentPage": ".document_page",
|
|
25
|
+
"EFFORT_ROWS": ".effort_page",
|
|
26
|
+
"EffortPage": ".effort_page",
|
|
27
|
+
"FollowUpQueue": ".follow_up",
|
|
28
|
+
"FORM_CHROME_ROWS": ".form_page",
|
|
29
|
+
"Field": ".form_page",
|
|
30
|
+
"FormPage": ".form_page",
|
|
31
|
+
"form_rows": ".form_page",
|
|
32
|
+
"model_settings_rows": ".model_settings_page",
|
|
33
|
+
"INPUT_ROWS": ".input_page",
|
|
34
|
+
"InputPage": ".input_page",
|
|
35
|
+
"LATER": ".update_page",
|
|
36
|
+
"ModelPage": ".model_page",
|
|
37
|
+
"ModelSettingsPage": ".model_settings_page",
|
|
38
|
+
"PetView": ".pet",
|
|
39
|
+
"PetPage": ".pet_page",
|
|
40
|
+
"PluginRow": ".plugins_page",
|
|
41
|
+
"PluginsPage": ".plugins_page",
|
|
42
|
+
"SEND_KIND_ROWS": ".send_kind_page",
|
|
43
|
+
"SELF_COMPACT_ROWS": ".self_compact_page",
|
|
44
|
+
"SKIP": ".update_page",
|
|
45
|
+
"SelfCompactPage": ".self_compact_page",
|
|
46
|
+
"SendKind": ".send_kind_page",
|
|
47
|
+
"SendKindPage": ".send_kind_page",
|
|
48
|
+
"SessionsPage": ".sessions_page",
|
|
49
|
+
"SteeringQueue": ".steering",
|
|
50
|
+
"StatuslinePage": ".statusline_page",
|
|
51
|
+
"TranscriptSource": ".transcript",
|
|
52
|
+
"TranscriptView": ".transcript",
|
|
53
|
+
"UPGRADE": ".update_page",
|
|
54
|
+
"UpdatePage": ".update_page",
|
|
55
|
+
"WELCOME": ".welcome",
|
|
56
|
+
"ZettCodeRoot": ".root",
|
|
57
|
+
"bottom_panel": "zettcode.plugin_api.panels",
|
|
58
|
+
"format_ago": ".sessions_page",
|
|
59
|
+
"help_text": ".completer",
|
|
60
|
+
"pet_rows": ".pet_page",
|
|
61
|
+
"statusline_rows": ".statusline_page",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
|
|
66
|
+
from zettcode.plugin_api.panels import bottom_panel
|
|
67
|
+
|
|
68
|
+
from .approval_page import ApprovalChoice, ApprovalPage
|
|
69
|
+
from .ask_page import AskUserPage
|
|
70
|
+
from .bash_page import BashPage
|
|
71
|
+
from .completer import CommandCompleter, help_text
|
|
72
|
+
from .composer import LOCK_CAPTIONS, UPDATING_CAPTIONS, Composer
|
|
73
|
+
from .document_page import DocumentPage
|
|
74
|
+
from .effort_page import EFFORT_ROWS, EffortPage
|
|
75
|
+
from .follow_up import FollowUpQueue
|
|
76
|
+
from .form_page import FORM_CHROME_ROWS, Field, FormPage, form_rows
|
|
77
|
+
from .input_page import INPUT_ROWS, InputPage
|
|
78
|
+
from .model_page import ModelPage
|
|
79
|
+
from .model_settings_page import ModelSettingsPage, model_settings_rows
|
|
80
|
+
from .pet import PetView
|
|
81
|
+
from .pet_page import PetPage, pet_rows
|
|
82
|
+
from .plugins_page import PluginRow, PluginsPage
|
|
83
|
+
from .root import ZettCodeRoot
|
|
84
|
+
from .self_compact_page import SELF_COMPACT_ROWS, SelfCompactPage
|
|
85
|
+
from .send_kind_page import SEND_KIND_ROWS, SendKind, SendKindPage
|
|
86
|
+
from .sessions_page import SessionsPage, format_ago
|
|
87
|
+
from .statusline_page import StatuslinePage, statusline_rows
|
|
88
|
+
from .steering import SteeringQueue
|
|
89
|
+
from .transcript import TranscriptSource, TranscriptView
|
|
90
|
+
from .update_page import LATER, SKIP, UPGRADE, UpdatePage
|
|
91
|
+
from .welcome import WELCOME
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def __getattr__(name: str) -> object:
|
|
95
|
+
"""Import the module a public name lives in, the first time it is asked for.
|
|
96
|
+
|
|
97
|
+
This package is a facade over several subpackages, and a caller that wants
|
|
98
|
+
one name should not pay for the rest of them — the TUI alone holds the
|
|
99
|
+
widget library, the Markdown parser, the diff viewer, and the syntax
|
|
100
|
+
scanners. ``__all__`` is still the public surface.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
module = _EXPORTS[name]
|
|
104
|
+
except KeyError:
|
|
105
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
106
|
+
return getattr(importlib.import_module(module, __name__), name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def __dir__() -> list[str]:
|
|
110
|
+
"""Return the names this package publishes."""
|
|
111
|
+
return sorted(__all__)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"ApprovalChoice",
|
|
116
|
+
"ApprovalPage",
|
|
117
|
+
"AskUserPage",
|
|
118
|
+
"BashPage",
|
|
119
|
+
"CommandCompleter",
|
|
120
|
+
"Composer",
|
|
121
|
+
"DocumentPage",
|
|
122
|
+
"EFFORT_ROWS",
|
|
123
|
+
"EffortPage",
|
|
124
|
+
"FollowUpQueue",
|
|
125
|
+
"FORM_CHROME_ROWS",
|
|
126
|
+
"Field",
|
|
127
|
+
"FormPage",
|
|
128
|
+
"form_rows",
|
|
129
|
+
"INPUT_ROWS",
|
|
130
|
+
"InputPage",
|
|
131
|
+
"LATER",
|
|
132
|
+
"LOCK_CAPTIONS",
|
|
133
|
+
"ModelPage",
|
|
134
|
+
"ModelSettingsPage",
|
|
135
|
+
"PetView",
|
|
136
|
+
"PetPage",
|
|
137
|
+
"PluginRow",
|
|
138
|
+
"PluginsPage",
|
|
139
|
+
"SEND_KIND_ROWS",
|
|
140
|
+
"SELF_COMPACT_ROWS",
|
|
141
|
+
"SKIP",
|
|
142
|
+
"SelfCompactPage",
|
|
143
|
+
"SendKind",
|
|
144
|
+
"SendKindPage",
|
|
145
|
+
"SessionsPage",
|
|
146
|
+
"SteeringQueue",
|
|
147
|
+
"StatuslinePage",
|
|
148
|
+
"TranscriptSource",
|
|
149
|
+
"TranscriptView",
|
|
150
|
+
"UPGRADE",
|
|
151
|
+
"UPDATING_CAPTIONS",
|
|
152
|
+
"UpdatePage",
|
|
153
|
+
"WELCOME",
|
|
154
|
+
"ZettCodeRoot",
|
|
155
|
+
"bottom_panel",
|
|
156
|
+
"format_ago",
|
|
157
|
+
"help_text",
|
|
158
|
+
"model_settings_rows",
|
|
159
|
+
"pet_rows",
|
|
160
|
+
"statusline_rows",
|
|
161
|
+
]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""The bottom-panel prompt that asks whether to run one shell command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from zettcode._compat import StrEnum
|
|
9
|
+
from zettcode.plugin_api.panels import pressed
|
|
10
|
+
from zettcode.tui import MARKER, SEPARATOR, Canvas, Host, MouseAction, MouseEvent, Widget
|
|
11
|
+
from zettcode.tui.render import ELLIPSIS, SHELL, Span, Style, highlight, wrap_spans
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApprovalChoice(StrEnum):
|
|
15
|
+
"""What the user wants done with one pending shell command.
|
|
16
|
+
|
|
17
|
+
The rows are the same ladder ``/permission`` offers, seen from one command: a
|
|
18
|
+
choice either leaves the level alone (``RUN``, ``ALWAYS``, ``ABORT``) or moves
|
|
19
|
+
it up a rung, which is a change to the session (``RUN_SAFE`` to ``safe``,
|
|
20
|
+
``RUN_AUTO`` to ``full``). Neither ``deny`` nor ``full`` ever shows the prompt,
|
|
21
|
+
so there is no row that goes down.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
RUN: Execute this command once; the next one asks again.
|
|
25
|
+
RUN_SAFE: Execute it and stop asking: later commands run in the sandbox.
|
|
26
|
+
RUN_AUTO: Execute it and stop asking for any later command, with full access; the session remembers it.
|
|
27
|
+
ALWAYS: Execute it and remember this exact command for the session.
|
|
28
|
+
ABORT: Do not execute it; the agent sees a normal tool failure.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
RUN = "run"
|
|
32
|
+
RUN_SAFE = "run_safe"
|
|
33
|
+
RUN_AUTO = "run_auto"
|
|
34
|
+
ALWAYS = "always"
|
|
35
|
+
ABORT = "abort"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ApprovalOption:
|
|
40
|
+
"""One numbered line of the prompt.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
choice: Decision handed to the page's ``on_choice`` callback.
|
|
44
|
+
key: Key that triggers this row without moving the selection first.
|
|
45
|
+
label: Sentence drawn after the number and before the key hint.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
choice: ApprovalChoice
|
|
49
|
+
key: str
|
|
50
|
+
label: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: How a key name is spelled in a hint; Escape is the one that shortens.
|
|
54
|
+
_KEY_HINTS = {"escape": "esc"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ApprovalPage(Widget):
|
|
58
|
+
"""Review one shell command before it runs.
|
|
59
|
+
|
|
60
|
+
Shape::
|
|
61
|
+
|
|
62
|
+
Would you like to run the following command?
|
|
63
|
+
|
|
64
|
+
Environment: local
|
|
65
|
+
|
|
66
|
+
$ rm -rf build && make dist <- the command, shell-highlighted
|
|
67
|
+
in clients/web <- only when the call names a directory
|
|
68
|
+
|
|
69
|
+
\u25b8 1. Yes, proceed (y) <- selected row is banded
|
|
70
|
+
2. Yes, and don't ask again for this command (p)
|
|
71
|
+
3. Yes, and stop asking; run commands sandboxed (s)
|
|
72
|
+
4. Yes, and stop asking; full access, no sandbox (a)
|
|
73
|
+
5. No, and say what to do differently (esc)
|
|
74
|
+
|
|
75
|
+
The ``s`` row is offered only while the level is ``ask``: at ``safe`` it would
|
|
76
|
+
change nothing, and a row that does nothing is a row that teaches the wrong
|
|
77
|
+
thing about the ladder.
|
|
78
|
+
|
|
79
|
+
The panel answers the question the agent is blocked on, so it names every
|
|
80
|
+
option and its key instead of relying on the list conventions alone; the
|
|
81
|
+
arrows and Enter still work, and the digits pick a row outright. Escape
|
|
82
|
+
takes the ``Abort`` row.
|
|
83
|
+
|
|
84
|
+
It owns no fill colour: the page clears its rectangle and paints text
|
|
85
|
+
straight onto the terminal's own background, with the surrounding border as
|
|
86
|
+
the only frame. Only the highlighted option bands a row, so the prompt
|
|
87
|
+
never reads as a second surface stacked on the conversation.
|
|
88
|
+
|
|
89
|
+
It paints into whatever rectangle it is given; the shell puts it in a
|
|
90
|
+
bottom panel so the conversation stays visible above the command.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
command: Shell command under review, shown verbatim and highlighted.
|
|
94
|
+
on_choice: Receives the picked :class:`ApprovalChoice`.
|
|
95
|
+
environment: Where the command will run; ZettCode has no sandbox, so
|
|
96
|
+
the default is ``"local"``.
|
|
97
|
+
remember_supported: Whether to offer ``Always allow``; the runtime can
|
|
98
|
+
only honour it when it has approval storage.
|
|
99
|
+
permission: The shell permission level in force, shown beside the
|
|
100
|
+
environment so the prompt says which rung it is on, and used to leave
|
|
101
|
+
out the rung it is already standing on.
|
|
102
|
+
work_dir: The directory the command runs in, when the call names one
|
|
103
|
+
instead of the workspace. It is drawn under the command, because a
|
|
104
|
+
command that runs somewhere else is a different thing to approve.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
QUESTION = "Would you like to run the following command?"
|
|
108
|
+
#: Rows spent on everything but the command and the options: a leading
|
|
109
|
+
#: blank, the question, the blank, the environment line, the blank after
|
|
110
|
+
#: the command, the blank before the options, and one spare bottom row.
|
|
111
|
+
FIXED_ROWS = 7
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
command: str,
|
|
116
|
+
*,
|
|
117
|
+
on_choice: Callable[[ApprovalChoice], None],
|
|
118
|
+
environment: str = "local",
|
|
119
|
+
remember_supported: bool = True,
|
|
120
|
+
permission: str = "ask",
|
|
121
|
+
work_dir: str | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Build the numbered options and start the highlight on the first."""
|
|
124
|
+
super().__init__()
|
|
125
|
+
self.command = command
|
|
126
|
+
self.on_choice = on_choice
|
|
127
|
+
self.environment = environment
|
|
128
|
+
self.permission = permission
|
|
129
|
+
self.work_dir = work_dir
|
|
130
|
+
self.options = self._options(remember_supported, permission)
|
|
131
|
+
self.selected = 0
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _options(remember_supported: bool, permission: str = "ask") -> tuple[ApprovalOption, ...]:
|
|
135
|
+
"""Return the rows to show, dropping the ones that would do nothing.
|
|
136
|
+
|
|
137
|
+
A row the runtime cannot honour (``Always`` without storage) or that would
|
|
138
|
+
move the level to where it already is (``safe`` at ``safe``) is left out.
|
|
139
|
+
"""
|
|
140
|
+
options = [ApprovalOption(ApprovalChoice.RUN, "y", "Yes, proceed")]
|
|
141
|
+
if remember_supported:
|
|
142
|
+
options.append(ApprovalOption(ApprovalChoice.ALWAYS, "p", "Yes, and don't ask again for this command"))
|
|
143
|
+
if permission == "ask":
|
|
144
|
+
options.append(ApprovalOption(ApprovalChoice.RUN_SAFE, "s", "Yes, and stop asking; run commands sandboxed"))
|
|
145
|
+
options.append(ApprovalOption(ApprovalChoice.RUN_AUTO, "a", "Yes, and stop asking; full access, no sandbox"))
|
|
146
|
+
options.append(ApprovalOption(ApprovalChoice.ABORT, "escape", "No, and say what to do differently"))
|
|
147
|
+
return tuple(options)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def focusable(self) -> bool:
|
|
151
|
+
"""Take focus when the shell pushes the page, so the keys reach it."""
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def choice(self) -> ApprovalChoice:
|
|
156
|
+
"""Return the decision the highlighted row would commit."""
|
|
157
|
+
return self.options[self.selected].choice
|
|
158
|
+
|
|
159
|
+
def move(self, amount: int) -> None:
|
|
160
|
+
"""Move the highlight, wrapping around the ends."""
|
|
161
|
+
self.selected = (self.selected + amount) % len(self.options)
|
|
162
|
+
|
|
163
|
+
def activate(self) -> None:
|
|
164
|
+
"""Hand the highlighted row to the shell."""
|
|
165
|
+
self.on_choice(self.choice)
|
|
166
|
+
|
|
167
|
+
def capture_event(self, event, host: Host) -> bool:
|
|
168
|
+
"""Commit a keyed row, or abort on Escape, before anything else sees it."""
|
|
169
|
+
key = pressed(event)
|
|
170
|
+
if key is None:
|
|
171
|
+
return False
|
|
172
|
+
if key == "escape":
|
|
173
|
+
self.on_choice(ApprovalChoice.ABORT)
|
|
174
|
+
return True
|
|
175
|
+
option = next((item for item in self.options if item.key == key), None)
|
|
176
|
+
if option is None:
|
|
177
|
+
return False
|
|
178
|
+
self.on_choice(option.choice)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def handle(self, event, host: Host) -> bool:
|
|
182
|
+
"""Move with the arrows, and commit with Enter, a row number, or a click."""
|
|
183
|
+
if isinstance(event, MouseEvent):
|
|
184
|
+
return self._handle_mouse(event)
|
|
185
|
+
key = pressed(event)
|
|
186
|
+
if key is None:
|
|
187
|
+
return False
|
|
188
|
+
match key:
|
|
189
|
+
case "up":
|
|
190
|
+
self.move(-1)
|
|
191
|
+
case "down":
|
|
192
|
+
self.move(1)
|
|
193
|
+
case "enter":
|
|
194
|
+
self.activate()
|
|
195
|
+
case digit if digit.isascii() and digit.isdigit() and 1 <= int(digit) <= len(self.options):
|
|
196
|
+
self.selected = int(digit) - 1
|
|
197
|
+
self.activate()
|
|
198
|
+
case _:
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def _handle_mouse(self, event: MouseEvent) -> bool:
|
|
203
|
+
"""Commit a clicked option row, the way Enter commits the highlighted one."""
|
|
204
|
+
if event.action is not MouseAction.DOWN or not self.rect.contains(event.x, event.y):
|
|
205
|
+
return False
|
|
206
|
+
top = self.rect.y + 6 + len(self._command_rows(max(0, self.rect.width - 4))) + (1 if self.work_dir else 0)
|
|
207
|
+
index = event.y - top
|
|
208
|
+
if not 0 <= index < len(self.options):
|
|
209
|
+
return False
|
|
210
|
+
self.selected = index
|
|
211
|
+
self.activate()
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
def render(self, canvas: Canvas) -> None:
|
|
215
|
+
"""Fill the panel, then draw the question, the command, and the options."""
|
|
216
|
+
if self.rect.empty:
|
|
217
|
+
return
|
|
218
|
+
theme = self.theme
|
|
219
|
+
# Fill the panel like every other page: it is a screen of its own over
|
|
220
|
+
# the conversation, so leaving the cells unstyled would let the
|
|
221
|
+
# terminal's background through instead of the palette's.
|
|
222
|
+
canvas.fill(
|
|
223
|
+
self.rect.x,
|
|
224
|
+
self.rect.y,
|
|
225
|
+
self.rect.width,
|
|
226
|
+
self.rect.height,
|
|
227
|
+
Style(background=theme.surface_alt),
|
|
228
|
+
)
|
|
229
|
+
x = self.rect.x + 2
|
|
230
|
+
room = max(0, self.rect.width - 4)
|
|
231
|
+
heading = Style(foreground=theme.text, bold=True)
|
|
232
|
+
hint = Style(foreground=theme.muted)
|
|
233
|
+
canvas.draw_text(x, self.rect.y + 1, self.QUESTION, heading, max_width=room)
|
|
234
|
+
canvas.draw_text(
|
|
235
|
+
x,
|
|
236
|
+
self.rect.y + 3,
|
|
237
|
+
f"Environment: {self.environment} {SEPARATOR} Permission: {self.permission}",
|
|
238
|
+
hint,
|
|
239
|
+
max_width=room,
|
|
240
|
+
)
|
|
241
|
+
rows = self._command_rows(room)
|
|
242
|
+
for offset, spans in enumerate(rows):
|
|
243
|
+
canvas.draw_spans(x, self.rect.y + 5 + offset, spans, max_width=room)
|
|
244
|
+
top = self.rect.y + 6 + len(rows)
|
|
245
|
+
if self.work_dir:
|
|
246
|
+
canvas.draw_text(x, top, f"in {self.work_dir}", hint, max_width=room)
|
|
247
|
+
top += 1
|
|
248
|
+
for index, option in enumerate(self.options):
|
|
249
|
+
self._render_option(canvas, top + index, index, option)
|
|
250
|
+
|
|
251
|
+
def _command_rows(self, width: int) -> list[tuple[Span, ...]]:
|
|
252
|
+
"""Return the highlighted command, wrapped and cut to the rows that fit.
|
|
253
|
+
|
|
254
|
+
The first row carries a ``$`` prompt and continuation rows are indented,
|
|
255
|
+
the way a shell transcript reads. A command longer than the panel leaves
|
|
256
|
+
a lone ellipsis row rather than looking complete.
|
|
257
|
+
"""
|
|
258
|
+
code = self.theme.code
|
|
259
|
+
prompt = Span("$ ", Style(foreground=self.theme.accent, bold=True))
|
|
260
|
+
indent = Span(" ", Style(foreground=code.text))
|
|
261
|
+
rows: list[tuple[Span, ...]] = []
|
|
262
|
+
for line in self.command.splitlines() or [""]:
|
|
263
|
+
for chunk in wrap_spans(highlight(line, SHELL, code), max(1, width - 2)):
|
|
264
|
+
rows.append((prompt if not rows else indent, *chunk))
|
|
265
|
+
budget = max(0, self.rect.height - len(self.options) - self.FIXED_ROWS - (1 if self.work_dir else 0))
|
|
266
|
+
if len(rows) <= budget:
|
|
267
|
+
return rows
|
|
268
|
+
return [*rows[: max(0, budget - 1)], (indent, Span(ELLIPSIS, Style(foreground=self.theme.muted)))]
|
|
269
|
+
|
|
270
|
+
def _render_option(self, canvas: Canvas, y: int, index: int, option: ApprovalOption) -> None:
|
|
271
|
+
"""Draw one numbered row, banding the highlighted one."""
|
|
272
|
+
if y >= self.rect.bottom - 1:
|
|
273
|
+
return
|
|
274
|
+
theme = self.theme
|
|
275
|
+
selected = index == self.selected
|
|
276
|
+
if selected:
|
|
277
|
+
style = Style(foreground=theme.text, background=theme.selection, bold=True)
|
|
278
|
+
canvas.fill(self.rect.x + 1, y, max(0, self.rect.width - 2), 1, style)
|
|
279
|
+
else:
|
|
280
|
+
style = Style(foreground=theme.text)
|
|
281
|
+
marker = f"{MARKER} " if selected else " "
|
|
282
|
+
hint = f" ({_KEY_HINTS.get(option.key, option.key)})"
|
|
283
|
+
text = f"{index + 1}. {option.label}{hint}"
|
|
284
|
+
canvas.draw_text(self.rect.x + 2, y, marker + text, style, max_width=max(0, self.rect.width - 4))
|