klaude-code 1.2.1__py3-none-any.whl → 1.2.3__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 (140) hide show
  1. klaude_code/cli/main.py +9 -4
  2. klaude_code/cli/runtime.py +42 -43
  3. klaude_code/command/__init__.py +7 -5
  4. klaude_code/command/clear_cmd.py +6 -29
  5. klaude_code/command/command_abc.py +44 -8
  6. klaude_code/command/diff_cmd.py +33 -27
  7. klaude_code/command/export_cmd.py +18 -26
  8. klaude_code/command/help_cmd.py +10 -8
  9. klaude_code/command/model_cmd.py +11 -40
  10. klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
  11. klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
  12. klaude_code/command/prompt-init.md +2 -5
  13. klaude_code/command/prompt_command.py +6 -6
  14. klaude_code/command/refresh_cmd.py +4 -5
  15. klaude_code/command/registry.py +16 -19
  16. klaude_code/command/terminal_setup_cmd.py +12 -11
  17. klaude_code/config/__init__.py +4 -0
  18. klaude_code/config/config.py +25 -26
  19. klaude_code/config/list_model.py +8 -3
  20. klaude_code/config/select_model.py +1 -1
  21. klaude_code/const/__init__.py +1 -1
  22. klaude_code/core/__init__.py +0 -3
  23. klaude_code/core/agent.py +25 -50
  24. klaude_code/core/executor.py +268 -101
  25. klaude_code/core/prompt.py +12 -12
  26. klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
  27. klaude_code/core/reminders.py +76 -95
  28. klaude_code/core/task.py +21 -14
  29. klaude_code/core/tool/__init__.py +45 -11
  30. klaude_code/core/tool/file/apply_patch.py +5 -1
  31. klaude_code/core/tool/file/apply_patch_tool.py +11 -13
  32. klaude_code/core/tool/file/edit_tool.py +27 -23
  33. klaude_code/core/tool/file/multi_edit_tool.py +15 -17
  34. klaude_code/core/tool/file/read_tool.py +41 -36
  35. klaude_code/core/tool/file/write_tool.py +13 -15
  36. klaude_code/core/tool/memory/memory_tool.py +85 -68
  37. klaude_code/core/tool/memory/skill_tool.py +10 -12
  38. klaude_code/core/tool/shell/bash_tool.py +24 -22
  39. klaude_code/core/tool/shell/command_safety.py +12 -1
  40. klaude_code/core/tool/sub_agent_tool.py +11 -12
  41. klaude_code/core/tool/todo/todo_write_tool.py +21 -28
  42. klaude_code/core/tool/todo/update_plan_tool.py +14 -24
  43. klaude_code/core/tool/tool_abc.py +3 -4
  44. klaude_code/core/tool/tool_context.py +7 -7
  45. klaude_code/core/tool/tool_registry.py +30 -47
  46. klaude_code/core/tool/tool_runner.py +35 -43
  47. klaude_code/core/tool/truncation.py +14 -20
  48. klaude_code/core/tool/web/mermaid_tool.py +12 -14
  49. klaude_code/core/tool/web/web_fetch_tool.py +15 -17
  50. klaude_code/core/turn.py +19 -7
  51. klaude_code/llm/__init__.py +3 -4
  52. klaude_code/llm/anthropic/client.py +30 -46
  53. klaude_code/llm/anthropic/input.py +4 -11
  54. klaude_code/llm/client.py +29 -8
  55. klaude_code/llm/input_common.py +66 -36
  56. klaude_code/llm/openai_compatible/client.py +42 -84
  57. klaude_code/llm/openai_compatible/input.py +11 -16
  58. klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
  59. klaude_code/llm/openrouter/client.py +40 -289
  60. klaude_code/llm/openrouter/input.py +13 -35
  61. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  62. klaude_code/llm/registry.py +5 -75
  63. klaude_code/llm/responses/client.py +34 -55
  64. klaude_code/llm/responses/input.py +24 -26
  65. klaude_code/llm/usage.py +109 -0
  66. klaude_code/protocol/__init__.py +4 -0
  67. klaude_code/protocol/events.py +3 -2
  68. klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
  69. klaude_code/protocol/model.py +49 -4
  70. klaude_code/protocol/op.py +18 -16
  71. klaude_code/protocol/op_handler.py +28 -0
  72. klaude_code/{core → protocol}/sub_agent.py +7 -0
  73. klaude_code/session/export.py +150 -70
  74. klaude_code/session/session.py +28 -14
  75. klaude_code/session/templates/export_session.html +180 -42
  76. klaude_code/trace/__init__.py +2 -2
  77. klaude_code/trace/log.py +11 -5
  78. klaude_code/ui/__init__.py +91 -8
  79. klaude_code/ui/core/__init__.py +1 -0
  80. klaude_code/ui/core/display.py +103 -0
  81. klaude_code/ui/core/input.py +71 -0
  82. klaude_code/ui/modes/__init__.py +1 -0
  83. klaude_code/ui/modes/debug/__init__.py +1 -0
  84. klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
  85. klaude_code/ui/modes/exec/__init__.py +1 -0
  86. klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
  87. klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
  88. klaude_code/ui/modes/repl/clipboard.py +152 -0
  89. klaude_code/ui/modes/repl/completers.py +429 -0
  90. klaude_code/ui/modes/repl/display.py +60 -0
  91. klaude_code/ui/modes/repl/event_handler.py +375 -0
  92. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  93. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  94. klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
  95. klaude_code/ui/renderers/assistant.py +21 -0
  96. klaude_code/ui/renderers/common.py +0 -16
  97. klaude_code/ui/renderers/developer.py +18 -18
  98. klaude_code/ui/renderers/diffs.py +36 -14
  99. klaude_code/ui/renderers/errors.py +1 -1
  100. klaude_code/ui/renderers/metadata.py +50 -27
  101. klaude_code/ui/renderers/sub_agent.py +43 -9
  102. klaude_code/ui/renderers/thinking.py +33 -1
  103. klaude_code/ui/renderers/tools.py +212 -20
  104. klaude_code/ui/renderers/user_input.py +19 -23
  105. klaude_code/ui/rich/__init__.py +1 -0
  106. klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
  107. klaude_code/ui/{renderers → rich}/status.py +29 -18
  108. klaude_code/ui/{base → rich}/theme.py +8 -2
  109. klaude_code/ui/terminal/__init__.py +1 -0
  110. klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
  111. klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
  112. klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
  113. klaude_code/ui/utils/__init__.py +1 -0
  114. klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
  115. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
  116. klaude_code-1.2.3.dist-info/RECORD +161 -0
  117. klaude_code/core/clipboard_manifest.py +0 -124
  118. klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
  119. klaude_code/ui/base/__init__.py +0 -1
  120. klaude_code/ui/base/display_abc.py +0 -36
  121. klaude_code/ui/base/input_abc.py +0 -20
  122. klaude_code/ui/repl/display.py +0 -36
  123. klaude_code/ui/repl/event_handler.py +0 -247
  124. klaude_code/ui/repl/input.py +0 -773
  125. klaude_code/ui/rich_ext/__init__.py +0 -1
  126. klaude_code-1.2.1.dist-info/RECORD +0 -151
  127. /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
  128. /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
  129. /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
  130. /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
  131. /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
  132. /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
  133. /klaude_code/ui/{base → core}/stage_manager.py +0 -0
  134. /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
  135. /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
  136. /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
  137. /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
  138. /klaude_code/ui/{base → utils}/debouncer.py +0 -0
  139. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
  140. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -1,773 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import re
5
- import shutil
6
- import subprocess
7
- import time
8
- import uuid
9
- from collections.abc import AsyncIterator, Callable, Iterable
10
- from pathlib import Path
11
- from typing import NamedTuple, cast, override
12
-
13
- from PIL import Image, ImageGrab
14
- from prompt_toolkit import PromptSession
15
- from prompt_toolkit.buffer import Buffer
16
- from prompt_toolkit.completion import Completer, Completion, ThreadedCompleter
17
- from prompt_toolkit.document import Document
18
- from prompt_toolkit.filters import Condition
19
- from prompt_toolkit.formatted_text import HTML, FormattedText
20
- from prompt_toolkit.history import FileHistory
21
- from prompt_toolkit.key_binding import KeyBindings
22
- from prompt_toolkit.patch_stdout import patch_stdout
23
- from prompt_toolkit.styles import Style
24
-
25
- from klaude_code.command import get_commands
26
- from klaude_code.core.clipboard_manifest import (
27
- CLIPBOARD_IMAGES_DIR,
28
- ClipboardManifest,
29
- ClipboardManifestEntry,
30
- next_session_token,
31
- persist_clipboard_manifest,
32
- )
33
- from klaude_code.ui.base.input_abc import InputProviderABC
34
- from klaude_code.ui.base.utils import get_current_git_branch, show_path_with_tilde
35
-
36
-
37
- class REPLStatusSnapshot(NamedTuple):
38
- """Snapshot of REPL status for bottom toolbar display."""
39
-
40
- model_name: str
41
- context_usage_percent: float | None
42
- llm_calls: int
43
- tool_calls: int
44
- update_message: str | None = None
45
-
46
-
47
- kb = KeyBindings()
48
-
49
- COMPLETION_SELECTED = "#5869f7"
50
- COMPLETION_MENU = "ansibrightblack"
51
- INPUT_PROMPT_STYLE = "ansimagenta"
52
-
53
-
54
- class ClipboardCaptureState:
55
- def __init__(self, images_dir: Path | None = None, session_token: str | None = None):
56
- self._images_dir = images_dir or CLIPBOARD_IMAGES_DIR
57
- self._session_token = session_token or next_session_token()
58
- self._pending: list[ClipboardManifestEntry] = []
59
- self._counter = 1
60
-
61
- def capture_from_clipboard(self) -> str | None:
62
- try:
63
- clipboard_data = ImageGrab.grabclipboard()
64
- except Exception:
65
- return None
66
- if not isinstance(clipboard_data, Image.Image):
67
- return None
68
- try:
69
- self._images_dir.mkdir(parents=True, exist_ok=True)
70
- except Exception:
71
- return None
72
- filename = f"clipboard_{uuid.uuid4().hex[:8]}.png"
73
- path = self._images_dir / filename
74
- try:
75
- clipboard_data.save(path, "PNG")
76
- except Exception:
77
- return None
78
- tag = f"[Image #{self._counter}]"
79
- self._counter += 1
80
- saved_entry = ClipboardManifestEntry(tag=tag, path=str(path), saved_at_ts=time.time())
81
- self._pending.append(saved_entry)
82
- return tag
83
-
84
- def flush_manifest(self) -> ClipboardManifest | None:
85
- if not self._pending:
86
- return None
87
- manifest = ClipboardManifest(
88
- entries=list(self._pending),
89
- created_at_ts=time.time(),
90
- source_id=self._session_token,
91
- )
92
- self._pending = []
93
- self._counter = 1
94
- return manifest
95
-
96
-
97
- _clipboard_state = ClipboardCaptureState()
98
-
99
-
100
- @kb.add("c-v")
101
- def _(event): # type: ignore
102
- """Paste image from clipboard as [Image #N]."""
103
- tag = _clipboard_state.capture_from_clipboard()
104
- if tag:
105
- try:
106
- event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
107
- except Exception:
108
- pass
109
-
110
-
111
- @kb.add("enter")
112
- def _(event): # type: ignore
113
- buf = event.current_buffer # type: ignore
114
- doc = buf.document # type: ignore
115
-
116
- # If VS Code/Windsurf/Cursor sent a "\\" sentinel before Enter (Shift+Enter mapping),
117
- # treat it as a request for a newline instead of submit.
118
- # This allows Shift+Enter to insert a newline in our multiline prompt.
119
- try:
120
- if doc.text_before_cursor.endswith("\\"): # type: ignore[reportUnknownMemberType]
121
- buf.delete_before_cursor() # remove the sentinel backslash # type: ignore[reportUnknownMemberType]
122
- buf.insert_text("\n") # type: ignore[reportUnknownMemberType]
123
- return
124
- except Exception:
125
- # Fall through to default behavior if anything goes wrong
126
- pass
127
-
128
- # If the entire buffer is whitespace-only, insert a newline rather than submitting.
129
- if len(buf.text.strip()) == 0: # type: ignore
130
- buf.insert_text("\n") # type: ignore
131
- return
132
-
133
- manifest = _clipboard_state.flush_manifest()
134
- if manifest:
135
- try:
136
- persist_clipboard_manifest(manifest)
137
- except Exception:
138
- pass
139
-
140
- buf.validate_and_handle() # type: ignore
141
-
142
-
143
- @kb.add("c-j")
144
- def _(event): # type: ignore
145
- event.current_buffer.insert_text("\n") # type: ignore
146
-
147
-
148
- @kb.add("c")
149
- def _(event): # type: ignore
150
- """Copy selected text to system clipboard, or insert 'c' if no selection."""
151
- buf = event.current_buffer # type: ignore
152
- if buf.selection_state: # type: ignore[reportUnknownMemberType]
153
- doc = buf.document # type: ignore[reportUnknownMemberType]
154
- start, end = doc.selection_range() # type: ignore[reportUnknownMemberType]
155
- selected_text: str = doc.text[start:end] # type: ignore[reportUnknownMemberType]
156
-
157
- if selected_text:
158
- _copy_to_clipboard(selected_text) # type: ignore[reportUnknownArgumentType]
159
- buf.exit_selection() # type: ignore[reportUnknownMemberType]
160
- else:
161
- buf.insert_text("c") # type: ignore[reportUnknownMemberType]
162
-
163
-
164
- def _copy_to_clipboard(text: str) -> None:
165
- """Copy text to system clipboard using platform-specific commands."""
166
- import sys
167
-
168
- try:
169
- if sys.platform == "darwin":
170
- subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
171
- elif sys.platform == "win32":
172
- subprocess.run(["clip"], input=text.encode("utf-16"), check=True)
173
- else:
174
- # Linux: try xclip first, then xsel
175
- if shutil.which("xclip"):
176
- subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True)
177
- elif shutil.which("xsel"):
178
- subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode("utf-8"), check=True)
179
- except Exception:
180
- pass
181
-
182
-
183
- @kb.add("backspace")
184
- def _(event): # type: ignore
185
- """Ensure completions refresh on backspace when editing an @token.
186
-
187
- We delete the character before cursor (default behavior), then explicitly
188
- trigger completion refresh if the caret is still within an @... token.
189
- """
190
- buf = event.current_buffer # type: ignore
191
- # Handle selection: cut selection if present, otherwise delete one character
192
- if buf.selection_state: # type: ignore[reportUnknownMemberType]
193
- buf.cut_selection() # type: ignore[reportUnknownMemberType]
194
- else:
195
- buf.delete_before_cursor() # type: ignore[reportUnknownMemberType]
196
- # If the token pattern still applies, refresh completion popup
197
- try:
198
- text_before = buf.document.text_before_cursor # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
199
- # Check for both @ tokens and / tokens (slash commands on first line only)
200
- should_refresh = False
201
- if _AtFilesCompleter._AT_TOKEN_RE.search(text_before): # type: ignore[reportPrivateUsage, reportUnknownArgumentType]
202
- should_refresh = True
203
- elif buf.document.cursor_position_row == 0: # type: ignore[reportUnknownMemberType]
204
- # Check for slash command pattern without accessing protected attribute
205
- text_before_str = cast(str, text_before or "")
206
- if text_before_str.strip().startswith("/") and " " not in text_before_str:
207
- should_refresh = True
208
-
209
- if should_refresh:
210
- buf.start_completion(select_first=False) # type: ignore[reportUnknownMemberType]
211
- except Exception:
212
- pass
213
-
214
-
215
- @kb.add("left")
216
- def _(event): # type: ignore
217
- """Support wrapping to previous line when pressing left at column 0."""
218
- buf = event.current_buffer # type: ignore
219
- try:
220
- doc = buf.document # type: ignore[reportUnknownMemberType]
221
- row = cast(int, doc.cursor_position_row) # type: ignore[reportUnknownMemberType]
222
- col = cast(int, doc.cursor_position_col) # type: ignore[reportUnknownMemberType]
223
-
224
- # At the beginning of a non-first line: jump to previous line end.
225
- if col == 0 and row > 0:
226
- lines = cast(list[str], doc.lines) # type: ignore[reportUnknownMemberType]
227
- prev_row = row - 1
228
- if 0 <= prev_row < len(lines):
229
- prev_line = lines[prev_row]
230
- new_index = doc.translate_row_col_to_index(prev_row, len(prev_line)) # type: ignore[reportUnknownMemberType]
231
- buf.cursor_position = new_index # type: ignore[reportUnknownMemberType]
232
- return
233
-
234
- # Default behavior: move one character left when possible.
235
- if doc.cursor_position > 0: # type: ignore[reportUnknownMemberType]
236
- buf.cursor_left() # type: ignore[reportUnknownMemberType]
237
- except Exception:
238
- pass
239
-
240
-
241
- @kb.add("right")
242
- def _(event): # type: ignore
243
- """Support wrapping to next line when pressing right at line end."""
244
- buf = event.current_buffer # type: ignore
245
- try:
246
- doc = buf.document # type: ignore[reportUnknownMemberType]
247
- row = cast(int, doc.cursor_position_row) # type: ignore[reportUnknownMemberType]
248
- col = cast(int, doc.cursor_position_col) # type: ignore[reportUnknownMemberType]
249
- lines = cast(list[str], doc.lines) # type: ignore[reportUnknownMemberType]
250
-
251
- current_line = lines[row] if 0 <= row < len(lines) else ""
252
- at_line_end = col >= len(current_line)
253
- is_last_line = row >= len(lines) - 1 if lines else True
254
-
255
- # At end of a non-last line: jump to next line start.
256
- if at_line_end and not is_last_line:
257
- next_row = row + 1
258
- new_index = doc.translate_row_col_to_index(next_row, 0) # type: ignore[reportUnknownMemberType]
259
- buf.cursor_position = new_index # type: ignore[reportUnknownMemberType]
260
- return
261
-
262
- # Default behavior: move one character right when possible.
263
- if doc.cursor_position < len(doc.text): # type: ignore[reportUnknownMemberType]
264
- buf.cursor_right() # type: ignore[reportUnknownMemberType]
265
- except Exception:
266
- pass
267
-
268
-
269
- class PromptToolkitInput(InputProviderABC):
270
- def __init__(self, prompt: str = "❯ ", status_provider: Callable[[], REPLStatusSnapshot] | None = None): # ▌
271
- self._status_provider = status_provider
272
-
273
- # Mouse is disabled by default; only enabled when input becomes multi-line.
274
- self._mouse_enabled: bool = False
275
-
276
- project = str(Path.cwd()).strip("/").replace("/", "-")
277
- history_path = Path.home() / ".klaude" / "projects" / f"{project}" / "input_history.txt"
278
-
279
- if not history_path.parent.exists():
280
- history_path.parent.mkdir(parents=True, exist_ok=True)
281
- if not history_path.exists():
282
- history_path.touch()
283
-
284
- mouse_support_filter = Condition(lambda: self._mouse_enabled)
285
-
286
- self._session: PromptSession[str] = PromptSession(
287
- [(INPUT_PROMPT_STYLE, prompt)],
288
- history=FileHistory(history_path),
289
- multiline=True,
290
- prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
291
- key_bindings=kb,
292
- completer=ThreadedCompleter(_ComboCompleter()),
293
- complete_while_typing=True,
294
- erase_when_done=True,
295
- bottom_toolbar=self._render_bottom_toolbar,
296
- mouse_support=mouse_support_filter,
297
- style=Style.from_dict(
298
- {
299
- "completion-menu": "bg:default",
300
- "completion-menu.border": "bg:default",
301
- "scrollbar.background": "bg:default",
302
- "scrollbar.button": "bg:default",
303
- "completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
304
- "completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
305
- "completion-menu.completion.current": f"noreverse bg:default fg:{COMPLETION_SELECTED} bold",
306
- "completion-menu.meta.completion.current": f"bg:default fg:{COMPLETION_SELECTED} bold",
307
- }
308
- ),
309
- )
310
-
311
- try:
312
- self._session.default_buffer.on_text_changed += self._on_buffer_text_changed
313
- except Exception:
314
- # If we can't hook the buffer events for any reason, fall back to static behavior.
315
- pass
316
-
317
- def _render_bottom_toolbar(self) -> FormattedText:
318
- """Render bottom toolbar with working directory, git branch on left, model name and context usage on right.
319
-
320
- If an update is available, only show the update message on the left side.
321
- """
322
- # Check for update message first
323
- update_message: str | None = None
324
- if self._status_provider:
325
- try:
326
- status = self._status_provider()
327
- update_message = status.update_message
328
- except Exception:
329
- pass
330
-
331
- # If update available, show only the update message
332
- if update_message:
333
- left_text = " " + update_message
334
- try:
335
- terminal_width = shutil.get_terminal_size().columns
336
- padding = " " * max(0, terminal_width - len(left_text))
337
- except Exception:
338
- padding = ""
339
- toolbar_text = left_text + padding
340
- return FormattedText([("#ansiyellow", toolbar_text)])
341
-
342
- # Normal mode: Left side: path and git branch
343
- left_parts: list[str] = []
344
- left_parts.append(show_path_with_tilde())
345
-
346
- git_branch = get_current_git_branch()
347
- if git_branch:
348
- left_parts.append(git_branch)
349
-
350
- # Right side: status info
351
- right_parts: list[str] = []
352
- if self._status_provider:
353
- try:
354
- status = self._status_provider()
355
- model_name = status.model_name or "N/A"
356
- right_parts.append(model_name)
357
-
358
- # Add context if available
359
- if status.context_usage_percent is not None:
360
- right_parts.append(f"context {status.context_usage_percent:.1f}%")
361
- except Exception:
362
- pass
363
-
364
- # Build left and right text with borders
365
- left_text = " " + " · ".join(left_parts)
366
- right_text = (" · ".join(right_parts) + " ") if right_parts else " "
367
-
368
- # Calculate padding
369
- try:
370
- terminal_width = shutil.get_terminal_size().columns
371
- used_width = len(left_text) + len(right_text)
372
- padding = " " * max(0, terminal_width - used_width)
373
- except Exception:
374
- padding = ""
375
-
376
- # Build result with style
377
- toolbar_text = left_text + padding + right_text
378
- return FormattedText([("#ansiblue", toolbar_text)])
379
-
380
- async def start(self) -> None:
381
- pass
382
-
383
- async def stop(self) -> None:
384
- pass
385
-
386
- @override
387
- async def iter_inputs(self) -> AsyncIterator[str]:
388
- while True:
389
- # For each new prompt, start with mouse disabled so users can select history.
390
- self._mouse_enabled = False
391
- with patch_stdout():
392
- line: str = await self._session.prompt_async()
393
- yield line
394
-
395
- def _on_buffer_text_changed(self, buf: Buffer) -> None:
396
- """Toggle mouse support based on current buffer content.
397
-
398
- Mouse stays disabled when input is empty. It is enabled only when
399
- the user has entered more than one line of text.
400
- """
401
- try:
402
- text = buf.text
403
- except Exception:
404
- return
405
- self._mouse_enabled = self._should_enable_mouse(text)
406
-
407
- def _should_enable_mouse(self, text: str) -> bool:
408
- """Return True when mouse support should be enabled for current input."""
409
- if not text.strip():
410
- return False
411
- # Enable mouse only when input spans multiple lines.
412
- return "\n" in text
413
-
414
-
415
- class _CmdResult(NamedTuple):
416
- ok: bool
417
- lines: list[str]
418
-
419
-
420
- class _SlashCommandCompleter(Completer):
421
- """Complete slash commands at the beginning of the first line.
422
-
423
- Behavior:
424
- - Only triggers when cursor is on first line and text matches /...
425
- - Shows available slash commands with descriptions
426
- - Inserts trailing space after completion
427
- """
428
-
429
- _SLASH_TOKEN_RE = re.compile(r"^/(?P<frag>\S*)$")
430
-
431
- def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
432
- # Only complete on first line
433
- if document.cursor_position_row != 0:
434
- return iter([])
435
-
436
- text_before = document.current_line_before_cursor
437
- m = self._SLASH_TOKEN_RE.search(text_before)
438
- if not m:
439
- return iter([])
440
-
441
- frag = m.group("frag")
442
- token_start = len(text_before) - len(f"/{frag}")
443
- start_position = token_start - len(text_before) # negative offset
444
-
445
- # Get available commands
446
- commands = get_commands()
447
-
448
- # Filter commands that match the fragment
449
- matched: list[tuple[str, object, str]] = []
450
- for cmd_name, cmd_obj in sorted(commands.items(), key=lambda x: str(x[1].name)):
451
- if cmd_name.startswith(frag):
452
- hint = " [args]" if cmd_obj.support_addition_params else ""
453
- matched.append((cmd_name, cmd_obj, hint))
454
-
455
- if not matched:
456
- return iter([])
457
-
458
- # Calculate max width for alignment
459
- # Find the longest command+hint length
460
- max_len = max(len(name) + len(hint) for name, _, hint in matched)
461
- # Set a minimum width (e.g. 20) and add some padding
462
- align_width = max(max_len, 20) + 2
463
-
464
- for cmd_name, cmd_obj, hint in matched:
465
- label_len = len(cmd_name) + len(hint)
466
- padding = " " * (align_width - label_len)
467
-
468
- # Using HTML for formatting: bold command name, normal hint, gray summary
469
- display_text = HTML(
470
- f"<b>{cmd_name}</b>{hint}{padding}<style color='ansibrightblack'>— {cmd_obj.summary}</style>" # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
471
- )
472
- completion_text = f"/{cmd_name} "
473
- yield Completion(text=completion_text, start_position=start_position, display=display_text)
474
-
475
- def is_slash_command_context(self, document: Document) -> bool:
476
- """Check if current context is a slash command."""
477
- if document.cursor_position_row != 0:
478
- return False
479
- text_before = document.current_line_before_cursor
480
- return bool(self._SLASH_TOKEN_RE.search(text_before))
481
-
482
-
483
- class _ComboCompleter(Completer):
484
- """Combined completer that handles both @ file paths and / slash commands."""
485
-
486
- def __init__(self) -> None:
487
- self._at_completer = _AtFilesCompleter()
488
- self._slash_completer = _SlashCommandCompleter()
489
-
490
- def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
491
- # Try slash command completion first (only on first line)
492
- if document.cursor_position_row == 0:
493
- if self._slash_completer.is_slash_command_context(document):
494
- yield from self._slash_completer.get_completions(document, complete_event)
495
- return
496
-
497
- # Fall back to @ file completion
498
- yield from self._at_completer.get_completions(document, complete_event)
499
-
500
-
501
- class _AtFilesCompleter(Completer):
502
- """Complete @path segments using fd or ripgrep.
503
-
504
- Behavior:
505
- - Only triggers when the cursor is after an "@..." token (until whitespace).
506
- - Completes paths relative to the current working directory.
507
- - Uses `fd` when available (files and directories), falls back to `rg --files` (files only).
508
- - Debounces external commands and caches results to avoid excessive spawning.
509
- - Inserts a trailing space after completion to stop further triggering.
510
- """
511
-
512
- _AT_TOKEN_RE = re.compile(r"(^|\s)@(?P<frag>[^\s]*)$")
513
-
514
- def __init__(self, debounce_sec: float = 0.25, cache_ttl_sec: float = 10.0, max_results: int = 20):
515
- self._debounce_sec = debounce_sec
516
- self._cache_ttl = cache_ttl_sec
517
- self._max_results = max_results
518
-
519
- # Debounce/caching state
520
- self._last_cmd_time: float = 0.0
521
- self._last_query_key: str | None = None
522
- self._last_results: list[str] = []
523
- self._last_results_time: float = 0.0
524
-
525
- # rg --files cache (used when fd is unavailable)
526
- self._rg_file_list: list[str] | None = None
527
- self._rg_file_list_time: float = 0.0
528
-
529
- # Cache for ignored paths (gitignored files)
530
- self._last_ignored_paths: set[str] = set()
531
-
532
- # ---- prompt_toolkit API ----
533
- def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
534
- text_before = document.text_before_cursor
535
- m = self._AT_TOKEN_RE.search(text_before)
536
- if not m:
537
- return [] # type: ignore[reportUnknownVariableType]
538
-
539
- frag = m.group("frag") # text after '@' and before cursor (no spaces)
540
- token_start_in_input = len(text_before) - len(f"@{frag}")
541
-
542
- cwd = Path.cwd()
543
-
544
- # If no fragment yet, show lightweight suggestions from current directory
545
- if frag.strip() == "":
546
- suggestions = self._suggest_for_empty_fragment(cwd)
547
- if not suggestions:
548
- return [] # type: ignore[reportUnknownVariableType]
549
- start_position = token_start_in_input - len(text_before)
550
- for s in suggestions[: self._max_results]:
551
- yield Completion(text=f"@{s} ", start_position=start_position, display=s)
552
- return [] # type: ignore[reportUnknownVariableType]
553
-
554
- # Gather suggestions with debounce/caching based on search keyword
555
- suggestions = self._complete_paths(cwd, frag)
556
- if not suggestions:
557
- return [] # type: ignore[reportUnknownVariableType]
558
-
559
- # Prepare Completion objects. Replace from the '@' character.
560
- start_position = token_start_in_input - len(text_before) # negative
561
- for s in suggestions[: self._max_results]:
562
- # Insert '@<path> ' so that subsequent typing does not keep triggering
563
- yield Completion(text=f"@{s} ", start_position=start_position, display=s)
564
-
565
- # ---- Core logic ----
566
- def _complete_paths(self, cwd: Path, keyword: str) -> list[str]:
567
- now = time.monotonic()
568
- key_norm = keyword.lower()
569
- query_key = f"{cwd.resolve()}::search::{key_norm}"
570
-
571
- # Debounce: if called too soon again, filter last results
572
- if self._last_results and self._last_query_key is not None:
573
- prev = self._last_query_key
574
- if self._same_scope(prev, query_key):
575
- # Determine if query is narrowing or broadening
576
- _, prev_kw = self._parse_query_key(prev)
577
- _, cur_kw = self._parse_query_key(query_key)
578
- is_narrowing = (
579
- prev_kw is not None
580
- and cur_kw is not None
581
- and len(cur_kw) >= len(prev_kw)
582
- and cur_kw.startswith(prev_kw)
583
- )
584
- if is_narrowing and (now - self._last_cmd_time) < self._debounce_sec:
585
- # For narrowing, fast-filter previous results to avoid expensive calls
586
- return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
587
-
588
- # Cache TTL: reuse cached results for same query within TTL
589
- if self._last_results and self._last_query_key == query_key and now - self._last_results_time < self._cache_ttl:
590
- return self._filter_and_format(self._last_results, cwd, key_norm, self._last_ignored_paths)
591
-
592
- # Prefer fd; otherwise fallback to rg --files
593
- results: list[str] = []
594
- ignored_paths: set[str] = set()
595
- if self._has_cmd("fd"):
596
- # Use fd to search anywhere in full path (files and directories), case-insensitive
597
- results, ignored_paths = self._run_fd_search(cwd, key_norm)
598
- elif self._has_cmd("rg"):
599
- # Use rg to search only in current directory
600
- if self._rg_file_list is None or now - self._rg_file_list_time > max(self._cache_ttl, 30.0):
601
- cmd = ["rg", "--files", "--no-ignore", "--hidden"]
602
- r = self._run_cmd(cmd, cwd=cwd) # Search from current directory
603
- if r.ok:
604
- self._rg_file_list = r.lines
605
- self._rg_file_list_time = now
606
- else:
607
- self._rg_file_list = []
608
- self._rg_file_list_time = now
609
- # Filter by keyword
610
- all_files = self._rg_file_list or []
611
- kn = key_norm
612
- results = [p for p in all_files if kn in p.lower()]
613
- # For rg fallback, we don't distinguish ignored files (no priority sorting)
614
- else:
615
- return []
616
-
617
- # Update caches
618
- self._last_cmd_time = now
619
- self._last_query_key = query_key
620
- self._last_results = results
621
- self._last_results_time = now
622
- self._last_ignored_paths = ignored_paths
623
- return self._filter_and_format(results, cwd, key_norm, ignored_paths)
624
-
625
- def _filter_and_format(
626
- self, paths_from_root: list[str], cwd: Path, keyword_norm: str, ignored_paths: set[str] | None = None
627
- ) -> list[str]:
628
- # Filter to keyword (case-insensitive) and rank by:
629
- # 1. Non-gitignored files first (is_ignored: 0 or 1)
630
- # 2. Basename hit first, then path hit position, then length
631
- # Since both fd and rg now search from current directory, all paths are relative to cwd
632
- kn = keyword_norm
633
- ignored_paths = ignored_paths or set()
634
- out: list[tuple[str, tuple[int, int, int, int, int]]] = []
635
- for p in paths_from_root:
636
- pl = p.lower()
637
- if kn not in pl:
638
- continue
639
-
640
- # Use path directly since it's already relative to current directory
641
- rel_to_cwd = p.lstrip("./")
642
- base = os.path.basename(p).lower()
643
- base_pos = base.find(kn)
644
- path_pos = pl.find(kn)
645
- # Check if this path is in the ignored set (gitignored files)
646
- is_ignored = 1 if rel_to_cwd in ignored_paths else 0
647
- score = (is_ignored, 0 if base_pos != -1 else 1, base_pos if base_pos != -1 else 10_000, path_pos, len(p))
648
-
649
- # Append trailing slash for directories
650
- full_path = cwd / rel_to_cwd
651
- if full_path.is_dir() and not rel_to_cwd.endswith("/"):
652
- rel_to_cwd = rel_to_cwd + "/"
653
- out.append((rel_to_cwd, score))
654
- # Sort by score
655
- out.sort(key=lambda x: x[1])
656
- # Unique while preserving order
657
- seen: set[str] = set()
658
- uniq: list[str] = []
659
- for s, _ in out:
660
- if s not in seen:
661
- seen.add(s)
662
- uniq.append(s)
663
- return uniq
664
-
665
- def _same_scope(self, prev_key: str, cur_key: str) -> bool:
666
- # Consider same scope if they share the same base directory and one prefix startswith the other
667
- try:
668
- prev_root, prev_pref = prev_key.split("::", 1)
669
- cur_root, cur_pref = cur_key.split("::", 1)
670
- except ValueError:
671
- return False
672
- return prev_root == cur_root and (prev_pref.startswith(cur_pref) or cur_pref.startswith(prev_pref))
673
-
674
- def _parse_query_key(self, key: str) -> tuple[str | None, str | None]:
675
- try:
676
- root, rest = key.split("::", 1)
677
- tag, kw = rest.split("::", 1)
678
- if tag != "search":
679
- return root, None
680
- return root, kw
681
- except Exception:
682
- return None, None
683
-
684
- # ---- Utilities ----
685
- def _run_fd_search(self, cwd: Path, keyword_norm: str) -> tuple[list[str], set[str]]:
686
- """Run fd search and return (all_results, ignored_paths).
687
-
688
- First runs fd without --no-ignore to get tracked files,
689
- then runs with --no-ignore to get all files including gitignored ones.
690
- Returns the combined results and a set of paths that are gitignored.
691
- """
692
- pattern = self._escape_regex(keyword_norm)
693
- base_cmd = [
694
- "fd",
695
- "--color=never",
696
- "--type",
697
- "f",
698
- "--type",
699
- "d",
700
- "--hidden",
701
- "--full-path",
702
- "-i",
703
- "--max-results",
704
- str(self._max_results * 3),
705
- "--exclude",
706
- ".git",
707
- "--exclude",
708
- ".venv",
709
- "--exclude",
710
- "node_modules",
711
- pattern,
712
- ".",
713
- ]
714
-
715
- # First run: get tracked (non-ignored) files
716
- r_tracked = self._run_cmd(base_cmd, cwd=cwd)
717
- tracked_paths: set[str] = set(p.lstrip("./") for p in r_tracked.lines) if r_tracked.ok else set()
718
-
719
- # Second run: get all files including ignored ones
720
- cmd_all = base_cmd.copy()
721
- cmd_all.insert(2, "--no-ignore") # Insert after --color=never
722
- r_all = self._run_cmd(cmd_all, cwd=cwd)
723
- all_paths = r_all.lines if r_all.ok else []
724
-
725
- # Calculate which paths are gitignored (in all but not in tracked)
726
- ignored_paths = set(p.lstrip("./") for p in all_paths) - tracked_paths
727
-
728
- return all_paths, ignored_paths
729
-
730
- def _escape_regex(self, s: str) -> str:
731
- # Escape for fd (regex by default). Keep '/' as is for path boundaries.
732
- return re.escape(s).replace("/", "/")
733
-
734
- def _has_cmd(self, name: str) -> bool:
735
- return shutil.which(name) is not None
736
-
737
- def _suggest_for_empty_fragment(self, cwd: Path) -> list[str]:
738
- """Lightweight suggestions when user typed only '@': list cwd's children.
739
-
740
- Avoids running external tools; shows immediate directories first, then files.
741
- Filters out .git, .venv, and node_modules to reduce noise.
742
- """
743
- excluded = {".git", ".venv", "node_modules"}
744
- items: list[str] = []
745
- try:
746
- for p in sorted(cwd.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
747
- name = p.name
748
- if name in excluded:
749
- continue
750
- rel = os.path.relpath(p, cwd)
751
- if p.is_dir() and not rel.endswith("/"):
752
- rel += "/"
753
- items.append(rel)
754
- except Exception:
755
- return []
756
- return items[: min(self._max_results, 100)]
757
-
758
- def _run_cmd(self, cmd: list[str], cwd: Path | None = None) -> _CmdResult:
759
- try:
760
- p = subprocess.run(
761
- cmd,
762
- cwd=str(cwd) if cwd else None,
763
- stdout=subprocess.PIPE,
764
- stderr=subprocess.DEVNULL,
765
- text=True,
766
- timeout=1.5,
767
- )
768
- if p.returncode == 0:
769
- lines = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
770
- return _CmdResult(True, lines)
771
- return _CmdResult(False, [])
772
- except Exception:
773
- return _CmdResult(False, [])