klaude-code 2.9.1__py3-none-any.whl → 2.10.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 (49) hide show
  1. klaude_code/app/runtime.py +5 -1
  2. klaude_code/cli/cost_cmd.py +4 -4
  3. klaude_code/cli/list_model.py +1 -2
  4. klaude_code/cli/main.py +10 -0
  5. klaude_code/config/assets/builtin_config.yaml +15 -14
  6. klaude_code/const.py +4 -3
  7. klaude_code/core/agent_profile.py +23 -0
  8. klaude_code/core/bash_mode.py +276 -0
  9. klaude_code/core/executor.py +40 -7
  10. klaude_code/core/manager/llm_clients.py +1 -0
  11. klaude_code/core/manager/llm_clients_builder.py +2 -2
  12. klaude_code/core/memory.py +140 -0
  13. klaude_code/core/prompts/prompt-sub-agent-web.md +2 -2
  14. klaude_code/core/reminders.py +17 -89
  15. klaude_code/core/tool/offload.py +4 -4
  16. klaude_code/core/tool/web/web_fetch_tool.md +2 -1
  17. klaude_code/core/tool/web/web_fetch_tool.py +1 -1
  18. klaude_code/core/turn.py +9 -4
  19. klaude_code/protocol/events.py +17 -0
  20. klaude_code/protocol/op.py +12 -0
  21. klaude_code/protocol/op_handler.py +5 -0
  22. klaude_code/session/templates/mermaid_viewer.html +85 -0
  23. klaude_code/tui/command/resume_cmd.py +1 -1
  24. klaude_code/tui/commands.py +15 -0
  25. klaude_code/tui/components/command_output.py +4 -5
  26. klaude_code/tui/components/developer.py +1 -3
  27. klaude_code/tui/components/metadata.py +28 -25
  28. klaude_code/tui/components/rich/code_panel.py +31 -16
  29. klaude_code/tui/components/rich/markdown.py +56 -124
  30. klaude_code/tui/components/rich/theme.py +22 -12
  31. klaude_code/tui/components/thinking.py +0 -35
  32. klaude_code/tui/components/tools.py +4 -2
  33. klaude_code/tui/components/user_input.py +49 -59
  34. klaude_code/tui/components/welcome.py +47 -2
  35. klaude_code/tui/display.py +14 -6
  36. klaude_code/tui/input/completers.py +8 -0
  37. klaude_code/tui/input/key_bindings.py +37 -1
  38. klaude_code/tui/input/prompt_toolkit.py +57 -31
  39. klaude_code/tui/machine.py +108 -28
  40. klaude_code/tui/renderer.py +117 -19
  41. klaude_code/tui/runner.py +22 -0
  42. klaude_code/tui/terminal/notifier.py +11 -12
  43. klaude_code/tui/terminal/selector.py +1 -1
  44. klaude_code/ui/terminal/title.py +4 -2
  45. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/METADATA +1 -1
  46. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/RECORD +48 -47
  47. klaude_code/tui/components/assistant.py +0 -2
  48. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/WHEEL +0 -0
  49. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,140 @@
1
+ """Memory file loading and management.
2
+
3
+ This module handles CLAUDE.md and AGENTS.md memory files - discovery, loading,
4
+ and providing summaries for UI display.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel
11
+
12
+ MEMORY_FILE_NAMES = ["CLAUDE.md", "AGENTS.md", "AGENT.md"]
13
+
14
+
15
+ class Memory(BaseModel):
16
+ """Represents a loaded memory file."""
17
+
18
+ path: str
19
+ instruction: str
20
+ content: str
21
+
22
+
23
+ def get_memory_paths(*, work_dir: Path) -> list[tuple[Path, str]]:
24
+ """Return all possible memory file paths with their descriptions."""
25
+ user_dirs = [Path.home() / ".claude", Path.home() / ".codex"]
26
+ project_dirs = [work_dir, work_dir / ".claude"]
27
+
28
+ paths: list[tuple[Path, str]] = []
29
+ for d in user_dirs:
30
+ for fname in MEMORY_FILE_NAMES:
31
+ paths.append((d / fname, "user's private global instructions for all projects"))
32
+ for d in project_dirs:
33
+ for fname in MEMORY_FILE_NAMES:
34
+ paths.append((d / fname, "project instructions, checked into the codebase"))
35
+ return paths
36
+
37
+
38
+ def get_existing_memory_files(*, work_dir: Path) -> dict[str, list[str]]:
39
+ """Return existing memory file paths grouped by location (user/project)."""
40
+ result: dict[str, list[str]] = {"user": [], "project": []}
41
+ work_dir = work_dir.resolve()
42
+
43
+ for memory_path, _instruction in get_memory_paths(work_dir=work_dir):
44
+ if memory_path.exists() and memory_path.is_file():
45
+ path_str = str(memory_path)
46
+ resolved = memory_path.resolve()
47
+ try:
48
+ resolved.relative_to(work_dir)
49
+ result["project"].append(path_str)
50
+ except ValueError:
51
+ result["user"].append(path_str)
52
+
53
+ return result
54
+
55
+
56
+ def get_existing_memory_paths_by_location(*, work_dir: Path) -> dict[str, list[str]]:
57
+ """Return existing memory file paths grouped by location for WelcomeEvent."""
58
+ result = get_existing_memory_files(work_dir=work_dir)
59
+ if not result.get("user") and not result.get("project"):
60
+ return {}
61
+ return result
62
+
63
+
64
+ def format_memory_content(memory: Memory) -> str:
65
+ """Format a single memory file content for display."""
66
+ return f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}"
67
+
68
+
69
+ def format_memories_reminder(memories: list[Memory], include_header: bool = True) -> str:
70
+ """Format memory files into a system reminder string."""
71
+ memories_str = "\n\n".join(format_memory_content(m) for m in memories)
72
+ if include_header:
73
+ return f"""<system-reminder>
74
+ Loaded memory files. Follow these instructions. Do not mention them to the user unless explicitly asked.
75
+
76
+ {memories_str}
77
+ </system-reminder>"""
78
+ return f"<system-reminder>{memories_str}\n</system-reminder>"
79
+
80
+
81
+ def discover_memory_files_near_paths(
82
+ paths: list[str],
83
+ *,
84
+ work_dir: Path,
85
+ is_memory_loaded: Callable[[str], bool],
86
+ mark_memory_loaded: Callable[[str], None],
87
+ ) -> list[Memory]:
88
+ """Discover and load CLAUDE.md/AGENTS.md from directories containing accessed files.
89
+
90
+ Args:
91
+ paths: List of file paths that have been accessed.
92
+ is_memory_loaded: Callback to check if a memory file is already loaded.
93
+ mark_memory_loaded: Callback to mark a memory file as loaded.
94
+
95
+ Returns:
96
+ List of newly discovered Memory objects.
97
+ """
98
+ memories: list[Memory] = []
99
+ work_dir = work_dir.resolve()
100
+ seen_memory_files: set[str] = set()
101
+
102
+ for p_str in paths:
103
+ p = Path(p_str)
104
+ full = (work_dir / p).resolve() if not p.is_absolute() else p.resolve()
105
+ try:
106
+ _ = full.relative_to(work_dir)
107
+ except ValueError:
108
+ continue
109
+
110
+ deepest_dir = full if full.is_dir() else full.parent
111
+
112
+ try:
113
+ rel_parts = deepest_dir.relative_to(work_dir).parts
114
+ except ValueError:
115
+ continue
116
+
117
+ current_dir = work_dir
118
+ for part in rel_parts:
119
+ current_dir = current_dir / part
120
+ for fname in MEMORY_FILE_NAMES:
121
+ mem_path = current_dir / fname
122
+ mem_path_str = str(mem_path)
123
+ if mem_path_str in seen_memory_files or is_memory_loaded(mem_path_str):
124
+ continue
125
+ if mem_path.exists() and mem_path.is_file():
126
+ try:
127
+ text = mem_path.read_text(encoding="utf-8", errors="replace")
128
+ except (PermissionError, UnicodeDecodeError, OSError):
129
+ continue
130
+ mark_memory_loaded(mem_path_str)
131
+ seen_memory_files.add(mem_path_str)
132
+ memories.append(
133
+ Memory(
134
+ path=mem_path_str,
135
+ instruction="project instructions, discovered near last accessed path",
136
+ content=text,
137
+ )
138
+ )
139
+
140
+ return memories
@@ -17,7 +17,7 @@ You are a web research subagent that searches and fetches web content to provide
17
17
  - HTML pages are automatically converted to Markdown
18
18
  - JSON responses are auto-formatted with indentation
19
19
  - Other text content returned as-is
20
- - **Content is always saved to a local file** - path shown in `[Web content saved to ...]` at output start
20
+ - **Content is always saved to a local file** - path shown in `[Full content saved to ...]` at output start
21
21
 
22
22
  ## Tool Usage Strategy
23
23
 
@@ -54,7 +54,7 @@ Balance efficiency with thoroughness. For open-ended questions (e.g., "recommend
54
54
  ## Response Guidelines
55
55
 
56
56
  - Only your last message is returned to the main agent
57
- - Include the file path from `[Web content saved to ...]` so the main agent can access full content
57
+ - Include the file path from `[Full content saved to ...]` so the main agent can access full content
58
58
  - **DO NOT copy full web page content** - the main agent can read the saved files directly
59
59
  - Provide a concise summary/analysis of key findings
60
60
  - Lead with the most recent info for evolving topics
@@ -4,9 +4,13 @@ import shlex
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
 
7
- from pydantic import BaseModel
8
-
9
- from klaude_code.const import MEMORY_FILE_NAMES, REMINDER_COOLDOWN_TURNS, TODO_REMINDER_TOOL_CALL_THRESHOLD
7
+ from klaude_code.const import REMINDER_COOLDOWN_TURNS, TODO_REMINDER_TOOL_CALL_THRESHOLD
8
+ from klaude_code.core.memory import (
9
+ Memory,
10
+ discover_memory_files_near_paths,
11
+ format_memories_reminder,
12
+ get_memory_paths,
13
+ )
10
14
  from klaude_code.core.tool import BashTool, ReadTool, build_todo_context
11
15
  from klaude_code.core.tool.context import ToolContext
12
16
  from klaude_code.core.tool.file._utils import hash_text_sha256
@@ -382,28 +386,6 @@ def _compute_file_content_sha256(path: str) -> str | None:
382
386
  return None
383
387
 
384
388
 
385
- def get_memory_paths() -> list[tuple[Path, str]]:
386
- return [
387
- (
388
- Path.home() / ".claude" / "CLAUDE.md",
389
- "user's private global instructions for all projects",
390
- ),
391
- (
392
- Path.home() / ".codex" / "AGENTS.md",
393
- "user's private global instructions for all projects",
394
- ),
395
- (Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
396
- (Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
397
- (Path.cwd() / ".claude" / "CLAUDE.md", "project instructions, checked into the codebase"),
398
- ]
399
-
400
-
401
- class Memory(BaseModel):
402
- path: str
403
- instruction: str
404
- content: str
405
-
406
-
407
389
  def get_last_user_message_image_paths(session: Session) -> list[str]:
408
390
  """Get image file paths from the last user message in conversation history."""
409
391
  for item in reversed(session.conversation_history):
@@ -502,7 +484,7 @@ def _mark_memory_loaded(session: Session, path: str) -> None:
502
484
 
503
485
  async def memory_reminder(session: Session) -> message.DeveloperMessage | None:
504
486
  """CLAUDE.md AGENTS.md"""
505
- memory_paths = get_memory_paths()
487
+ memory_paths = get_memory_paths(work_dir=session.work_dir)
506
488
  memories: list[Memory] = []
507
489
  for memory_path, instruction in memory_paths:
508
490
  path_str = str(memory_path)
@@ -514,21 +496,12 @@ async def memory_reminder(session: Session) -> message.DeveloperMessage | None:
514
496
  except (PermissionError, UnicodeDecodeError, OSError):
515
497
  continue
516
498
  if len(memories) > 0:
517
- memories_str = "\n\n".join(
518
- [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
519
- )
520
499
  loaded_files = [
521
500
  model.MemoryFileLoaded(path=memory.path, mentioned_patterns=_extract_at_patterns(memory.content))
522
501
  for memory in memories
523
502
  ]
524
503
  return message.DeveloperMessage(
525
- parts=message.text_parts_from_str(
526
- f"""<system-reminder>
527
- Loaded memory files. Follow these instructions. Do not mention them to the user unless explicitly asked.
528
-
529
- {memories_str}
530
- </system-reminder>"""
531
- ),
504
+ parts=message.text_parts_from_str(format_memories_reminder(memories, include_header=True)),
532
505
  ui_extra=model.DeveloperUIExtra(items=[model.MemoryLoadedUIItem(files=loaded_files)]),
533
506
  )
534
507
  return None
@@ -546,68 +519,23 @@ async def last_path_memory_reminder(
546
519
  return None
547
520
 
548
521
  paths = list(session.file_tracker.keys())
549
- memories: list[Memory] = []
550
-
551
- cwd = Path.cwd().resolve()
552
- seen_memory_files: set[str] = set()
553
-
554
- for p_str in paths:
555
- p = Path(p_str)
556
- full = (cwd / p).resolve() if not p.is_absolute() else p.resolve()
557
- try:
558
- _ = full.relative_to(cwd)
559
- except ValueError:
560
- # Not under cwd; skip
561
- continue
562
-
563
- # Determine the deepest directory to scan (file parent or directory itself)
564
- deepest_dir = full if full.is_dir() else full.parent
565
-
566
- # Iterate each directory level from cwd to deepest_dir
567
- try:
568
- rel_parts = deepest_dir.relative_to(cwd).parts
569
- except ValueError:
570
- # Shouldn't happen due to check above, but guard anyway
571
- continue
572
-
573
- current_dir = cwd
574
- for part in rel_parts:
575
- current_dir = current_dir / part
576
- for fname in MEMORY_FILE_NAMES:
577
- mem_path = current_dir / fname
578
- mem_path_str = str(mem_path)
579
- if mem_path_str in seen_memory_files or _is_memory_loaded(session, mem_path_str):
580
- continue
581
- if mem_path.exists() and mem_path.is_file():
582
- try:
583
- text = mem_path.read_text(encoding="utf-8", errors="replace")
584
- except (PermissionError, UnicodeDecodeError, OSError):
585
- continue
586
- _mark_memory_loaded(session, mem_path_str)
587
- seen_memory_files.add(mem_path_str)
588
- memories.append(
589
- Memory(
590
- path=mem_path_str,
591
- instruction="project instructions, discovered near last accessed path",
592
- content=text,
593
- )
594
- )
522
+ memories = discover_memory_files_near_paths(
523
+ paths,
524
+ work_dir=session.work_dir,
525
+ is_memory_loaded=lambda p: _is_memory_loaded(session, p),
526
+ mark_memory_loaded=lambda p: _mark_memory_loaded(session, p),
527
+ )
595
528
 
596
529
  if len(memories) > 0:
597
- memories_str = "\n\n".join(
598
- [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
599
- )
600
530
  loaded_files = [
601
531
  model.MemoryFileLoaded(path=memory.path, mentioned_patterns=_extract_at_patterns(memory.content))
602
532
  for memory in memories
603
533
  ]
604
534
  return message.DeveloperMessage(
605
- parts=message.text_parts_from_str(
606
- f"""<system-reminder>{memories_str}
607
- </system-reminder>"""
608
- ),
535
+ parts=message.text_parts_from_str(format_memories_reminder(memories, include_header=False)),
609
536
  ui_extra=model.DeveloperUIExtra(items=[model.MemoryLoadedUIItem(files=loaded_files)]),
610
537
  )
538
+ return None
611
539
 
612
540
 
613
541
  ALL_REMINDERS = [
@@ -227,11 +227,11 @@ class HeadTailOffloadStrategy(OffloadStrategy):
227
227
  if self._should_offload(needs_truncation):
228
228
  offloaded_path = self._save_to_file(output, tool_call)
229
229
 
230
- # Prefer line-based truncation if line limit exceeded
231
- if needs_line_truncation:
232
- truncated_output, hidden = self._truncate_by_lines(output, lines, offloaded_path)
233
- else:
230
+ # Prefer char-based truncation if char limit exceeded (stricter limit)
231
+ if needs_char_truncation:
234
232
  truncated_output, hidden = self._truncate_by_chars(output, offloaded_path)
233
+ else:
234
+ truncated_output, hidden = self._truncate_by_lines(output, lines, offloaded_path)
235
235
 
236
236
  return OffloadResult(
237
237
  output=truncated_output,
@@ -1,8 +1,9 @@
1
1
  Fetch content from a URL and return it in a readable format.
2
2
 
3
3
  The tool automatically processes the response based on Content-Type:
4
+
4
5
  - HTML pages are converted to Markdown for easier reading
5
6
  - JSON responses are formatted with indentation
6
7
  - Markdown and other text content is returned as-is
7
8
 
8
- Content is always saved to a local file. The file path is shown at the start of the output in `[Web content saved to ...]` format. For large content that gets truncated, you can read the saved file directly.
9
+ Content is always saved to a local file. The file path is shown at the start of the output in `[Full content saved to ...]` format. For large content that gets truncated, you can read the saved file directly.
@@ -235,7 +235,7 @@ class WebFetchTool(ToolABC):
235
235
  text = _decode_content(data, charset)
236
236
  processed = _process_content(content_type, text)
237
237
  saved_path = _save_text_content(url, processed)
238
- output = f"[Web content saved to {saved_path}]\n\n{processed}" if saved_path else processed
238
+ output = f"[Full content saved to {saved_path}]\n\n{processed}" if saved_path else processed
239
239
 
240
240
  return message.ToolResultMessage(
241
241
  status="success",
klaude_code/core/turn.py CHANGED
@@ -194,11 +194,16 @@ class TurnExecutor:
194
194
  and self._turn_result.assistant_message is not None
195
195
  and self._turn_result.assistant_message.parts
196
196
  ):
197
- session_ctx.append_history([self._turn_result.assistant_message])
198
- # Add continuation prompt to avoid Anthropic thinking block requirement
199
- session_ctx.append_history(
200
- [message.UserMessage(parts=[message.TextPart(text="<system>continue</system>")])]
197
+ # Discard partial message if it only contains thinking parts
198
+ has_non_thinking = any(
199
+ not isinstance(part, message.ThinkingTextPart) for part in self._turn_result.assistant_message.parts
201
200
  )
201
+ if has_non_thinking:
202
+ session_ctx.append_history([self._turn_result.assistant_message])
203
+ # Add continuation prompt to avoid Anthropic thinking block requirement
204
+ session_ctx.append_history(
205
+ [message.UserMessage(parts=[message.TextPart(text="<system>continue</system>")])]
206
+ )
202
207
  yield events.TurnEndEvent(session_id=session_ctx.session_id)
203
208
  raise TurnError(self._turn_result.stream_error.error)
204
209
 
@@ -14,6 +14,9 @@ __all__ = [
14
14
  "AssistantTextDeltaEvent",
15
15
  "AssistantTextEndEvent",
16
16
  "AssistantTextStartEvent",
17
+ "BashCommandEndEvent",
18
+ "BashCommandOutputDeltaEvent",
19
+ "BashCommandStartEvent",
17
20
  "CommandOutputEvent",
18
21
  "CompactionEndEvent",
19
22
  "CompactionStartEvent",
@@ -81,6 +84,19 @@ class CommandOutputEvent(Event):
81
84
  is_error: bool = False
82
85
 
83
86
 
87
+ class BashCommandStartEvent(Event):
88
+ command: str
89
+
90
+
91
+ class BashCommandOutputDeltaEvent(Event):
92
+ content: str
93
+
94
+
95
+ class BashCommandEndEvent(Event):
96
+ exit_code: int | None = None
97
+ cancelled: bool = False
98
+
99
+
84
100
  class TaskStartEvent(Event):
85
101
  sub_agent_state: model.SubAgentState | None = None
86
102
  model_id: str | None = None
@@ -166,6 +182,7 @@ class WelcomeEvent(Event):
166
182
  llm_config: llm_param.LLMConfigParameter
167
183
  show_klaude_code_info: bool = True
168
184
  loaded_skills: dict[str, list[str]] = Field(default_factory=dict)
185
+ loaded_memories: dict[str, list[str]] = Field(default_factory=dict)
169
186
 
170
187
 
171
188
  class ErrorEvent(Event):
@@ -24,6 +24,7 @@ class OperationType(Enum):
24
24
  """Enumeration of supported operation types."""
25
25
 
26
26
  RUN_AGENT = "run_agent"
27
+ RUN_BASH = "run_bash"
27
28
  CONTINUE_AGENT = "continue_agent"
28
29
  COMPACT_SESSION = "compact_session"
29
30
  CHANGE_MODEL = "change_model"
@@ -60,6 +61,17 @@ class RunAgentOperation(Operation):
60
61
  await handler.handle_run_agent(self)
61
62
 
62
63
 
64
+ class RunBashOperation(Operation):
65
+ """Operation for running a user-entered bash-mode command."""
66
+
67
+ type: OperationType = OperationType.RUN_BASH
68
+ session_id: str
69
+ command: str
70
+
71
+ async def execute(self, handler: OperationHandler) -> None:
72
+ await handler.handle_run_bash(self)
73
+
74
+
63
75
  class ContinueAgentOperation(Operation):
64
76
  """Operation for continuing an agent task without adding a new user message.
65
77
 
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
22
22
  InterruptOperation,
23
23
  ResumeSessionOperation,
24
24
  RunAgentOperation,
25
+ RunBashOperation,
25
26
  )
26
27
 
27
28
 
@@ -32,6 +33,10 @@ class OperationHandler(Protocol):
32
33
  """Handle a run agent operation."""
33
34
  ...
34
35
 
36
+ async def handle_run_bash(self, operation: RunBashOperation) -> None:
37
+ """Handle a bash-mode command execution operation."""
38
+ ...
39
+
35
40
  async def handle_continue_agent(self, operation: ContinueAgentOperation) -> None:
36
41
  """Handle a continue agent operation (resume without adding user message)."""
37
42
  ...
@@ -539,6 +539,21 @@ __KLAUDE_CODE__</textarea
539
539
  </svg>
540
540
  <span>SVG</span>
541
541
  </button>
542
+ <button class="tool-btn" id="btn-download-png" title="Download PNG">
543
+ <svg
544
+ width="16"
545
+ height="16"
546
+ viewBox="0 0 24 24"
547
+ fill="none"
548
+ stroke="currentColor"
549
+ stroke-width="2"
550
+ >
551
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
552
+ <polyline points="7 10 12 15 17 10"></polyline>
553
+ <line x1="12" y1="15" x2="12" y2="3"></line>
554
+ </svg>
555
+ <span>PNG</span>
556
+ </button>
542
557
  </div>
543
558
  </div>
544
559
 
@@ -570,6 +585,7 @@ __KLAUDE_CODE__</textarea
570
585
  zoomOut: document.getElementById("btn-zoom-out"),
571
586
  reset: document.getElementById("btn-reset"),
572
587
  download: document.getElementById("btn-download"),
588
+ downloadPng: document.getElementById("btn-download-png"),
573
589
  copy: document.getElementById("btn-copy-code"),
574
590
  collapse: document.getElementById("btn-collapse"),
575
591
  expand: document.getElementById("btn-expand"),
@@ -894,6 +910,75 @@ __KLAUDE_CODE__</textarea
894
910
  URL.revokeObjectURL(url);
895
911
  };
896
912
 
913
+ els.btns.downloadPng.onclick = async () => {
914
+ const svg = els.canvas.querySelector("svg");
915
+ if (!svg) return;
916
+
917
+ const clone = svg.cloneNode(true);
918
+
919
+ const bbox = svg.getBBox();
920
+ const width = bbox.width + 40;
921
+ const height = bbox.height + 40;
922
+
923
+ clone.setAttribute("width", width);
924
+ clone.setAttribute("height", height);
925
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
926
+ clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
927
+
928
+ // Remove foreignObject elements (they cause tainted canvas)
929
+ clone.querySelectorAll("foreignObject").forEach((fo) => {
930
+ const text = fo.textContent || "";
931
+ const parent = fo.parentNode;
932
+ if (parent) {
933
+ const textEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
934
+ textEl.textContent = text;
935
+ textEl.setAttribute("font-family", "sans-serif");
936
+ textEl.setAttribute("font-size", "14");
937
+ parent.replaceChild(textEl, fo);
938
+ }
939
+ });
940
+
941
+ // Add white background rect
942
+ const bgRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
943
+ bgRect.setAttribute("width", "100%");
944
+ bgRect.setAttribute("height", "100%");
945
+ bgRect.setAttribute("fill", "white");
946
+ clone.insertBefore(bgRect, clone.firstChild);
947
+
948
+ const svgData = new XMLSerializer().serializeToString(clone);
949
+ const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
950
+ const dataUrl = "data:image/svg+xml;base64," + svgBase64;
951
+
952
+ const img = new Image();
953
+ img.onload = () => {
954
+ const scale = 2;
955
+ const canvas = document.createElement("canvas");
956
+ canvas.width = width * scale;
957
+ canvas.height = height * scale;
958
+
959
+ const ctx = canvas.getContext("2d");
960
+ ctx.fillStyle = "white";
961
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
962
+ ctx.scale(scale, scale);
963
+ ctx.drawImage(img, 0, 0);
964
+
965
+ canvas.toBlob((blob) => {
966
+ const pngUrl = URL.createObjectURL(blob);
967
+ const a = document.createElement("a");
968
+ a.href = pngUrl;
969
+ a.download = "diagram.png";
970
+ document.body.appendChild(a);
971
+ a.click();
972
+ a.remove();
973
+ URL.revokeObjectURL(pngUrl);
974
+ }, "image/png");
975
+ };
976
+ img.onerror = (e) => {
977
+ console.error("PNG export failed:", e);
978
+ };
979
+ img.src = dataUrl;
980
+ };
981
+
897
982
  els.btns.copy.onclick = async () => {
898
983
  try {
899
984
  await navigator.clipboard.writeText(els.textarea.value);
@@ -34,7 +34,7 @@ def select_session_sync(session_ids: list[str] | None = None) -> str | None:
34
34
  if msg == "⋮":
35
35
  title.append(("class:msg", f" {msg}\n"))
36
36
  else:
37
- prefix = "└─" if is_last else "├─"
37
+ prefix = "╰─" if is_last else "├─"
38
38
  title.append(("fg:ansibrightblack dim", f" {prefix} "))
39
39
  title.append(("class:msg", f"{msg}\n"))
40
40
  title.append(("", "\n"))
@@ -38,6 +38,21 @@ class RenderCommandOutput(RenderCommand):
38
38
  event: events.CommandOutputEvent
39
39
 
40
40
 
41
+ @dataclass(frozen=True, slots=True)
42
+ class RenderBashCommandStart(RenderCommand):
43
+ event: events.BashCommandStartEvent
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AppendBashCommandOutput(RenderCommand):
48
+ event: events.BashCommandOutputDeltaEvent
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class RenderBashCommandEnd(RenderCommand):
53
+ event: events.BashCommandEndEvent
54
+
55
+
41
56
  @dataclass(frozen=True, slots=True)
42
57
  class RenderTurnStart(RenderCommand):
43
58
  event: events.TurnStartEvent
@@ -1,5 +1,4 @@
1
1
  from rich.console import RenderableType
2
- from rich.padding import Padding
3
2
  from rich.table import Table
4
3
  from rich.text import Text
5
4
 
@@ -19,7 +18,7 @@ def render_command_output(e: events.CommandOutputEvent) -> RenderableType:
19
18
  case _:
20
19
  content = e.content or "(no content)"
21
20
  style = ThemeKey.TOOL_RESULT if not e.is_error else ThemeKey.ERROR
22
- return Padding.indent(truncate_middle(content, base_style=style), level=2)
21
+ return truncate_middle(content, base_style=style)
23
22
 
24
23
 
25
24
  def _format_tokens(tokens: int) -> str:
@@ -44,7 +43,7 @@ def _format_cost(cost: float | None, currency: str = "USD") -> str:
44
43
  def _render_fork_session_output(e: events.CommandOutputEvent) -> RenderableType:
45
44
  """Render fork session output with usage instructions."""
46
45
  if not isinstance(e.ui_extra, model.SessionIdUIExtra):
47
- return Padding.indent(Text(e.content, style=ThemeKey.TOOL_RESULT), level=2)
46
+ return Text(e.content, style=ThemeKey.TOOL_RESULT)
48
47
 
49
48
  grid = Table.grid(padding=(0, 1))
50
49
  session_id = e.ui_extra.session_id
@@ -54,7 +53,7 @@ def _render_fork_session_output(e: events.CommandOutputEvent) -> RenderableType:
54
53
  grid.add_row(Text("Session forked. Resume command copied to clipboard:", style=ThemeKey.TOOL_RESULT))
55
54
  grid.add_row(Text(f" klaude -r {short_id}", style=ThemeKey.TOOL_RESULT_BOLD))
56
55
 
57
- return Padding.indent(grid, level=2)
56
+ return grid
58
57
 
59
58
 
60
59
  def _render_status_output(e: events.CommandOutputEvent) -> RenderableType:
@@ -95,4 +94,4 @@ def _render_status_output(e: events.CommandOutputEvent) -> RenderableType:
95
94
  usage_detail = "(no usage data)"
96
95
  table.add_row(f"{model_label}:", usage_detail)
97
96
 
98
- return Padding.indent(table, level=2)
97
+ return table
@@ -6,7 +6,7 @@ from klaude_code.tui.components.common import create_grid
6
6
  from klaude_code.tui.components.rich.theme import ThemeKey
7
7
  from klaude_code.tui.components.tools import render_path
8
8
 
9
- REMINDER_BULLET = " ⧉"
9
+ REMINDER_BULLET = "⧉"
10
10
 
11
11
 
12
12
  def need_render_developer_message(e: events.DeveloperMessageEvent) -> bool:
@@ -56,8 +56,6 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
56
56
  text = "Todo hasn't been updated recently"
57
57
  case "empty":
58
58
  text = "Todo list is empty"
59
- case _:
60
- text = "Todo reminder"
61
59
  grid = create_grid()
62
60
  grid.add_row(
63
61
  Text(REMINDER_BULLET, style=ThemeKey.REMINDER),