klaude-code 1.2.15__py3-none-any.whl → 1.2.17__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 (59) hide show
  1. klaude_code/cli/main.py +66 -42
  2. klaude_code/cli/runtime.py +34 -13
  3. klaude_code/command/__init__.py +3 -0
  4. klaude_code/command/export_cmd.py +2 -2
  5. klaude_code/command/export_online_cmd.py +149 -0
  6. klaude_code/command/prompt-handoff.md +33 -0
  7. klaude_code/command/thinking_cmd.py +5 -1
  8. klaude_code/config/config.py +20 -21
  9. klaude_code/config/list_model.py +1 -1
  10. klaude_code/const/__init__.py +3 -0
  11. klaude_code/core/executor.py +2 -2
  12. klaude_code/core/manager/llm_clients_builder.py +1 -1
  13. klaude_code/core/manager/sub_agent_manager.py +30 -6
  14. klaude_code/core/prompt.py +15 -13
  15. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +0 -1
  16. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  17. klaude_code/core/prompts/prompt-sub-agent-web.md +48 -0
  18. klaude_code/core/reminders.py +75 -32
  19. klaude_code/core/task.py +18 -22
  20. klaude_code/core/tool/__init__.py +4 -0
  21. klaude_code/core/tool/report_back_tool.py +84 -0
  22. klaude_code/core/tool/sub_agent_tool.py +6 -0
  23. klaude_code/core/tool/tool_runner.py +9 -1
  24. klaude_code/core/tool/web/web_search_tool.md +23 -0
  25. klaude_code/core/tool/web/web_search_tool.py +126 -0
  26. klaude_code/core/turn.py +45 -4
  27. klaude_code/llm/anthropic/input.py +14 -5
  28. klaude_code/llm/openrouter/input.py +14 -3
  29. klaude_code/llm/responses/input.py +19 -0
  30. klaude_code/protocol/commands.py +1 -0
  31. klaude_code/protocol/events.py +9 -0
  32. klaude_code/protocol/model.py +24 -14
  33. klaude_code/protocol/sub_agent/__init__.py +117 -0
  34. klaude_code/protocol/sub_agent/explore.py +63 -0
  35. klaude_code/protocol/sub_agent/oracle.py +91 -0
  36. klaude_code/protocol/sub_agent/task.py +61 -0
  37. klaude_code/protocol/sub_agent/web.py +78 -0
  38. klaude_code/protocol/tools.py +2 -0
  39. klaude_code/session/export.py +12 -6
  40. klaude_code/session/session.py +12 -2
  41. klaude_code/session/templates/export_session.html +111 -36
  42. klaude_code/ui/modes/repl/completers.py +1 -1
  43. klaude_code/ui/modes/repl/event_handler.py +65 -8
  44. klaude_code/ui/modes/repl/renderer.py +11 -9
  45. klaude_code/ui/renderers/developer.py +18 -7
  46. klaude_code/ui/renderers/metadata.py +24 -12
  47. klaude_code/ui/renderers/sub_agent.py +63 -3
  48. klaude_code/ui/renderers/thinking.py +1 -1
  49. klaude_code/ui/renderers/tools.py +24 -37
  50. klaude_code/ui/rich/markdown.py +20 -48
  51. klaude_code/ui/rich/status.py +61 -17
  52. klaude_code/ui/rich/theme.py +8 -7
  53. {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/METADATA +114 -22
  54. {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/RECORD +57 -48
  55. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  56. klaude_code/protocol/sub_agent.py +0 -354
  57. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  58. {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/WHEEL +0 -0
  59. {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/entry_points.txt +0 -0
@@ -121,7 +121,7 @@ class ActivityState:
121
121
  for name, count in self._tool_calls.items():
122
122
  if not first:
123
123
  activity_text.append(", ")
124
- activity_text.append(name, style="bold")
124
+ activity_text.append(name)
125
125
  if count > 1:
126
126
  activity_text.append(f" x {count}")
127
127
  first = False
@@ -137,11 +137,13 @@ class SpinnerStatusState:
137
137
  Composed of two independent layers:
138
138
  - base_status: Set by TodoChange, persistent within a turn
139
139
  - activity: Current activity (composing or tool_calls), mutually exclusive
140
+ - context_percent: Context usage percentage, updated during task execution
140
141
 
141
142
  Display logic:
142
143
  - If activity: show base + activity (if base exists) or activity + "..."
143
144
  - Elif base_status: show base_status
144
145
  - Else: show "Thinking …"
146
+ - Context percent is appended at the end if available
145
147
  """
146
148
 
147
149
  DEFAULT_STATUS = "Thinking …"
@@ -149,11 +151,13 @@ class SpinnerStatusState:
149
151
  def __init__(self) -> None:
150
152
  self._base_status: str | None = None
151
153
  self._activity = ActivityState()
154
+ self._context_percent: float | None = None
152
155
 
153
156
  def reset(self) -> None:
154
157
  """Reset all layers."""
155
158
  self._base_status = None
156
159
  self._activity.reset()
160
+ self._context_percent = None
157
161
 
158
162
  def set_base_status(self, status: str | None) -> None:
159
163
  """Set base status from TodoChange."""
@@ -175,8 +179,16 @@ class SpinnerStatusState:
175
179
  """Clear activity state for a new turn."""
176
180
  self._activity.reset()
177
181
 
182
+ def set_context_percent(self, percent: float) -> None:
183
+ """Set context usage percentage."""
184
+ self._context_percent = percent
185
+
186
+ def get_activity_text(self) -> Text | None:
187
+ """Get current activity text. Returns None if idle."""
188
+ return self._activity.get_activity_text()
189
+
178
190
  def get_status(self) -> Text:
179
- """Get current spinner status as rich Text."""
191
+ """Get current spinner status as rich Text (without context)."""
180
192
  activity_text = self._activity.get_activity_text()
181
193
 
182
194
  if self._base_status:
@@ -184,11 +196,19 @@ class SpinnerStatusState:
184
196
  if activity_text:
185
197
  result.append(" | ")
186
198
  result.append_text(activity_text)
187
- return result
188
- if activity_text:
199
+ elif activity_text:
189
200
  activity_text.append(" …")
190
- return activity_text
191
- return Text(self.DEFAULT_STATUS)
201
+ result = activity_text
202
+ else:
203
+ result = Text(self.DEFAULT_STATUS)
204
+
205
+ return result
206
+
207
+ def get_context_text(self) -> Text | None:
208
+ """Get context usage text for right-aligned display."""
209
+ if self._context_percent is None:
210
+ return None
211
+ return Text(f"{self._context_percent:.1f}%", style=ThemeKey.METADATA_DIM)
192
212
 
193
213
 
194
214
  class DisplayEventHandler:
@@ -243,6 +263,8 @@ class DisplayEventHandler:
243
263
  self._on_task_metadata(e)
244
264
  case events.TodoChangeEvent() as e:
245
265
  self._on_todo_change(e)
266
+ case events.ContextUsageEvent() as e:
267
+ self._on_context_usage(e)
246
268
  case events.TurnEndEvent():
247
269
  pass
248
270
  case events.ResponseMetadataEvent():
@@ -377,6 +399,7 @@ class DisplayEventHandler:
377
399
  self.spinner_status.set_composing(False)
378
400
  self._update_spinner()
379
401
  await self.stage_manager.transition_to(Stage.WAITING)
402
+ self.renderer.print()
380
403
  self.renderer.spinner_start()
381
404
 
382
405
  def _on_tool_call_start(self, event: events.TurnToolCallStartEvent) -> None:
@@ -407,6 +430,12 @@ class DisplayEventHandler:
407
430
  self.spinner_status.clear_for_new_turn()
408
431
  self._update_spinner()
409
432
 
433
+ def _on_context_usage(self, event: events.ContextUsageEvent) -> None:
434
+ if self.renderer.is_sub_agent_session(event.session_id):
435
+ return
436
+ self.spinner_status.set_context_percent(event.context_percent)
437
+ self._update_spinner()
438
+
410
439
  async def _on_task_finish(self, event: events.TaskFinishEvent) -> None:
411
440
  self.renderer.display_task_finish(event)
412
441
  if not self.renderer.is_sub_agent_session(event.session_id):
@@ -454,7 +483,10 @@ class DisplayEventHandler:
454
483
 
455
484
  def _update_spinner(self) -> None:
456
485
  """Update spinner text from current status state."""
457
- self.renderer.spinner_update(self.spinner_status.get_status())
486
+ self.renderer.spinner_update(
487
+ self.spinner_status.get_status(),
488
+ self.spinner_status.get_context_text(),
489
+ )
458
490
 
459
491
  async def _flush_assistant_buffer(self, state: StreamState) -> None:
460
492
  if state.is_active:
@@ -476,6 +508,7 @@ class DisplayEventHandler:
476
508
  mdstream.update(self.thinking_stream.buffer, final=True)
477
509
  self.thinking_stream.finish()
478
510
  self.renderer.console.pop_theme()
511
+ self.renderer.print()
479
512
  self.renderer.spinner_start()
480
513
 
481
514
  def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
@@ -512,7 +545,31 @@ class DisplayEventHandler:
512
545
  if len(todo.content) > 0:
513
546
  status_text = todo.content
514
547
  status_text = status_text.replace("\n", "")
515
- return self._truncate_status_text(status_text, max_length=50)
548
+ max_length = self._calculate_base_status_max_length()
549
+ return self._truncate_status_text(status_text, max_length=max_length)
550
+
551
+ def _calculate_base_status_max_length(self) -> int:
552
+ """Calculate max length for base_status based on terminal width.
553
+
554
+ Reserve space for:
555
+ - Spinner glyph + space + context text: 2 chars + context text length 10 chars
556
+ - " | " separator: 3 chars (only if activity text present)
557
+ - Activity text: actual length (only if present)
558
+ - Status hint text (esc to interrupt)
559
+ """
560
+ terminal_width = self.renderer.console.size.width
561
+
562
+ # Base reserved space: spinner + context + status hint
563
+ reserved_space = 12 + len(const.STATUS_HINT_TEXT)
564
+
565
+ # Add space for activity text if present
566
+ activity_text = self.spinner_status.get_activity_text()
567
+ if activity_text:
568
+ # " | " separator + actual activity text length
569
+ reserved_space += 3 + len(activity_text.plain)
570
+
571
+ max_length = max(10, terminal_width - reserved_space)
572
+ return max_length
516
573
 
517
574
  def _truncate_status_text(self, text: str, max_length: int) -> str:
518
575
  if len(text) <= max_length:
@@ -50,7 +50,7 @@ class REPLRenderer:
50
50
 
51
51
  self.session_map: dict[str, SessionStatus] = {}
52
52
  self.current_sub_agent_color: Style | None = None
53
- self.subagent_color_index = 0
53
+ self.sub_agent_color_index = 0
54
54
 
55
55
  def register_session(self, session_id: str, sub_agent_state: model.SubAgentState | None = None) -> None:
56
56
  session_status = SessionStatus(
@@ -66,16 +66,16 @@ class REPLRenderer:
66
66
  def _advance_sub_agent_color_index(self) -> None:
67
67
  palette_size = len(self.themes.sub_agent_colors)
68
68
  if palette_size == 0:
69
- self.subagent_color_index = 0
69
+ self.sub_agent_color_index = 0
70
70
  return
71
- self.subagent_color_index = (self.subagent_color_index + 1) % palette_size
71
+ self.sub_agent_color_index = (self.sub_agent_color_index + 1) % palette_size
72
72
 
73
73
  def pick_sub_agent_color(self) -> Style:
74
74
  self._advance_sub_agent_color_index()
75
75
  palette = self.themes.sub_agent_colors
76
76
  if not palette:
77
77
  return Style()
78
- return palette[self.subagent_color_index]
78
+ return palette[self.sub_agent_color_index]
79
79
 
80
80
  def get_session_sub_agent_color(self, session_id: str) -> Style:
81
81
  status = self.session_map.get(session_id)
@@ -100,9 +100,9 @@ class REPLRenderer:
100
100
  if self.current_sub_agent_color:
101
101
  if objects:
102
102
  content = objects[0] if len(objects) == 1 else objects
103
- self.console.print(Quote(content, style=self.current_sub_agent_color))
103
+ self.console.print(Quote(content, style=self.current_sub_agent_color), overflow="ellipsis")
104
104
  return
105
- self.console.print(*objects, style=style, end=end)
105
+ self.console.print(*objects, style=style, end=end, overflow="ellipsis")
106
106
 
107
107
  def display_tool_call(self, e: events.ToolCallEvent) -> None:
108
108
  if r_tools.is_sub_agent_tool(e.tool_name):
@@ -152,6 +152,7 @@ class REPLRenderer:
152
152
  case events.ThinkingEvent() as e:
153
153
  if is_sub_agent:
154
154
  continue
155
+ self.display_thinking_prefix()
155
156
  self.display_thinking(e.content)
156
157
  case events.DeveloperMessageEvent() as e:
157
158
  self.display_developer_message(e)
@@ -233,6 +234,7 @@ class REPLRenderer:
233
234
  r_sub_agent.render_sub_agent_result(
234
235
  event.task_result,
235
236
  code_theme=self.themes.code_theme,
237
+ has_structured_output=event.has_structured_output,
236
238
  )
237
239
  )
238
240
 
@@ -262,9 +264,9 @@ class REPLRenderer:
262
264
  """Stop the spinner animation."""
263
265
  self._spinner.stop()
264
266
 
265
- def spinner_update(self, status_text: str | Text) -> None:
266
- """Update the spinner status text."""
267
- self._spinner.update(ShimmerStatusText(status_text, ThemeKey.SPINNER_STATUS_TEXT))
267
+ def spinner_update(self, status_text: str | Text, right_text: Text | None = None) -> None:
268
+ """Update the spinner status text with optional right-aligned text."""
269
+ self._spinner.update(ShimmerStatusText(status_text, ThemeKey.SPINNER_STATUS_TEXT, right_text))
268
270
 
269
271
  def spinner_renderable(self) -> Spinner:
270
272
  """Return the spinner's renderable for embedding in other components."""
@@ -67,13 +67,24 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
67
67
  if e.item.at_files:
68
68
  grid = create_grid()
69
69
  for at_file in e.item.at_files:
70
- grid.add_row(
71
- Text(" +", style=ThemeKey.REMINDER),
72
- Text.assemble(
73
- (f"{at_file.operation} ", ThemeKey.REMINDER),
74
- render_path(at_file.path, ThemeKey.REMINDER_BOLD),
75
- ),
76
- )
70
+ if at_file.mentioned_in:
71
+ grid.add_row(
72
+ Text(" +", style=ThemeKey.REMINDER),
73
+ Text.assemble(
74
+ (f"{at_file.operation} ", ThemeKey.REMINDER),
75
+ render_path(at_file.path, ThemeKey.REMINDER_BOLD),
76
+ (" mentioned in ", ThemeKey.REMINDER),
77
+ render_path(at_file.mentioned_in, ThemeKey.REMINDER_BOLD),
78
+ ),
79
+ )
80
+ else:
81
+ grid.add_row(
82
+ Text(" +", style=ThemeKey.REMINDER),
83
+ Text.assemble(
84
+ (f"{at_file.operation} ", ThemeKey.REMINDER),
85
+ render_path(at_file.path, ThemeKey.REMINDER_BOLD),
86
+ ),
87
+ )
77
88
  parts.append(grid)
78
89
 
79
90
  if uic := e.item.user_image_count:
@@ -59,20 +59,32 @@ def _render_task_metadata_block(
59
59
  parts: list[Text] = []
60
60
 
61
61
  if metadata.usage is not None:
62
- # Tokens: ↑37k c5k ↓907 r45k
63
- token_parts: list[tuple[str, str]] = [
64
- ("↑", ThemeKey.METADATA_DIM),
65
- (format_number(metadata.usage.input_tokens), ThemeKey.METADATA),
62
+ # Tokens: ↑ 37k cache 5k 907 think 45k
63
+ token_parts: list[Text] = [
64
+ Text.assemble(
65
+ ("↑ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.input_tokens), ThemeKey.METADATA)
66
+ )
66
67
  ]
67
68
  if metadata.usage.cached_tokens > 0:
68
- token_parts.append((" c", ThemeKey.METADATA_DIM))
69
- token_parts.append((format_number(metadata.usage.cached_tokens), ThemeKey.METADATA))
70
- token_parts.append((" ", ThemeKey.METADATA_DIM))
71
- token_parts.append((format_number(metadata.usage.output_tokens), ThemeKey.METADATA))
69
+ token_parts.append(
70
+ Text.assemble(
71
+ Text("cache ", style=ThemeKey.METADATA_DIM),
72
+ Text(format_number(metadata.usage.cached_tokens), style=ThemeKey.METADATA),
73
+ )
74
+ )
75
+ token_parts.append(
76
+ Text.assemble(
77
+ ("↓ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.output_tokens), ThemeKey.METADATA)
78
+ )
79
+ )
72
80
  if metadata.usage.reasoning_tokens > 0:
73
- token_parts.append((" r", ThemeKey.METADATA_DIM))
74
- token_parts.append((format_number(metadata.usage.reasoning_tokens), ThemeKey.METADATA))
75
- parts.append(Text.assemble(*token_parts))
81
+ token_parts.append(
82
+ Text.assemble(
83
+ ("think ", ThemeKey.METADATA_DIM),
84
+ (format_number(metadata.usage.reasoning_tokens), ThemeKey.METADATA),
85
+ )
86
+ )
87
+ parts.append(Text(" · ").join(token_parts))
76
88
 
77
89
  # Cost
78
90
  if metadata.usage is not None and metadata.usage.total_cost is not None:
@@ -141,7 +153,7 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
141
153
  for meta in sorted_items:
142
154
  renderables.append(_render_task_metadata_block(meta, is_sub_agent=True, show_context_and_time=False))
143
155
 
144
- return Padding(Group(*renderables), (0, 0, 0, 1))
156
+ return Group(*renderables)
145
157
 
146
158
 
147
159
  def render_welcome(e: events.WelcomeEvent, *, box_style: Box | None = None) -> RenderableType:
@@ -1,6 +1,8 @@
1
1
  import json
2
+ from typing import Any, cast
2
3
 
3
4
  from rich.console import Group, RenderableType
5
+ from rich.json import JSON
4
6
  from rich.panel import Panel
5
7
  from rich.style import Style
6
8
  from rich.text import Text
@@ -12,20 +14,71 @@ from klaude_code.ui.rich.markdown import NoInsetMarkdown
12
14
  from klaude_code.ui.rich.theme import ThemeKey
13
15
 
14
16
 
17
+ def _compact_schema_value(value: dict[str, Any]) -> str | list[Any] | dict[str, Any]:
18
+ """Convert a JSON Schema value to compact representation."""
19
+ value_type = value.get("type", "any").lower()
20
+ desc = value.get("description", "")
21
+
22
+ if value_type == "object":
23
+ props = value.get("properties", {})
24
+ return {k: _compact_schema_value(v) for k, v in props.items()}
25
+ elif value_type == "array":
26
+ items = value.get("items", {})
27
+ # If items have no description, use the array's description
28
+ if desc and not items.get("description"):
29
+ items = {**items, "description": desc}
30
+ return [_compact_schema_value(items)]
31
+ else:
32
+ if desc:
33
+ return f"{value_type} // {desc}"
34
+ return value_type
35
+
36
+
37
+ def _compact_schema(schema: dict[str, Any]) -> dict[str, Any] | list[Any] | str:
38
+ """Convert JSON Schema to compact representation for display."""
39
+ return _compact_schema_value(schema)
40
+
41
+
15
42
  def render_sub_agent_call(e: model.SubAgentState, style: Style | None = None) -> RenderableType:
16
43
  """Render sub-agent tool call header and prompt body."""
17
44
  desc = Text(
18
45
  f" {e.sub_agent_desc} ",
19
46
  style=Style(color=style.color if style else None, bold=True, reverse=True),
20
47
  )
21
- return Group(
48
+ elements: list[RenderableType] = [
22
49
  Text.assemble((e.sub_agent_type, ThemeKey.TOOL_NAME), " ", desc),
23
50
  Text(e.sub_agent_prompt, style=style or ""),
24
- )
51
+ ]
52
+ if e.output_schema:
53
+ elements.append(Text("\nExpected Output Format JSON:", style=style or ""))
54
+ compact = _compact_schema(e.output_schema)
55
+ schema_text = json.dumps(compact, ensure_ascii=False, indent=2)
56
+ elements.append(JSON(schema_text))
57
+ return Group(*elements)
25
58
 
26
59
 
27
- def render_sub_agent_result(result: str, *, code_theme: str, style: Style | None = None) -> RenderableType:
60
+ def render_sub_agent_result(
61
+ result: str, *, code_theme: str, style: Style | None = None, has_structured_output: bool = False
62
+ ) -> RenderableType:
28
63
  stripped_result = result.strip()
64
+
65
+ # Use rich JSON for structured output
66
+ if has_structured_output:
67
+ try:
68
+ return Panel.fit(
69
+ Group(
70
+ Text(
71
+ "use /export to view full output",
72
+ style=ThemeKey.TOOL_RESULT,
73
+ ),
74
+ JSON(stripped_result),
75
+ ),
76
+ border_style=ThemeKey.LINES,
77
+ )
78
+ except json.JSONDecodeError:
79
+ # Fall back to markdown if not valid JSON
80
+ pass
81
+
29
82
  lines = stripped_result.splitlines()
30
83
  if len(lines) > const.SUB_AGENT_RESULT_MAX_LINES:
31
84
  hidden_count = len(lines) - const.SUB_AGENT_RESULT_MAX_LINES
@@ -53,6 +106,7 @@ def build_sub_agent_state_from_tool_call(e: events.ToolCallEvent) -> model.SubAg
53
106
  return None
54
107
  description = profile.name
55
108
  prompt = ""
109
+ output_schema: dict[str, Any] | None = None
56
110
  if e.arguments:
57
111
  try:
58
112
  payload: dict[str, object] = json.loads(e.arguments)
@@ -64,8 +118,14 @@ def build_sub_agent_state_from_tool_call(e: events.ToolCallEvent) -> model.SubAg
64
118
  prompt_value = payload.get("prompt") or payload.get("task")
65
119
  if isinstance(prompt_value, str):
66
120
  prompt = prompt_value.strip()
121
+ # Extract output_schema if profile supports it
122
+ if profile.output_schema_arg:
123
+ schema_value = payload.get(profile.output_schema_arg)
124
+ if isinstance(schema_value, dict):
125
+ output_schema = cast(dict[str, Any], schema_value)
67
126
  return model.SubAgentState(
68
127
  sub_agent_type=profile.name,
69
128
  sub_agent_desc=description,
70
129
  sub_agent_prompt=prompt,
130
+ output_schema=output_schema,
71
131
  )
@@ -16,7 +16,7 @@ def _normalize_thinking_content(content: str) -> str:
16
16
  content.rstrip()
17
17
  .replace("**\n\n", "** \n")
18
18
  .replace("\\n\\n\n\n", "") # Weird case of Gemini 3
19
- .replace("****", "**\n\n**") # remove extra newlines after bold titles
19
+ .replace("****", "**\n\n**") # Remove extra newlines after bold titles
20
20
  )
21
21
 
22
22
 
@@ -7,7 +7,7 @@ from rich.padding import Padding
7
7
  from rich.text import Text
8
8
 
9
9
  from klaude_code import const
10
- from klaude_code.protocol import events, model
10
+ from klaude_code.protocol import events, model, tools
11
11
  from klaude_code.protocol.sub_agent import is_sub_agent_tool as _is_sub_agent_tool
12
12
  from klaude_code.ui.renderers import diffs as r_diffs
13
13
  from klaude_code.ui.renderers.common import create_grid
@@ -186,14 +186,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
186
186
  try:
187
187
  json_dict = json.loads(arguments)
188
188
  file_path = json_dict.get("file_path")
189
- op_label = "Create"
190
- if isinstance(file_path, str):
191
- abs_path = Path(file_path)
192
- if not abs_path.is_absolute():
193
- abs_path = (Path().cwd() / abs_path).resolve()
194
- if abs_path.exists():
195
- op_label = "Overwrite"
196
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", (op_label, ThemeKey.TOOL_NAME))
189
+ tool_name_column = Text.assemble(("", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
197
190
  arguments_column = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
198
191
  except json.JSONDecodeError:
199
192
  tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
@@ -435,35 +428,29 @@ def get_truncation_info(tr: events.ToolResultEvent) -> model.TruncationUIExtra |
435
428
  return _extract_truncation(tr.ui_extra)
436
429
 
437
430
 
438
- # Tool name to mark mapping
439
- _TOOL_MARKS: dict[str, str] = {
440
- "Read": "",
441
- "Edit": "",
442
- "Write": "→",
443
- "MultiEdit": "→",
444
- "Bash": ">",
445
- "apply_patch": "→",
446
- "TodoWrite": "◎",
447
- "update_plan": "◎",
448
- "Mermaid": "⧉",
449
- "Memory": "★",
450
- "Skill": "◈",
451
- }
431
+ def render_report_back_tool_call() -> RenderableType:
432
+ grid = create_grid()
433
+ tool_name_column = Text.assemble(("", ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
434
+ grid.add_row(tool_name_column, "")
435
+ return grid
436
+
452
437
 
453
438
  # Tool name to active form mapping (for spinner status)
454
439
  _TOOL_ACTIVE_FORM: dict[str, str] = {
455
- "Bash": "Bashing",
456
- "apply_patch": "Patching",
457
- "Edit": "Editing",
458
- "MultiEdit": "Editing",
459
- "Read": "Reading",
460
- "Write": "Writing",
461
- "TodoWrite": "Planning",
462
- "update_plan": "Planning",
463
- "Skill": "Skilling",
464
- "Mermaid": "Diagramming",
465
- "Memory": "Memorizing",
466
- "WebFetch": "Fetching",
440
+ tools.BASH: "Bashing",
441
+ tools.APPLY_PATCH: "Patching",
442
+ tools.EDIT: "Editing",
443
+ tools.MULTI_EDIT: "Editing",
444
+ tools.READ: "Reading",
445
+ tools.WRITE: "Writing",
446
+ tools.TODO_WRITE: "Planning",
447
+ tools.UPDATE_PLAN: "Planning",
448
+ tools.SKILL: "Skilling",
449
+ tools.MERMAID: "Diagramming",
450
+ tools.MEMORY: "Memorizing",
451
+ tools.WEB_FETCH: "Fetching Web",
452
+ tools.WEB_SEARCH: "Searching Web",
453
+ tools.REPORT_BACK: "Reporting",
467
454
  }
468
455
 
469
456
 
@@ -490,7 +477,6 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
490
477
 
491
478
  Returns a Rich Renderable or None if the tool call should not be rendered.
492
479
  """
493
- from klaude_code.protocol import tools
494
480
 
495
481
  if is_sub_agent_tool(e.tool_name):
496
482
  return None
@@ -518,6 +504,8 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
518
504
  return render_memory_tool_call(e.arguments)
519
505
  case tools.SKILL:
520
506
  return render_generic_tool_call(e.tool_name, e.arguments, "◈")
507
+ case tools.REPORT_BACK:
508
+ return render_report_back_tool_call()
521
509
  case _:
522
510
  return render_generic_tool_call(e.tool_name, e.arguments)
523
511
 
@@ -533,7 +521,6 @@ def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
533
521
 
534
522
  Returns a Rich Renderable or None if the tool result should not be rendered.
535
523
  """
536
- from klaude_code.protocol import tools
537
524
  from klaude_code.ui.renderers import errors as r_errors
538
525
 
539
526
  if is_sub_agent_tool(e.tool_name):
@@ -32,9 +32,9 @@ class NoInsetCodeBlock(CodeBlock):
32
32
  self.lexer_name,
33
33
  theme=self.theme,
34
34
  word_wrap=True,
35
- padding=(0, 1),
35
+ padding=(0, 0),
36
36
  )
37
- yield Panel.fit(syntax, padding=(0, 0), style="markdown.code.block", box=box.SIMPLE)
37
+ yield Panel.fit(syntax, padding=(0, 0), box=box.HORIZONTALS, border_style="markdown.code.border")
38
38
 
39
39
 
40
40
  class ThinkingCodeBlock(CodeBlock):
@@ -42,7 +42,7 @@ class ThinkingCodeBlock(CodeBlock):
42
42
 
43
43
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
44
44
  code = str(self.text).rstrip()
45
- text = Text("```\n" + code + "\n```")
45
+ text = Text(code, "markdown.code.block")
46
46
  yield text
47
47
 
48
48
 
@@ -52,7 +52,10 @@ class LeftHeading(Heading):
52
52
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
53
53
  text = self.text
54
54
  text.justify = "left" # Override justification
55
- if self.tag == "h2":
55
+ if self.tag == "h1":
56
+ h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
57
+ yield h1_text
58
+ elif self.tag == "h2":
56
59
  text.stylize(Style(bold=True, underline=False))
57
60
  yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
58
61
  else:
@@ -123,10 +126,6 @@ class MarkdownStream:
123
126
  self.when: float = 0.0 # Timestamp of last update
124
127
  self.min_delay: float = 1.0 / 20 # Minimum time between updates (20fps)
125
128
  self.live_window: int = const.MARKDOWN_STREAM_LIVE_WINDOW
126
- # Track the maximum height the live window has ever reached
127
- # so we only pad when it shrinks from a previous height,
128
- # instead of always padding to live_window from the start.
129
- self._live_window_seen_height: int = 0
130
129
 
131
130
  self.theme = theme
132
131
  self.console = console
@@ -221,18 +220,16 @@ class MarkdownStream:
221
220
  Markdown going to the console works better in terminal scrollback buffers.
222
221
  The live window doesn't play nice with terminal scrollback.
223
222
  """
224
- # On the first call, start the Live renderer
225
223
  if not self._live_started:
226
224
  initial_content = self._live_renderable(Text(""), final=False)
225
+ # transient=False keeps final frame on screen after stop()
227
226
  self.live = Live(
228
227
  initial_content,
229
228
  refresh_per_second=1.0 / self.min_delay,
230
229
  console=self.console,
231
230
  )
232
231
  self.live.start()
233
- # Note: self._live_started is now a property derived from self.live
234
232
 
235
- # If live rendering isn't available (e.g., after a final update), stop.
236
233
  if self.live is None:
237
234
  return
238
235
 
@@ -252,61 +249,36 @@ class MarkdownStream:
252
249
 
253
250
  num_lines = len(lines)
254
251
 
255
- # How many lines have "left" the live window and are now considered stable?
256
- # Or if final, consider all lines to be stable.
257
- if not final:
258
- num_lines = max(num_lines - self.live_window, 0)
252
+ # Reserve last live_window lines for Live area to keep height stable
253
+ num_lines = max(num_lines - self.live_window, 0)
259
254
 
260
- # If there is new stable content, append only the new part
261
- # Update Live window to prevent visual duplication
262
- if final or num_lines > 0:
263
- # Lines to append to stable area
255
+ # Print new stable lines above Live window
256
+ if num_lines > 0:
264
257
  num_printed = len(self.printed)
265
258
  to_append_count = num_lines - num_printed
266
259
 
267
260
  if to_append_count > 0:
268
- # Print new stable lines above Live window
269
261
  append_chunk = lines[num_printed:num_lines]
270
262
  append_chunk_text = Text.from_ansi("".join(append_chunk))
271
263
  live = self.live
272
264
  assert live is not None
273
- live.console.print(append_chunk_text) # Print above Live area
274
-
275
- # Track printed stable lines
265
+ live.console.print(append_chunk_text)
276
266
  self.printed = lines[:num_lines]
277
267
 
278
- # Handle final update cleanup
268
+ rest_lines = lines[num_lines:]
269
+
270
+ # Final: render remaining lines without spinner, then stop Live
279
271
  if final:
280
272
  live = self.live
281
273
  assert live is not None
282
- live.update(Text(""))
274
+ rest = "".join(rest_lines)
275
+ rest_text = Text.from_ansi(rest)
276
+ final_renderable = self._live_renderable(rest_text, final=True)
277
+ live.update(final_renderable)
283
278
  live.stop()
284
279
  self.live = None
285
280
  return
286
281
 
287
- # Update Live window to prevent timing issues
288
- # with console.print above. We pad the live region
289
- # so that its height stays stable when it shrinks
290
- # from a previously reached height, avoiding spinner jitter.
291
- rest_lines = lines[num_lines:]
292
-
293
- if not final:
294
- current_height = len(rest_lines)
295
-
296
- # Update the maximum height we've seen so far for this live window.
297
- if current_height > self._live_window_seen_height:
298
- # Never exceed configured live_window, even if logic changes later.
299
- self._live_window_seen_height = min(current_height, self.live_window)
300
-
301
- target_height = min(self._live_window_seen_height, self.live_window)
302
- if target_height > 0 and current_height < target_height:
303
- # Pad only up to the maximum height we've seen so far.
304
- # This keeps the Live region height stable without overshooting,
305
- # which can cause the spinner to jump by a line.
306
- pad_count = target_height - current_height
307
- # Pad after the existing lines so spinner visually stays at the bottom.
308
- rest_lines = rest_lines + ["\n"] * pad_count
309
-
310
282
  rest = "".join(rest_lines)
311
283
  rest = Text.from_ansi(rest)
312
284
  live = self.live