klaude-code 1.2.27__py3-none-any.whl → 1.2.29__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 (39) hide show
  1. klaude_code/cli/config_cmd.py +13 -6
  2. klaude_code/cli/debug.py +9 -1
  3. klaude_code/cli/list_model.py +1 -1
  4. klaude_code/cli/main.py +39 -14
  5. klaude_code/cli/runtime.py +11 -5
  6. klaude_code/command/__init__.py +3 -0
  7. klaude_code/command/export_online_cmd.py +15 -12
  8. klaude_code/command/fork_session_cmd.py +42 -0
  9. klaude_code/config/__init__.py +11 -1
  10. klaude_code/config/config.py +21 -17
  11. klaude_code/config/select_model.py +1 -0
  12. klaude_code/core/executor.py +2 -1
  13. klaude_code/core/reminders.py +52 -16
  14. klaude_code/core/tool/web/mermaid_tool.md +17 -0
  15. klaude_code/core/tool/web/mermaid_tool.py +2 -2
  16. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  17. klaude_code/protocol/commands.py +1 -0
  18. klaude_code/protocol/model.py +2 -0
  19. klaude_code/session/export.py +61 -17
  20. klaude_code/session/session.py +23 -1
  21. klaude_code/session/templates/mermaid_viewer.html +926 -0
  22. klaude_code/trace/log.py +7 -1
  23. klaude_code/ui/modes/repl/__init__.py +3 -44
  24. klaude_code/ui/modes/repl/completers.py +35 -3
  25. klaude_code/ui/modes/repl/event_handler.py +9 -5
  26. klaude_code/ui/modes/repl/input_prompt_toolkit.py +32 -65
  27. klaude_code/ui/modes/repl/renderer.py +1 -6
  28. klaude_code/ui/renderers/assistant.py +4 -2
  29. klaude_code/ui/renderers/common.py +11 -4
  30. klaude_code/ui/renderers/developer.py +26 -7
  31. klaude_code/ui/renderers/errors.py +10 -5
  32. klaude_code/ui/renderers/mermaid_viewer.py +58 -0
  33. klaude_code/ui/renderers/tools.py +46 -18
  34. klaude_code/ui/rich/markdown.py +4 -4
  35. klaude_code/ui/rich/theme.py +12 -2
  36. {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/METADATA +1 -1
  37. {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/RECORD +39 -36
  38. {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/entry_points.txt +1 -0
  39. {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/WHEEL +0 -0
klaude_code/trace/log.py CHANGED
@@ -302,6 +302,12 @@ def _trash_path(path: Path) -> None:
302
302
  """Send a path to trash, falling back to unlink if trash is unavailable."""
303
303
 
304
304
  try:
305
- subprocess.run(["trash", str(path)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
305
+ subprocess.run(
306
+ ["trash", str(path)],
307
+ stdin=subprocess.DEVNULL,
308
+ stdout=subprocess.DEVNULL,
309
+ stderr=subprocess.DEVNULL,
310
+ check=False,
311
+ )
306
312
  except FileNotFoundError:
307
313
  path.unlink(missing_ok=True)
@@ -1,47 +1,6 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING
4
-
5
- from klaude_code.protocol import model
6
1
  from klaude_code.ui.modes.repl.input_prompt_toolkit import REPLStatusSnapshot
7
2
 
8
- if TYPE_CHECKING:
9
- from klaude_code.core.agent import Agent
10
-
11
-
12
- def build_repl_status_snapshot(agent: Agent | None, update_message: str | None) -> REPLStatusSnapshot:
13
- """Build a status snapshot for the REPL bottom toolbar.
14
-
15
- Aggregates model name, context usage, and basic call counts from the
16
- provided agent's session history.
17
- """
18
-
19
- model_name = ""
20
- context_usage_percent: float | None = None
21
- llm_calls = 0
22
- tool_calls = 0
23
-
24
- if agent is not None:
25
- model_name = agent.profile.llm_client.model_name or ""
26
-
27
- history = agent.session.conversation_history
28
- for item in history:
29
- if isinstance(item, model.AssistantMessageItem):
30
- llm_calls += 1
31
- elif isinstance(item, model.ToolCallItem):
32
- tool_calls += 1
33
-
34
- for item in reversed(history):
35
- if isinstance(item, model.ResponseMetadataItem):
36
- usage = item.usage
37
- if usage is not None and hasattr(usage, "context_usage_percent"):
38
- context_usage_percent = usage.context_usage_percent
39
- break
40
3
 
41
- return REPLStatusSnapshot(
42
- model_name=model_name,
43
- context_usage_percent=context_usage_percent,
44
- llm_calls=llm_calls,
45
- tool_calls=tool_calls,
46
- update_message=update_message,
47
- )
4
+ def build_repl_status_snapshot(update_message: str | None) -> REPLStatusSnapshot:
5
+ """Build a status snapshot for the REPL bottom toolbar."""
6
+ return REPLStatusSnapshot(update_message=update_message)
@@ -398,8 +398,14 @@ class _AtFilesCompleter(Completer):
398
398
 
399
399
  if not results:
400
400
  if self._has_cmd("fd"):
401
+ # First, get immediate children matching the keyword (depth=0).
402
+ # fd's traversal order is not depth-first, so --max-results may
403
+ # truncate shallow matches. We ensure depth=0 items are always included.
404
+ immediate = self._get_immediate_matches(cwd, key_norm)
401
405
  # Use fd to search anywhere in full path (files and directories), case-insensitive
402
- results, truncated = self._run_fd_search(cwd, key_norm, max_results=max_scan_results)
406
+ fd_results, truncated = self._run_fd_search(cwd, key_norm, max_results=max_scan_results)
407
+ # Merge: immediate matches first, then fd results (deduped in _filter_and_format)
408
+ results = immediate + fd_results
403
409
  elif self._has_cmd("rg"):
404
410
  # Use rg to search only in current directory
405
411
  rg_cache_ttl = max(self._cache_ttl, 30.0)
@@ -451,10 +457,11 @@ class _AtFilesCompleter(Completer):
451
457
  keyword_norm: str,
452
458
  ) -> list[str]:
453
459
  # Filter to keyword (case-insensitive) and rank by:
454
- # 1. Basename hit first, then path hit position, then length
460
+ # 1. Directory depth (shallower first)
461
+ # 2. Basename hit first, then path hit position, then length
455
462
  # Since both fd and rg now search from current directory, all paths are relative to cwd
456
463
  kn = keyword_norm
457
- out: list[tuple[str, tuple[int, int, int, int]]] = []
464
+ out: list[tuple[str, tuple[int, int, int, int, int]]] = []
458
465
  for p in paths_from_root:
459
466
  pl = p.lower()
460
467
  if kn not in pl:
@@ -469,7 +476,9 @@ class _AtFilesCompleter(Completer):
469
476
  base = os.path.basename(rel_to_cwd.rstrip("/")).lower()
470
477
  base_pos = base.find(kn)
471
478
  path_pos = pl.find(kn)
479
+ depth = rel_to_cwd.rstrip("/").count("/")
472
480
  score = (
481
+ depth,
473
482
  0 if base_pos != -1 else 1,
474
483
  base_pos if base_pos != -1 else 10_000,
475
484
  path_pos,
@@ -684,6 +693,28 @@ class _AtFilesCompleter(Completer):
684
693
  return []
685
694
  return items[: min(self._max_results, 100)]
686
695
 
696
+ def _get_immediate_matches(self, cwd: Path, keyword_norm: str) -> list[str]:
697
+ """Get immediate children of cwd that match the keyword (case-insensitive).
698
+
699
+ This ensures depth=0 matches are always included, even when fd's
700
+ --max-results truncates before reaching them.
701
+ """
702
+ excluded = {".git", ".venv", "node_modules"}
703
+ items: list[str] = []
704
+ try:
705
+ for p in cwd.iterdir():
706
+ name = p.name
707
+ if name in excluded:
708
+ continue
709
+ if keyword_norm in name.lower():
710
+ rel = name
711
+ if p.is_dir():
712
+ rel += "/"
713
+ items.append(rel)
714
+ except OSError:
715
+ return []
716
+ return items
717
+
687
718
  def _run_cmd(self, cmd: list[str], cwd: Path | None = None, *, timeout_sec: float) -> _CmdResult:
688
719
  cmd_str = " ".join(cmd)
689
720
  start = time.monotonic()
@@ -691,6 +722,7 @@ class _AtFilesCompleter(Completer):
691
722
  p = subprocess.run(
692
723
  cmd,
693
724
  cwd=str(cwd) if cwd else None,
725
+ stdin=subprocess.DEVNULL,
694
726
  stdout=subprocess.PIPE,
695
727
  stderr=subprocess.DEVNULL,
696
728
  text=True,
@@ -167,10 +167,10 @@ class ActivityState:
167
167
  return activity_text
168
168
  if self._composing:
169
169
  # Main status text with creative verb
170
- text = Text.assemble(
171
- ("Composing ", ThemeKey.STATUS_TEXT_BOLD),
172
- (f"({self._buffer_length:,})", ThemeKey.STATUS_TEXT),
173
- )
170
+ text = Text()
171
+ text.append("Composing", style=ThemeKey.STATUS_TEXT_BOLD)
172
+ if self._buffer_length > 0:
173
+ text.append(f" ({self._buffer_length:,})", style=ThemeKey.STATUS_TEXT)
174
174
  return text
175
175
  return None
176
176
 
@@ -249,10 +249,13 @@ class SpinnerStatusState:
249
249
  base_status = self._reasoning_status or self._todo_status
250
250
 
251
251
  if base_status:
252
- result = Text(base_status, style=ThemeKey.STATUS_TEXT_BOLD)
253
252
  if activity_text:
253
+ result = Text()
254
+ result.append(base_status, style=ThemeKey.STATUS_TEXT_BOLD_ITALIC)
254
255
  result.append(" | ")
255
256
  result.append_text(activity_text)
257
+ else:
258
+ result = Text(base_status, style=ThemeKey.STATUS_TEXT_BOLD_ITALIC)
256
259
  elif activity_text:
257
260
  activity_text.append(" …")
258
261
  result = activity_text
@@ -361,6 +364,7 @@ class DisplayEventHandler:
361
364
  emit_osc94(OSC94States.INDETERMINATE)
362
365
  self.renderer.display_turn_start(event)
363
366
  self.spinner_status.clear_for_new_turn()
367
+ self.spinner_status.set_reasoning_status(None)
364
368
  self._update_spinner()
365
369
 
366
370
  async def _on_thinking(self, event: events.ThinkingEvent) -> None:
@@ -21,16 +21,11 @@ from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_c
21
21
  from klaude_code.ui.modes.repl.key_bindings import create_key_bindings
22
22
  from klaude_code.ui.renderers.user_input import USER_MESSAGE_MARK
23
23
  from klaude_code.ui.terminal.color import is_light_terminal_background
24
- from klaude_code.ui.utils.common import get_current_git_branch, show_path_with_tilde
25
24
 
26
25
 
27
26
  class REPLStatusSnapshot(NamedTuple):
28
27
  """Snapshot of REPL status for bottom toolbar display."""
29
28
 
30
- model_name: str
31
- context_usage_percent: float | None
32
- llm_calls: int
33
- tool_calls: int
34
29
  update_message: str | None = None
35
30
 
36
31
 
@@ -54,11 +49,16 @@ class PromptToolkitInput(InputProviderABC):
54
49
  status_provider: Callable[[], REPLStatusSnapshot] | None = None,
55
50
  pre_prompt: Callable[[], None] | None = None,
56
51
  post_prompt: Callable[[], None] | None = None,
52
+ is_light_background: bool | None = None,
57
53
  ): # ▌
58
54
  self._status_provider = status_provider
59
55
  self._pre_prompt = pre_prompt
60
56
  self._post_prompt = post_prompt
61
- self._is_light_terminal_background = is_light_terminal_background(timeout=0.2)
57
+ # Use provided value if available to avoid redundant TTY queries that may interfere
58
+ # with prompt_toolkit's terminal state after questionary has been used.
59
+ self._is_light_terminal_background = (
60
+ is_light_background if is_light_background is not None else is_light_terminal_background(timeout=0.2)
61
+ )
62
62
 
63
63
  project = str(Path.cwd()).strip("/").replace("/", "-")
64
64
  history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
@@ -91,7 +91,6 @@ class PromptToolkitInput(InputProviderABC):
91
91
  completer=ThreadedCompleter(create_repl_completer()),
92
92
  complete_while_typing=True,
93
93
  erase_when_done=True,
94
- bottom_toolbar=self._render_bottom_toolbar,
95
94
  mouse_support=False,
96
95
  style=Style.from_dict(
97
96
  {
@@ -107,68 +106,29 @@ class PromptToolkitInput(InputProviderABC):
107
106
  ),
108
107
  )
109
108
 
110
- def _render_bottom_toolbar(self) -> FormattedText:
111
- """Render bottom toolbar with working directory, git branch on left, model name and context usage on right.
112
-
113
- If an update is available, only show the update message on the left side.
114
- """
115
- # Check for update message first
116
- update_message: str | None = None
117
- if self._status_provider:
118
- try:
119
- status = self._status_provider()
120
- update_message = status.update_message
121
- except (AttributeError, RuntimeError):
122
- pass
123
-
124
- # If update available, show only the update message
125
- if update_message:
126
- left_text = " " + update_message
127
- try:
128
- terminal_width = shutil.get_terminal_size().columns
129
- padding = " " * max(0, terminal_width - len(left_text))
130
- except (OSError, ValueError):
131
- padding = ""
132
- toolbar_text = left_text + padding
133
- return FormattedText([("#ansiyellow", toolbar_text)])
134
-
135
- # Normal mode: Left side: path and git branch
136
- left_parts: list[str] = []
137
- left_parts.append(show_path_with_tilde())
138
-
139
- git_branch = get_current_git_branch()
140
- if git_branch:
141
- left_parts.append(git_branch)
142
-
143
- # Right side: status info
144
- right_parts: list[str] = []
145
- if self._status_provider:
146
- try:
147
- status = self._status_provider()
148
- model_name = status.model_name or "N/A"
149
- right_parts.append(model_name)
150
-
151
- # Add context if available
152
- if status.context_usage_percent is not None:
153
- right_parts.append(f"context {status.context_usage_percent:.1f}%")
154
- except (AttributeError, RuntimeError):
155
- pass
156
-
157
- # Build left and right text with borders
158
- left_text = " " + " · ".join(left_parts)
159
- right_text = (" · ".join(right_parts) + " ") if right_parts else " "
160
-
161
- # Calculate padding
109
+ def _get_bottom_toolbar(self) -> FormattedText | None:
110
+ """Return bottom toolbar content only when there's an update message available."""
111
+ if not self._status_provider:
112
+ return None
113
+
114
+ try:
115
+ status = self._status_provider()
116
+ update_message = status.update_message
117
+ except (AttributeError, RuntimeError):
118
+ return None
119
+
120
+ if not update_message:
121
+ return None
122
+
123
+ left_text = " " + update_message
162
124
  try:
163
125
  terminal_width = shutil.get_terminal_size().columns
164
- used_width = len(left_text) + len(right_text)
165
- padding = " " * max(0, terminal_width - used_width)
126
+ padding = " " * max(0, terminal_width - len(left_text))
166
127
  except (OSError, ValueError):
167
128
  padding = ""
168
129
 
169
- # Build result with style
170
- toolbar_text = left_text + padding + right_text
171
- return FormattedText([("#2c7eac", toolbar_text)])
130
+ toolbar_text = left_text + padding
131
+ return FormattedText([("#ansiyellow", toolbar_text)])
172
132
 
173
133
  def _render_input_placeholder(self) -> FormattedText:
174
134
  if self._is_light_terminal_background is True:
@@ -210,8 +170,15 @@ class PromptToolkitInput(InputProviderABC):
210
170
  if self._pre_prompt is not None:
211
171
  with contextlib.suppress(Exception):
212
172
  self._pre_prompt()
173
+
174
+ # Only show bottom toolbar if there's an update message
175
+ bottom_toolbar = self._get_bottom_toolbar()
176
+
213
177
  with patch_stdout():
214
- line: str = await self._session.prompt_async(placeholder=self._render_input_placeholder())
178
+ line: str = await self._session.prompt_async(
179
+ placeholder=self._render_input_placeholder(),
180
+ bottom_toolbar=bottom_toolbar,
181
+ )
215
182
  if self._post_prompt is not None:
216
183
  with contextlib.suppress(Exception):
217
184
  self._post_prompt()
@@ -266,12 +266,7 @@ class REPLRenderer:
266
266
  self.print(r_user_input.render_interrupt())
267
267
 
268
268
  def display_error(self, event: events.ErrorEvent) -> None:
269
- self.print(
270
- r_errors.render_error(
271
- truncate_display(event.error_message),
272
- indent=0,
273
- )
274
- )
269
+ self.print(r_errors.render_error(truncate_display(event.error_message)))
275
270
 
276
271
  # -------------------------------------------------------------------------
277
272
  # Spinner control methods
@@ -1,12 +1,14 @@
1
1
  from rich.console import RenderableType
2
2
  from rich.padding import Padding
3
+ from rich.text import Text
3
4
 
4
5
  from klaude_code import const
5
6
  from klaude_code.ui.renderers.common import create_grid
6
7
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
8
+ from klaude_code.ui.rich.theme import ThemeKey
7
9
 
8
10
  # UI markers
9
- ASSISTANT_MESSAGE_MARK = ""
11
+ ASSISTANT_MESSAGE_MARK = ""
10
12
 
11
13
 
12
14
  def render_assistant_message(content: str, *, code_theme: str) -> RenderableType | None:
@@ -20,7 +22,7 @@ def render_assistant_message(content: str, *, code_theme: str) -> RenderableType
20
22
 
21
23
  grid = create_grid()
22
24
  grid.add_row(
23
- ASSISTANT_MESSAGE_MARK,
25
+ Text(ASSISTANT_MESSAGE_MARK, style=ThemeKey.ASSISTANT_MESSAGE_MARK),
24
26
  Padding(NoInsetMarkdown(stripped, code_theme=code_theme), (0, const.MARKDOWN_RIGHT_MARGIN, 0, 0)),
25
27
  )
26
28
  return grid
@@ -37,10 +37,17 @@ def truncate_display(
37
37
 
38
38
  if len(lines) > max_lines:
39
39
  truncated_lines = len(lines) - max_lines
40
- head_count = max_lines // 2
41
- tail_count = max_lines - head_count
42
- head_lines = lines[:head_count]
43
- tail_lines = lines[-tail_count:]
40
+
41
+ # If the hidden section is too small, show everything instead of inserting
42
+ # the "(more N lines)" indicator.
43
+ if truncated_lines < 5:
44
+ truncated_lines = 0
45
+ head_lines = lines
46
+ else:
47
+ head_count = max_lines // 2
48
+ tail_count = max_lines - head_count
49
+ head_lines = lines[:head_count]
50
+ tail_lines = lines[-tail_count:]
44
51
  else:
45
52
  head_lines = lines
46
53
 
@@ -9,6 +9,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
11
11
 
12
+ REMINDER_BULLET = " ⧉"
13
+
12
14
 
13
15
  def need_render_developer_message(e: events.DeveloperMessageEvent) -> bool:
14
16
  return bool(
@@ -32,7 +34,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
32
34
  if mp := e.item.memory_paths:
33
35
  grid = create_grid()
34
36
  grid.add_row(
35
- Text(" +", style=ThemeKey.REMINDER),
37
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
36
38
  Text.assemble(
37
39
  ("Load memory ", ThemeKey.REMINDER),
38
40
  Text(", ", ThemeKey.REMINDER).join(
@@ -46,7 +48,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
46
48
  grid = create_grid()
47
49
  for file_path in fc:
48
50
  grid.add_row(
49
- Text(" +", style=ThemeKey.REMINDER),
51
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
50
52
  Text.assemble(
51
53
  ("Read ", ThemeKey.REMINDER),
52
54
  render_path(file_path, ThemeKey.REMINDER_BOLD),
@@ -58,7 +60,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
58
60
  if e.item.todo_use:
59
61
  grid = create_grid()
60
62
  grid.add_row(
61
- Text(" +", style=ThemeKey.REMINDER),
63
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
62
64
  Text("Todo hasn't been updated recently", ThemeKey.REMINDER),
63
65
  )
64
66
  parts.append(grid)
@@ -68,7 +70,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
68
70
  for at_file in e.item.at_files:
69
71
  if at_file.mentioned_in:
70
72
  grid.add_row(
71
- Text(" +", style=ThemeKey.REMINDER),
73
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
72
74
  Text.assemble(
73
75
  (f"{at_file.operation} ", ThemeKey.REMINDER),
74
76
  render_path(at_file.path, ThemeKey.REMINDER_BOLD),
@@ -78,7 +80,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
78
80
  )
79
81
  else:
80
82
  grid.add_row(
81
- Text(" +", style=ThemeKey.REMINDER),
83
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
82
84
  Text.assemble(
83
85
  (f"{at_file.operation} ", ThemeKey.REMINDER),
84
86
  render_path(at_file.path, ThemeKey.REMINDER_BOLD),
@@ -89,7 +91,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
89
91
  if uic := e.item.user_image_count:
90
92
  grid = create_grid()
91
93
  grid.add_row(
92
- Text(" +", style=ThemeKey.REMINDER),
94
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
93
95
  Text(f"Attached {uic} image{'s' if uic > 1 else ''}", style=ThemeKey.REMINDER),
94
96
  )
95
97
  parts.append(grid)
@@ -97,7 +99,7 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
97
99
  if sn := e.item.skill_name:
98
100
  grid = create_grid()
99
101
  grid.add_row(
100
- Text(" +", style=ThemeKey.REMINDER),
102
+ Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
101
103
  Text.assemble(
102
104
  ("Activated skill ", ThemeKey.REMINDER),
103
105
  (sn, ThemeKey.REMINDER_BOLD),
@@ -120,6 +122,8 @@ def render_command_output(e: events.DeveloperMessageEvent) -> RenderableType:
120
122
  return _render_status_output(e.item.command_output)
121
123
  case commands.CommandName.RELEASE_NOTES:
122
124
  return Padding.indent(NoInsetMarkdown(e.item.content or ""), level=2)
125
+ case commands.CommandName.FORK_SESSION:
126
+ return _render_fork_session_output(e.item.command_output)
123
127
  case _:
124
128
  content = e.item.content or "(no content)"
125
129
  style = ThemeKey.TOOL_RESULT if not e.item.command_output.is_error else ThemeKey.ERROR
@@ -145,6 +149,21 @@ def _format_cost(cost: float | None, currency: str = "USD") -> str:
145
149
  return f"{symbol}{cost:.2f}"
146
150
 
147
151
 
152
+ def _render_fork_session_output(command_output: model.CommandOutput) -> RenderableType:
153
+ """Render fork session output with usage instructions."""
154
+ if not isinstance(command_output.ui_extra, model.SessionIdUIExtra):
155
+ return Text("(no session id)", style=ThemeKey.METADATA)
156
+
157
+ session_id = command_output.ui_extra.session_id
158
+ grid = Table.grid(padding=(0, 1))
159
+ grid.add_column(style=ThemeKey.METADATA, overflow="fold")
160
+
161
+ grid.add_row(Text("Session forked. To continue in a new conversation:", style=ThemeKey.METADATA))
162
+ grid.add_row(Text(f" klaude --resume-by-id {session_id}", style=ThemeKey.METADATA_BOLD))
163
+
164
+ return Padding.indent(grid, level=2)
165
+
166
+
148
167
  def _render_status_output(command_output: model.CommandOutput) -> RenderableType:
149
168
  """Render session status with total cost and per-model breakdown."""
150
169
  if not isinstance(command_output.ui_extra, model.SessionStatusUIExtra):
@@ -5,12 +5,17 @@ from klaude_code.ui.renderers.common import create_grid
5
5
  from klaude_code.ui.rich.theme import ThemeKey
6
6
 
7
7
 
8
- def render_error(error_msg: Text, indent: int = 2) -> RenderableType:
9
- """Stateless error renderer.
8
+ def render_error(error_msg: Text) -> RenderableType:
9
+ """Render error with X mark for error events."""
10
+ grid = create_grid()
11
+ error_msg.style = ThemeKey.ERROR
12
+ grid.add_row(Text("✘", style=ThemeKey.ERROR_BOLD), error_msg)
13
+ return grid
14
+
10
15
 
11
- Shows a two-column grid with an error mark and truncated message.
12
- """
16
+ def render_tool_error(error_msg: Text) -> RenderableType:
17
+ """Render error with indent for tool results."""
13
18
  grid = create_grid()
14
19
  error_msg.style = ThemeKey.ERROR
15
- grid.add_row(Text(" " * indent + "✘", style=ThemeKey.ERROR_BOLD), error_msg)
20
+ grid.add_row(Text(" "), error_msg)
16
21
  return grid
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import importlib.resources
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+
8
+ from klaude_code import const
9
+
10
+
11
+ def artifacts_dir() -> Path:
12
+ return Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "mermaid"
13
+
14
+
15
+ @lru_cache(maxsize=1)
16
+ def load_template() -> str:
17
+ template_file = importlib.resources.files("klaude_code.session.templates").joinpath("mermaid_viewer.html")
18
+ return template_file.read_text(encoding="utf-8")
19
+
20
+
21
+ def ensure_viewer_file(*, code: str, link: str, tool_call_id: str) -> Path | None:
22
+ """Create a local HTML viewer with large preview + editor."""
23
+
24
+ if not tool_call_id:
25
+ return None
26
+
27
+ safe_id = tool_call_id.replace("/", "_")
28
+ path = artifacts_dir() / f"mermaid-viewer-{safe_id}.html"
29
+ if path.exists():
30
+ return path
31
+
32
+ try:
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ escaped_code = html.escape(code)
36
+ escaped_view_link = html.escape(link, quote=True)
37
+ escaped_edit_link = html.escape(link.replace("/view#pako:", "/edit#pako:"), quote=True)
38
+
39
+ template = load_template()
40
+ content = (
41
+ template.replace("__KLAUDE_VIEW_LINK__", escaped_view_link)
42
+ .replace("__KLAUDE_EDIT_LINK__", escaped_edit_link)
43
+ .replace("__KLAUDE_CODE__", escaped_code)
44
+ )
45
+ path.write_text(content, encoding="utf-8")
46
+ except OSError:
47
+ return None
48
+
49
+ return path
50
+
51
+
52
+ def build_viewer(*, code: str, link: str, tool_call_id: str) -> Path | None:
53
+ """Create a local Mermaid viewer HTML file.
54
+ """
55
+
56
+ if not code:
57
+ return None
58
+ return ensure_viewer_file(code=code, link=link, tool_call_id=tool_call_id)