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.
- klaude_code/app/runtime.py +5 -1
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/cli/main.py +10 -0
- klaude_code/config/assets/builtin_config.yaml +15 -14
- klaude_code/const.py +4 -3
- klaude_code/core/agent_profile.py +23 -0
- klaude_code/core/bash_mode.py +276 -0
- klaude_code/core/executor.py +40 -7
- klaude_code/core/manager/llm_clients.py +1 -0
- klaude_code/core/manager/llm_clients_builder.py +2 -2
- klaude_code/core/memory.py +140 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +2 -2
- klaude_code/core/reminders.py +17 -89
- klaude_code/core/tool/offload.py +4 -4
- klaude_code/core/tool/web/web_fetch_tool.md +2 -1
- klaude_code/core/tool/web/web_fetch_tool.py +1 -1
- klaude_code/core/turn.py +9 -4
- klaude_code/protocol/events.py +17 -0
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/templates/mermaid_viewer.html +85 -0
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/metadata.py +28 -25
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +56 -124
- klaude_code/tui/components/rich/theme.py +22 -12
- klaude_code/tui/components/thinking.py +0 -35
- klaude_code/tui/components/tools.py +4 -2
- klaude_code/tui/components/user_input.py +49 -59
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +14 -6
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +57 -31
- klaude_code/tui/machine.py +108 -28
- klaude_code/tui/renderer.py +117 -19
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/notifier.py +11 -12
- klaude_code/tui/terminal/selector.py +1 -1
- klaude_code/ui/terminal/title.py +4 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/METADATA +1 -1
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/RECORD +48 -47
- klaude_code/tui/components/assistant.py +0 -2
- {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/WHEEL +0 -0
- {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 `[
|
|
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 `[
|
|
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
|
klaude_code/core/reminders.py
CHANGED
|
@@ -4,9 +4,13 @@ import shlex
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 = [
|
klaude_code/core/tool/offload.py
CHANGED
|
@@ -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
|
|
231
|
-
if
|
|
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 `[
|
|
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"[
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
klaude_code/protocol/events.py
CHANGED
|
@@ -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):
|
klaude_code/protocol/op.py
CHANGED
|
@@ -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 = "
|
|
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"))
|
klaude_code/tui/commands.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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),
|