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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. 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))