klaude-code 1.9.0__py3-none-any.whl → 2.0.1__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 (132) hide show
  1. klaude_code/auth/base.py +2 -6
  2. klaude_code/cli/auth_cmd.py +4 -4
  3. klaude_code/cli/cost_cmd.py +1 -1
  4. klaude_code/cli/list_model.py +1 -1
  5. klaude_code/cli/main.py +1 -1
  6. klaude_code/cli/runtime.py +7 -5
  7. klaude_code/cli/self_update.py +1 -1
  8. klaude_code/cli/session_cmd.py +1 -1
  9. klaude_code/command/clear_cmd.py +6 -2
  10. klaude_code/command/command_abc.py +2 -2
  11. klaude_code/command/debug_cmd.py +4 -4
  12. klaude_code/command/export_cmd.py +2 -2
  13. klaude_code/command/export_online_cmd.py +12 -12
  14. klaude_code/command/fork_session_cmd.py +29 -23
  15. klaude_code/command/help_cmd.py +4 -4
  16. klaude_code/command/model_cmd.py +4 -4
  17. klaude_code/command/model_select.py +1 -1
  18. klaude_code/command/prompt-commit.md +11 -2
  19. klaude_code/command/prompt_command.py +3 -3
  20. klaude_code/command/refresh_cmd.py +2 -2
  21. klaude_code/command/registry.py +7 -5
  22. klaude_code/command/release_notes_cmd.py +4 -4
  23. klaude_code/command/resume_cmd.py +15 -11
  24. klaude_code/command/status_cmd.py +4 -4
  25. klaude_code/command/terminal_setup_cmd.py +8 -8
  26. klaude_code/command/thinking_cmd.py +4 -4
  27. klaude_code/config/assets/builtin_config.yaml +20 -0
  28. klaude_code/config/builtin_config.py +16 -5
  29. klaude_code/config/config.py +7 -2
  30. klaude_code/const.py +147 -91
  31. klaude_code/core/agent.py +3 -12
  32. klaude_code/core/executor.py +18 -39
  33. klaude_code/core/manager/sub_agent_manager.py +71 -7
  34. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  35. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  36. klaude_code/core/reminders.py +88 -69
  37. klaude_code/core/task.py +44 -45
  38. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  39. klaude_code/core/tool/file/diff_builder.py +3 -5
  40. klaude_code/core/tool/file/edit_tool.py +23 -23
  41. klaude_code/core/tool/file/move_tool.py +43 -43
  42. klaude_code/core/tool/file/read_tool.py +44 -39
  43. klaude_code/core/tool/file/write_tool.py +14 -14
  44. klaude_code/core/tool/report_back_tool.py +4 -4
  45. klaude_code/core/tool/shell/bash_tool.py +23 -23
  46. klaude_code/core/tool/skill/skill_tool.py +7 -7
  47. klaude_code/core/tool/sub_agent_tool.py +38 -9
  48. klaude_code/core/tool/todo/todo_write_tool.py +9 -10
  49. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  50. klaude_code/core/tool/tool_abc.py +2 -2
  51. klaude_code/core/tool/tool_context.py +27 -0
  52. klaude_code/core/tool/tool_runner.py +88 -42
  53. klaude_code/core/tool/truncation.py +38 -20
  54. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  55. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  56. klaude_code/core/tool/web/web_search_tool.py +15 -17
  57. klaude_code/core/turn.py +120 -73
  58. klaude_code/llm/anthropic/client.py +79 -44
  59. klaude_code/llm/anthropic/input.py +116 -108
  60. klaude_code/llm/bedrock/client.py +8 -5
  61. klaude_code/llm/claude/client.py +18 -8
  62. klaude_code/llm/client.py +4 -3
  63. klaude_code/llm/codex/client.py +15 -9
  64. klaude_code/llm/google/client.py +122 -60
  65. klaude_code/llm/google/input.py +94 -108
  66. klaude_code/llm/image.py +123 -0
  67. klaude_code/llm/input_common.py +136 -189
  68. klaude_code/llm/openai_compatible/client.py +17 -7
  69. klaude_code/llm/openai_compatible/input.py +36 -66
  70. klaude_code/llm/openai_compatible/stream.py +119 -67
  71. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  72. klaude_code/llm/openrouter/client.py +34 -9
  73. klaude_code/llm/openrouter/input.py +63 -64
  74. klaude_code/llm/openrouter/reasoning.py +22 -24
  75. klaude_code/llm/registry.py +20 -17
  76. klaude_code/llm/responses/client.py +107 -45
  77. klaude_code/llm/responses/input.py +115 -98
  78. klaude_code/llm/usage.py +52 -25
  79. klaude_code/protocol/__init__.py +1 -0
  80. klaude_code/protocol/events.py +16 -12
  81. klaude_code/protocol/llm_param.py +20 -2
  82. klaude_code/protocol/message.py +250 -0
  83. klaude_code/protocol/model.py +95 -285
  84. klaude_code/protocol/op.py +2 -15
  85. klaude_code/protocol/op_handler.py +0 -5
  86. klaude_code/protocol/sub_agent/__init__.py +1 -0
  87. klaude_code/protocol/sub_agent/explore.py +10 -0
  88. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  89. klaude_code/protocol/sub_agent/task.py +10 -0
  90. klaude_code/protocol/sub_agent/web.py +10 -0
  91. klaude_code/session/codec.py +6 -6
  92. klaude_code/session/export.py +261 -62
  93. klaude_code/session/selector.py +7 -24
  94. klaude_code/session/session.py +126 -54
  95. klaude_code/session/store.py +5 -32
  96. klaude_code/session/templates/export_session.html +1 -1
  97. klaude_code/session/templates/mermaid_viewer.html +1 -1
  98. klaude_code/trace/log.py +11 -6
  99. klaude_code/ui/core/input.py +1 -1
  100. klaude_code/ui/core/stage_manager.py +1 -8
  101. klaude_code/ui/modes/debug/display.py +2 -2
  102. klaude_code/ui/modes/repl/clipboard.py +2 -2
  103. klaude_code/ui/modes/repl/completers.py +18 -10
  104. klaude_code/ui/modes/repl/event_handler.py +138 -132
  105. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  106. klaude_code/ui/modes/repl/key_bindings.py +136 -2
  107. klaude_code/ui/modes/repl/renderer.py +107 -15
  108. klaude_code/ui/renderers/assistant.py +2 -2
  109. klaude_code/ui/renderers/bash_syntax.py +36 -4
  110. klaude_code/ui/renderers/common.py +70 -10
  111. klaude_code/ui/renderers/developer.py +7 -6
  112. klaude_code/ui/renderers/diffs.py +11 -11
  113. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  114. klaude_code/ui/renderers/metadata.py +33 -5
  115. klaude_code/ui/renderers/sub_agent.py +57 -16
  116. klaude_code/ui/renderers/thinking.py +37 -2
  117. klaude_code/ui/renderers/tools.py +188 -178
  118. klaude_code/ui/rich/live.py +3 -1
  119. klaude_code/ui/rich/markdown.py +39 -7
  120. klaude_code/ui/rich/quote.py +76 -1
  121. klaude_code/ui/rich/status.py +14 -8
  122. klaude_code/ui/rich/theme.py +20 -14
  123. klaude_code/ui/terminal/image.py +34 -0
  124. klaude_code/ui/terminal/notifier.py +2 -1
  125. klaude_code/ui/terminal/progress_bar.py +4 -4
  126. klaude_code/ui/terminal/selector.py +22 -4
  127. klaude_code/ui/utils/common.py +11 -2
  128. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/METADATA +4 -2
  129. klaude_code-2.0.1.dist-info/RECORD +229 -0
  130. klaude_code-1.9.0.dist-info/RECORD +0 -224
  131. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/WHEEL +0 -0
  132. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -12,17 +12,22 @@ from rich.spinner import Spinner
12
12
  from rich.style import Style, StyleType
13
13
  from rich.text import Text
14
14
 
15
- from klaude_code import const
16
- from klaude_code.protocol import events, model
15
+ from klaude_code.const import (
16
+ MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
17
+ STATUS_DEFAULT_TEXT,
18
+ STREAM_MAX_HEIGHT_SHRINK_RESET_LINES,
19
+ )
20
+ from klaude_code.protocol import events, model, tools
17
21
  from klaude_code.ui.renderers import assistant as r_assistant
18
22
  from klaude_code.ui.renderers import developer as r_developer
19
23
  from klaude_code.ui.renderers import errors as r_errors
24
+ from klaude_code.ui.renderers import mermaid_viewer as r_mermaid_viewer
20
25
  from klaude_code.ui.renderers import metadata as r_metadata
21
26
  from klaude_code.ui.renderers import sub_agent as r_sub_agent
22
27
  from klaude_code.ui.renderers import thinking as r_thinking
23
28
  from klaude_code.ui.renderers import tools as r_tools
24
29
  from klaude_code.ui.renderers import user_input as r_user_input
25
- from klaude_code.ui.renderers.common import truncate_display
30
+ from klaude_code.ui.renderers.common import truncate_head, truncate_middle
26
31
  from klaude_code.ui.rich import status as r_status
27
32
  from klaude_code.ui.rich.live import CropAboveLive, SingleLine
28
33
  from klaude_code.ui.rich.quote import Quote
@@ -51,7 +56,7 @@ class REPLRenderer:
51
56
  self._stream_last_width: int = 0
52
57
  self._spinner_visible: bool = False
53
58
 
54
- self._status_text: ShimmerStatusText = ShimmerStatusText(const.STATUS_DEFAULT_TEXT)
59
+ self._status_text: ShimmerStatusText = ShimmerStatusText(STATUS_DEFAULT_TEXT)
55
60
  self._status_spinner: Spinner = BreathingSpinner(
56
61
  r_status.spinner_name(),
57
62
  text=SingleLine(self._status_text),
@@ -75,6 +80,13 @@ class REPLRenderer:
75
80
  def is_sub_agent_session(self, session_id: str) -> bool:
76
81
  return session_id in self.session_map and self.session_map[session_id].sub_agent_state is not None
77
82
 
83
+ def should_display_sub_agent_thinking_header(self, session_id: str) -> bool:
84
+ # Hardcoded: only show sub-agent thinking headers for ImageGen.
85
+ status = self.session_map.get(session_id)
86
+ if status is None or status.sub_agent_state is None:
87
+ return False
88
+ return status.sub_agent_state.sub_agent_type == "ImageGen"
89
+
78
90
  def _advance_sub_agent_color_index(self) -> None:
79
91
  palette_size = len(self.themes.sub_agent_colors)
80
92
  if palette_size == 0:
@@ -127,10 +139,26 @@ class REPLRenderer:
127
139
  if renderable is not None:
128
140
  self.print(renderable)
129
141
 
130
- def display_tool_call_result(self, e: events.ToolResultEvent) -> None:
142
+ def display_tool_call_result(self, e: events.ToolResultEvent, *, is_sub_agent: bool = False) -> None:
131
143
  if r_tools.is_sub_agent_tool(e.tool_name):
132
144
  return
133
- renderable = r_tools.render_tool_result(e, code_theme=self.themes.code_theme)
145
+ # Sub-agent errors: show only first 2 lines
146
+ if is_sub_agent and e.status == "error":
147
+ error_msg = truncate_head(e.result)
148
+ self.print(r_errors.render_tool_error(error_msg))
149
+ return
150
+ if not is_sub_agent and e.tool_name == tools.MERMAID and isinstance(e.ui_extra, model.MermaidLinkUIExtra):
151
+ image_path = r_mermaid_viewer.download_mermaid_png(
152
+ link=e.ui_extra.link,
153
+ tool_call_id=e.tool_call_id,
154
+ session_id=e.session_id,
155
+ )
156
+ if image_path is not None:
157
+ self.display_image(str(image_path), height=None)
158
+
159
+ renderable = r_tools.render_tool_result(e, code_theme=self.themes.code_theme, session_id=e.session_id)
160
+ else:
161
+ renderable = r_tools.render_tool_result(e, code_theme=self.themes.code_theme, session_id=e.session_id)
134
162
  if renderable is not None:
135
163
  self.print(renderable)
136
164
 
@@ -146,8 +174,26 @@ class REPLRenderer:
146
174
  self.console.pop_theme()
147
175
  self.print()
148
176
 
177
+ def display_thinking_header(self, header: str) -> None:
178
+ """Display a single thinking header line.
179
+
180
+ Used by sub-agent sessions to avoid verbose thinking streaming.
181
+ """
182
+
183
+ stripped = header.strip()
184
+ if not stripped:
185
+ return
186
+ self.print(
187
+ Text.assemble(
188
+ (r_thinking.THINKING_MESSAGE_MARK, ThemeKey.THINKING),
189
+ " ",
190
+ (stripped, ThemeKey.THINKING_BOLD),
191
+ )
192
+ )
193
+
149
194
  async def replay_history(self, history_events: events.ReplayHistoryEvent) -> None:
150
195
  tool_call_dict: dict[str, events.ToolCallEvent] = {}
196
+ self.print()
151
197
  for event in history_events.events:
152
198
  event_session_id = getattr(event, "session_id", history_events.session_id)
153
199
  is_sub_agent = self.is_sub_agent_session(event_session_id)
@@ -158,17 +204,23 @@ class REPLRenderer:
158
204
  self.display_task_start(e)
159
205
  case events.TurnStartEvent():
160
206
  self.print()
207
+ case events.AssistantImageDeltaEvent() as e:
208
+ self.display_image(e.file_path)
161
209
  case events.AssistantMessageEvent() as e:
162
210
  if is_sub_agent:
211
+ if self.should_display_sub_agent_thinking_header(event_session_id) and e.thinking_text:
212
+ header = r_thinking.extract_last_bold_header(
213
+ r_thinking.normalize_thinking_content(e.thinking_text)
214
+ )
215
+ if header:
216
+ self.display_thinking_header(header)
163
217
  continue
218
+ if e.thinking_text:
219
+ self.display_thinking(e.thinking_text)
164
220
  renderable = r_assistant.render_assistant_message(e.content, code_theme=self.themes.code_theme)
165
221
  if renderable is not None:
166
222
  self.print(renderable)
167
223
  self.print()
168
- case events.ThinkingEvent() as e:
169
- if is_sub_agent:
170
- continue
171
- self.display_thinking(e.content)
172
224
  case events.DeveloperMessageEvent() as e:
173
225
  self.display_developer_message(e)
174
226
  self.display_command_output(e)
@@ -187,6 +239,7 @@ class REPLRenderer:
187
239
  continue
188
240
  self.display_tool_call_result(e)
189
241
  case events.TaskMetadataEvent() as e:
242
+ self.print()
190
243
  self.print(r_metadata.render_task_metadata(e))
191
244
  self.print()
192
245
  case events.InterruptEvent():
@@ -237,6 +290,37 @@ class REPLRenderer:
237
290
  self.print(renderable)
238
291
  self.print()
239
292
 
293
+ def display_image(self, file_path: str, height: int | None = 40) -> None:
294
+ """Display an image in the terminal.
295
+
296
+ Args:
297
+ file_path: Path to the image file.
298
+ height: Height in terminal lines for displaying the image.
299
+ """
300
+ from klaude_code.ui.terminal.image import print_kitty_image
301
+
302
+ # Suspend the Live status bar while emitting raw terminal output to avoid
303
+ # interleaving refreshes with Kitty graphics escape sequences.
304
+ had_live = self._bottom_live is not None
305
+ was_spinner_visible = self._spinner_visible
306
+ has_stream = MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._stream_renderable is not None
307
+ resume_live = had_live and (was_spinner_visible or has_stream)
308
+
309
+ if self._bottom_live is not None:
310
+ with contextlib.suppress(Exception):
311
+ self._bottom_live.stop()
312
+ self._bottom_live = None
313
+
314
+ try:
315
+ print_kitty_image(file_path, height=height, file=self.console.file)
316
+ finally:
317
+ if resume_live:
318
+ if was_spinner_visible:
319
+ self.spinner_start()
320
+ else:
321
+ self._ensure_bottom_live_started()
322
+ self._refresh_bottom_live()
323
+
240
324
  def display_task_metadata(self, event: events.TaskMetadataEvent) -> None:
241
325
  with self.session_print_context(event.session_id):
242
326
  self.print(r_metadata.render_task_metadata(event))
@@ -268,9 +352,9 @@ class REPLRenderer:
268
352
  def display_error(self, event: events.ErrorEvent) -> None:
269
353
  if event.session_id:
270
354
  with self.session_print_context(event.session_id):
271
- self.print(r_errors.render_error(truncate_display(event.error_message)))
355
+ self.print(r_errors.render_error(truncate_middle(event.error_message)))
272
356
  else:
273
- self.print(r_errors.render_error(truncate_display(event.error_message)))
357
+ self.print(r_errors.render_error(truncate_middle(event.error_message)))
274
358
 
275
359
  # -------------------------------------------------------------------------
276
360
  # Spinner control methods
@@ -314,7 +398,11 @@ class REPLRenderer:
314
398
  height = len(self.console.render_lines(renderable, self.console.options, pad=False))
315
399
  self._stream_last_height = height
316
400
  self._stream_last_width = self.console.size.width
317
- self._stream_max_height = max(self._stream_max_height, height)
401
+
402
+ if self._stream_max_height - height > STREAM_MAX_HEIGHT_SHRINK_RESET_LINES:
403
+ self._stream_max_height = height
404
+ else:
405
+ self._stream_max_height = max(self._stream_max_height, height)
318
406
  self._refresh_bottom_live()
319
407
 
320
408
  def _ensure_bottom_live_started(self) -> None:
@@ -334,7 +422,7 @@ class REPLRenderer:
334
422
  stream_part: RenderableType = Group()
335
423
  gap_part: RenderableType = Group()
336
424
 
337
- if const.MARKDOWN_STREAM_LIVE_REPAINT_ENABLED:
425
+ if MARKDOWN_STREAM_LIVE_REPAINT_ENABLED:
338
426
  stream = self._stream_renderable
339
427
  if stream is not None:
340
428
  current_width = self.console.size.width
@@ -342,7 +430,11 @@ class REPLRenderer:
342
430
  height = len(self.console.render_lines(stream, self.console.options, pad=False))
343
431
  self._stream_last_height = height
344
432
  self._stream_last_width = current_width
345
- self._stream_max_height = max(self._stream_max_height, height)
433
+
434
+ if self._stream_max_height - height > STREAM_MAX_HEIGHT_SHRINK_RESET_LINES:
435
+ self._stream_max_height = height
436
+ else:
437
+ self._stream_max_height = max(self._stream_max_height, height)
346
438
  else:
347
439
  height = self._stream_last_height
348
440
 
@@ -2,7 +2,7 @@ from rich.console import RenderableType
2
2
  from rich.padding import Padding
3
3
  from rich.text import Text
4
4
 
5
- from klaude_code import const
5
+ from klaude_code.const import MARKDOWN_RIGHT_MARGIN
6
6
  from klaude_code.ui.renderers.common import create_grid
7
7
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
8
8
  from klaude_code.ui.rich.theme import ThemeKey
@@ -23,6 +23,6 @@ def render_assistant_message(content: str, *, code_theme: str) -> RenderableType
23
23
  grid = create_grid()
24
24
  grid.add_row(
25
25
  Text(ASSISTANT_MESSAGE_MARK, style=ThemeKey.ASSISTANT_MESSAGE_MARK),
26
- Padding(NoInsetMarkdown(stripped, code_theme=code_theme), (0, const.MARKDOWN_RIGHT_MARGIN, 0, 0)),
26
+ Padding(NoInsetMarkdown(stripped, code_theme=code_theme), (0, MARKDOWN_RIGHT_MARGIN, 0, 0)),
27
27
  )
28
28
  return grid
@@ -7,6 +7,8 @@ from pygments.lexers import BashLexer # pyright: ignore[reportUnknownVariableTy
7
7
  from pygments.token import Token
8
8
  from rich.text import Text
9
9
 
10
+ from klaude_code.const import BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES
11
+ from klaude_code.ui.renderers.common import truncate_head
10
12
  from klaude_code.ui.rich.theme import ThemeKey
11
13
 
12
14
  # Token types for bash syntax highlighting
@@ -110,13 +112,34 @@ def _append_heredoc(result: Text, token_value: str) -> None:
110
112
  # Extra content on first line (e.g., "> file.py")
111
113
  if extra:
112
114
  result.append(extra, style=ThemeKey.BASH_ARGUMENT)
113
- # Body content
114
- result.append(body, style=ThemeKey.BASH_STRING)
115
+
116
+ # Body content (truncate to keep tool call rendering compact)
117
+ body_inner = body.strip("\n")
118
+ result.append("\n")
119
+ if body_inner:
120
+ body_text = truncate_head(
121
+ body_inner,
122
+ max_lines=BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES,
123
+ base_style=ThemeKey.BASH_STRING,
124
+ truncated_style=ThemeKey.TOOL_RESULT_TRUNCATED,
125
+ )
126
+ result.append_text(body_text)
127
+ result.append("\n")
128
+
115
129
  # End delimiter
116
130
  result.append(end_delimiter, style=ThemeKey.BASH_HEREDOC_DELIMITER)
117
131
  else:
118
132
  # Fallback: couldn't parse heredoc structure
119
- result.append(token_value, style=ThemeKey.BASH_STRING)
133
+ if "\n" in token_value and len(token_value.splitlines()) > BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES:
134
+ truncated = truncate_head(
135
+ token_value,
136
+ max_lines=BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES,
137
+ base_style=ThemeKey.BASH_STRING,
138
+ truncated_style=ThemeKey.TOOL_RESULT_TRUNCATED,
139
+ )
140
+ result.append_text(truncated)
141
+ else:
142
+ result.append(token_value, style=ThemeKey.BASH_STRING)
120
143
 
121
144
 
122
145
  def highlight_bash_command(command: str) -> Text:
@@ -145,7 +168,16 @@ def highlight_bash_command(command: str) -> Text:
145
168
  if token_value.startswith("<<"):
146
169
  _append_heredoc(result, token_value)
147
170
  else:
148
- result.append(token_value, style=ThemeKey.BASH_STRING)
171
+ if "\n" in token_value and len(token_value.splitlines()) > BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES:
172
+ truncated = truncate_head(
173
+ token_value,
174
+ max_lines=BASH_MULTILINE_STRING_TRUNCATE_MAX_LINES,
175
+ base_style=ThemeKey.BASH_STRING,
176
+ truncated_style=ThemeKey.TOOL_RESULT_TRUNCATED,
177
+ )
178
+ result.append_text(truncated)
179
+ else:
180
+ result.append(token_value, style=ThemeKey.BASH_STRING)
149
181
  expect_subcommand = False
150
182
  elif token_type in _OPERATOR_TOKENS:
151
183
  result.append(token_value, style=ThemeKey.BASH_OPERATOR)
@@ -1,22 +1,30 @@
1
+ from typing import Literal
2
+
1
3
  from rich.style import Style
2
4
  from rich.table import Table
3
5
  from rich.text import Text
4
6
 
5
- from klaude_code import const
7
+ from klaude_code.const import (
8
+ MIN_HIDDEN_LINES_FOR_INDICATOR,
9
+ TAB_EXPAND_WIDTH,
10
+ TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
11
+ TRUNCATE_DISPLAY_MAX_LINES,
12
+ TRUNCATE_HEAD_MAX_LINES,
13
+ )
6
14
  from klaude_code.ui.rich.theme import ThemeKey
7
15
 
8
16
 
9
- def create_grid() -> Table:
17
+ def create_grid(*, overflow: Literal["fold", "crop", "ellipsis", "ignore"] = "fold") -> Table:
10
18
  grid = Table.grid(padding=(0, 1))
11
19
  grid.add_column(no_wrap=True)
12
- grid.add_column(overflow="fold")
20
+ grid.add_column(overflow=overflow)
13
21
  return grid
14
22
 
15
23
 
16
- def truncate_display(
24
+ def truncate_middle(
17
25
  text: str,
18
- max_lines: int = const.TRUNCATE_DISPLAY_MAX_LINES,
19
- max_line_length: int = const.TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
26
+ max_lines: int = TRUNCATE_DISPLAY_MAX_LINES,
27
+ max_line_length: int = TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
20
28
  *,
21
29
  base_style: str | Style | None = None,
22
30
  ) -> Text:
@@ -25,12 +33,12 @@ def truncate_display(
25
33
  Applies `ThemeKey.TOOL_RESULT_TRUNCATED` style to truncation indicators.
26
34
  """
27
35
  # Expand tabs to spaces to ensure correct alignment when Rich applies padding.
28
- text = text.expandtabs(8)
36
+ text = text.expandtabs(TAB_EXPAND_WIDTH)
29
37
 
30
38
  if max_lines <= 0:
31
39
  truncated_lines = text.split("\n")
32
40
  remaining = max(0, len(truncated_lines))
33
- return Text(f"… (more {remaining} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED)
41
+ return Text(f" … (more {remaining} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED)
34
42
 
35
43
  lines = text.split("\n")
36
44
  truncated_lines = 0
@@ -42,7 +50,7 @@ def truncate_display(
42
50
 
43
51
  # If the hidden section is too small, show everything instead of inserting
44
52
  # the "(more N lines)" indicator.
45
- if truncated_lines < 5:
53
+ if truncated_lines < MIN_HIDDEN_LINES_FOR_INDICATOR:
46
54
  truncated_lines = 0
47
55
  head_lines = lines
48
56
  else:
@@ -76,7 +84,7 @@ def truncate_display(
76
84
  out.append("\n")
77
85
 
78
86
  if truncated_lines > 0:
79
- out.append_text(Text(f" (more {truncated_lines} lines)\n", style=ThemeKey.TOOL_RESULT_TRUNCATED))
87
+ out.append_text(Text(f" (more {truncated_lines} lines)\n", style=ThemeKey.TOOL_RESULT_TRUNCATED))
80
88
 
81
89
  for idx, line in enumerate(tail_lines):
82
90
  append_line(out, line)
@@ -84,3 +92,55 @@ def truncate_display(
84
92
  out.append("\n")
85
93
 
86
94
  return out
95
+
96
+
97
+ def truncate_head(
98
+ text: str,
99
+ max_lines: int = TRUNCATE_HEAD_MAX_LINES,
100
+ max_line_length: int = TRUNCATE_DISPLAY_MAX_LINE_LENGTH,
101
+ *,
102
+ base_style: str | Style | None = None,
103
+ truncated_style: str | Style | None = None,
104
+ ) -> Text:
105
+ """Truncate text to show only the first N lines."""
106
+ text = text.expandtabs(TAB_EXPAND_WIDTH)
107
+ lines = [line for line in text.split("\n") if line.strip()]
108
+
109
+ out = Text()
110
+ if base_style is not None:
111
+ out.style = base_style
112
+
113
+ if len(lines) <= max_lines:
114
+ for idx, line in enumerate(lines):
115
+ if len(line) > max_line_length:
116
+ out.append(line[:max_line_length])
117
+ out.append_text(
118
+ Text(
119
+ f" … (more {len(line) - max_line_length} characters)",
120
+ style=truncated_style or ThemeKey.TOOL_RESULT_TRUNCATED,
121
+ )
122
+ )
123
+ else:
124
+ out.append(line)
125
+ if idx < len(lines) - 1:
126
+ out.append("\n")
127
+ return out
128
+
129
+ for idx in range(max_lines):
130
+ line = lines[idx]
131
+ if len(line) > max_line_length:
132
+ out.append(line[:max_line_length])
133
+ out.append_text(
134
+ Text(
135
+ f" … (more {len(line) - max_line_length} characters)",
136
+ style=truncated_style or ThemeKey.TOOL_RESULT_TRUNCATED,
137
+ )
138
+ )
139
+ else:
140
+ out.append(line)
141
+ out.append("\n")
142
+
143
+ remaining = len(lines) - max_lines
144
+ out.append_text(Text(f" … (more {remaining} lines)", style=truncated_style or ThemeKey.TOOL_RESULT_TRUNCATED))
145
+
146
+ return out
@@ -3,8 +3,8 @@ from rich.padding import Padding
3
3
  from rich.table import Table
4
4
  from rich.text import Text
5
5
 
6
- from klaude_code.protocol import commands, events, model
7
- from klaude_code.ui.renderers.common import create_grid, truncate_display
6
+ from klaude_code.protocol import commands, events, message, model
7
+ from klaude_code.ui.renderers.common import create_grid, truncate_middle
8
8
  from klaude_code.ui.renderers.tools import render_path
9
9
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
10
10
  from klaude_code.ui.rich.theme import ThemeKey
@@ -124,19 +124,20 @@ def render_command_output(e: events.DeveloperMessageEvent) -> RenderableType:
124
124
  if not e.item.command_output:
125
125
  return Text("")
126
126
 
127
+ content = message.join_text_parts(e.item.parts)
127
128
  match e.item.command_output.command_name:
128
129
  case commands.CommandName.HELP:
129
- return Padding.indent(Text.from_markup(e.item.content or ""), level=2)
130
+ return Padding.indent(Text.from_markup(content or ""), level=2)
130
131
  case commands.CommandName.STATUS:
131
132
  return _render_status_output(e.item.command_output)
132
133
  case commands.CommandName.RELEASE_NOTES:
133
- return Padding.indent(NoInsetMarkdown(e.item.content or ""), level=2)
134
+ return Padding.indent(NoInsetMarkdown(content or ""), level=2)
134
135
  case commands.CommandName.FORK_SESSION:
135
136
  return _render_fork_session_output(e.item.command_output)
136
137
  case _:
137
- content = e.item.content or "(no content)"
138
+ content = content or "(no content)"
138
139
  style = ThemeKey.TOOL_RESULT if not e.item.command_output.is_error else ThemeKey.ERROR
139
- return Padding.indent(truncate_display(content, base_style=style), level=2)
140
+ return Padding.indent(truncate_middle(content, base_style=style), level=2)
140
141
 
141
142
 
142
143
  def _format_tokens(tokens: int) -> str:
@@ -4,7 +4,7 @@ from rich.padding import Padding
4
4
  from rich.panel import Panel
5
5
  from rich.text import Text
6
6
 
7
- from klaude_code import const
7
+ from klaude_code.const import DIFF_PREFIX_WIDTH, MAX_DIFF_LINES
8
8
  from klaude_code.protocol import model
9
9
  from klaude_code.ui.renderers.common import create_grid
10
10
  from klaude_code.ui.rich.theme import ThemeKey
@@ -65,7 +65,7 @@ def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
65
65
  elif line.strip(): # Non-empty line in untracked section
66
66
  file_text = Text(line.strip(), style=ThemeKey.TOOL_PARAM_BOLD)
67
67
  grid.add_row(
68
- Text(f"{'+':>{const.DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_PARAM_BOLD),
68
+ Text(f"{'+':>{DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_PARAM_BOLD),
69
69
  file_text,
70
70
  )
71
71
  continue
@@ -130,7 +130,7 @@ def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
130
130
  file_mark = "±"
131
131
 
132
132
  grid.add_row(
133
- Text(f"{file_mark:>{const.DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME),
133
+ Text(f"{file_mark:>{DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME),
134
134
  file_line,
135
135
  )
136
136
  has_rendered_file_header = True
@@ -151,7 +151,7 @@ def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
151
151
  except (IndexError, ValueError):
152
152
  new_ln = None
153
153
  if has_rendered_diff_content:
154
- grid.add_row(Text(f"{'⋮':>{const.DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_RESULT), "")
154
+ grid.add_row(Text(f"{'⋮':>{DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_RESULT), "")
155
155
  continue
156
156
 
157
157
  # Skip +++ lines (already handled above)
@@ -159,12 +159,12 @@ def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
159
159
  continue
160
160
 
161
161
  # Only handle unified diff hunk lines; ignore other metadata like
162
- # "diff --git" or "index ..." which would otherwise skew counters.
162
+ # "diff --git" or "index " which would otherwise skew counters.
163
163
  if not line or line[:1] not in {" ", "+", "-"}:
164
164
  continue
165
165
 
166
166
  # Compute line number prefix and style diff content
167
- prefix, new_ln = _make_diff_prefix(line, new_ln, const.DIFF_PREFIX_WIDTH)
167
+ prefix, new_ln = _make_diff_prefix(line, new_ln, DIFF_PREFIX_WIDTH)
168
168
 
169
169
  if line.startswith("-"):
170
170
  text = Text(line[1:])
@@ -197,7 +197,7 @@ def render_structured_diff(ui_extra: model.DiffUIExtra, show_file_name: bool = F
197
197
  grid.add_row(*_render_file_header(file_diff))
198
198
 
199
199
  for line in file_diff.lines:
200
- prefix = _make_structured_prefix(line, const.DIFF_PREFIX_WIDTH)
200
+ prefix = _make_structured_prefix(line, DIFF_PREFIX_WIDTH)
201
201
  text = _render_structured_line(line)
202
202
  grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), text)
203
203
 
@@ -213,9 +213,9 @@ def render_diff_panel(
213
213
  ) -> RenderableType:
214
214
  lines = diff_text.splitlines()
215
215
  truncated_notice: Text | None = None
216
- if len(lines) > const.MAX_DIFF_LINES:
217
- truncated_lines = len(lines) - const.MAX_DIFF_LINES
218
- diff_text = "\n".join(lines[: const.MAX_DIFF_LINES])
216
+ if len(lines) > MAX_DIFF_LINES:
217
+ truncated_lines = len(lines) - MAX_DIFF_LINES
218
+ diff_text = "\n".join(lines[:MAX_DIFF_LINES])
219
219
  truncated_notice = Text(f"… truncated {truncated_lines} lines", style=ThemeKey.TOOL_MARK)
220
220
 
221
221
  diff_body = render_diff(diff_text, show_file_name=show_file_name)
@@ -261,7 +261,7 @@ def _render_file_header(file_diff: model.DiffFileDiff) -> tuple[Text, Text]:
261
261
  else:
262
262
  file_mark = "±"
263
263
 
264
- prefix = Text(f"{file_mark:>{const.DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME)
264
+ prefix = Text(f"{file_mark:>{DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME)
265
265
  return prefix, file_line
266
266
 
267
267
 
@@ -5,11 +5,58 @@ import importlib.resources
5
5
  from functools import lru_cache
6
6
  from pathlib import Path
7
7
 
8
- from klaude_code import const
8
+ import httpx
9
+
10
+ from klaude_code.const import TOOL_OUTPUT_TRUNCATION_DIR
11
+ from klaude_code.llm.image import get_assistant_image_output_dir
12
+
13
+ _MERMAID_INK_PREFIX = "https://mermaid.ink/img/pako:"
14
+ _MERMAID_DEFAULT_PNG_WIDTH = 1600
15
+ _MERMAID_DEFAULT_PNG_SCALE = 2
9
16
 
10
17
 
11
18
  def artifacts_dir() -> Path:
12
- return Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "mermaid"
19
+ return Path(TOOL_OUTPUT_TRUNCATION_DIR) / "mermaid"
20
+
21
+
22
+ def _extract_pako_from_link(link: str) -> str | None:
23
+ """Extract pako encoded string from mermaid.live link."""
24
+ # link format: https://mermaid.live/view#pako:xxxx
25
+ if "#pako:" not in link:
26
+ return None
27
+ return link.split("#pako:", 1)[1]
28
+
29
+
30
+ def download_mermaid_png(
31
+ *,
32
+ link: str,
33
+ tool_call_id: str,
34
+ session_id: str | None = None,
35
+ ) -> Path | None:
36
+ """Download PNG image from mermaid.ink and save locally."""
37
+ pako = _extract_pako_from_link(link)
38
+ if not pako:
39
+ return None
40
+
41
+ safe_id = tool_call_id.replace("/", "_")
42
+ output_dir = get_assistant_image_output_dir(session_id)
43
+ output_dir.mkdir(parents=True, exist_ok=True)
44
+ image_path = output_dir / f"mermaid-{safe_id}.png"
45
+
46
+ if image_path.exists():
47
+ return image_path
48
+
49
+ png_url = (
50
+ f"{_MERMAID_INK_PREFIX}{pako}?type=png&width={_MERMAID_DEFAULT_PNG_WIDTH}&scale={_MERMAID_DEFAULT_PNG_SCALE}"
51
+ )
52
+ try:
53
+ with httpx.Client(timeout=10.0) as client:
54
+ resp = client.get(png_url)
55
+ resp.raise_for_status()
56
+ image_path.write_bytes(resp.content)
57
+ return image_path
58
+ except Exception:
59
+ return None
13
60
 
14
61
 
15
62
  @lru_cache(maxsize=1)
@@ -6,7 +6,7 @@ from rich.padding import Padding
6
6
  from rich.panel import Panel
7
7
  from rich.text import Text
8
8
 
9
- from klaude_code import const
9
+ from klaude_code.const import DEFAULT_MAX_TOKENS
10
10
  from klaude_code.protocol import events, model
11
11
  from klaude_code.trace import is_debug_enabled
12
12
  from klaude_code.ui.renderers.common import create_grid
@@ -47,7 +47,7 @@ def _render_task_metadata_block(
47
47
  # First column: mark only
48
48
  mark = Text("└", style=ThemeKey.METADATA_DIM) if is_sub_agent else Text("⇅", style=ThemeKey.METADATA)
49
49
 
50
- # Second column: model@provider / tokens / cost / ...
50
+ # Second column: model@provider / tokens / cost /
51
51
  content = Text()
52
52
  content.append_text(Text(metadata.model_name, style=ThemeKey.METADATA_BOLD))
53
53
  if metadata.provider is not None:
@@ -82,6 +82,13 @@ def _render_task_metadata_block(
82
82
  (format_number(metadata.usage.reasoning_tokens), ThemeKey.METADATA),
83
83
  )
84
84
  )
85
+ if metadata.usage.image_tokens > 0:
86
+ token_parts.append(
87
+ Text.assemble(
88
+ ("image ", ThemeKey.METADATA_DIM),
89
+ (format_number(metadata.usage.image_tokens), ThemeKey.METADATA),
90
+ )
91
+ )
85
92
  parts.append(Text(" · ").join(token_parts))
86
93
 
87
94
  # Cost
@@ -97,9 +104,7 @@ def _render_task_metadata_block(
97
104
  if show_context_and_time and metadata.usage.context_usage_percent is not None:
98
105
  context_size = format_number(metadata.usage.context_size or 0)
99
106
  # Calculate effective limit (same as Usage.context_usage_percent)
100
- effective_limit = (metadata.usage.context_limit or 0) - (
101
- metadata.usage.max_tokens or const.DEFAULT_MAX_TOKENS
102
- )
107
+ effective_limit = (metadata.usage.context_limit or 0) - (metadata.usage.max_tokens or DEFAULT_MAX_TOKENS)
103
108
  effective_limit_str = format_number(effective_limit) if effective_limit > 0 else "?"
104
109
  parts.append(
105
110
  Text.assemble(
@@ -167,6 +172,29 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
167
172
  for meta in e.metadata.sub_agent_task_metadata:
168
173
  renderables.append(_render_task_metadata_block(meta, is_sub_agent=True, show_context_and_time=False))
169
174
 
175
+ # Add total cost line when there are sub-agents
176
+ if e.metadata.sub_agent_task_metadata:
177
+ total_cost = 0.0
178
+ currency = "USD"
179
+ # Sum up costs from main agent and all sub-agents
180
+ if e.metadata.main_agent.usage and e.metadata.main_agent.usage.total_cost:
181
+ total_cost += e.metadata.main_agent.usage.total_cost
182
+ currency = e.metadata.main_agent.usage.currency
183
+ for meta in e.metadata.sub_agent_task_metadata:
184
+ if meta.usage and meta.usage.total_cost:
185
+ total_cost += meta.usage.total_cost
186
+
187
+ currency_symbol = "¥" if currency == "CNY" else "$"
188
+ total_line = Text.assemble(
189
+ ("Σ ", ThemeKey.METADATA_DIM),
190
+ ("total ", ThemeKey.METADATA_DIM),
191
+ (currency_symbol, ThemeKey.METADATA_DIM),
192
+ (f"{total_cost:.4f}", ThemeKey.METADATA_BOLD),
193
+ )
194
+ grid = create_grid()
195
+ grid.add_row(Text(" ", style=ThemeKey.METADATA_DIM), total_line)
196
+ renderables.append(Padding(grid, (0, 0, 0, 2)))
197
+
170
198
  return Group(*renderables)
171
199
 
172
200