klaude-code 1.2.22__py3-none-any.whl → 1.2.24__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/command/prompt-jj-describe.md +32 -0
- klaude_code/command/status_cmd.py +1 -1
- klaude_code/{const/__init__.py → const.py} +11 -2
- klaude_code/core/executor.py +1 -1
- klaude_code/core/manager/sub_agent_manager.py +1 -1
- klaude_code/core/reminders.py +51 -0
- klaude_code/core/task.py +37 -18
- klaude_code/core/tool/__init__.py +1 -4
- klaude_code/core/tool/file/read_tool.py +23 -1
- klaude_code/core/tool/file/write_tool.py +7 -3
- klaude_code/core/tool/skill/__init__.py +0 -0
- klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -39
- klaude_code/llm/openai_compatible/client.py +29 -102
- klaude_code/llm/openai_compatible/stream.py +272 -0
- klaude_code/llm/openrouter/client.py +29 -109
- klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
- klaude_code/protocol/model.py +15 -2
- klaude_code/session/export.py +1 -1
- klaude_code/session/store.py +4 -2
- klaude_code/skill/__init__.py +27 -0
- klaude_code/skill/assets/deslop/SKILL.md +17 -0
- klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
- klaude_code/skill/assets/handoff/SKILL.md +39 -0
- klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
- klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
- klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +60 -24
- klaude_code/skill/manager.py +70 -0
- klaude_code/skill/system_skills.py +192 -0
- klaude_code/ui/core/stage_manager.py +0 -3
- klaude_code/ui/modes/repl/completers.py +103 -3
- klaude_code/ui/modes/repl/event_handler.py +101 -49
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +55 -6
- klaude_code/ui/modes/repl/renderer.py +24 -17
- klaude_code/ui/renderers/assistant.py +7 -2
- klaude_code/ui/renderers/developer.py +12 -0
- klaude_code/ui/renderers/diffs.py +1 -1
- klaude_code/ui/renderers/metadata.py +6 -8
- klaude_code/ui/renderers/sub_agent.py +28 -5
- klaude_code/ui/renderers/thinking.py +16 -10
- klaude_code/ui/renderers/tools.py +83 -34
- klaude_code/ui/renderers/user_input.py +32 -2
- klaude_code/ui/rich/markdown.py +40 -20
- klaude_code/ui/rich/status.py +15 -19
- klaude_code/ui/rich/theme.py +70 -17
- {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/METADATA +18 -13
- {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/RECORD +49 -45
- klaude_code/command/prompt-deslop.md +0 -14
- klaude_code/command/prompt-dev-docs-update.md +0 -56
- klaude_code/command/prompt-dev-docs.md +0 -46
- klaude_code/command/prompt-handoff.md +0 -33
- klaude_code/command/prompt-jj-workspace.md +0 -18
- klaude_code/core/tool/memory/__init__.py +0 -5
- klaude_code/llm/openai_compatible/stream_processor.py +0 -83
- /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
- {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""System skills management - install built-in skills to user directory.
|
|
2
|
+
|
|
3
|
+
This module handles extracting bundled skills from the package to ~/.klaude/skills/.system/
|
|
4
|
+
on application startup. It uses a fingerprint mechanism to avoid unnecessary re-extraction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from klaude_code.trace import log_debug
|
|
15
|
+
|
|
16
|
+
# Marker file name for tracking installed skills version
|
|
17
|
+
SYSTEM_SKILLS_MARKER_FILENAME = ".klaude-system-skills.marker"
|
|
18
|
+
|
|
19
|
+
# Salt for fingerprint calculation (increment to force re-extraction)
|
|
20
|
+
SYSTEM_SKILLS_MARKER_SALT = "v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_system_skills_dir() -> Path:
|
|
24
|
+
"""Get the system skills installation directory.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Path to ~/.klaude/skills/.system/
|
|
28
|
+
"""
|
|
29
|
+
return Path.home() / ".klaude" / "skills" / ".system"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _calculate_fingerprint(assets_dir: Path) -> str:
|
|
33
|
+
"""Calculate a fingerprint hash for the embedded skills assets.
|
|
34
|
+
|
|
35
|
+
The fingerprint is based on all file paths and their contents.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
assets_dir: Path to the assets directory
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Hex string of the hash
|
|
42
|
+
"""
|
|
43
|
+
hasher = hashlib.sha256()
|
|
44
|
+
hasher.update(SYSTEM_SKILLS_MARKER_SALT.encode())
|
|
45
|
+
|
|
46
|
+
if not assets_dir.exists():
|
|
47
|
+
return hasher.hexdigest()
|
|
48
|
+
|
|
49
|
+
# Sort entries for consistent ordering
|
|
50
|
+
for entry in sorted(assets_dir.rglob("*")):
|
|
51
|
+
if entry.is_file():
|
|
52
|
+
# Hash the relative path
|
|
53
|
+
rel_path = entry.relative_to(assets_dir)
|
|
54
|
+
hasher.update(str(rel_path).encode())
|
|
55
|
+
# Hash the file contents
|
|
56
|
+
hasher.update(entry.read_bytes())
|
|
57
|
+
|
|
58
|
+
return hasher.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_marker(marker_path: Path) -> str | None:
|
|
62
|
+
"""Read the fingerprint from the marker file.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
marker_path: Path to the marker file
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The stored fingerprint, or None if the file doesn't exist or is invalid
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if marker_path.exists():
|
|
72
|
+
return marker_path.read_text(encoding="utf-8").strip()
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_marker(marker_path: Path, fingerprint: str) -> None:
|
|
79
|
+
"""Write the fingerprint to the marker file.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
marker_path: Path to the marker file
|
|
83
|
+
fingerprint: The fingerprint to store
|
|
84
|
+
"""
|
|
85
|
+
marker_path.write_text(f"{fingerprint}\n", encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@contextmanager
|
|
89
|
+
def _with_embedded_assets_dir() -> Iterator[Path | None]:
|
|
90
|
+
"""Resolve the embedded assets directory as a real filesystem path.
|
|
91
|
+
|
|
92
|
+
Uses `importlib.resources.as_file()` so it works for both normal installs
|
|
93
|
+
and zipimport-style environments.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
assets_ref = resources.files("klaude_code.skill").joinpath("assets")
|
|
97
|
+
with resources.as_file(assets_ref) as assets_path:
|
|
98
|
+
p = Path(assets_path)
|
|
99
|
+
yield p if p.exists() else None
|
|
100
|
+
return
|
|
101
|
+
except (TypeError, AttributeError, ImportError, FileNotFoundError, OSError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
module_dir = Path(__file__).parent
|
|
106
|
+
assets_path = module_dir / "assets"
|
|
107
|
+
yield assets_path if assets_path.exists() else None
|
|
108
|
+
except (TypeError, NameError, OSError):
|
|
109
|
+
yield None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_system_skills() -> bool:
|
|
113
|
+
"""Install system skills from the embedded assets to the user directory.
|
|
114
|
+
|
|
115
|
+
This function:
|
|
116
|
+
1. Calculates a fingerprint of the embedded assets
|
|
117
|
+
2. Checks if the installed skills match (via marker file)
|
|
118
|
+
3. If they don't match, clears and re-extracts the skills
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if skills were installed/updated, False if already up-to-date
|
|
122
|
+
"""
|
|
123
|
+
dest_dir = get_system_skills_dir()
|
|
124
|
+
marker_path = dest_dir / SYSTEM_SKILLS_MARKER_FILENAME
|
|
125
|
+
|
|
126
|
+
with _with_embedded_assets_dir() as assets_path:
|
|
127
|
+
if assets_path is None or not assets_path.exists():
|
|
128
|
+
log_debug("No embedded system skills found")
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
# Calculate fingerprint of embedded assets
|
|
132
|
+
expected_fingerprint = _calculate_fingerprint(assets_path)
|
|
133
|
+
|
|
134
|
+
# Check if already installed with matching fingerprint
|
|
135
|
+
current_fingerprint = _read_marker(marker_path)
|
|
136
|
+
if current_fingerprint == expected_fingerprint and dest_dir.exists():
|
|
137
|
+
log_debug("System skills already up-to-date")
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
log_debug(f"Installing system skills to {dest_dir}")
|
|
141
|
+
|
|
142
|
+
# Clear existing installation
|
|
143
|
+
if dest_dir.exists():
|
|
144
|
+
try:
|
|
145
|
+
shutil.rmtree(dest_dir)
|
|
146
|
+
except OSError as e:
|
|
147
|
+
log_debug(f"Failed to clear existing system skills: {e}")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Create destination directory
|
|
151
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
# Copy all skill directories from assets
|
|
154
|
+
try:
|
|
155
|
+
for item in assets_path.iterdir():
|
|
156
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
157
|
+
dest_skill_dir = dest_dir / item.name
|
|
158
|
+
shutil.copytree(item, dest_skill_dir)
|
|
159
|
+
log_debug(f"Installed system skill: {item.name}")
|
|
160
|
+
except OSError as e:
|
|
161
|
+
log_debug(f"Failed to copy system skills: {e}")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# Write marker file
|
|
165
|
+
try:
|
|
166
|
+
_write_marker(marker_path, expected_fingerprint)
|
|
167
|
+
except OSError as e:
|
|
168
|
+
log_debug(f"Failed to write marker file: {e}")
|
|
169
|
+
# Installation succeeded, just marker failed
|
|
170
|
+
|
|
171
|
+
log_debug("System skills installation complete")
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_installed_system_skills() -> list[Path]:
|
|
176
|
+
"""Get list of installed system skill directories.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of paths to installed skill directories
|
|
180
|
+
"""
|
|
181
|
+
dest_dir = get_system_skills_dir()
|
|
182
|
+
if not dest_dir.exists():
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
skills: list[Path] = []
|
|
186
|
+
for item in dest_dir.iterdir():
|
|
187
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
188
|
+
skill_file = item / "SKILL.md"
|
|
189
|
+
if skill_file.exists():
|
|
190
|
+
skills.append(item)
|
|
191
|
+
|
|
192
|
+
return skills
|
|
@@ -20,12 +20,10 @@ class StageManager:
|
|
|
20
20
|
*,
|
|
21
21
|
finish_assistant: Callable[[], Awaitable[None]],
|
|
22
22
|
finish_thinking: Callable[[], Awaitable[None]],
|
|
23
|
-
on_enter_thinking: Callable[[], None],
|
|
24
23
|
):
|
|
25
24
|
self._stage = Stage.WAITING
|
|
26
25
|
self._finish_assistant = finish_assistant
|
|
27
26
|
self._finish_thinking = finish_thinking
|
|
28
|
-
self._on_enter_thinking = on_enter_thinking
|
|
29
27
|
|
|
30
28
|
@property
|
|
31
29
|
def current_stage(self) -> Stage:
|
|
@@ -41,7 +39,6 @@ class StageManager:
|
|
|
41
39
|
if self._stage == Stage.THINKING:
|
|
42
40
|
return
|
|
43
41
|
await self.transition_to(Stage.THINKING)
|
|
44
|
-
self._on_enter_thinking()
|
|
45
42
|
|
|
46
43
|
async def finish_assistant(self) -> None:
|
|
47
44
|
if self._stage != Stage.ASSISTANT:
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
"""REPL completion handlers for @ file paths
|
|
1
|
+
"""REPL completion handlers for @ file paths, / slash commands, and $ skills.
|
|
2
2
|
|
|
3
3
|
This module provides completers for the REPL input:
|
|
4
4
|
- _SlashCommandCompleter: Completes slash commands on the first line
|
|
5
|
+
- _SkillCompleter: Completes skill names on the first line with $ prefix
|
|
5
6
|
- _AtFilesCompleter: Completes @path segments using fd or ripgrep
|
|
6
|
-
- _ComboCompleter: Combines
|
|
7
|
+
- _ComboCompleter: Combines all completers with priority logic
|
|
7
8
|
|
|
8
9
|
Public API:
|
|
9
10
|
- create_repl_completer(): Factory function to create the combined completer
|
|
10
11
|
- AT_TOKEN_PATTERN: Regex pattern for @token matching (used by key bindings)
|
|
12
|
+
- SKILL_TOKEN_PATTERN: Regex pattern for $skill matching (used by key bindings)
|
|
11
13
|
"""
|
|
12
14
|
|
|
13
15
|
from __future__ import annotations
|
|
@@ -34,6 +36,9 @@ from klaude_code.trace.log import DebugType, log_debug
|
|
|
34
36
|
# single logical token.
|
|
35
37
|
AT_TOKEN_PATTERN = re.compile(r'(^|\s)@(?P<frag>"[^"]*"|[^\s]*)$')
|
|
36
38
|
|
|
39
|
+
# Pattern to match $skill or ¥skill token for skill completion (used by key bindings).
|
|
40
|
+
SKILL_TOKEN_PATTERN = re.compile(r"^[$¥](?P<frag>\S*)$")
|
|
41
|
+
|
|
37
42
|
|
|
38
43
|
def create_repl_completer() -> Completer:
|
|
39
44
|
"""Create and return the combined REPL completer.
|
|
@@ -121,12 +126,102 @@ class _SlashCommandCompleter(Completer):
|
|
|
121
126
|
return bool(self._SLASH_TOKEN_RE.search(text_before))
|
|
122
127
|
|
|
123
128
|
|
|
129
|
+
class _SkillCompleter(Completer):
|
|
130
|
+
"""Complete skill names at the beginning of the first line.
|
|
131
|
+
|
|
132
|
+
Behavior:
|
|
133
|
+
- Only triggers when cursor is on first line and text matches $ or ¥...
|
|
134
|
+
- Shows available skills with descriptions
|
|
135
|
+
- Inserts trailing space after completion
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
_SKILL_TOKEN_RE = SKILL_TOKEN_PATTERN
|
|
139
|
+
|
|
140
|
+
def get_completions(
|
|
141
|
+
self,
|
|
142
|
+
document: Document,
|
|
143
|
+
complete_event, # type: ignore[override]
|
|
144
|
+
) -> Iterable[Completion]:
|
|
145
|
+
# Only complete on first line
|
|
146
|
+
if document.cursor_position_row != 0:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
text_before = document.current_line_before_cursor
|
|
150
|
+
m = self._SKILL_TOKEN_RE.search(text_before)
|
|
151
|
+
if not m:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
frag = m.group("frag").lower()
|
|
155
|
+
# Get the prefix character ($ or ¥)
|
|
156
|
+
prefix_char = text_before[0]
|
|
157
|
+
token_start = len(text_before) - len(f"{prefix_char}{m.group('frag')}")
|
|
158
|
+
start_position = token_start - len(text_before) # negative offset
|
|
159
|
+
|
|
160
|
+
# Get available skills from SkillTool
|
|
161
|
+
skills = self._get_available_skills()
|
|
162
|
+
if not skills:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Filter skills that match the fragment (case-insensitive)
|
|
166
|
+
matched: list[tuple[str, str, str]] = [] # (name, description, location)
|
|
167
|
+
for name, desc, location in skills:
|
|
168
|
+
if frag in name.lower() or frag in desc.lower():
|
|
169
|
+
matched.append((name, desc, location))
|
|
170
|
+
|
|
171
|
+
if not matched:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Calculate max width for alignment
|
|
175
|
+
max_name_len = max(len(name) for name, _, _ in matched)
|
|
176
|
+
align_width = max(max_name_len, 20) + 2
|
|
177
|
+
|
|
178
|
+
for name, desc, location in matched:
|
|
179
|
+
# Format: name [location] description
|
|
180
|
+
# Align location tags (max length is "project" = 7, plus brackets = 9)
|
|
181
|
+
padding_name = " " * (align_width - len(name))
|
|
182
|
+
location_tag = f"[{location}]".ljust(9)
|
|
183
|
+
|
|
184
|
+
# Using HTML for formatting: bold skill name, cyan location tag, gray description
|
|
185
|
+
display_text = HTML(
|
|
186
|
+
f"<b>{name}</b>{padding_name}<style color='ansicyan'>{location_tag}</style> "
|
|
187
|
+
f"<style color='ansibrightblack'>{desc}</style>"
|
|
188
|
+
)
|
|
189
|
+
completion_text = f"${name} "
|
|
190
|
+
yield Completion(
|
|
191
|
+
text=completion_text,
|
|
192
|
+
start_position=start_position,
|
|
193
|
+
display=display_text,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _get_available_skills(self) -> list[tuple[str, str, str]]:
|
|
197
|
+
"""Get available skills from skill module.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of (name, description, location) tuples
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
# Import here to avoid circular imports
|
|
204
|
+
from klaude_code.skill import get_available_skills
|
|
205
|
+
|
|
206
|
+
return get_available_skills()
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def is_skill_context(self, document: Document) -> bool:
|
|
211
|
+
"""Check if current context is a skill completion."""
|
|
212
|
+
if document.cursor_position_row != 0:
|
|
213
|
+
return False
|
|
214
|
+
text_before = document.current_line_before_cursor
|
|
215
|
+
return bool(self._SKILL_TOKEN_RE.search(text_before))
|
|
216
|
+
|
|
217
|
+
|
|
124
218
|
class _ComboCompleter(Completer):
|
|
125
|
-
"""Combined completer that handles
|
|
219
|
+
"""Combined completer that handles @ file paths, / slash commands, and $ skills."""
|
|
126
220
|
|
|
127
221
|
def __init__(self) -> None:
|
|
128
222
|
self._at_completer = _AtFilesCompleter()
|
|
129
223
|
self._slash_completer = _SlashCommandCompleter()
|
|
224
|
+
self._skill_completer = _SkillCompleter()
|
|
130
225
|
|
|
131
226
|
def get_completions(
|
|
132
227
|
self,
|
|
@@ -138,6 +233,11 @@ class _ComboCompleter(Completer):
|
|
|
138
233
|
yield from self._slash_completer.get_completions(document, complete_event)
|
|
139
234
|
return
|
|
140
235
|
|
|
236
|
+
# Try skill completion (only on first line with $ prefix)
|
|
237
|
+
if document.cursor_position_row == 0 and self._skill_completer.is_skill_context(document):
|
|
238
|
+
yield from self._skill_completer.get_completions(document, complete_event)
|
|
239
|
+
return
|
|
240
|
+
|
|
141
241
|
# Fall back to @ file completion
|
|
142
242
|
yield from self._at_completer.get_completions(document, complete_event)
|
|
143
243
|
|
|
@@ -2,19 +2,57 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
+
from rich.cells import cell_len
|
|
6
|
+
from rich.rule import Rule
|
|
5
7
|
from rich.text import Text
|
|
6
8
|
|
|
7
9
|
from klaude_code import const
|
|
8
10
|
from klaude_code.protocol import events
|
|
9
11
|
from klaude_code.ui.core.stage_manager import Stage, StageManager
|
|
10
12
|
from klaude_code.ui.modes.repl.renderer import REPLRenderer
|
|
11
|
-
from klaude_code.ui.renderers.
|
|
13
|
+
from klaude_code.ui.renderers.assistant import ASSISTANT_MESSAGE_MARK
|
|
14
|
+
from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_thinking_content
|
|
12
15
|
from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
13
16
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
14
17
|
from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
|
|
15
18
|
from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
|
|
16
19
|
|
|
17
20
|
|
|
21
|
+
def extract_last_bold_header(text: str) -> str | None:
|
|
22
|
+
"""Extract the latest complete bold header ("**...**") from text.
|
|
23
|
+
|
|
24
|
+
We treat a bold segment as a "header" only if it appears at the beginning
|
|
25
|
+
of a line (ignoring leading whitespace). This avoids picking up incidental
|
|
26
|
+
emphasis inside paragraphs.
|
|
27
|
+
|
|
28
|
+
Returns None if no complete bold segment is available yet.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
last: str | None = None
|
|
32
|
+
i = 0
|
|
33
|
+
while True:
|
|
34
|
+
start = text.find("**", i)
|
|
35
|
+
if start < 0:
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
line_start = text.rfind("\n", 0, start) + 1
|
|
39
|
+
if text[line_start:start].strip():
|
|
40
|
+
i = start + 2
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
end = text.find("**", start + 2)
|
|
44
|
+
if end < 0:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
inner = " ".join(text[start + 2 : end].split())
|
|
48
|
+
if inner and "\n" not in inner:
|
|
49
|
+
last = inner
|
|
50
|
+
|
|
51
|
+
i = end + 2
|
|
52
|
+
|
|
53
|
+
return last
|
|
54
|
+
|
|
55
|
+
|
|
18
56
|
@dataclass
|
|
19
57
|
class ActiveStream:
|
|
20
58
|
"""Active streaming state containing buffer and markdown renderer.
|
|
@@ -115,7 +153,7 @@ class ActivityState:
|
|
|
115
153
|
for name, count in self._tool_calls.items():
|
|
116
154
|
if not first:
|
|
117
155
|
activity_text.append(", ")
|
|
118
|
-
activity_text.append(Text(name, style=ThemeKey.
|
|
156
|
+
activity_text.append(Text(name, style=ThemeKey.STATUS_TEXT_BOLD))
|
|
119
157
|
if count > 1:
|
|
120
158
|
activity_text.append(f" x {count}")
|
|
121
159
|
first = False
|
|
@@ -128,8 +166,9 @@ class ActivityState:
|
|
|
128
166
|
class SpinnerStatusState:
|
|
129
167
|
"""Multi-layer spinner status state management.
|
|
130
168
|
|
|
131
|
-
|
|
132
|
-
-
|
|
169
|
+
Layers:
|
|
170
|
+
- todo_status: Set by TodoChange (preferred when present)
|
|
171
|
+
- reasoning_status: Derived from Thinking/ThinkingDelta bold headers
|
|
133
172
|
- activity: Current activity (composing or tool_calls), mutually exclusive
|
|
134
173
|
- context_percent: Context usage percentage, updated during task execution
|
|
135
174
|
|
|
@@ -140,25 +179,31 @@ class SpinnerStatusState:
|
|
|
140
179
|
- Context percent is appended at the end if available
|
|
141
180
|
"""
|
|
142
181
|
|
|
143
|
-
DEFAULT_STATUS = "Thinking …"
|
|
144
|
-
|
|
145
182
|
def __init__(self) -> None:
|
|
146
|
-
self.
|
|
183
|
+
self._todo_status: str | None = None
|
|
184
|
+
self._reasoning_status: str | None = None
|
|
147
185
|
self._activity = ActivityState()
|
|
148
186
|
self._context_percent: float | None = None
|
|
149
187
|
|
|
150
188
|
def reset(self) -> None:
|
|
151
189
|
"""Reset all layers."""
|
|
152
|
-
self.
|
|
190
|
+
self._todo_status = None
|
|
191
|
+
self._reasoning_status = None
|
|
153
192
|
self._activity.reset()
|
|
154
193
|
self._context_percent = None
|
|
155
194
|
|
|
156
|
-
def
|
|
195
|
+
def set_todo_status(self, status: str | None) -> None:
|
|
157
196
|
"""Set base status from TodoChange."""
|
|
158
|
-
self.
|
|
197
|
+
self._todo_status = status
|
|
198
|
+
|
|
199
|
+
def set_reasoning_status(self, status: str | None) -> None:
|
|
200
|
+
"""Set reasoning-derived base status from ThinkingDelta bold headers."""
|
|
201
|
+
self._reasoning_status = status
|
|
159
202
|
|
|
160
203
|
def set_composing(self, composing: bool) -> None:
|
|
161
204
|
"""Set composing state when assistant is streaming."""
|
|
205
|
+
if composing:
|
|
206
|
+
self._reasoning_status = None
|
|
162
207
|
self._activity.set_composing(composing)
|
|
163
208
|
|
|
164
209
|
def add_tool_call(self, tool_name: str) -> None:
|
|
@@ -185,8 +230,10 @@ class SpinnerStatusState:
|
|
|
185
230
|
"""Get current spinner status as rich Text (without context)."""
|
|
186
231
|
activity_text = self._activity.get_activity_text()
|
|
187
232
|
|
|
188
|
-
|
|
189
|
-
|
|
233
|
+
base_status = self._todo_status or self._reasoning_status
|
|
234
|
+
|
|
235
|
+
if base_status:
|
|
236
|
+
result = Text(base_status, style=ThemeKey.STATUS_TEXT_BOLD)
|
|
190
237
|
if activity_text:
|
|
191
238
|
result.append(" | ")
|
|
192
239
|
result.append_text(activity_text)
|
|
@@ -194,7 +241,7 @@ class SpinnerStatusState:
|
|
|
194
241
|
activity_text.append(" …")
|
|
195
242
|
result = activity_text
|
|
196
243
|
else:
|
|
197
|
-
result = Text(
|
|
244
|
+
result = Text(const.STATUS_DEFAULT_TEXT, style=ThemeKey.STATUS_TEXT)
|
|
198
245
|
|
|
199
246
|
return result
|
|
200
247
|
|
|
@@ -218,7 +265,6 @@ class DisplayEventHandler:
|
|
|
218
265
|
self.stage_manager = StageManager(
|
|
219
266
|
finish_assistant=self._finish_assistant_stream,
|
|
220
267
|
finish_thinking=self._finish_thinking_stream,
|
|
221
|
-
on_enter_thinking=self._print_thinking_prefix,
|
|
222
268
|
)
|
|
223
269
|
|
|
224
270
|
async def consume_event(self, event: events.Event) -> None:
|
|
@@ -309,6 +355,10 @@ class DisplayEventHandler:
|
|
|
309
355
|
await self._finish_thinking_stream()
|
|
310
356
|
else:
|
|
311
357
|
# Non-streaming path (history replay or models without delta support)
|
|
358
|
+
reasoning_status = extract_last_bold_header(normalize_thinking_content(event.content))
|
|
359
|
+
if reasoning_status:
|
|
360
|
+
self.spinner_status.set_reasoning_status(reasoning_status)
|
|
361
|
+
self._update_spinner()
|
|
312
362
|
await self.stage_manager.enter_thinking_stage()
|
|
313
363
|
self.renderer.display_thinking(event.content)
|
|
314
364
|
|
|
@@ -327,7 +377,9 @@ class DisplayEventHandler:
|
|
|
327
377
|
theme=self.renderer.themes.thinking_markdown_theme,
|
|
328
378
|
console=self.renderer.console,
|
|
329
379
|
spinner=self.renderer.spinner_renderable(),
|
|
330
|
-
|
|
380
|
+
mark=THINKING_MESSAGE_MARK,
|
|
381
|
+
mark_style=ThemeKey.THINKING,
|
|
382
|
+
left_margin=const.MARKDOWN_LEFT_MARGIN,
|
|
331
383
|
markdown_class=ThinkingMarkdown,
|
|
332
384
|
)
|
|
333
385
|
self.thinking_stream.start(mdstream)
|
|
@@ -335,6 +387,11 @@ class DisplayEventHandler:
|
|
|
335
387
|
|
|
336
388
|
self.thinking_stream.append(event.content)
|
|
337
389
|
|
|
390
|
+
reasoning_status = extract_last_bold_header(normalize_thinking_content(self.thinking_stream.buffer))
|
|
391
|
+
if reasoning_status:
|
|
392
|
+
self.spinner_status.set_reasoning_status(reasoning_status)
|
|
393
|
+
self._update_spinner()
|
|
394
|
+
|
|
338
395
|
if first_delta and self.thinking_stream.mdstream is not None:
|
|
339
396
|
self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
|
|
340
397
|
|
|
@@ -358,8 +415,8 @@ class DisplayEventHandler:
|
|
|
358
415
|
theme=self.renderer.themes.markdown_theme,
|
|
359
416
|
console=self.renderer.console,
|
|
360
417
|
spinner=self.renderer.spinner_renderable(),
|
|
361
|
-
mark=
|
|
362
|
-
|
|
418
|
+
mark=ASSISTANT_MESSAGE_MARK,
|
|
419
|
+
left_margin=const.MARKDOWN_LEFT_MARGIN,
|
|
363
420
|
)
|
|
364
421
|
self.assistant_stream.start(mdstream)
|
|
365
422
|
self.assistant_stream.append(event.content)
|
|
@@ -413,7 +470,7 @@ class DisplayEventHandler:
|
|
|
413
470
|
|
|
414
471
|
def _on_todo_change(self, event: events.TodoChangeEvent) -> None:
|
|
415
472
|
active_form_status_text = self._extract_active_form_text(event)
|
|
416
|
-
self.spinner_status.
|
|
473
|
+
self.spinner_status.set_todo_status(active_form_status_text if active_form_status_text else None)
|
|
417
474
|
# Clear tool calls when todo changes, as the tool execution has advanced
|
|
418
475
|
self.spinner_status.clear_for_new_turn()
|
|
419
476
|
self._update_spinner()
|
|
@@ -430,6 +487,8 @@ class DisplayEventHandler:
|
|
|
430
487
|
emit_osc94(OSC94States.HIDDEN)
|
|
431
488
|
self.spinner_status.reset()
|
|
432
489
|
self.renderer.spinner_stop()
|
|
490
|
+
self.renderer.console.print(Rule(characters="-", style=ThemeKey.LINES))
|
|
491
|
+
self.renderer.print()
|
|
433
492
|
await self.stage_manager.transition_to(Stage.WAITING)
|
|
434
493
|
self._maybe_notify_task_finish(event)
|
|
435
494
|
|
|
@@ -465,14 +524,14 @@ class DisplayEventHandler:
|
|
|
465
524
|
mdstream.update(self.assistant_stream.buffer, final=True)
|
|
466
525
|
self.assistant_stream.finish()
|
|
467
526
|
|
|
468
|
-
def _print_thinking_prefix(self) -> None:
|
|
469
|
-
self.renderer.display_thinking_prefix()
|
|
470
|
-
|
|
471
527
|
def _update_spinner(self) -> None:
|
|
472
528
|
"""Update spinner text from current status state."""
|
|
529
|
+
status_text = self.spinner_status.get_status()
|
|
530
|
+
context_text = self.spinner_status.get_context_text()
|
|
531
|
+
status_text = self._truncate_spinner_status_text(status_text, right_text=context_text)
|
|
473
532
|
self.renderer.spinner_update(
|
|
474
|
-
|
|
475
|
-
|
|
533
|
+
status_text,
|
|
534
|
+
context_text,
|
|
476
535
|
)
|
|
477
536
|
|
|
478
537
|
async def _flush_assistant_buffer(self, state: StreamState) -> None:
|
|
@@ -530,35 +589,28 @@ class DisplayEventHandler:
|
|
|
530
589
|
status_text = todo.active_form
|
|
531
590
|
if len(todo.content) > 0:
|
|
532
591
|
status_text = todo.content
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
Reserve space for:
|
|
541
|
-
- Spinner glyph + space + context text: 2 chars + context text length 10 chars
|
|
542
|
-
- " | " separator: 3 chars (only if activity text present)
|
|
543
|
-
- Activity text: actual length (only if present)
|
|
544
|
-
- Status hint text (esc to interrupt)
|
|
592
|
+
return status_text.replace("\n", " ").strip()
|
|
593
|
+
|
|
594
|
+
def _truncate_spinner_status_text(self, status_text: Text, *, right_text: Text | None) -> Text:
|
|
595
|
+
"""Truncate spinner status to a single line based on terminal width.
|
|
596
|
+
|
|
597
|
+
Rich wraps based on terminal cell width (CJK chars count as 2). Use
|
|
598
|
+
cell-aware truncation to prevent the status from wrapping into two lines.
|
|
545
599
|
"""
|
|
600
|
+
|
|
546
601
|
terminal_width = self.renderer.console.size.width
|
|
547
602
|
|
|
548
|
-
#
|
|
549
|
-
|
|
603
|
+
# BreathingSpinner renders as a 2-column Table.grid(padding=1):
|
|
604
|
+
# 1 cell for glyph + 1 cell of padding between columns (collapsed).
|
|
605
|
+
spinner_prefix_cells = 2
|
|
550
606
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if activity_text:
|
|
554
|
-
# " | " separator + actual activity text length
|
|
555
|
-
reserved_space += 3 + len(activity_text.plain)
|
|
607
|
+
hint_cells = cell_len(const.STATUS_HINT_TEXT)
|
|
608
|
+
right_cells = cell_len(right_text.plain) if right_text is not None else 0
|
|
556
609
|
|
|
557
|
-
|
|
558
|
-
|
|
610
|
+
max_main_cells = terminal_width - spinner_prefix_cells - hint_cells - right_cells - 1
|
|
611
|
+
# rich.text.Text.truncate behaves unexpectedly for 0; clamp to at least 1.
|
|
612
|
+
max_main_cells = max(1, max_main_cells)
|
|
559
613
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
truncated = text[:max_length]
|
|
564
|
-
return truncated + "…"
|
|
614
|
+
truncated = status_text.copy()
|
|
615
|
+
truncated.truncate(max_main_cells, overflow="ellipsis", pad=False)
|
|
616
|
+
return truncated
|