kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/repl.py
ADDED
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
"""Interactive REPL: the main loop launched by the bare ``kiwimatecoder`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import html
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
try: # pragma: no cover - platform dependent
|
|
11
|
+
# Importing readline makes blocking ``input()`` (approvals, ask-user)
|
|
12
|
+
# coexist with the active steering prompt, which holds the terminal in
|
|
13
|
+
# raw mode. Without it, ``input()`` never sees a newline while a turn runs.
|
|
14
|
+
import readline # noqa: F401
|
|
15
|
+
except ImportError: # pragma: no cover - Windows
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable, Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, TypeVar
|
|
21
|
+
|
|
22
|
+
from prompt_toolkit import PromptSession
|
|
23
|
+
from prompt_toolkit.application import Application
|
|
24
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
25
|
+
from prompt_toolkit.document import Document
|
|
26
|
+
from prompt_toolkit.filters import (
|
|
27
|
+
Condition,
|
|
28
|
+
is_done,
|
|
29
|
+
renderer_height_is_known,
|
|
30
|
+
to_filter,
|
|
31
|
+
)
|
|
32
|
+
from prompt_toolkit.formatted_text import (
|
|
33
|
+
AnyFormattedText,
|
|
34
|
+
HTML,
|
|
35
|
+
StyleAndTextTuples,
|
|
36
|
+
to_formatted_text,
|
|
37
|
+
)
|
|
38
|
+
from prompt_toolkit.history import FileHistory, History, InMemoryHistory
|
|
39
|
+
from prompt_toolkit.input import Input
|
|
40
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
41
|
+
from prompt_toolkit.layout.containers import (
|
|
42
|
+
AnyContainer,
|
|
43
|
+
ConditionalContainer,
|
|
44
|
+
HSplit,
|
|
45
|
+
Window,
|
|
46
|
+
)
|
|
47
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
48
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
49
|
+
from prompt_toolkit.layout.layout import Layout
|
|
50
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
51
|
+
from prompt_toolkit.shortcuts import CompleteStyle, choice
|
|
52
|
+
from prompt_toolkit.shortcuts import prompt as read_line
|
|
53
|
+
from prompt_toolkit.shortcuts.choice_input import create_default_choice_input_style
|
|
54
|
+
from prompt_toolkit.styles import BaseStyle, Style, merge_styles
|
|
55
|
+
from prompt_toolkit.widgets import Box, Frame, Label
|
|
56
|
+
from prompt_toolkit.widgets.base import _DialogList
|
|
57
|
+
from rich.console import Console
|
|
58
|
+
from rich.markup import escape
|
|
59
|
+
from rich.panel import Panel
|
|
60
|
+
from rich.syntax import Syntax
|
|
61
|
+
|
|
62
|
+
from kiwimatecoder import __version__, browser, events, hooks, images, lsp, mcp, notify, plugins, ui
|
|
63
|
+
from kiwimatecoder.agent import Agent
|
|
64
|
+
from kiwimatecoder.commands import (
|
|
65
|
+
CommandOption,
|
|
66
|
+
CommandResult,
|
|
67
|
+
MultiSelectionPrompt,
|
|
68
|
+
SelectionPrompt,
|
|
69
|
+
dispatch,
|
|
70
|
+
has_command,
|
|
71
|
+
line_carries_secret,
|
|
72
|
+
slash_argument_completions,
|
|
73
|
+
slash_command_completions,
|
|
74
|
+
)
|
|
75
|
+
from kiwimatecoder.config import get_ui, get_vision
|
|
76
|
+
from kiwimatecoder.hunks import Hunk, parse_hunk_selection, split_hunks
|
|
77
|
+
from kiwimatecoder.i18n import apply_config_locale, t
|
|
78
|
+
from kiwimatecoder.permissions import ApprovalResult, ConfirmFn
|
|
79
|
+
from kiwimatecoder.pickers import OptionGroup, grouped_checkbox_choice, grouped_choice
|
|
80
|
+
from kiwimatecoder.redaction import redact
|
|
81
|
+
from kiwimatecoder.session import Session
|
|
82
|
+
from kiwimatecoder.shell import close_shell
|
|
83
|
+
from kiwimatecoder.templates import find_template, render_template
|
|
84
|
+
from kiwimatecoder.tools.paths import (
|
|
85
|
+
PathError,
|
|
86
|
+
display_path,
|
|
87
|
+
get_workspace_ignore,
|
|
88
|
+
resolve_in_workspace,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
console = Console()
|
|
92
|
+
|
|
93
|
+
_AT_MENTION_LIMIT = 25
|
|
94
|
+
_AT_MENTION_SCAN_LIMIT = 5000
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _workspace_file_candidates(
|
|
98
|
+
workspace_root: Path, prefix: str = "", limit: int = _AT_MENTION_LIMIT
|
|
99
|
+
) -> list[str]:
|
|
100
|
+
"""Return ``@``-prefixed workspace file paths starting with ``prefix``.
|
|
101
|
+
|
|
102
|
+
The walk skips gitignored files and the usual build/cache directories
|
|
103
|
+
(via :func:`get_workspace_ignore`) and stops once ``limit`` matches are
|
|
104
|
+
found or ``_AT_MENTION_SCAN_LIMIT`` entries have been examined.
|
|
105
|
+
"""
|
|
106
|
+
root = Path(workspace_root).resolve()
|
|
107
|
+
ignore = get_workspace_ignore(root)
|
|
108
|
+
wanted = prefix.replace("\\", "/").lstrip("./")
|
|
109
|
+
matches: list[str] = []
|
|
110
|
+
scanned = 0
|
|
111
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
112
|
+
dirnames[:] = sorted(
|
|
113
|
+
name
|
|
114
|
+
for name in dirnames
|
|
115
|
+
if not ignore.is_ignored(Path(dirpath) / name, is_dir=True)
|
|
116
|
+
)
|
|
117
|
+
for name in sorted(filenames):
|
|
118
|
+
full = Path(dirpath) / name
|
|
119
|
+
scanned += 1
|
|
120
|
+
if ignore.is_ignored(full):
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
rel = full.relative_to(root).as_posix()
|
|
124
|
+
except ValueError:
|
|
125
|
+
continue
|
|
126
|
+
if wanted and not rel.startswith(wanted):
|
|
127
|
+
if scanned >= _AT_MENTION_SCAN_LIMIT:
|
|
128
|
+
return matches
|
|
129
|
+
continue
|
|
130
|
+
matches.append("@" + rel)
|
|
131
|
+
if len(matches) >= limit:
|
|
132
|
+
return matches
|
|
133
|
+
if scanned >= _AT_MENTION_SCAN_LIMIT:
|
|
134
|
+
break
|
|
135
|
+
return matches
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SlashCommandCompleter(Completer):
|
|
139
|
+
"""Prompt-toolkit completer for KiwiMate slash commands and @file mentions."""
|
|
140
|
+
|
|
141
|
+
session: Session | None
|
|
142
|
+
|
|
143
|
+
def __init__(self, session: Session | None = None) -> None:
|
|
144
|
+
self.session = session
|
|
145
|
+
|
|
146
|
+
def get_completions(
|
|
147
|
+
self, document: Document, complete_event: CompleteEvent
|
|
148
|
+
) -> Iterable[Completion]:
|
|
149
|
+
text = document.text_before_cursor
|
|
150
|
+
if "\n" in text:
|
|
151
|
+
return
|
|
152
|
+
if text.startswith("/"):
|
|
153
|
+
yield from self._slash_completions(text)
|
|
154
|
+
return
|
|
155
|
+
yield from self._file_completions(text)
|
|
156
|
+
|
|
157
|
+
def _slash_completions(self, text: str) -> Iterable[Completion]:
|
|
158
|
+
body = text[1:]
|
|
159
|
+
if " " not in body:
|
|
160
|
+
for command, description in slash_command_completions(body):
|
|
161
|
+
yield Completion(
|
|
162
|
+
command,
|
|
163
|
+
start_position=-len(text),
|
|
164
|
+
display=command,
|
|
165
|
+
display_meta=description,
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
command, arg_text = body.split(" ", 1)
|
|
170
|
+
if " " in arg_text.strip():
|
|
171
|
+
return
|
|
172
|
+
for value, description in slash_argument_completions(
|
|
173
|
+
command, arg_text, self.session
|
|
174
|
+
):
|
|
175
|
+
yield Completion(
|
|
176
|
+
value,
|
|
177
|
+
start_position=-len(arg_text),
|
|
178
|
+
display=value,
|
|
179
|
+
display_meta=description,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _file_completions(self, text: str) -> Iterable[Completion]:
|
|
183
|
+
if self.session is None or not text or text[-1].isspace():
|
|
184
|
+
return
|
|
185
|
+
token = text.split()[-1]
|
|
186
|
+
if not token.startswith("@"):
|
|
187
|
+
return
|
|
188
|
+
prefix = token[1:]
|
|
189
|
+
for candidate in _workspace_file_candidates(
|
|
190
|
+
self.session.workspace_root, prefix
|
|
191
|
+
):
|
|
192
|
+
yield Completion(
|
|
193
|
+
candidate,
|
|
194
|
+
start_position=-len(token),
|
|
195
|
+
display=candidate,
|
|
196
|
+
display_meta="file",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _git_info(root: Path) -> str | None:
|
|
201
|
+
"""Read active git branch if inside a git repository."""
|
|
202
|
+
head = root / ".git" / "HEAD"
|
|
203
|
+
if head.is_file():
|
|
204
|
+
try:
|
|
205
|
+
ref = head.read_text(encoding="utf-8").strip()
|
|
206
|
+
if ref.startswith("ref: refs/heads/"):
|
|
207
|
+
return ref.split("/")[-1]
|
|
208
|
+
return ref[:7]
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _banner(session: Session) -> Panel:
|
|
215
|
+
accent = ui.theme_accent()
|
|
216
|
+
folder = ui.glyph("folder")
|
|
217
|
+
folder_prefix = f"{folder} " if folder else ""
|
|
218
|
+
git_branch = _git_info(session.workspace_root)
|
|
219
|
+
git_badge = f" · [magenta]git:{escape(git_branch)}[/magenta]" if git_branch else ""
|
|
220
|
+
ctx_badge = (
|
|
221
|
+
f" · [cyan]{t('banner.pinned', count=len(session.context_files))}[/cyan]"
|
|
222
|
+
if session.context_files
|
|
223
|
+
else ""
|
|
224
|
+
)
|
|
225
|
+
dry_badge = f" · [yellow]{t('banner.dry_run')}[/yellow]" if session.dry_run else ""
|
|
226
|
+
|
|
227
|
+
active = session.active_providers
|
|
228
|
+
experimental = " [yellow](experimental)[/yellow]" if session.provider.experimental else ""
|
|
229
|
+
provider_summary = (
|
|
230
|
+
f"[bold cyan]{escape(session.provider.name)}[/bold cyan]{experimental} "
|
|
231
|
+
f"([dim]{escape(session.model or 'no model chosen — use /model')}[/dim])"
|
|
232
|
+
)
|
|
233
|
+
if len(active) > 1:
|
|
234
|
+
fallback_names = ", ".join(escape(p.name) for p in active[1:])
|
|
235
|
+
provider_summary += f" [dim]+ {fallback_names}[/dim]"
|
|
236
|
+
|
|
237
|
+
content = (
|
|
238
|
+
f"[bold {accent}]KiwiMateCoder[/bold {accent}] [dim]v{__version__}[/dim] — "
|
|
239
|
+
f"{provider_summary}\n"
|
|
240
|
+
f"[dim]{folder_prefix}{escape(session.workspace_root.name)}{git_badge} · "
|
|
241
|
+
f"mode:[bold]{session.mode.value}[/bold]{ctx_badge}{dry_badge}\n"
|
|
242
|
+
f"{t('banner.help_hint')}[/dim]"
|
|
243
|
+
)
|
|
244
|
+
return Panel(
|
|
245
|
+
content,
|
|
246
|
+
border_style=accent,
|
|
247
|
+
padding=(0, 1),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _prompt_text(session: Session) -> HTML:
|
|
252
|
+
accent = f"ansi{ui.theme_accent()}"
|
|
253
|
+
mode_color = (
|
|
254
|
+
"ansimagenta"
|
|
255
|
+
if session.mode.value == "plan"
|
|
256
|
+
else "ansiyellow"
|
|
257
|
+
if session.mode.value == "ask"
|
|
258
|
+
else "ansigreen"
|
|
259
|
+
)
|
|
260
|
+
provider_display = f"{session.provider_id}:{session.model or 'no model'}"
|
|
261
|
+
if len(session.active_provider_ids) > 1:
|
|
262
|
+
provider_display += f" +{len(session.active_provider_ids) - 1}"
|
|
263
|
+
return HTML(
|
|
264
|
+
f"<{accent}><b>kiwi</b></{accent}> "
|
|
265
|
+
f"<{mode_color}>({html.escape(provider_display)} · {session.mode.value})</{mode_color}> "
|
|
266
|
+
f"<{accent}>›</{accent}> "
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _option_groups(options: Sequence[CommandOption]) -> list[OptionGroup[str]]:
|
|
271
|
+
"""Split options into titled boxes (in first-seen order) above the rest."""
|
|
272
|
+
titled: dict[str, list[tuple[str, AnyFormattedText]]] = {}
|
|
273
|
+
rest: list[tuple[str, AnyFormattedText]] = []
|
|
274
|
+
for option in options:
|
|
275
|
+
if option.group:
|
|
276
|
+
titled.setdefault(option.group, []).append((option.value, option.label))
|
|
277
|
+
else:
|
|
278
|
+
rest.append((option.value, option.label))
|
|
279
|
+
return [*titled.items(), (None, rest)]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _select_command_option(prompt: SelectionPrompt) -> str | None:
|
|
283
|
+
"""Render a keyboard-driven selector for a choice-based slash command.
|
|
284
|
+
|
|
285
|
+
Grouped options (experimental providers) get their own box on top.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
if any(option.group for option in prompt.options):
|
|
289
|
+
return grouped_choice(
|
|
290
|
+
message=f"{prompt.title}\n{prompt.text}",
|
|
291
|
+
groups=_option_groups(prompt.options),
|
|
292
|
+
default=prompt.selected,
|
|
293
|
+
show_frame=True,
|
|
294
|
+
bottom_toolbar="↑/↓ move • Enter select • Ctrl-C cancel",
|
|
295
|
+
)
|
|
296
|
+
return choice(
|
|
297
|
+
message=f"{prompt.title}\n{prompt.text}",
|
|
298
|
+
options=[(option.value, option.label) for option in prompt.options],
|
|
299
|
+
default=prompt.selected,
|
|
300
|
+
show_frame=True,
|
|
301
|
+
bottom_toolbar="↑/↓ move • Enter select • Ctrl-C cancel",
|
|
302
|
+
)
|
|
303
|
+
except (EOFError, KeyboardInterrupt):
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_T = TypeVar("_T")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _CheckboxChoiceList(_DialogList[_T]):
|
|
311
|
+
"""Inline checklist widget with keyboard navigation and checkbox toggles."""
|
|
312
|
+
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
values: Sequence[tuple[_T, AnyFormattedText]],
|
|
316
|
+
default_values: Sequence[_T] | None = None,
|
|
317
|
+
) -> None:
|
|
318
|
+
super().__init__(
|
|
319
|
+
values=values,
|
|
320
|
+
default_values=default_values,
|
|
321
|
+
multiple_selection=True,
|
|
322
|
+
show_scrollbar=False,
|
|
323
|
+
show_cursor=False,
|
|
324
|
+
show_numbers=False,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _get_text_fragments(self) -> StyleAndTextTuples:
|
|
328
|
+
def mouse_handler(mouse_event: MouseEvent) -> None:
|
|
329
|
+
if mouse_event.event_type == MouseEventType.MOUSE_UP:
|
|
330
|
+
self._selected_index = mouse_event.position.y
|
|
331
|
+
self._handle_enter()
|
|
332
|
+
|
|
333
|
+
result: StyleAndTextTuples = []
|
|
334
|
+
for i, value in enumerate(self.values):
|
|
335
|
+
checked = value[0] in self.current_values
|
|
336
|
+
selected = i == self._selected_index
|
|
337
|
+
|
|
338
|
+
cursor = "> " if selected else " "
|
|
339
|
+
box = "[*] " if checked else "[ ] "
|
|
340
|
+
|
|
341
|
+
cursor_style = "class:cursor" if selected else ""
|
|
342
|
+
box_style = (
|
|
343
|
+
"class:checkbox-checked" if checked else "class:checkbox-unchecked"
|
|
344
|
+
)
|
|
345
|
+
text_style = "class:option"
|
|
346
|
+
if checked:
|
|
347
|
+
text_style += " class:selected-option"
|
|
348
|
+
if selected:
|
|
349
|
+
text_style += " class:focused-option"
|
|
350
|
+
|
|
351
|
+
result.append((cursor_style, cursor))
|
|
352
|
+
if selected:
|
|
353
|
+
result.append(("[SetCursorPosition]", ""))
|
|
354
|
+
result.append((box_style, box))
|
|
355
|
+
result.extend(to_formatted_text(value[1], style=text_style))
|
|
356
|
+
result.append(("", "\n"))
|
|
357
|
+
|
|
358
|
+
for i in range(len(result)):
|
|
359
|
+
result[i] = (result[i][0], result[i][1], mouse_handler)
|
|
360
|
+
|
|
361
|
+
if result:
|
|
362
|
+
result.pop()
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def checkbox_choice(
|
|
367
|
+
message: AnyFormattedText,
|
|
368
|
+
*,
|
|
369
|
+
options: Sequence[tuple[_T, AnyFormattedText]],
|
|
370
|
+
default_values: Sequence[_T] | None = None,
|
|
371
|
+
bottom_toolbar: AnyFormattedText = None,
|
|
372
|
+
show_frame: bool = True,
|
|
373
|
+
mouse_support: bool = False,
|
|
374
|
+
style: BaseStyle | None = None,
|
|
375
|
+
input: Input | None = None,
|
|
376
|
+
) -> list[_T]:
|
|
377
|
+
"""Inline multiple-choice checkbox prompt matching the style of ``choice``."""
|
|
378
|
+
if not options:
|
|
379
|
+
return []
|
|
380
|
+
|
|
381
|
+
cb_list = _CheckboxChoiceList(options, default_values=default_values)
|
|
382
|
+
container: AnyContainer = HSplit(
|
|
383
|
+
[
|
|
384
|
+
Box(
|
|
385
|
+
Label(text=message, dont_extend_height=True),
|
|
386
|
+
padding_top=0,
|
|
387
|
+
padding_left=1,
|
|
388
|
+
padding_right=1,
|
|
389
|
+
padding_bottom=0,
|
|
390
|
+
),
|
|
391
|
+
Box(
|
|
392
|
+
cb_list,
|
|
393
|
+
padding_top=0,
|
|
394
|
+
padding_left=1,
|
|
395
|
+
padding_right=1,
|
|
396
|
+
padding_bottom=0,
|
|
397
|
+
),
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@Condition
|
|
402
|
+
def show_frame_filter() -> bool:
|
|
403
|
+
return to_filter(show_frame)()
|
|
404
|
+
|
|
405
|
+
show_bottom_toolbar = (
|
|
406
|
+
Condition(lambda: bottom_toolbar is not None)
|
|
407
|
+
& ~is_done
|
|
408
|
+
& renderer_height_is_known
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
framed_container = ConditionalContainer(
|
|
412
|
+
Frame(container),
|
|
413
|
+
alternative_content=container,
|
|
414
|
+
filter=show_frame_filter,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
toolbar_container = ConditionalContainer(
|
|
418
|
+
Window(
|
|
419
|
+
FormattedTextControl(
|
|
420
|
+
lambda: bottom_toolbar, style="class:bottom-toolbar.text"
|
|
421
|
+
),
|
|
422
|
+
style="class:bottom-toolbar",
|
|
423
|
+
dont_extend_height=True,
|
|
424
|
+
height=Dimension(min=1),
|
|
425
|
+
),
|
|
426
|
+
filter=show_bottom_toolbar,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
layout = Layout(
|
|
430
|
+
HSplit(
|
|
431
|
+
[
|
|
432
|
+
framed_container,
|
|
433
|
+
ConditionalContainer(Window(), filter=show_bottom_toolbar),
|
|
434
|
+
toolbar_container,
|
|
435
|
+
]
|
|
436
|
+
),
|
|
437
|
+
focused_element=cb_list,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
kb = KeyBindings()
|
|
441
|
+
|
|
442
|
+
@kb.add("enter", eager=True)
|
|
443
|
+
def _accept_input(event: KeyPressEvent) -> None:
|
|
444
|
+
event.app.exit(result=list(cb_list.current_values), style="class:accepted")
|
|
445
|
+
|
|
446
|
+
@kb.add("c-c", eager=True)
|
|
447
|
+
@kb.add("<sigint>", eager=True)
|
|
448
|
+
def _keyboard_interrupt(event: KeyPressEvent) -> None:
|
|
449
|
+
event.app.exit(exception=KeyboardInterrupt(), style="class:aborting")
|
|
450
|
+
|
|
451
|
+
effective_style = merge_styles(
|
|
452
|
+
[
|
|
453
|
+
create_default_choice_input_style(),
|
|
454
|
+
Style.from_dict(
|
|
455
|
+
{
|
|
456
|
+
"cursor": "bold cyan",
|
|
457
|
+
"checkbox-checked": "bold green",
|
|
458
|
+
"checkbox-unchecked": "dim",
|
|
459
|
+
"focused-option": "bold",
|
|
460
|
+
}
|
|
461
|
+
),
|
|
462
|
+
style if style is not None else Style([]),
|
|
463
|
+
]
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
app: Application[list[_T]] = Application(
|
|
467
|
+
layout=layout,
|
|
468
|
+
full_screen=False,
|
|
469
|
+
mouse_support=mouse_support,
|
|
470
|
+
key_bindings=kb,
|
|
471
|
+
style=effective_style,
|
|
472
|
+
input=input,
|
|
473
|
+
)
|
|
474
|
+
return app.run()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _select_command_options(prompt: MultiSelectionPrompt) -> list[str] | None:
|
|
478
|
+
"""Render a keyboard-driven checklist for multi-select slash commands."""
|
|
479
|
+
try:
|
|
480
|
+
if any(option.group for option in prompt.options):
|
|
481
|
+
return grouped_checkbox_choice(
|
|
482
|
+
message=f"{prompt.title}\n{prompt.text}",
|
|
483
|
+
groups=_option_groups(prompt.options),
|
|
484
|
+
default_values=prompt.selected,
|
|
485
|
+
show_frame=True,
|
|
486
|
+
bottom_toolbar="↑/↓ move • Space toggle • Enter confirm • Ctrl-C cancel",
|
|
487
|
+
)
|
|
488
|
+
return checkbox_choice(
|
|
489
|
+
message=f"{prompt.title}\n{prompt.text}",
|
|
490
|
+
options=[(option.value, option.label) for option in prompt.options],
|
|
491
|
+
default_values=prompt.selected,
|
|
492
|
+
show_frame=True,
|
|
493
|
+
bottom_toolbar="↑/↓ move • Space toggle • Enter confirm • Ctrl-C cancel",
|
|
494
|
+
)
|
|
495
|
+
except (EOFError, KeyboardInterrupt):
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _hunk_overview(hunk: Hunk, limit: int = 2) -> list[str]:
|
|
500
|
+
"""Return up to ``limit`` changed lines for display in the hunk list."""
|
|
501
|
+
changed = [line.rstrip("\n") for line in hunk.lines if line.startswith(("+", "-"))]
|
|
502
|
+
return changed[:limit]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _review_hunks(hunk_list: Sequence[Hunk]) -> ApprovalResult:
|
|
506
|
+
"""Prompt for a 1-based hunk selection; deny after repeated bad input."""
|
|
507
|
+
console.print()
|
|
508
|
+
console.print(
|
|
509
|
+
Panel(
|
|
510
|
+
f"[bold]{t('approval.review_hunks')}[/bold]",
|
|
511
|
+
title=f"[cyan]{t('approval.review_hunks_title')}[/cyan]",
|
|
512
|
+
border_style="cyan",
|
|
513
|
+
padding=(0, 1),
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
console.print()
|
|
517
|
+
|
|
518
|
+
for hunk in hunk_list:
|
|
519
|
+
console.print(
|
|
520
|
+
f"[bold cyan]{t('approval.hunk_label')} {hunk.index}[/bold cyan]: "
|
|
521
|
+
f"{escape(hunk.header)}"
|
|
522
|
+
)
|
|
523
|
+
# Display changed lines with syntax highlighting
|
|
524
|
+
for line in _hunk_overview(hunk):
|
|
525
|
+
if line.startswith("+"):
|
|
526
|
+
console.print(f" [green]{escape(line)}[/green]")
|
|
527
|
+
elif line.startswith("-"):
|
|
528
|
+
console.print(f" [red]{escape(line)}[/red]")
|
|
529
|
+
else:
|
|
530
|
+
console.print(f" {escape(line)}")
|
|
531
|
+
console.print()
|
|
532
|
+
|
|
533
|
+
console.print(f"[dim]{t('approval.options_label')}[/dim]")
|
|
534
|
+
console.print(f" [green]all[/green] - {t('approval.option_all')}")
|
|
535
|
+
console.print(f" [red]none[/red] - {t('approval.option_none')}")
|
|
536
|
+
console.print(f" [cyan]1,2,3[/cyan] - {t('approval.option_specific')}")
|
|
537
|
+
console.print()
|
|
538
|
+
|
|
539
|
+
for attempt in range(3):
|
|
540
|
+
try:
|
|
541
|
+
answer = console.input(
|
|
542
|
+
f"[bold]{t('approval.apply_hunks')}[/bold] "
|
|
543
|
+
f"[dim]{t('approval.attempt', attempt=attempt + 1, total=3)}[/dim]: "
|
|
544
|
+
).strip()
|
|
545
|
+
except (EOFError, KeyboardInterrupt):
|
|
546
|
+
console.print(f"\n[yellow]{t('approval.denied')}[/yellow]")
|
|
547
|
+
return ApprovalResult(allowed=False)
|
|
548
|
+
|
|
549
|
+
selection = parse_hunk_selection(answer, len(hunk_list))
|
|
550
|
+
if selection is None:
|
|
551
|
+
console.print(f"[yellow]{t('approval.retry_selection')}[/yellow]")
|
|
552
|
+
continue
|
|
553
|
+
if selection == "all":
|
|
554
|
+
console.print(f"[green]✓ {t('approval.applying_all')}[/green]")
|
|
555
|
+
return ApprovalResult(allowed=True)
|
|
556
|
+
if selection == "none":
|
|
557
|
+
console.print(f"[yellow]✓ {t('approval.rejecting_all')}[/yellow]")
|
|
558
|
+
return ApprovalResult(allowed=False)
|
|
559
|
+
console.print(
|
|
560
|
+
f"[green]✓ {t('approval.applying_hunks', selection=selection)}[/green]"
|
|
561
|
+
)
|
|
562
|
+
return ApprovalResult(allowed=True, selected_hunks=selection)
|
|
563
|
+
|
|
564
|
+
console.print(f"[yellow]{t('approval.too_many_attempts')}[/yellow]")
|
|
565
|
+
return ApprovalResult(allowed=False)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _make_confirm(session: Session) -> ConfirmFn:
|
|
569
|
+
"""Build the approval callback used by the permission gate."""
|
|
570
|
+
|
|
571
|
+
def confirm(summary: str, preview_text: str | None) -> bool | ApprovalResult:
|
|
572
|
+
# ``summary`` is plain text such as run_bash(command='ls [a-z]*'); shown as
|
|
573
|
+
# markup, the user would approve something other than what will run.
|
|
574
|
+
shown = escape(summary)
|
|
575
|
+
console.print()
|
|
576
|
+
hunk_list: list[Hunk] = []
|
|
577
|
+
if preview_text:
|
|
578
|
+
is_diff = preview_text.lstrip().startswith(
|
|
579
|
+
("---", "+++", "@@", "+", "-")
|
|
580
|
+
)
|
|
581
|
+
lexer = "diff" if is_diff else "bash"
|
|
582
|
+
|
|
583
|
+
if is_diff:
|
|
584
|
+
lines = preview_text.splitlines()
|
|
585
|
+
added = sum(
|
|
586
|
+
1
|
|
587
|
+
for line in lines
|
|
588
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
589
|
+
)
|
|
590
|
+
removed = sum(
|
|
591
|
+
1
|
|
592
|
+
for line in lines
|
|
593
|
+
if line.startswith("-") and not line.startswith("---")
|
|
594
|
+
)
|
|
595
|
+
stats = (
|
|
596
|
+
f" ([bold green]+{added}[/bold green] [bold red]-{removed}[/bold red])"
|
|
597
|
+
if (added or removed)
|
|
598
|
+
else ""
|
|
599
|
+
)
|
|
600
|
+
title = (
|
|
601
|
+
f"[bold yellow]{t('approval.approve_change', summary=shown)}"
|
|
602
|
+
f"[/bold yellow]{stats}"
|
|
603
|
+
)
|
|
604
|
+
border = "yellow"
|
|
605
|
+
hunk_list = split_hunks(preview_text)
|
|
606
|
+
else:
|
|
607
|
+
title = (
|
|
608
|
+
f"[bold magenta]{t('approval.approve_shell', summary=shown)}"
|
|
609
|
+
f"[/bold magenta]"
|
|
610
|
+
)
|
|
611
|
+
border = "magenta"
|
|
612
|
+
|
|
613
|
+
console.print(
|
|
614
|
+
Panel(
|
|
615
|
+
Syntax(
|
|
616
|
+
preview_text,
|
|
617
|
+
lexer,
|
|
618
|
+
theme="ansi_dark",
|
|
619
|
+
word_wrap=True,
|
|
620
|
+
line_numbers=is_diff,
|
|
621
|
+
),
|
|
622
|
+
title=title,
|
|
623
|
+
border_style=border,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
# No preview - show a simple confirmation panel
|
|
628
|
+
console.print(
|
|
629
|
+
Panel(
|
|
630
|
+
f"[bold]{t('approval.approve', summary=shown)}[/bold]",
|
|
631
|
+
title=f"[yellow]{t('approval.required_title')}[/yellow]",
|
|
632
|
+
border_style="yellow",
|
|
633
|
+
padding=(0, 1),
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
multi_hunk = len(hunk_list) >= 2
|
|
638
|
+
choices = (
|
|
639
|
+
"([green]y[/green]es / [red]n[/red]o / [cyan]a[/cyan]lways this tool"
|
|
640
|
+
)
|
|
641
|
+
if multi_hunk:
|
|
642
|
+
choices = (
|
|
643
|
+
"([green]y[/green])es / ([red]n[/red])o / "
|
|
644
|
+
"([cyan]a[/cyan])lways / ([magenta]h[/magenta])unks"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
console.print()
|
|
648
|
+
try:
|
|
649
|
+
answer = console.input(
|
|
650
|
+
f"[bold]{t('approval.allow')}[/bold] {choices}): "
|
|
651
|
+
).strip().lower()
|
|
652
|
+
except (EOFError, KeyboardInterrupt):
|
|
653
|
+
console.print(f"\n[yellow]{t('approval.denied')}[/yellow]")
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
if answer in ("a", "always"):
|
|
657
|
+
tool_name = summary.split("(", 1)[0].strip()
|
|
658
|
+
session.allow_always(tool_name)
|
|
659
|
+
try:
|
|
660
|
+
from kiwimatecoder.config import persist_always_allowed_tool
|
|
661
|
+
|
|
662
|
+
persist_always_allowed_tool(tool_name)
|
|
663
|
+
console.print(
|
|
664
|
+
"[dim]"
|
|
665
|
+
+ escape(t("approval.always_saved", tool=tool_name))
|
|
666
|
+
+ "[/dim]"
|
|
667
|
+
)
|
|
668
|
+
except OSError:
|
|
669
|
+
pass
|
|
670
|
+
console.print(f"[green]✓ {t('approval.approved_always')}[/green]")
|
|
671
|
+
return True
|
|
672
|
+
if multi_hunk and answer in ("h", "hunks"):
|
|
673
|
+
return _review_hunks(hunk_list)
|
|
674
|
+
if answer in ("y", "yes"):
|
|
675
|
+
console.print(f"[green]✓ {t('approval.approved')}[/green]")
|
|
676
|
+
return True
|
|
677
|
+
console.print(f"[red]✗ {t('approval.denied_mark')}[/red]")
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
return confirm
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _make_private(path: str | Path) -> None:
|
|
684
|
+
"""Create ``path`` if needed and restrict it to its owner (0600)."""
|
|
685
|
+
try:
|
|
686
|
+
os.close(os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600))
|
|
687
|
+
os.chmod(path, 0o600)
|
|
688
|
+
except OSError:
|
|
689
|
+
pass # best effort: an unwritable history is handled where it is written
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
class _PrivateHistory(FileHistory):
|
|
693
|
+
"""A history file that never holds an API key and is readable only by you.
|
|
694
|
+
|
|
695
|
+
``/config key set <provider> <key>`` carries a secret, so that line stays in
|
|
696
|
+
memory (the up-arrow still finds it this session) but is not written to disk.
|
|
697
|
+
Older versions did write such lines; they are removed when the file is opened.
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
def __init__(self, filename: str | Path) -> None:
|
|
701
|
+
super().__init__(str(filename))
|
|
702
|
+
_make_private(filename)
|
|
703
|
+
self._scrub()
|
|
704
|
+
|
|
705
|
+
def store_string(self, string: str) -> None:
|
|
706
|
+
if line_carries_secret(string):
|
|
707
|
+
return
|
|
708
|
+
super().store_string(string)
|
|
709
|
+
|
|
710
|
+
def _scrub(self) -> None:
|
|
711
|
+
"""Drop keys that an earlier version saved, keeping everything else."""
|
|
712
|
+
try:
|
|
713
|
+
entries = list(self.load_history_strings()) # newest first
|
|
714
|
+
kept = [entry for entry in entries if not line_carries_secret(entry)]
|
|
715
|
+
if len(kept) == len(entries):
|
|
716
|
+
return
|
|
717
|
+
clean = Path(str(self.filename) + ".tmp")
|
|
718
|
+
_make_private(clean)
|
|
719
|
+
clean.write_bytes(b"")
|
|
720
|
+
rewritten = FileHistory(str(clean))
|
|
721
|
+
for entry in reversed(kept): # oldest first, as they were written
|
|
722
|
+
rewritten.store_string(entry)
|
|
723
|
+
os.replace(clean, self.filename)
|
|
724
|
+
except OSError:
|
|
725
|
+
pass # leave the history as it is rather than lose it
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _build_history() -> History:
|
|
729
|
+
"""Return the prompt history, persisted between sessions when possible."""
|
|
730
|
+
try:
|
|
731
|
+
from kiwimatecoder.config import ensure_config_dir
|
|
732
|
+
|
|
733
|
+
return _PrivateHistory(ensure_config_dir() / "history")
|
|
734
|
+
except OSError:
|
|
735
|
+
# Unwritable home directory: keep history for this session only.
|
|
736
|
+
return InMemoryHistory()
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _autosave(session: Session) -> None:
|
|
740
|
+
"""Silently persist the session so ``--continue`` can pick it up."""
|
|
741
|
+
from kiwimatecoder.session import save_autosave
|
|
742
|
+
|
|
743
|
+
try:
|
|
744
|
+
path = save_autosave(session)
|
|
745
|
+
except OSError:
|
|
746
|
+
return
|
|
747
|
+
if path is not None:
|
|
748
|
+
console.print(
|
|
749
|
+
"[dim]Session saved — resume with "
|
|
750
|
+
"[bold]kiwimatecoder --continue[/bold].[/dim]"
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _make_ask_user(console: Console):
|
|
755
|
+
"""Build the interactive callback used by the ask_user tool."""
|
|
756
|
+
|
|
757
|
+
def ask(question: str, options: list[str]) -> str:
|
|
758
|
+
console.print()
|
|
759
|
+
# Display the question in a styled panel for better visibility
|
|
760
|
+
console.print(
|
|
761
|
+
Panel(
|
|
762
|
+
f"[bold]{escape(question)}[/bold]",
|
|
763
|
+
title=f"[yellow]{t('ask.title')}[/yellow]",
|
|
764
|
+
border_style="yellow",
|
|
765
|
+
padding=(0, 1),
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
if not options:
|
|
770
|
+
try:
|
|
771
|
+
answer = console.input(f"[bold cyan]{t('ask.prompt')}[/bold cyan]: ").strip()
|
|
772
|
+
except (EOFError, KeyboardInterrupt):
|
|
773
|
+
console.print(f"\n[yellow]{t('ask.cancelled')}[/yellow]")
|
|
774
|
+
return ""
|
|
775
|
+
return answer
|
|
776
|
+
|
|
777
|
+
console.print(f"[dim]{t('ask.choose_option')}[/dim]")
|
|
778
|
+
for index, option in enumerate(options, 1):
|
|
779
|
+
console.print(f" [green]{index}[/green]. {escape(option)}")
|
|
780
|
+
console.print()
|
|
781
|
+
console.print(f"[dim]{t('ask.instructions')}[/dim]")
|
|
782
|
+
|
|
783
|
+
for _ in range(2):
|
|
784
|
+
try:
|
|
785
|
+
answer = console.input(f"[bold cyan]{t('ask.prompt')}[/bold cyan]: ").strip()
|
|
786
|
+
except (EOFError, KeyboardInterrupt):
|
|
787
|
+
console.print(f"\n[yellow]{t('ask.cancelled')}[/yellow]")
|
|
788
|
+
return ""
|
|
789
|
+
|
|
790
|
+
if answer.isdigit():
|
|
791
|
+
position = int(answer)
|
|
792
|
+
if 1 <= position <= len(options):
|
|
793
|
+
selected = options[position - 1]
|
|
794
|
+
console.print(f"[green]✓ {t('ask.selected')}[/green] {escape(selected)}")
|
|
795
|
+
return selected
|
|
796
|
+
console.print(
|
|
797
|
+
f"[yellow]{t('ask.invalid_option', count=len(options))}[/yellow]"
|
|
798
|
+
)
|
|
799
|
+
continue
|
|
800
|
+
return answer
|
|
801
|
+
|
|
802
|
+
return answer
|
|
803
|
+
|
|
804
|
+
return ask
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
_STEERING_STYLE = "ansibrightblack"
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _steering_prompt() -> HTML:
|
|
811
|
+
from html import escape as html_escape
|
|
812
|
+
|
|
813
|
+
return HTML(
|
|
814
|
+
f'<style fg="{_STEERING_STYLE}">{html_escape(t("prompt.steering"))}</style> '
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _resolve_slash_line(line: str, session: Session) -> tuple[str, str] | None:
|
|
819
|
+
"""Resolve a line to a custom-template prompt, if one matches.
|
|
820
|
+
|
|
821
|
+
Registered slash commands always win. Returns ``("template", prompt)`` for
|
|
822
|
+
a discovered template, or None when the line is a command or unknown.
|
|
823
|
+
"""
|
|
824
|
+
text = line.strip()
|
|
825
|
+
if not text.startswith("/"):
|
|
826
|
+
return None
|
|
827
|
+
parts = text[1:].strip().split(maxsplit=1)
|
|
828
|
+
name = parts[0].lower() if parts else ""
|
|
829
|
+
if not name or has_command(name):
|
|
830
|
+
return None
|
|
831
|
+
template = find_template(name, session.workspace_root)
|
|
832
|
+
if template is None:
|
|
833
|
+
return None
|
|
834
|
+
arguments = parts[1] if len(parts) > 1 else ""
|
|
835
|
+
return ("template", render_template(template.body, arguments))
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _extract_image_mentions(text: str, workspace_root: Path) -> tuple[str, list[str]]:
|
|
839
|
+
"""Pull ``@path`` image mentions out of a line of user input.
|
|
840
|
+
|
|
841
|
+
A token is an image mention when it starts with ``@``, resolves to an
|
|
842
|
+
existing file inside the workspace, and has a supported image suffix.
|
|
843
|
+
Mentions are removed from the returned text; everything else, including
|
|
844
|
+
paths that are missing or outside the workspace, is left untouched.
|
|
845
|
+
"""
|
|
846
|
+
kept: list[str] = []
|
|
847
|
+
found: list[str] = []
|
|
848
|
+
for token in text.split():
|
|
849
|
+
if not token.startswith("@") or len(token) < 2:
|
|
850
|
+
kept.append(token)
|
|
851
|
+
continue
|
|
852
|
+
try:
|
|
853
|
+
resolved = resolve_in_workspace(token[1:], workspace_root)
|
|
854
|
+
except PathError:
|
|
855
|
+
kept.append(token)
|
|
856
|
+
continue
|
|
857
|
+
if resolved.is_file() and images.detect_media_type(resolved):
|
|
858
|
+
found.append(str(resolved))
|
|
859
|
+
else:
|
|
860
|
+
kept.append(token)
|
|
861
|
+
return " ".join(kept), found
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
MAX_MENTION_FILES = 5
|
|
865
|
+
MAX_MENTION_FILE_BYTES = 64 * 1024
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _is_text_file(path: Path) -> bool:
|
|
869
|
+
"""Whether ``path`` looks like readable text (no NUL byte in the first KB)."""
|
|
870
|
+
try:
|
|
871
|
+
with path.open("rb") as handle:
|
|
872
|
+
sample = handle.read(1024)
|
|
873
|
+
except OSError:
|
|
874
|
+
return False
|
|
875
|
+
return b"\x00" not in sample
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _extract_file_mentions(
|
|
879
|
+
text: str, workspace_root: Path
|
|
880
|
+
) -> tuple[str, list[str]]:
|
|
881
|
+
"""Pull ``@path`` text-file mentions out of a line of user input.
|
|
882
|
+
|
|
883
|
+
A token is a file mention when it starts with ``@``, resolves to an
|
|
884
|
+
existing non-image, non-binary file inside the workspace. Mentions are
|
|
885
|
+
removed from the returned text; everything else, including paths that are
|
|
886
|
+
missing, outside the workspace, images, or binary files, is left untouched.
|
|
887
|
+
"""
|
|
888
|
+
kept: list[str] = []
|
|
889
|
+
found: list[str] = []
|
|
890
|
+
for token in text.split():
|
|
891
|
+
if not token.startswith("@") or len(token) < 2:
|
|
892
|
+
kept.append(token)
|
|
893
|
+
continue
|
|
894
|
+
try:
|
|
895
|
+
resolved = resolve_in_workspace(token[1:], workspace_root)
|
|
896
|
+
except PathError:
|
|
897
|
+
kept.append(token)
|
|
898
|
+
continue
|
|
899
|
+
if (
|
|
900
|
+
resolved.is_file()
|
|
901
|
+
and images.detect_media_type(resolved) is None
|
|
902
|
+
and _is_text_file(resolved)
|
|
903
|
+
):
|
|
904
|
+
found.append(str(resolved))
|
|
905
|
+
else:
|
|
906
|
+
kept.append(token)
|
|
907
|
+
return " ".join(kept), found
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _render_mentioned_file(path: Path, workspace_root: Path) -> str | None:
|
|
911
|
+
"""Return a numbered, byte-bounded block for one mentioned file."""
|
|
912
|
+
try:
|
|
913
|
+
size = path.stat().st_size
|
|
914
|
+
with path.open("rb") as handle:
|
|
915
|
+
clipped = handle.read(MAX_MENTION_FILE_BYTES)
|
|
916
|
+
except OSError:
|
|
917
|
+
return None
|
|
918
|
+
text = clipped.decode("utf-8", "replace")
|
|
919
|
+
numbered = "\n".join(
|
|
920
|
+
f"{index}\t{line}" for index, line in enumerate(text.splitlines(), 1)
|
|
921
|
+
)
|
|
922
|
+
header = display_path(path, workspace_root)
|
|
923
|
+
if size > len(clipped):
|
|
924
|
+
numbered += (
|
|
925
|
+
f'\n<truncated original_bytes="{size}" shown_bytes="{len(clipped)}" />'
|
|
926
|
+
)
|
|
927
|
+
return f"--- {header} ---\n{numbered or '[empty file]'}"
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _attach_file_mentions(line: str, session: Session) -> str:
|
|
931
|
+
"""Prepend a ``[mentioned files]`` block for ``@path`` text mentions.
|
|
932
|
+
|
|
933
|
+
Files are read as UTF-8 (bounded to :data:`MAX_MENTION_FILE_BYTES` each)
|
|
934
|
+
and at most :data:`MAX_MENTION_FILES` are included per turn. This is user
|
|
935
|
+
context, so it is deliberately not redacted.
|
|
936
|
+
"""
|
|
937
|
+
cleaned, paths = _extract_file_mentions(line, session.workspace_root)
|
|
938
|
+
if not paths:
|
|
939
|
+
return line
|
|
940
|
+
if len(paths) > MAX_MENTION_FILES:
|
|
941
|
+
console.print(
|
|
942
|
+
f"[yellow]File mention limit ({MAX_MENTION_FILES} per turn) reached; "
|
|
943
|
+
f"{len(paths) - MAX_MENTION_FILES} file(s) were not included.[/yellow]"
|
|
944
|
+
)
|
|
945
|
+
blocks: list[str] = []
|
|
946
|
+
for path in paths[:MAX_MENTION_FILES]:
|
|
947
|
+
block = _render_mentioned_file(Path(path), session.workspace_root)
|
|
948
|
+
if block is not None:
|
|
949
|
+
blocks.append(block)
|
|
950
|
+
if not blocks:
|
|
951
|
+
return cleaned
|
|
952
|
+
context = "[mentioned files]\n" + "\n\n".join(blocks)
|
|
953
|
+
if cleaned.strip():
|
|
954
|
+
return context + "\n\n" + cleaned
|
|
955
|
+
return context
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _attach_images(line: str, session: Session) -> str:
|
|
959
|
+
"""Stash ``@path`` images from ``line`` for the next turn.
|
|
960
|
+
|
|
961
|
+
Respects the configured per-turn image limit and byte cap, printing a dim
|
|
962
|
+
note for each batch. Returns the line with image mentions removed, or a
|
|
963
|
+
default prompt when the line was only image mentions.
|
|
964
|
+
"""
|
|
965
|
+
cleaned, paths = _extract_image_mentions(line, session.workspace_root)
|
|
966
|
+
if not paths:
|
|
967
|
+
return line
|
|
968
|
+
|
|
969
|
+
vision = get_vision()
|
|
970
|
+
max_images = int(vision["max_images_per_turn"])
|
|
971
|
+
max_bytes = int(vision["max_image_bytes"])
|
|
972
|
+
attached: list[str] = []
|
|
973
|
+
for path in paths:
|
|
974
|
+
if len(session.pending_images) >= max_images:
|
|
975
|
+
console.print(
|
|
976
|
+
f"[yellow]Image limit ({max_images} per turn) reached; "
|
|
977
|
+
f"{escape(Path(path).name)} was not attached.[/yellow]"
|
|
978
|
+
)
|
|
979
|
+
break
|
|
980
|
+
try:
|
|
981
|
+
entry = images.encode_image(path, max_bytes)
|
|
982
|
+
except ValueError as exc:
|
|
983
|
+
console.print(f"[yellow]{escape(str(exc))}[/yellow]")
|
|
984
|
+
continue
|
|
985
|
+
session.pending_images.append(entry)
|
|
986
|
+
attached.append(entry["name"])
|
|
987
|
+
|
|
988
|
+
if attached:
|
|
989
|
+
label = "image" if len(attached) == 1 else "images"
|
|
990
|
+
names = ", ".join(escape(name) for name in attached)
|
|
991
|
+
console.print(f"[dim]Attached {label}: {names}[/dim]")
|
|
992
|
+
if not cleaned.strip() and attached:
|
|
993
|
+
return "Please analyze the attached image(s)."
|
|
994
|
+
return cleaned
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _route_steering_line(session: Session, line: str) -> str:
|
|
998
|
+
"""Route a line typed while a turn is running.
|
|
999
|
+
|
|
1000
|
+
Blank input is ignored, slash commands are queued to run after the turn,
|
|
1001
|
+
custom template commands are steered like normal text, and anything else is
|
|
1002
|
+
queued as steering for the agent. Returns one of ``"steered"``,
|
|
1003
|
+
``"deferred"``, or ``"ignored"``.
|
|
1004
|
+
"""
|
|
1005
|
+
text = line.strip()
|
|
1006
|
+
if not text:
|
|
1007
|
+
return "ignored"
|
|
1008
|
+
if text.startswith("/"):
|
|
1009
|
+
resolved = _resolve_slash_line(text, session)
|
|
1010
|
+
if resolved is not None:
|
|
1011
|
+
session.steering.append(resolved[1])
|
|
1012
|
+
return "steered"
|
|
1013
|
+
session.deferred_commands.append(text)
|
|
1014
|
+
console.print(f"[dim]{t('prompt.command_queued')}[/dim]")
|
|
1015
|
+
return "deferred"
|
|
1016
|
+
session.steering.append(text)
|
|
1017
|
+
return "steered"
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _read_command_input(message: str) -> str:
|
|
1021
|
+
"""Read one typed line for a slash command's follow-up question.
|
|
1022
|
+
|
|
1023
|
+
Commands run in a worker thread (see ``_dispatch_command``), where a plain
|
|
1024
|
+
``input()`` never sees Ctrl-C: the signal goes to the event loop in the
|
|
1025
|
+
main thread, so the prompt ignores it and the REPL exits on the next Enter.
|
|
1026
|
+
prompt_toolkit reads Ctrl-C and Ctrl-D as keys, as the selectors do, so they
|
|
1027
|
+
raise here and the command cancels.
|
|
1028
|
+
"""
|
|
1029
|
+
return read_line(message)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _read_secret_input(message: str) -> str:
|
|
1033
|
+
"""Like ``_read_command_input`` but nothing typed is echoed (an API key).
|
|
1034
|
+
|
|
1035
|
+
prompt_toolkit shows bullet characters instead of the text, and the text is
|
|
1036
|
+
never written to the REPL's history file. Ctrl-C and Ctrl-D raise, so the
|
|
1037
|
+
caller cancels the same way.
|
|
1038
|
+
"""
|
|
1039
|
+
return read_line(message, is_password=True)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
async def _dispatch_command(line: str, session: Session) -> str:
|
|
1043
|
+
"""Run a slash command off the event loop.
|
|
1044
|
+
|
|
1045
|
+
Selector commands call ``Application.run()`` internally, which cannot run
|
|
1046
|
+
inside the live asyncio loop, so the whole dispatch happens in a worker.
|
|
1047
|
+
"""
|
|
1048
|
+
return await asyncio.to_thread(
|
|
1049
|
+
dispatch,
|
|
1050
|
+
line,
|
|
1051
|
+
session,
|
|
1052
|
+
console,
|
|
1053
|
+
_select_command_option,
|
|
1054
|
+
_read_command_input,
|
|
1055
|
+
_select_command_options,
|
|
1056
|
+
_read_secret_input,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
async def _process_deferred_commands(session: Session) -> bool:
|
|
1061
|
+
"""Run queued slash commands FIFO; returns True when one requests exit."""
|
|
1062
|
+
while session.deferred_commands:
|
|
1063
|
+
command = session.deferred_commands.popleft()
|
|
1064
|
+
console.print(f"[dim]→ {escape(command)}[/dim]")
|
|
1065
|
+
if await _dispatch_command(command, session) == CommandResult.EXIT:
|
|
1066
|
+
session.deferred_commands.clear()
|
|
1067
|
+
return True
|
|
1068
|
+
return False
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
async def _cancel_task(task: asyncio.Task[Any]) -> None:
|
|
1072
|
+
"""Cancel ``task`` and wait for it, swallowing prompt interruptions."""
|
|
1073
|
+
if not task.done():
|
|
1074
|
+
task.cancel()
|
|
1075
|
+
try:
|
|
1076
|
+
await task
|
|
1077
|
+
except (asyncio.CancelledError, KeyboardInterrupt, EOFError):
|
|
1078
|
+
pass
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
async def _steering_input(pt_session: PromptSession[str]) -> tuple[str, str]:
|
|
1082
|
+
"""Read one steering line, turning interrupts into sentinel outcomes.
|
|
1083
|
+
|
|
1084
|
+
A ``KeyboardInterrupt`` raised inside a separate asyncio task is re-raised
|
|
1085
|
+
by ``Task.__step`` and escapes the event loop instead of being stored on the
|
|
1086
|
+
task, so it must not cross the task boundary.
|
|
1087
|
+
"""
|
|
1088
|
+
try:
|
|
1089
|
+
return await pt_session.prompt_async(_steering_prompt()), "input"
|
|
1090
|
+
except KeyboardInterrupt:
|
|
1091
|
+
return "", "interrupt"
|
|
1092
|
+
except EOFError:
|
|
1093
|
+
return "", "eof"
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _notify_turn_finished(elapsed: float) -> None:
|
|
1097
|
+
"""Send a turn-finished notification when configured and slow enough."""
|
|
1098
|
+
settings = get_ui()
|
|
1099
|
+
threshold = float(settings["notify_after_seconds"])
|
|
1100
|
+
if not notify.should_notify(elapsed, threshold):
|
|
1101
|
+
return
|
|
1102
|
+
notify.notify(
|
|
1103
|
+
"KiwiMateCoder",
|
|
1104
|
+
t("prompt.turn_finished", seconds=int(elapsed)),
|
|
1105
|
+
mode=settings["notify"],
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
async def _run_turn_with_steering(
|
|
1110
|
+
agent: Agent,
|
|
1111
|
+
pt_session: PromptSession[str],
|
|
1112
|
+
session: Session,
|
|
1113
|
+
line: str,
|
|
1114
|
+
) -> bool:
|
|
1115
|
+
"""Run one agent turn while steering input is accepted concurrently.
|
|
1116
|
+
|
|
1117
|
+
Returns True when the REPL should exit (Ctrl-D during the turn).
|
|
1118
|
+
"""
|
|
1119
|
+
started_at = time.monotonic()
|
|
1120
|
+
turn_task: asyncio.Task[None] = asyncio.create_task(agent.run_turn(line))
|
|
1121
|
+
interrupted = False
|
|
1122
|
+
exit_requested = False
|
|
1123
|
+
|
|
1124
|
+
try:
|
|
1125
|
+
while not turn_task.done():
|
|
1126
|
+
prompt_task: asyncio.Task[tuple[str, str]] = asyncio.create_task(
|
|
1127
|
+
_steering_input(pt_session)
|
|
1128
|
+
)
|
|
1129
|
+
pending: set[asyncio.Task[Any]] = {turn_task, prompt_task}
|
|
1130
|
+
try:
|
|
1131
|
+
done, _ = await asyncio.wait(
|
|
1132
|
+
pending, return_when=asyncio.FIRST_COMPLETED
|
|
1133
|
+
)
|
|
1134
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1135
|
+
# Ctrl-C surfaced from the event loop (rather than the prompt)
|
|
1136
|
+
# cancels the whole REPL task; treat it like a prompt interrupt.
|
|
1137
|
+
await _cancel_task(prompt_task)
|
|
1138
|
+
await _cancel_task(turn_task)
|
|
1139
|
+
interrupted = True
|
|
1140
|
+
break
|
|
1141
|
+
|
|
1142
|
+
if turn_task in done:
|
|
1143
|
+
await _cancel_task(prompt_task)
|
|
1144
|
+
break
|
|
1145
|
+
|
|
1146
|
+
typed, outcome = prompt_task.result()
|
|
1147
|
+
if outcome == "interrupt":
|
|
1148
|
+
await _cancel_task(turn_task)
|
|
1149
|
+
interrupted = True
|
|
1150
|
+
break
|
|
1151
|
+
if outcome == "eof":
|
|
1152
|
+
await _cancel_task(turn_task)
|
|
1153
|
+
console.print(f"[dim]{t('prompt.goodbye')}[/dim]")
|
|
1154
|
+
exit_requested = True
|
|
1155
|
+
break
|
|
1156
|
+
|
|
1157
|
+
_route_steering_line(session, typed)
|
|
1158
|
+
|
|
1159
|
+
if not interrupted and not exit_requested:
|
|
1160
|
+
# Surface unexpected agent errors from the completed turn.
|
|
1161
|
+
await turn_task
|
|
1162
|
+
_notify_turn_finished(time.monotonic() - started_at)
|
|
1163
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1164
|
+
await _cancel_task(turn_task)
|
|
1165
|
+
interrupted = True
|
|
1166
|
+
except BaseException:
|
|
1167
|
+
await _cancel_task(turn_task)
|
|
1168
|
+
raise
|
|
1169
|
+
|
|
1170
|
+
if interrupted:
|
|
1171
|
+
console.print(f"\n[yellow]{t('prompt.interrupted')}[/yellow]")
|
|
1172
|
+
|
|
1173
|
+
if await _process_deferred_commands(session):
|
|
1174
|
+
exit_requested = True
|
|
1175
|
+
return exit_requested
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _run_lifecycle_hooks(
|
|
1179
|
+
bus: events.EventBus, event: str, session: Session
|
|
1180
|
+
) -> None:
|
|
1181
|
+
"""Emit a session lifecycle event and run its hooks; never raises."""
|
|
1182
|
+
try:
|
|
1183
|
+
bus.emit(event, workspace=str(session.workspace_root))
|
|
1184
|
+
results = hooks.run_hooks(event, session=session, console=console)
|
|
1185
|
+
except Exception as exc: # hooks must never prevent startup or shutdown
|
|
1186
|
+
console.print(f"[dim]Hook error during {escape(event)}: {escape(str(exc))}[/dim]")
|
|
1187
|
+
return
|
|
1188
|
+
for result in results:
|
|
1189
|
+
if not result.ok:
|
|
1190
|
+
status = "timed out" if result.timed_out else f"exit {result.exit_code}"
|
|
1191
|
+
console.print(
|
|
1192
|
+
f"[dim]Hook {escape(event)} failed ({status}): "
|
|
1193
|
+
f"{escape(redact(result.command))}[/dim]"
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _load_session_plugins(
|
|
1198
|
+
session: Session, bus: events.EventBus
|
|
1199
|
+
) -> plugins.PluginLoadResult:
|
|
1200
|
+
"""Load plugins before the agent exists; failures are dim, never fatal."""
|
|
1201
|
+
result = plugins.load_plugins(session.workspace_root, console=console, bus=bus)
|
|
1202
|
+
for name, reason in result.failed:
|
|
1203
|
+
console.print(
|
|
1204
|
+
f"[dim]Plugin '{escape(name)}' failed to load: {escape(reason)}[/dim]"
|
|
1205
|
+
)
|
|
1206
|
+
return result
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _load_session_mcp(bus: events.EventBus) -> mcp.McpManager:
|
|
1210
|
+
"""Connect configured MCP servers; failures are dim warnings, never fatal."""
|
|
1211
|
+
manager = mcp.McpManager(console=console, bus=bus)
|
|
1212
|
+
mcp.set_manager(manager)
|
|
1213
|
+
manager.load()
|
|
1214
|
+
return manager
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
async def _check_update_background(session: Session) -> None:
|
|
1218
|
+
"""Check for updates in the background; show a dim notice if one exists.
|
|
1219
|
+
|
|
1220
|
+
Runs the (blocking) check in a thread so the prompt is never delayed.
|
|
1221
|
+
Failures are silent — an update check must never interrupt startup.
|
|
1222
|
+
"""
|
|
1223
|
+
from kiwimatecoder import updater
|
|
1224
|
+
|
|
1225
|
+
if not updater.should_check_for_update():
|
|
1226
|
+
return
|
|
1227
|
+
try:
|
|
1228
|
+
info = await asyncio.to_thread(updater.check_for_update)
|
|
1229
|
+
except Exception:
|
|
1230
|
+
return
|
|
1231
|
+
if info.update_available and not info.error:
|
|
1232
|
+
if info.current_sha and info.latest_sha:
|
|
1233
|
+
behind = f" ({info.commits_behind} commit(s) behind)" if info.commits_behind else ""
|
|
1234
|
+
console.print(
|
|
1235
|
+
f"[yellow]Update available:[/yellow] "
|
|
1236
|
+
f"[cyan]{escape(info.current_sha)}[/cyan] → "
|
|
1237
|
+
f"[cyan]{escape(info.latest_sha)}[/cyan]{behind} — "
|
|
1238
|
+
f"run [bold]/update[/bold] or press [bold]Ctrl+U[/bold] to install."
|
|
1239
|
+
)
|
|
1240
|
+
else:
|
|
1241
|
+
console.print(
|
|
1242
|
+
f"[yellow]Update available:[/yellow] "
|
|
1243
|
+
f"[cyan]{escape(info.current_version)}[/cyan] → "
|
|
1244
|
+
f"[cyan]{escape(info.latest_version)}[/cyan] — "
|
|
1245
|
+
f"run [bold]/update[/bold] or press [bold]Ctrl+U[/bold] to install."
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
async def _run_interactive(
|
|
1250
|
+
session: Session, bus: events.EventBus | None = None
|
|
1251
|
+
) -> None:
|
|
1252
|
+
"""Run the async interactive loop until the user exits."""
|
|
1253
|
+
bus = bus if bus is not None else events.BUS
|
|
1254
|
+
_load_session_plugins(session, bus)
|
|
1255
|
+
_load_session_mcp(bus)
|
|
1256
|
+
console.print(_banner(session))
|
|
1257
|
+
_run_lifecycle_hooks(bus, events.SESSION_START, session)
|
|
1258
|
+
confirm = _make_confirm(session)
|
|
1259
|
+
session.ask_user = _make_ask_user(console)
|
|
1260
|
+
ui_config = ui.ui_config()
|
|
1261
|
+
agent = Agent(
|
|
1262
|
+
session,
|
|
1263
|
+
console,
|
|
1264
|
+
confirm,
|
|
1265
|
+
bus=bus,
|
|
1266
|
+
ascii_mode=ui_config["ascii"],
|
|
1267
|
+
output_mode=ui_config["output_mode"],
|
|
1268
|
+
spinner=ui_config["spinner"],
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
kb = KeyBindings()
|
|
1272
|
+
|
|
1273
|
+
@kb.add("escape", "enter")
|
|
1274
|
+
def _(event: KeyPressEvent) -> None:
|
|
1275
|
+
"""Alt+Enter / Escape+Enter inserts newline for multi-line prompts."""
|
|
1276
|
+
event.current_buffer.insert_text("\n")
|
|
1277
|
+
|
|
1278
|
+
@kb.add("c-u")
|
|
1279
|
+
def _(event: KeyPressEvent) -> None:
|
|
1280
|
+
"""Ctrl+U: insert '/update' into the buffer so the user can confirm."""
|
|
1281
|
+
event.current_buffer.insert_text("/update")
|
|
1282
|
+
|
|
1283
|
+
pt_session: PromptSession[str] = PromptSession(
|
|
1284
|
+
history=_build_history(),
|
|
1285
|
+
completer=SlashCommandCompleter(session),
|
|
1286
|
+
complete_while_typing=True,
|
|
1287
|
+
complete_style=CompleteStyle.MULTI_COLUMN,
|
|
1288
|
+
key_bindings=kb,
|
|
1289
|
+
vi_mode=ui.vi_mode_enabled(),
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
update_task = asyncio.create_task(_check_update_background(session))
|
|
1293
|
+
|
|
1294
|
+
multiline_buffer: list[str] = []
|
|
1295
|
+
in_multiline_block = False
|
|
1296
|
+
|
|
1297
|
+
try:
|
|
1298
|
+
while True:
|
|
1299
|
+
try:
|
|
1300
|
+
prompt_str = (
|
|
1301
|
+
HTML("<ansicyan>... </ansicyan>")
|
|
1302
|
+
if in_multiline_block
|
|
1303
|
+
else _prompt_text(session)
|
|
1304
|
+
)
|
|
1305
|
+
line = await pt_session.prompt_async(prompt_str)
|
|
1306
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1307
|
+
# Ctrl-C at the prompt: clear the line / buffer, keep going.
|
|
1308
|
+
multiline_buffer.clear()
|
|
1309
|
+
in_multiline_block = False
|
|
1310
|
+
continue
|
|
1311
|
+
except EOFError:
|
|
1312
|
+
# Ctrl-D: exit.
|
|
1313
|
+
console.print(f"[dim]{t('prompt.goodbye')}[/dim]")
|
|
1314
|
+
break
|
|
1315
|
+
|
|
1316
|
+
# Check for triple-quote multiline block mode
|
|
1317
|
+
stripped = line.strip()
|
|
1318
|
+
if not in_multiline_block and stripped.startswith('"""') and not (
|
|
1319
|
+
len(stripped) > 3 and stripped.endswith('"""')
|
|
1320
|
+
):
|
|
1321
|
+
in_multiline_block = True
|
|
1322
|
+
multiline_buffer.append(stripped[3:])
|
|
1323
|
+
continue
|
|
1324
|
+
|
|
1325
|
+
if in_multiline_block:
|
|
1326
|
+
if stripped.endswith('"""'):
|
|
1327
|
+
in_multiline_block = False
|
|
1328
|
+
multiline_buffer.append(stripped[:-3])
|
|
1329
|
+
line = "\n".join(multiline_buffer).strip()
|
|
1330
|
+
multiline_buffer.clear()
|
|
1331
|
+
else:
|
|
1332
|
+
multiline_buffer.append(line)
|
|
1333
|
+
continue
|
|
1334
|
+
|
|
1335
|
+
line = line.strip()
|
|
1336
|
+
if not line:
|
|
1337
|
+
continue
|
|
1338
|
+
|
|
1339
|
+
if line.startswith("/"):
|
|
1340
|
+
resolved = _resolve_slash_line(line, session)
|
|
1341
|
+
if resolved is not None:
|
|
1342
|
+
# A custom template runs as a normal agent turn.
|
|
1343
|
+
turn_line = _attach_file_mentions(
|
|
1344
|
+
_attach_images(resolved[1], session), session
|
|
1345
|
+
)
|
|
1346
|
+
if await _run_turn_with_steering(
|
|
1347
|
+
agent, pt_session, session, turn_line
|
|
1348
|
+
):
|
|
1349
|
+
break
|
|
1350
|
+
continue
|
|
1351
|
+
if await _dispatch_command(line, session) == CommandResult.EXIT:
|
|
1352
|
+
break
|
|
1353
|
+
continue
|
|
1354
|
+
|
|
1355
|
+
line = _attach_file_mentions(_attach_images(line, session), session)
|
|
1356
|
+
if not line.strip():
|
|
1357
|
+
continue
|
|
1358
|
+
if await _run_turn_with_steering(agent, pt_session, session, line):
|
|
1359
|
+
break
|
|
1360
|
+
finally:
|
|
1361
|
+
update_task.cancel()
|
|
1362
|
+
try:
|
|
1363
|
+
await update_task
|
|
1364
|
+
except (asyncio.CancelledError, Exception):
|
|
1365
|
+
pass
|
|
1366
|
+
_run_lifecycle_hooks(bus, events.SESSION_END, session)
|
|
1367
|
+
current_mcp = mcp.get_manager()
|
|
1368
|
+
if current_mcp is not None:
|
|
1369
|
+
current_mcp.shutdown()
|
|
1370
|
+
mcp.set_manager(None)
|
|
1371
|
+
current_lsp = lsp.get_manager()
|
|
1372
|
+
if current_lsp is not None:
|
|
1373
|
+
current_lsp.shutdown()
|
|
1374
|
+
lsp.set_manager(None)
|
|
1375
|
+
browser.reset_driver()
|
|
1376
|
+
close_shell(session)
|
|
1377
|
+
_autosave(session)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def run(session: Session, bus: events.EventBus | None = None) -> None:
|
|
1381
|
+
"""Run the interactive loop until the user exits."""
|
|
1382
|
+
global console
|
|
1383
|
+
# Apply the persisted locale before anything is rendered.
|
|
1384
|
+
apply_config_locale()
|
|
1385
|
+
# Rebuild the console for this session so the persisted color setting and
|
|
1386
|
+
# NO_COLOR/FORCE_COLOR take effect at launch (Rich auto-detects the TTY).
|
|
1387
|
+
console = ui.make_console()
|
|
1388
|
+
asyncio.run(_run_interactive(session, bus=bus))
|