klaude-code 1.2.27__py3-none-any.whl → 1.2.28__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 (32) hide show
  1. klaude_code/cli/debug.py +9 -1
  2. klaude_code/cli/main.py +39 -14
  3. klaude_code/cli/runtime.py +11 -5
  4. klaude_code/command/__init__.py +3 -0
  5. klaude_code/command/export_online_cmd.py +15 -12
  6. klaude_code/command/fork_session_cmd.py +42 -0
  7. klaude_code/config/select_model.py +1 -0
  8. klaude_code/core/executor.py +2 -1
  9. klaude_code/core/reminders.py +52 -16
  10. klaude_code/core/tool/web/mermaid_tool.md +17 -0
  11. klaude_code/core/tool/web/mermaid_tool.py +2 -2
  12. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  13. klaude_code/protocol/commands.py +1 -0
  14. klaude_code/protocol/model.py +1 -0
  15. klaude_code/session/export.py +51 -6
  16. klaude_code/session/session.py +22 -0
  17. klaude_code/trace/log.py +7 -1
  18. klaude_code/ui/modes/repl/__init__.py +3 -44
  19. klaude_code/ui/modes/repl/completers.py +35 -3
  20. klaude_code/ui/modes/repl/event_handler.py +7 -5
  21. klaude_code/ui/modes/repl/input_prompt_toolkit.py +32 -65
  22. klaude_code/ui/modes/repl/renderer.py +1 -6
  23. klaude_code/ui/renderers/common.py +11 -4
  24. klaude_code/ui/renderers/developer.py +17 -0
  25. klaude_code/ui/renderers/errors.py +10 -5
  26. klaude_code/ui/renderers/tools.py +7 -3
  27. klaude_code/ui/rich/markdown.py +4 -4
  28. klaude_code/ui/rich/theme.py +6 -2
  29. {klaude_code-1.2.27.dist-info → klaude_code-1.2.28.dist-info}/METADATA +1 -1
  30. {klaude_code-1.2.27.dist-info → klaude_code-1.2.28.dist-info}/RECORD +32 -31
  31. {klaude_code-1.2.27.dist-info → klaude_code-1.2.28.dist-info}/entry_points.txt +1 -0
  32. {klaude_code-1.2.27.dist-info → klaude_code-1.2.28.dist-info}/WHEEL +0 -0
klaude_code/cli/debug.py CHANGED
@@ -63,7 +63,15 @@ def open_log_file_in_editor(path: Path) -> None:
63
63
  editor = "xdg-open"
64
64
 
65
65
  try:
66
- subprocess.Popen([editor, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
66
+ # Detach stdin to prevent the editor from interfering with terminal input state.
67
+ # Without this, the spawned process inherits the parent's TTY and can disrupt
68
+ # prompt_toolkit's keyboard handling (e.g., history navigation with up/down keys).
69
+ subprocess.Popen(
70
+ [editor, str(path)],
71
+ stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ )
67
75
  except FileNotFoundError:
68
76
  log((f"Error: Editor '{editor}' not found", "red"))
69
77
  except Exception as exc: # pragma: no cover - best effort
klaude_code/cli/main.py CHANGED
@@ -16,6 +16,10 @@ from klaude_code.trace import DebugType, prepare_debug_log_file
16
16
 
17
17
  def set_terminal_title(title: str) -> None:
18
18
  """Set terminal window title using ANSI escape sequence."""
19
+ # Never write terminal control sequences when stdout is not a TTY (pipes/CI/redirects).
20
+ # This avoids corrupting machine-readable output (e.g., JSON streaming) and log captures.
21
+ if not sys.stdout.isatty():
22
+ return
19
23
  sys.stdout.write(f"\033]0;{title}\007")
20
24
  sys.stdout.flush()
21
25
 
@@ -242,9 +246,43 @@ def main_callback(
242
246
  ) -> None:
243
247
  # Only run interactive mode when no subcommand is invoked
244
248
  if ctx.invoked_subcommand is None:
249
+ from klaude_code.trace import log
250
+
251
+ resume_by_id_value = resume_by_id.strip() if resume_by_id is not None else None
252
+ if resume_by_id_value == "":
253
+ log(("Error: --resume-by-id cannot be empty", "red"))
254
+ raise typer.Exit(2)
255
+
256
+ if resume_by_id_value is not None and (resume or continue_):
257
+ log(("Error: --resume-by-id cannot be combined with --resume/--continue", "red"))
258
+ raise typer.Exit(2)
259
+
260
+ if resume_by_id_value is not None and not Session.exists(resume_by_id_value):
261
+ log((f"Error: session id '{resume_by_id_value}' not found for this project", "red"))
262
+ log(("Hint: run `klaude --resume` to select an existing session", "yellow"))
263
+ raise typer.Exit(2)
264
+
265
+ # In non-interactive environments, default to exec-mode behavior.
266
+ # This allows: echo "..." | klaude
267
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
268
+ if continue_ or resume or resume_by_id is not None:
269
+ log(("Error: --continue/--resume options require a TTY", "red"))
270
+ log(("Hint: use `klaude exec` for non-interactive usage", "yellow"))
271
+ raise typer.Exit(2)
272
+
273
+ exec_command(
274
+ input_content="",
275
+ model=model,
276
+ select_model=select_model,
277
+ debug=debug,
278
+ debug_filter=debug_filter,
279
+ vanilla=vanilla,
280
+ stream_json=False,
281
+ )
282
+ return
283
+
245
284
  from klaude_code.cli.runtime import AppInitConfig, run_interactive
246
285
  from klaude_code.config.select_model import select_model_from_config
247
- from klaude_code.trace import log
248
286
 
249
287
  update_terminal_title()
250
288
 
@@ -258,15 +296,6 @@ def main_callback(
258
296
  # session_id=None means create a new session
259
297
  session_id: str | None = None
260
298
 
261
- resume_by_id_value = resume_by_id.strip() if resume_by_id is not None else None
262
- if resume_by_id_value == "":
263
- log(("Error: --resume-by-id cannot be empty", "red"))
264
- raise typer.Exit(2)
265
-
266
- if resume_by_id_value is not None and (resume or continue_):
267
- log(("Error: --resume-by-id cannot be combined with --resume/--continue", "red"))
268
- raise typer.Exit(2)
269
-
270
299
  if resume:
271
300
  session_id = resume_select_session()
272
301
  if session_id is None:
@@ -276,10 +305,6 @@ def main_callback(
276
305
  session_id = Session.most_recent_session_id()
277
306
 
278
307
  if resume_by_id_value is not None:
279
- if not Session.exists(resume_by_id_value):
280
- log((f"Error: session id '{resume_by_id_value}' not found for this project", "red"))
281
- log(("Hint: run `klaude --resume` to select an existing session", "yellow"))
282
- raise typer.Exit(2)
283
308
  session_id = resume_by_id_value
284
309
  # If still no session_id, leave as None to create a new session
285
310
 
@@ -255,12 +255,8 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
255
255
 
256
256
  # Create status provider for bottom toolbar
257
257
  def _status_provider() -> REPLStatusSnapshot:
258
- # Check for updates (returns None if uv not available)
259
258
  update_message = get_update_message()
260
-
261
- return build_repl_status_snapshot(
262
- agent=components.executor.context.current_agent, update_message=update_message
263
- )
259
+ return build_repl_status_snapshot(update_message)
264
260
 
265
261
  # Set up input provider for interactive mode
266
262
  def _stop_rich_bottom_ui() -> None:
@@ -276,9 +272,19 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
276
272
  display.wrapped_display.renderer.spinner_stop()
277
273
  display.wrapped_display.renderer.stop_bottom_live()
278
274
 
275
+ # Pass the pre-detected theme to avoid redundant TTY queries.
276
+ # Querying the terminal background again after questionary's interactive selection
277
+ # can interfere with prompt_toolkit's terminal state and break history navigation.
278
+ is_light_background: bool | None = None
279
+ if components.theme == "light":
280
+ is_light_background = True
281
+ elif components.theme == "dark":
282
+ is_light_background = False
283
+
279
284
  input_provider: ui.InputProviderABC = ui.PromptToolkitInput(
280
285
  status_provider=_status_provider,
281
286
  pre_prompt=_stop_rich_bottom_ui,
287
+ is_light_background=is_light_background,
282
288
  )
283
289
 
284
290
  # --- Custom Ctrl+C handler: double-press within 2s to exit, single press shows toast ---
@@ -31,6 +31,7 @@ def ensure_commands_loaded() -> None:
31
31
  from .debug_cmd import DebugCommand
32
32
  from .export_cmd import ExportCommand
33
33
  from .export_online_cmd import ExportOnlineCommand
34
+ from .fork_session_cmd import ForkSessionCommand
34
35
  from .help_cmd import HelpCommand
35
36
  from .model_cmd import ModelCommand
36
37
  from .refresh_cmd import RefreshTerminalCommand
@@ -45,6 +46,7 @@ def ensure_commands_loaded() -> None:
45
46
  register(RefreshTerminalCommand())
46
47
  register(ThinkingCommand())
47
48
  register(ModelCommand())
49
+ register(ForkSessionCommand())
48
50
  load_prompt_commands()
49
51
  register(StatusCommand())
50
52
  register(HelpCommand())
@@ -63,6 +65,7 @@ def __getattr__(name: str) -> object:
63
65
  "DebugCommand": "debug_cmd",
64
66
  "ExportCommand": "export_cmd",
65
67
  "ExportOnlineCommand": "export_online_cmd",
68
+ "ForkSessionCommand": "fork_session_cmd",
66
69
  "HelpCommand": "help_cmd",
67
70
  "ModelCommand": "model_cmd",
68
71
  "RefreshTerminalCommand": "refresh_cmd",
@@ -47,20 +47,23 @@ class ExportOnlineCommand(CommandABC):
47
47
  )
48
48
  return CommandResult(events=[event])
49
49
 
50
- # Check if user is logged in to surge
51
- if not self._is_surge_logged_in(surge_cmd):
52
- login_cmd = " ".join([*surge_cmd, "login"])
53
- event = events.DeveloperMessageEvent(
54
- session_id=agent.session.id,
55
- item=model.DeveloperMessageItem(
56
- content=f"Not logged in to surge.sh. Please run: {login_cmd}",
57
- command_output=model.CommandOutput(command_name=self.name, is_error=True),
58
- ),
59
- )
60
- return CommandResult(events=[event])
61
-
62
50
  try:
63
51
  console = Console()
52
+ # Check login status inside status context since npx surge whoami can be slow
53
+ with console.status(Text("Checking surge.sh login status...", style="dim"), spinner_style="dim"):
54
+ logged_in = self._is_surge_logged_in(surge_cmd)
55
+
56
+ if not logged_in:
57
+ login_cmd = " ".join([*surge_cmd, "login"])
58
+ event = events.DeveloperMessageEvent(
59
+ session_id=agent.session.id,
60
+ item=model.DeveloperMessageItem(
61
+ content=f"Not logged in to surge.sh. Please run: {login_cmd}",
62
+ command_output=model.CommandOutput(command_name=self.name, is_error=True),
63
+ ),
64
+ )
65
+ return CommandResult(events=[event])
66
+
64
67
  with console.status(Text("Deploying to surge.sh...", style="dim"), spinner_style="dim"):
65
68
  html_doc = self._build_html(agent)
66
69
  domain = self._generate_domain()
@@ -0,0 +1,42 @@
1
+ from klaude_code.command.command_abc import Agent, CommandABC, CommandResult
2
+ from klaude_code.protocol import commands, events, model
3
+
4
+
5
+ class ForkSessionCommand(CommandABC):
6
+ """Fork current session to a new session id and show a resume command."""
7
+
8
+ @property
9
+ def name(self) -> commands.CommandName:
10
+ return commands.CommandName.FORK_SESSION
11
+
12
+ @property
13
+ def summary(self) -> str:
14
+ return "Fork the current session and show a resume-by-id command"
15
+
16
+ async def run(self, agent: Agent, user_input: model.UserInputPayload) -> CommandResult:
17
+ del user_input # unused
18
+
19
+ if agent.session.messages_count == 0:
20
+ event = events.DeveloperMessageEvent(
21
+ session_id=agent.session.id,
22
+ item=model.DeveloperMessageItem(
23
+ content="(no messages to fork)",
24
+ command_output=model.CommandOutput(command_name=self.name),
25
+ ),
26
+ )
27
+ return CommandResult(events=[event])
28
+
29
+ new_session = agent.session.fork()
30
+ await new_session.wait_for_flush()
31
+
32
+ event = events.DeveloperMessageEvent(
33
+ session_id=agent.session.id,
34
+ item=model.DeveloperMessageItem(
35
+ content=f"Session forked successfully. New session id: {new_session.id}",
36
+ command_output=model.CommandOutput(
37
+ command_name=self.name,
38
+ ui_extra=model.SessionIdUIExtra(session_id=new_session.id),
39
+ ),
40
+ ),
41
+ )
42
+ return CommandResult(events=[event])
@@ -96,6 +96,7 @@ def select_model_from_config(preferred: str | None = None) -> str | None:
96
96
  else:
97
97
  # No matches: show all models without filter hint
98
98
  preferred = None
99
+ log(("No matching models found. Showing all models.", "yellow"))
99
100
 
100
101
  # Non-interactive environments (CI/pipes) should never enter an interactive prompt.
101
102
  # If we couldn't resolve to a single model deterministically above, fail with a clear hint.
@@ -404,7 +404,8 @@ class ExecutorContext:
404
404
 
405
405
  def _open_file(self, path: Path) -> None:
406
406
  try:
407
- subprocess.run(["open", str(path)], check=True)
407
+ # Detach stdin to prevent interference with prompt_toolkit's terminal state
408
+ subprocess.run(["open", str(path)], stdin=subprocess.DEVNULL, check=True)
408
409
  except FileNotFoundError as exc: # pragma: no cover
409
410
  msg = "`open` command not found; please open the HTML manually."
410
411
  raise RuntimeError(msg) from exc
@@ -46,6 +46,17 @@ class AtPatternSource:
46
46
  mentioned_in: str | None = None
47
47
 
48
48
 
49
+ def _extract_at_patterns(content: str) -> list[str]:
50
+ """Extract @ patterns from content."""
51
+ patterns: list[str] = []
52
+ if "@" in content:
53
+ for match in AT_FILE_PATTERN.finditer(content):
54
+ path_str = match.group("quoted") or match.group("plain")
55
+ if path_str:
56
+ patterns.append(path_str)
57
+ return patterns
58
+
59
+
49
60
  def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
50
61
  """Get @ patterns from last user input and developer messages, preserving source info."""
51
62
  patterns: list[AtPatternSource] = []
@@ -56,24 +67,14 @@ def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
56
67
 
57
68
  if isinstance(item, model.UserMessageItem):
58
69
  content = item.content or ""
59
- if "@" in content:
60
- for match in AT_FILE_PATTERN.finditer(content):
61
- path_str = match.group("quoted") or match.group("plain")
62
- if path_str:
63
- patterns.append(AtPatternSource(pattern=path_str, mentioned_in=None))
70
+ for path_str in _extract_at_patterns(content):
71
+ patterns.append(AtPatternSource(pattern=path_str, mentioned_in=None))
64
72
  break
65
73
 
66
- if isinstance(item, model.DeveloperMessageItem):
67
- content = item.content or ""
68
- if "@" not in content:
69
- continue
70
- # Use first memory_path as the source if available
71
- source = item.memory_paths[0] if item.memory_paths else None
72
- for match in AT_FILE_PATTERN.finditer(content):
73
- path_str = match.group("quoted") or match.group("plain")
74
- if path_str:
75
- patterns.append(AtPatternSource(pattern=path_str, mentioned_in=source))
76
-
74
+ if isinstance(item, model.DeveloperMessageItem) and item.memory_mentioned:
75
+ for memory_path, mentioned_patterns in item.memory_mentioned.items():
76
+ for pattern in mentioned_patterns:
77
+ patterns.append(AtPatternSource(pattern=pattern, mentioned_in=memory_path))
77
78
  return patterns
78
79
 
79
80
 
@@ -92,6 +93,23 @@ def get_skill_from_user_input(session: Session) -> str | None:
92
93
  return None
93
94
 
94
95
 
96
+ def _is_tracked_file_unchanged(session: Session, path: str) -> bool:
97
+ status = session.file_tracker.get(path)
98
+ if status is None or status.content_sha256 is None:
99
+ return False
100
+
101
+ try:
102
+ current_mtime = Path(path).stat().st_mtime
103
+ except (OSError, FileNotFoundError):
104
+ return False
105
+
106
+ if current_mtime == status.mtime:
107
+ return True
108
+
109
+ current_sha256 = _compute_file_content_sha256(path)
110
+ return current_sha256 is not None and current_sha256 == status.content_sha256
111
+
112
+
95
113
  async def _load_at_file_recursive(
96
114
  session: Session,
97
115
  pattern: str,
@@ -112,6 +130,8 @@ async def _load_at_file_recursive(
112
130
  context_token = set_tool_context_from_session(session)
113
131
  try:
114
132
  if path.exists() and path.is_file():
133
+ if _is_tracked_file_unchanged(session, path_str):
134
+ return
115
135
  args = ReadTool.ReadArguments(file_path=path_str)
116
136
  tool_result = await ReadTool.call_with_args(args)
117
137
  at_files[path_str] = model.AtPatternParseResult(
@@ -458,6 +478,13 @@ async def memory_reminder(session: Session) -> model.DeveloperMessageItem | None
458
478
  memories_str = "\n\n".join(
459
479
  [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
460
480
  )
481
+ # Build memory_mentioned: extract @ patterns from each memory's content
482
+ memory_mentioned: dict[str, list[str]] = {}
483
+ for memory in memories:
484
+ patterns = _extract_at_patterns(memory.content)
485
+ if patterns:
486
+ memory_mentioned[memory.path] = patterns
487
+
461
488
  return model.DeveloperMessageItem(
462
489
  content=f"""<system-reminder>As you answer the user's questions, you can use the following context:
463
490
 
@@ -474,6 +501,7 @@ NEVER proactively create documentation files (*.md) or README files. Only create
474
501
  IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
475
502
  </system-reminder>""",
476
503
  memory_paths=[memory.path for memory in memories],
504
+ memory_mentioned=memory_mentioned or None,
477
505
  )
478
506
  return None
479
507
 
@@ -544,10 +572,18 @@ async def last_path_memory_reminder(
544
572
  memories_str = "\n\n".join(
545
573
  [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
546
574
  )
575
+ # Build memory_mentioned: extract @ patterns from each memory's content
576
+ memory_mentioned: dict[str, list[str]] = {}
577
+ for memory in memories:
578
+ patterns = _extract_at_patterns(memory.content)
579
+ if patterns:
580
+ memory_mentioned[memory.path] = patterns
581
+
547
582
  return model.DeveloperMessageItem(
548
583
  content=f"""<system-reminder>{memories_str}
549
584
  </system-reminder>""",
550
585
  memory_paths=[memory.path for memory in memories],
586
+ memory_mentioned=memory_mentioned or None,
551
587
  )
552
588
 
553
589
 
@@ -45,3 +45,20 @@ sequenceDiagram
45
45
 
46
46
  # Styling
47
47
  - When defining custom classDefs, always define fill color, stroke color, and text color ("fill", "stroke", "color") explicitly
48
+ - Use colors to distinguish node types and improve readability
49
+
50
+ ## Color Palette
51
+ - Cyan #e0f0f0 - information, data flow
52
+ - Green #e0f0e0 - success, completion
53
+ - Blue #e0e8f5 - primary actions, main flow
54
+ - Purple #ede0f5 - highlights, special nodes
55
+ - Orange #f5ebe0 - warnings, pending
56
+ - Red #f5e0e0 - errors, critical
57
+ - Grey #e8e8e8 - neutral elements
58
+ - Yellow #f5f5e0 - attention, notes
59
+
60
+ Example:
61
+ ```mermaid
62
+ classDef primary fill:#e0e8f5,stroke:#3078C5,color:#1a1a1a
63
+ classDef success fill:#e0f0e0,stroke:#00875f,color:#1a1a1a
64
+ ```
@@ -11,7 +11,7 @@ from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
11
  from klaude_code.core.tool.tool_registry import register
12
12
  from klaude_code.protocol import llm_param, model, tools
13
13
 
14
- _MERMAID_LIVE_PREFIX = "https://mermaid.live/view#pako:"
14
+ _MERMAID_LIVE_PREFIX = "https://mermaid.live/edit#pako:"
15
15
 
16
16
 
17
17
  @register(tools.MERMAID)
@@ -31,7 +31,7 @@ class MermaidTool(ToolABC):
31
31
  "type": "object",
32
32
  "properties": {
33
33
  "code": {
34
- "description": "The Mermaid diagram code to render (DO NOT override with custom colors or other styles, DO NOT use HTML tags in node labels)",
34
+ "description": "The Mermaid diagram code to render (DO NOT use HTML tags in node labels)",
35
35
  "type": "string",
36
36
  },
37
37
  },
@@ -1,9 +1,25 @@
1
+ import re
1
2
  from abc import ABC, abstractmethod
2
3
 
3
4
  from openai.types.chat.chat_completion_chunk import ChoiceDeltaToolCall
4
5
  from pydantic import BaseModel, Field
5
6
 
6
7
  from klaude_code.protocol import model
8
+ from klaude_code.trace.log import log_debug
9
+
10
+
11
+ def normalize_tool_name(name: str) -> str:
12
+ """Normalize tool name from Gemini-3 format.
13
+
14
+ Gemini-3 sometimes returns tool names in format like 'tool_Edit_mUoY2p3W3r3z8uO5P2nZ'.
15
+ This function extracts the actual tool name (e.g., 'Edit').
16
+ """
17
+ match = re.match(r"^tool_([A-Za-z]+)_[A-Za-z0-9]+$", name)
18
+ if match:
19
+ normalized = match.group(1)
20
+ log_debug(f"Gemini-3 tool name normalized: {name} -> {normalized}", style="yellow")
21
+ return normalized
22
+ return name
7
23
 
8
24
 
9
25
  class ToolCallAccumulatorABC(ABC):
@@ -74,7 +90,7 @@ class BasicToolCallAccumulator(ToolCallAccumulatorABC, BaseModel):
74
90
  if first_chunk.function is None:
75
91
  continue
76
92
  if first_chunk.function.name:
77
- result[-1].name = first_chunk.function.name
93
+ result[-1].name = normalize_tool_name(first_chunk.function.name)
78
94
  if first_chunk.function.arguments:
79
95
  result[-1].arguments += first_chunk.function.arguments
80
96
  return result
@@ -15,6 +15,7 @@ class CommandName(str, Enum):
15
15
  STATUS = "status"
16
16
  RELEASE_NOTES = "release-notes"
17
17
  THINKING = "thinking"
18
+ FORK_SESSION = "fork-session"
18
19
  # PLAN and DOC are dynamically registered now, but kept here if needed for reference
19
20
  # or we can remove them if no code explicitly imports them.
20
21
  # PLAN = "plan"
@@ -260,6 +260,7 @@ class DeveloperMessageItem(BaseModel):
260
260
 
261
261
  # Special fields for reminders UI
262
262
  memory_paths: list[str] | None = None
263
+ memory_mentioned: dict[str, list[str]] | None = None # memory_path -> list of @ patterns mentioned in it
263
264
  external_file_changes: list[str] | None = None
264
265
  todo_use: bool | None = None
265
266
  at_files: list[AtPatternParseResult] | None = None
@@ -427,6 +427,41 @@ def _get_diff_ui_extra(ui_extra: model.ToolResultUIExtra | None) -> model.DiffUI
427
427
  return None
428
428
 
429
429
 
430
+ def _render_markdown_doc(doc: model.MarkdownDocUIExtra) -> str:
431
+ encoded = _escape_html(doc.content)
432
+ file_path = _escape_html(doc.file_path)
433
+ header = f'<div class="diff-file">{file_path} <span style="font-weight: normal; color: var(--text-dim); font-size: 12px; margin-left: 8px;">(markdown content)</span></div>'
434
+
435
+ # Using a container that mimics diff-view but for markdown
436
+ content = (
437
+ f'<div class="markdown-content markdown-body" data-raw="{encoded}" '
438
+ f'style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-body); margin-top: 4px;">'
439
+ f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
440
+ f"</div>"
441
+ )
442
+
443
+ line_count = doc.content.count("\n") + 1
444
+ open_attr = " open"
445
+
446
+ return (
447
+ f'<details class="diff-collapsible"{open_attr}>'
448
+ f"<summary>File Content ({line_count} lines)</summary>"
449
+ f'<div style="margin-top: 8px;">'
450
+ f"{header}"
451
+ f"{content}"
452
+ f"</div>"
453
+ f"</details>"
454
+ )
455
+
456
+
457
+ def _collect_ui_extras(ui_extra: model.ToolResultUIExtra | None) -> list[model.ToolResultUIExtra]:
458
+ if ui_extra is None:
459
+ return []
460
+ if isinstance(ui_extra, model.MultiUIExtra):
461
+ return list(ui_extra.items)
462
+ return [ui_extra]
463
+
464
+
430
465
  def _build_add_only_diff(text: str, file_path: str) -> model.DiffUIExtra:
431
466
  lines: list[model.DiffLine] = []
432
467
  new_line_no = 1
@@ -567,19 +602,26 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
567
602
  ]
568
603
 
569
604
  if result:
570
- diff_ui = _get_diff_ui_extra(result.ui_extra)
571
- mermaid_html = _get_mermaid_link_html(result.ui_extra, tool_call)
605
+ extras = _collect_ui_extras(result.ui_extra)
606
+
607
+ mermaid_extra = next((x for x in extras if isinstance(x, model.MermaidLinkUIExtra)), None)
608
+ mermaid_source = mermaid_extra if mermaid_extra else result.ui_extra
609
+ mermaid_html = _get_mermaid_link_html(mermaid_source, tool_call)
572
610
 
573
611
  should_hide_text = tool_call.name in ("TodoWrite", "update_plan") and result.status != "error"
574
612
 
575
- if tool_call.name == "Edit" and not diff_ui and result.status != "error":
613
+ if (
614
+ tool_call.name == "Edit"
615
+ and not any(isinstance(x, model.DiffUIExtra) for x in extras)
616
+ and result.status != "error"
617
+ ):
576
618
  try:
577
619
  args_data = json.loads(tool_call.arguments)
578
620
  file_path = args_data.get("file_path", "Unknown file")
579
621
  old_string = args_data.get("old_string", "")
580
622
  new_string = args_data.get("new_string", "")
581
623
  if old_string == "" and new_string:
582
- diff_ui = _build_add_only_diff(new_string, file_path)
624
+ extras.append(_build_add_only_diff(new_string, file_path))
583
625
  except (json.JSONDecodeError, TypeError):
584
626
  pass
585
627
 
@@ -591,8 +633,11 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
591
633
  else:
592
634
  items_to_render.append(_render_text_block(result.output))
593
635
 
594
- if diff_ui:
595
- items_to_render.append(_render_diff_block(diff_ui))
636
+ for extra in extras:
637
+ if isinstance(extra, model.DiffUIExtra):
638
+ items_to_render.append(_render_diff_block(extra))
639
+ elif isinstance(extra, model.MarkdownDocUIExtra):
640
+ items_to_render.append(_render_markdown_doc(extra))
596
641
 
597
642
  if mermaid_html:
598
643
  items_to_render.append(mermaid_html)
@@ -197,6 +197,28 @@ class Session(BaseModel):
197
197
  )
198
198
  self._store.append_and_flush(session_id=self.id, items=items, meta=meta)
199
199
 
200
+ def fork(self, *, new_id: str | None = None) -> Session:
201
+ """Create a new session as a fork of the current session.
202
+
203
+ The forked session copies metadata and conversation history, but does not
204
+ modify the current session.
205
+ """
206
+
207
+ forked = Session.create(id=new_id, work_dir=self.work_dir)
208
+
209
+ forked.sub_agent_state = None
210
+ forked.model_name = self.model_name
211
+ forked.model_config_name = self.model_config_name
212
+ forked.model_thinking = self.model_thinking.model_copy(deep=True) if self.model_thinking is not None else None
213
+ forked.file_tracker = {k: v.model_copy(deep=True) for k, v in self.file_tracker.items()}
214
+ forked.todos = [todo.model_copy(deep=True) for todo in self.todos]
215
+
216
+ items = [cast(model.ConversationItem, it.model_copy(deep=True)) for it in self.conversation_history]
217
+ if items:
218
+ forked.append_history(items)
219
+
220
+ return forked
221
+
200
222
  async def wait_for_flush(self) -> None:
201
223
  await self._store.wait_for_flush(self.id)
202
224
 
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)