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.
- klaude_code/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- 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
|