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.
Files changed (56) hide show
  1. klaude_code/command/prompt-jj-describe.md +32 -0
  2. klaude_code/command/status_cmd.py +1 -1
  3. klaude_code/{const/__init__.py → const.py} +11 -2
  4. klaude_code/core/executor.py +1 -1
  5. klaude_code/core/manager/sub_agent_manager.py +1 -1
  6. klaude_code/core/reminders.py +51 -0
  7. klaude_code/core/task.py +37 -18
  8. klaude_code/core/tool/__init__.py +1 -4
  9. klaude_code/core/tool/file/read_tool.py +23 -1
  10. klaude_code/core/tool/file/write_tool.py +7 -3
  11. klaude_code/core/tool/skill/__init__.py +0 -0
  12. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -39
  13. klaude_code/llm/openai_compatible/client.py +29 -102
  14. klaude_code/llm/openai_compatible/stream.py +272 -0
  15. klaude_code/llm/openrouter/client.py +29 -109
  16. klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
  17. klaude_code/protocol/model.py +15 -2
  18. klaude_code/session/export.py +1 -1
  19. klaude_code/session/store.py +4 -2
  20. klaude_code/skill/__init__.py +27 -0
  21. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  22. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  23. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  24. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  25. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  26. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +60 -24
  27. klaude_code/skill/manager.py +70 -0
  28. klaude_code/skill/system_skills.py +192 -0
  29. klaude_code/ui/core/stage_manager.py +0 -3
  30. klaude_code/ui/modes/repl/completers.py +103 -3
  31. klaude_code/ui/modes/repl/event_handler.py +101 -49
  32. klaude_code/ui/modes/repl/input_prompt_toolkit.py +55 -6
  33. klaude_code/ui/modes/repl/renderer.py +24 -17
  34. klaude_code/ui/renderers/assistant.py +7 -2
  35. klaude_code/ui/renderers/developer.py +12 -0
  36. klaude_code/ui/renderers/diffs.py +1 -1
  37. klaude_code/ui/renderers/metadata.py +6 -8
  38. klaude_code/ui/renderers/sub_agent.py +28 -5
  39. klaude_code/ui/renderers/thinking.py +16 -10
  40. klaude_code/ui/renderers/tools.py +83 -34
  41. klaude_code/ui/renderers/user_input.py +32 -2
  42. klaude_code/ui/rich/markdown.py +40 -20
  43. klaude_code/ui/rich/status.py +15 -19
  44. klaude_code/ui/rich/theme.py +70 -17
  45. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/METADATA +18 -13
  46. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/RECORD +49 -45
  47. klaude_code/command/prompt-deslop.md +0 -14
  48. klaude_code/command/prompt-dev-docs-update.md +0 -56
  49. klaude_code/command/prompt-dev-docs.md +0 -46
  50. klaude_code/command/prompt-handoff.md +0 -33
  51. klaude_code/command/prompt-jj-workspace.md +0 -18
  52. klaude_code/core/tool/memory/__init__.py +0 -5
  53. klaude_code/llm/openai_compatible/stream_processor.py +0 -83
  54. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  55. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/WHEEL +0 -0
  56. {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 and / slash commands.
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 both completers with priority logic
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 both @ file paths and / slash commands."""
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.thinking import normalize_thinking_content
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.SPINNER_STATUS_TEXT_BOLD))
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
- Composed of two independent layers:
132
- - base_status: Set by TodoChange, persistent within a turn
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._base_status: str | None = None
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._base_status = None
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 set_base_status(self, status: str | None) -> None:
195
+ def set_todo_status(self, status: str | None) -> None:
157
196
  """Set base status from TodoChange."""
158
- self._base_status = status
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
- if self._base_status:
189
- result = Text(self._base_status)
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(self.DEFAULT_STATUS)
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
- indent=2,
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
- indent=2,
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.set_base_status(active_form_status_text if active_form_status_text else None)
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
- self.spinner_status.get_status(),
475
- self.spinner_status.get_context_text(),
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
- status_text = status_text.replace("\n", "")
534
- max_length = self._calculate_base_status_max_length()
535
- return self._truncate_status_text(status_text, max_length=max_length)
536
-
537
- def _calculate_base_status_max_length(self) -> int:
538
- """Calculate max length for base_status based on terminal width.
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
- # Base reserved space: spinner + context + status hint
549
- reserved_space = 12 + len(const.STATUS_HINT_TEXT)
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
- # Add space for activity text if present
552
- activity_text = self.spinner_status.get_activity_text()
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
- max_length = max(10, terminal_width - reserved_space)
558
- return max_length
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
- def _truncate_status_text(self, text: str, max_length: int) -> str:
561
- if len(text) <= max_length:
562
- return text
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