klaude-code 1.2.6__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 (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,308 @@
1
+ # copy from https://github.com/Aider-AI/aider/blob/main/aider/mdstream.py
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import time
6
+ from typing import Any, ClassVar
7
+
8
+ from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
9
+ from rich.live import Live
10
+ from rich.markdown import CodeBlock, Heading, Markdown
11
+ from rich.panel import Panel
12
+ from rich.rule import Rule
13
+ from rich.spinner import Spinner
14
+ from rich.style import Style
15
+ from rich.syntax import Syntax
16
+ from rich.text import Text
17
+ from rich.theme import Theme
18
+
19
+ from klaude_code import const
20
+
21
+
22
+ class NoInsetCodeBlock(CodeBlock):
23
+ """A code block with syntax highlighting and no padding."""
24
+
25
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
26
+ code = str(self.text).rstrip()
27
+ syntax = Syntax(
28
+ code,
29
+ self.lexer_name,
30
+ theme=self.theme,
31
+ word_wrap=True,
32
+ padding=(0, 1),
33
+ )
34
+ yield Panel.fit(syntax, padding=0, border_style="markdown.code.panel")
35
+
36
+
37
+ class LeftHeading(Heading):
38
+ """A heading class that renders left-justified."""
39
+
40
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
41
+ text = self.text
42
+ text.justify = "left" # Override justification
43
+ # if self.tag == "h1":
44
+ # from rich.panel import Panel
45
+ # from rich import box
46
+ # # Draw a border around h1s, but keep text left-aligned
47
+ # yield Panel(
48
+ # text,
49
+ # box=box.SQUARE,
50
+ # style="markdown.h1.border",
51
+ # )
52
+ if self.tag == "h2":
53
+ text.stylize(Style(bold=True, underline=False))
54
+ yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
55
+ else:
56
+ yield text
57
+
58
+
59
+ class NoInsetMarkdown(Markdown):
60
+ """Markdown with code blocks that have no padding and left-justified headings."""
61
+
62
+ elements: ClassVar[dict[str, type[Any]]] = {
63
+ **Markdown.elements,
64
+ "fence": NoInsetCodeBlock,
65
+ "code_block": NoInsetCodeBlock,
66
+ "heading_open": LeftHeading,
67
+ }
68
+
69
+
70
+ class MarkdownStream:
71
+ """Streaming markdown renderer that progressively displays content with a live updating window.
72
+
73
+ Uses rich.console and rich.live to render markdown content with smooth scrolling
74
+ and partial updates. Maintains a sliding window of visible content while streaming
75
+ in new markdown text.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ mdargs: dict[str, Any] | None = None,
81
+ theme: Theme | None = None,
82
+ console: Console | None = None,
83
+ spinner: Spinner | None = None,
84
+ mark: str | None = None,
85
+ indent: int = 0,
86
+ ) -> None:
87
+ """Initialize the markdown stream.
88
+
89
+ Args:
90
+ mdargs (dict, optional): Additional arguments to pass to rich Markdown renderer
91
+ theme (Theme, optional): Theme for rendering markdown
92
+ console (Console, optional): External console to use for rendering
93
+ mark (str | None, optional): Marker shown before the first non-empty line when indent >= 2
94
+ indent (int, optional): Number of spaces to indent all rendered lines on the left
95
+ """
96
+ self.printed: list[str] = [] # Stores lines that have already been printed
97
+
98
+ if mdargs:
99
+ self.mdargs: dict[str, Any] = mdargs
100
+ else:
101
+ self.mdargs = {}
102
+
103
+ # Defer Live creation until the first update.
104
+ self.live: Live | None = None
105
+ self._live_started: bool = False
106
+
107
+ # Streaming control
108
+ self.when: float = 0.0 # Timestamp of last update
109
+ self.min_delay: float = 1.0 / 20 # Minimum time between updates (20fps)
110
+ self.live_window: int = const.MARKDOWN_STREAM_LIVE_WINDOW
111
+ # Track the maximum height the live window has ever reached
112
+ # so we only pad when it shrinks from a previous height,
113
+ # instead of always padding to live_window from the start.
114
+ self._live_window_seen_height: int = 0
115
+
116
+ self.theme = theme
117
+ self.console = console
118
+ self.spinner: Spinner | None = spinner
119
+ self.mark: str | None = mark
120
+ self.indent: int = max(indent, 0)
121
+
122
+ # Defer Live creation until the first update
123
+ self.live: Live | None = None
124
+ self._live_started: bool = False
125
+
126
+ def _render_markdown_to_lines(self, text: str) -> list[str]:
127
+ """Render markdown text to a list of lines.
128
+
129
+ Args:
130
+ text (str): Markdown text to render
131
+
132
+ Returns:
133
+ list: List of rendered lines with line endings preserved
134
+ """
135
+ # Render the markdown to a string buffer
136
+ string_io = io.StringIO()
137
+
138
+ # Determine console width and adjust for left indent so that
139
+ # the rendered content plus indent does not exceed the available width.
140
+ if self.console is not None:
141
+ base_width = self.console.options.max_width
142
+ else:
143
+ probe_console = Console(theme=self.theme)
144
+ base_width = probe_console.options.max_width
145
+
146
+ effective_width = max(base_width - self.indent, 1)
147
+
148
+ # Use external console for consistent theming, or create temporary one
149
+ temp_console = Console(
150
+ file=string_io,
151
+ force_terminal=True,
152
+ theme=self.theme,
153
+ width=effective_width,
154
+ )
155
+
156
+ markdown = NoInsetMarkdown(text, **self.mdargs)
157
+ temp_console.print(markdown)
158
+ output = string_io.getvalue()
159
+
160
+ # Split rendered output into lines, strip trailing spaces, and apply left indent.
161
+ lines = output.splitlines(keepends=True)
162
+ indent_prefix = " " * self.indent if self.indent > 0 else ""
163
+ processed_lines: list[str] = []
164
+ mark_applied = False
165
+ use_mark = bool(self.mark) and self.indent >= 2
166
+
167
+ for line in lines:
168
+ stripped = line.rstrip()
169
+
170
+ # Apply mark to the first non-empty line only when indent is at least 2.
171
+ if use_mark and not mark_applied and stripped:
172
+ stripped = f"{self.mark} {stripped}"
173
+ mark_applied = True
174
+ elif indent_prefix:
175
+ stripped = indent_prefix + stripped
176
+
177
+ if line.endswith("\n"):
178
+ stripped += "\n"
179
+ processed_lines.append(stripped)
180
+
181
+ return processed_lines
182
+
183
+ def __del__(self) -> None:
184
+ """Destructor to ensure Live display is properly cleaned up."""
185
+ if self.live:
186
+ try:
187
+ self.live.stop()
188
+ except Exception:
189
+ pass # Ignore any errors during cleanup
190
+
191
+ def update(self, text: str, final: bool = False) -> None:
192
+ """Update the displayed markdown content.
193
+
194
+ Args:
195
+ text (str): The markdown text received so far
196
+ final (bool): If True, this is the final update and we should clean up
197
+
198
+ Splits the output into "stable" older lines and the "last few" lines
199
+ which aren't considered stable. They may shift around as new chunks
200
+ are appended to the markdown text.
201
+
202
+ The stable lines emit to the console above the Live window.
203
+ The unstable lines emit into the Live window so they can be repainted.
204
+
205
+ Markdown going to the console works better in terminal scrollback buffers.
206
+ The live window doesn't play nice with terminal scrollback.
207
+ """
208
+ # On the first call, start the Live renderer
209
+ if not self._live_started:
210
+ initial_content = self._live_renderable(Text(""), final=False)
211
+ self.live = Live(
212
+ initial_content,
213
+ refresh_per_second=1.0 / self.min_delay,
214
+ console=self.console,
215
+ )
216
+ self.live.start()
217
+ self._live_started = True
218
+
219
+ # If live rendering isn't available (e.g., after a final update), stop.
220
+ if self.live is None:
221
+ return
222
+
223
+ now = time.time()
224
+ # Throttle updates to maintain smooth rendering
225
+ if not final and now - self.when < self.min_delay:
226
+ return
227
+ self.when = now
228
+
229
+ # Measure render time and adjust min_delay to maintain smooth rendering
230
+ start = time.time()
231
+ lines = self._render_markdown_to_lines(text)
232
+ render_time = time.time() - start
233
+
234
+ # Set min_delay to render time plus a small buffer
235
+ self.min_delay = min(max(render_time * 10, 1.0 / 20), 2)
236
+
237
+ num_lines = len(lines)
238
+
239
+ # How many lines have "left" the live window and are now considered stable?
240
+ # Or if final, consider all lines to be stable.
241
+ if not final:
242
+ num_lines = max(num_lines - self.live_window, 0)
243
+
244
+ # If there is new stable content, append only the new part
245
+ # Update Live window to prevent visual duplication
246
+ if final or num_lines > 0:
247
+ # Lines to append to stable area
248
+ num_printed = len(self.printed)
249
+ to_append_count = num_lines - num_printed
250
+
251
+ if to_append_count > 0:
252
+ # Print new stable lines above Live window
253
+ append_chunk = lines[num_printed:num_lines]
254
+ append_chunk_text = Text.from_ansi("".join(append_chunk))
255
+ live = self.live
256
+ assert live is not None
257
+ live.console.print(append_chunk_text) # Print above Live area
258
+
259
+ # Track printed stable lines
260
+ self.printed = lines[:num_lines]
261
+
262
+ # Handle final update cleanup
263
+ if final:
264
+ live = self.live
265
+ assert live is not None
266
+ live.update(Text(""))
267
+ live.stop()
268
+ self.live = None
269
+ return
270
+
271
+ # Update Live window to prevent timing issues
272
+ # with console.print above. We pad the live region
273
+ # so that its height stays stable when it shrinks
274
+ # from a previously reached height, avoiding spinner jitter.
275
+ rest_lines = lines[num_lines:]
276
+
277
+ if not final:
278
+ current_height = len(rest_lines)
279
+
280
+ # Update the maximum height we've seen so far for this live window.
281
+ if current_height > self._live_window_seen_height:
282
+ # Never exceed configured live_window, even if logic changes later.
283
+ self._live_window_seen_height = min(current_height, self.live_window)
284
+
285
+ target_height = min(self._live_window_seen_height, self.live_window)
286
+ if target_height > 0 and current_height < target_height:
287
+ pad_count = target_height - current_height + 1
288
+ # Pad after the existing lines so spinner visually stays at the bottom.
289
+ rest_lines = rest_lines + ["\n"] * pad_count
290
+
291
+ rest = "".join(rest_lines)
292
+ rest = Text.from_ansi(rest)
293
+ live = self.live
294
+ assert live is not None
295
+ live_renderable = self._live_renderable(rest, final)
296
+ live.update(live_renderable)
297
+
298
+ def _live_renderable(self, rest: Text, final: bool) -> RenderableType:
299
+ if final or not self.spinner:
300
+ return rest
301
+ else:
302
+ return Group(rest, Text(), self.spinner)
303
+
304
+ def find_minimal_suffix(self, text: str, match_lines: int = 50) -> None:
305
+ """
306
+ Splits text into chunks on blank lines "\n\n".
307
+ """
308
+ return None
@@ -0,0 +1,34 @@
1
+ from typing import Any
2
+
3
+ from rich.console import Console, ConsoleOptions, RenderResult
4
+ from rich.segment import Segment
5
+ from rich.style import Style
6
+
7
+
8
+ class Quote:
9
+ """Wrapper to add quote prefix to any content"""
10
+
11
+ def __init__(self, content: Any, prefix: str = "▌ ", style: str | Style = "magenta"):
12
+ self.content = content
13
+ self.prefix = prefix
14
+ self.style = style
15
+
16
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
17
+ # Reduce width to leave space for prefix
18
+ prefix_width = len(self.prefix)
19
+ render_options = options.update(width=options.max_width - prefix_width)
20
+
21
+ # Get style
22
+ quote_style = console.get_style(self.style) if isinstance(self.style, str) else self.style
23
+
24
+ # Add prefix to each line
25
+ prefix_segment = Segment(self.prefix, quote_style)
26
+ new_line = Segment("\n")
27
+
28
+ # Render content as lines
29
+ lines = console.render_lines(self.content, render_options)
30
+
31
+ for line in lines:
32
+ yield prefix_segment
33
+ yield from line
34
+ yield new_line
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, List, Sequence, Tuple
4
+
5
+
6
+ class SearchableFormattedText:
7
+ """
8
+ Wrapper for prompt_toolkit formatted text that also supports string-like
9
+ methods used by questionary's search filter (e.g., ``.lower()``).
10
+
11
+ This allows using ``use_search_filter=True`` with a formatted ``Choice.title``.
12
+
13
+ - ``fragments``: A sequence of (style, text) tuples accepted by
14
+ prompt_toolkit's ``to_formatted_text``.
15
+ - ``plain``: Optional plain text for searching. If omitted, it is derived by
16
+ concatenating the text parts of the fragments.
17
+ """
18
+
19
+ def __init__(self, fragments: Sequence[Tuple[str, str]], plain: str | None = None):
20
+ self._fragments: List[Tuple[str, str]] = list(fragments)
21
+ if plain is None:
22
+ plain = "".join(text for _, text in self._fragments)
23
+ self._plain = plain
24
+
25
+ # Recognized by prompt_toolkit's to_formatted_text(value)
26
+ def __pt_formatted_text__(
27
+ self,
28
+ ) -> Iterable[Tuple[str, str]]: # pragma: no cover - passthrough
29
+ return self._fragments
30
+
31
+ # Provide a human-readable representation.
32
+ def __str__(self) -> str: # pragma: no cover - utility
33
+ return self._plain
34
+
35
+ # Minimal string API to satisfy questionary's search filter logic.
36
+ def lower(self) -> str:
37
+ return self._plain.lower()
38
+
39
+ def upper(self) -> str: # pragma: no cover - convenience
40
+ return self._plain.upper()
41
+
42
+ # Expose the plain text if needed elsewhere.
43
+ @property
44
+ def plain(self) -> str:
45
+ return self._plain
46
+
47
+
48
+ class SearchableFormattedList(list[Tuple[str, str]]):
49
+ """
50
+ List variant compatible with questionary's expected ``Choice.title`` type.
51
+
52
+ - Behaves like ``List[Tuple[str, str]]`` for rendering (so ``isinstance(..., list)`` works),
53
+ preserving existing styling behavior in questionary.
54
+ - Provides ``.lower()``/``.upper()`` returning the plain text for search filtering.
55
+ """
56
+
57
+ def __init__(self, fragments: Sequence[Tuple[str, str]], plain: str | None = None):
58
+ super().__init__(fragments)
59
+ if plain is None:
60
+ plain = "".join(text for _, text in fragments)
61
+ self._plain = plain
62
+
63
+ def lower(self) -> str:
64
+ return self._plain.lower()
65
+
66
+ def upper(self) -> str: # pragma: no cover - convenience
67
+ return self._plain.upper()
68
+
69
+ @property
70
+ def plain(self) -> str:
71
+ return self._plain
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import time
5
+
6
+ import rich.status as rich_status
7
+ from rich._spinners import SPINNERS
8
+ from rich.color import Color
9
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
10
+ from rich.spinner import Spinner as RichSpinner
11
+ from rich.style import Style
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from klaude_code import const
16
+ from klaude_code.ui.rich.theme import ThemeKey
17
+ from klaude_code.ui.terminal.color import get_last_terminal_background_rgb
18
+
19
+ BREATHING_SPINNER_NAME = "dot"
20
+
21
+ SPINNERS.update(
22
+ {
23
+ BREATHING_SPINNER_NAME: {
24
+ "interval": 100,
25
+ # Frames content is ignored by the custom breathing spinner implementation,
26
+ # but we keep a single-frame list for correct width measurement.
27
+ "frames": ["⏺"],
28
+ }
29
+ }
30
+ )
31
+
32
+ _process_start: float | None = None
33
+
34
+
35
+ def _elapsed_since_start() -> float:
36
+ """Return seconds elapsed since first call in this process."""
37
+ global _process_start
38
+ now = time.perf_counter()
39
+ if _process_start is None:
40
+ _process_start = now
41
+ return now - _process_start
42
+
43
+
44
+ def _shimmer_profile(main_text: str) -> list[tuple[str, float]]:
45
+ """Compute per-character shimmer intensity for a horizontal band.
46
+
47
+ Returns a list of (character, intensity) where intensity is in [0, 1].
48
+ """
49
+
50
+ chars = list(main_text)
51
+ if not chars:
52
+ return []
53
+
54
+ padding = const.STATUS_SHIMMER_PADDING
55
+ char_count = len(chars)
56
+ period = char_count + padding * 2
57
+
58
+ # Keep a roughly constant shimmer speed (characters per second)
59
+ # regardless of text length by deriving a character velocity from a
60
+ # baseline text length and the configured sweep duration.
61
+ # The baseline is chosen to be close to the default
62
+ # "Thinking … (esc to interrupt)" status line.
63
+ baseline_chars = 30
64
+ base_period = baseline_chars + padding * 2
65
+ sweep_seconds = const.STATUS_SHIMMER_SWEEP_SECONDS
66
+ char_speed = base_period / sweep_seconds if sweep_seconds > 0 else base_period
67
+
68
+ elapsed = _elapsed_since_start()
69
+ pos_f = (elapsed * char_speed) % float(period)
70
+ pos = int(pos_f)
71
+ band_half_width = const.STATUS_SHIMMER_BAND_HALF_WIDTH
72
+
73
+ profile: list[tuple[str, float]] = []
74
+ for index, ch in enumerate(chars):
75
+ i_pos = index + padding
76
+ dist = abs(i_pos - pos)
77
+ if dist <= band_half_width:
78
+ x = math.pi * (dist / band_half_width)
79
+ intensity = 0.5 * (1.0 + math.cos(x))
80
+ else:
81
+ intensity = 0.0
82
+ profile.append((ch, intensity))
83
+ return profile
84
+
85
+
86
+ def _shimmer_style(console: Console, base_style: Style, intensity: float) -> Style:
87
+ """Compute shimmer style for a single character.
88
+
89
+ When intensity is 0, returns the base style. As intensity increases, the
90
+ foreground color is blended towards the terminal background color, similar
91
+ to codex-rs shimmer's use of default_fg/default_bg and blend().
92
+ """
93
+
94
+ if intensity <= 0.0:
95
+ return base_style
96
+
97
+ alpha = max(0.0, min(1.0, intensity * const.STATUS_SHIMMER_ALPHA_SCALE))
98
+
99
+ base_color = base_style.color or Color.default()
100
+ base_triplet = base_color.get_truecolor()
101
+ bg_triplet = Color.default().get_truecolor(foreground=False)
102
+
103
+ base_r, base_g, base_b = base_triplet
104
+ bg_r, bg_g, bg_b = bg_triplet
105
+
106
+ r = int(bg_r * alpha + base_r * (1.0 - alpha))
107
+ g = int(bg_g * alpha + base_g * (1.0 - alpha))
108
+ b = int(bg_b * alpha + base_b * (1.0 - alpha))
109
+
110
+ shimmer_color = Color.from_rgb(r, g, b)
111
+ return base_style + Style(color=shimmer_color)
112
+
113
+
114
+ def _breathing_intensity() -> float:
115
+ """Compute breathing intensity in [0, 1] for the spinner.
116
+
117
+ Intensity follows a smooth cosine curve over the configured period, starting
118
+ from 0 (fully blended into background), rising to 1 (full style color),
119
+ then returning to 0, giving a subtle "breathing" effect.
120
+ """
121
+
122
+ period = max(const.SPINNER_BREATH_PERIOD_SECONDS, 0.1)
123
+ elapsed = _elapsed_since_start()
124
+ phase = (elapsed % period) / period
125
+ return 0.5 * (1.0 - math.cos(2.0 * math.pi * phase))
126
+
127
+
128
+ def _breathing_style(console: Console, base_style: Style, intensity: float) -> Style:
129
+ """Blend a base style's foreground color toward terminal background.
130
+
131
+ When intensity is 0, the color matches the background (effectively
132
+ "transparent"); when intensity is 1, the color is the base style color.
133
+ """
134
+
135
+ base_color = base_style.color or Color.default()
136
+ base_triplet = base_color.get_truecolor()
137
+ base_r, base_g, base_b = base_triplet
138
+
139
+ cached_bg = get_last_terminal_background_rgb()
140
+ if cached_bg is not None:
141
+ bg_r, bg_g, bg_b = cached_bg
142
+ else:
143
+ bg_triplet = Color.default().get_truecolor(foreground=False)
144
+ bg_r, bg_g, bg_b = bg_triplet
145
+
146
+ intensity_clamped = max(0.0, min(1.0, intensity))
147
+ r = int(bg_r * (1.0 - intensity_clamped) + base_r * intensity_clamped)
148
+ g = int(bg_g * (1.0 - intensity_clamped) + base_g * intensity_clamped)
149
+ b = int(bg_b * (1.0 - intensity_clamped) + base_b * intensity_clamped)
150
+
151
+ breathing_color = Color.from_rgb(r, g, b)
152
+ return base_style + Style(color=breathing_color)
153
+
154
+
155
+ class ShimmerStatusText:
156
+ """Renderable status line with shimmer effect on the main text and hint."""
157
+
158
+ def __init__(self, main_text: str | Text, main_style: ThemeKey) -> None:
159
+ self._main_text = main_text if isinstance(main_text, Text) else Text(main_text)
160
+ self._main_style = main_style
161
+ self._hint_text = Text(" (esc to interrupt)")
162
+ self._hint_style = ThemeKey.STATUS_HINT
163
+
164
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
165
+ result = Text()
166
+ main_style = console.get_style(str(self._main_style))
167
+ hint_style = console.get_style(str(self._hint_style))
168
+
169
+ combined_text = self._main_text.plain + self._hint_text.plain
170
+ split_index = len(self._main_text.plain)
171
+
172
+ for index, (ch, intensity) in enumerate(_shimmer_profile(combined_text)):
173
+ if index < split_index:
174
+ # Get style from main_text, merge with main_style
175
+ char_style = self._main_text.get_style_at_offset(console, index)
176
+ base_style = main_style + char_style
177
+ else:
178
+ base_style = hint_style
179
+ style = _shimmer_style(console, base_style, intensity)
180
+ result.append(ch, style=style)
181
+
182
+ yield result
183
+
184
+
185
+ def spinner_name() -> str:
186
+ return BREATHING_SPINNER_NAME
187
+
188
+
189
+ class BreathingSpinner(RichSpinner):
190
+ """Custom spinner that animates color instead of glyphs.
191
+
192
+ The spinner always renders a single "⏺" glyph whose foreground color
193
+ smoothly interpolates between the terminal background and the spinner
194
+ style color, producing a breathing effect.
195
+ """
196
+
197
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: # type: ignore[override]
198
+ if self.name != BREATHING_SPINNER_NAME:
199
+ # Fallback to Rich's default behavior for other spinners.
200
+ yield from super().__rich_console__(console, options)
201
+ return
202
+
203
+ yield self._render_breathing(console)
204
+
205
+ def _resolve_base_style(self, console: Console) -> Style:
206
+ style = self.style
207
+ if isinstance(style, Style):
208
+ return style
209
+ if style is None:
210
+ return Style()
211
+ style_name = str(style).strip()
212
+ if not style_name:
213
+ return Style()
214
+ return console.get_style(style_name)
215
+
216
+ def _render_breathing(self, console: Console) -> RenderableType:
217
+ base_style = self._resolve_base_style(console)
218
+ intensity = _breathing_intensity()
219
+ style = _breathing_style(console, base_style, intensity)
220
+
221
+ glyph = self.frames[0] if self.frames else "⏺"
222
+ frame = Text(glyph, style=style)
223
+
224
+ if not self.text:
225
+ return frame
226
+ if isinstance(self.text, (str, Text)):
227
+ return Text.assemble(frame, " ", self.text)
228
+
229
+ table = Table.grid(padding=1)
230
+ table.add_row(frame, self.text)
231
+ return table
232
+
233
+
234
+ # Monkey-patch Rich's Status module to use the breathing spinner implementation
235
+ # for the configured spinner name, while preserving default behavior elsewhere.
236
+ try:
237
+ rich_status.Spinner = BreathingSpinner # type: ignore[assignment]
238
+ except Exception:
239
+ # Best-effort patch; if it fails we silently fall back to default spinner.
240
+ pass