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.
- klaude_code/cli/config_cmd.py +13 -6
- klaude_code/cli/debug.py +9 -1
- klaude_code/cli/list_model.py +1 -1
- klaude_code/cli/main.py +39 -14
- klaude_code/cli/runtime.py +11 -5
- klaude_code/command/__init__.py +3 -0
- klaude_code/command/export_online_cmd.py +15 -12
- klaude_code/command/fork_session_cmd.py +42 -0
- klaude_code/config/__init__.py +11 -1
- klaude_code/config/config.py +21 -17
- klaude_code/config/select_model.py +1 -0
- klaude_code/core/executor.py +2 -1
- klaude_code/core/reminders.py +52 -16
- klaude_code/core/tool/web/mermaid_tool.md +17 -0
- klaude_code/core/tool/web/mermaid_tool.py +2 -2
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/model.py +2 -0
- klaude_code/session/export.py +61 -17
- klaude_code/session/session.py +23 -1
- klaude_code/session/templates/mermaid_viewer.html +926 -0
- klaude_code/trace/log.py +7 -1
- klaude_code/ui/modes/repl/__init__.py +3 -44
- klaude_code/ui/modes/repl/completers.py +35 -3
- klaude_code/ui/modes/repl/event_handler.py +9 -5
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +32 -65
- klaude_code/ui/modes/repl/renderer.py +1 -6
- klaude_code/ui/renderers/assistant.py +4 -2
- klaude_code/ui/renderers/common.py +11 -4
- klaude_code/ui/renderers/developer.py +26 -7
- klaude_code/ui/renderers/errors.py +10 -5
- klaude_code/ui/renderers/mermaid_viewer.py +58 -0
- klaude_code/ui/renderers/tools.py +46 -18
- klaude_code/ui/rich/markdown.py +4 -4
- klaude_code/ui/rich/theme.py +12 -2
- {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/METADATA +1 -1
- {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/RECORD +39 -36
- {klaude_code-1.2.27.dist-info → klaude_code-1.2.29.dist-info}/entry_points.txt +1 -0
- {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(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
"""
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
9
|
-
"""
|
|
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
|
-
|
|
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("
|
|
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)
|