klaude-code 1.2.15__py3-none-any.whl → 1.2.17__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/main.py +66 -42
- klaude_code/cli/runtime.py +34 -13
- klaude_code/command/__init__.py +3 -0
- klaude_code/command/export_cmd.py +2 -2
- klaude_code/command/export_online_cmd.py +149 -0
- klaude_code/command/prompt-handoff.md +33 -0
- klaude_code/command/thinking_cmd.py +5 -1
- klaude_code/config/config.py +20 -21
- klaude_code/config/list_model.py +1 -1
- klaude_code/const/__init__.py +3 -0
- klaude_code/core/executor.py +2 -2
- klaude_code/core/manager/llm_clients_builder.py +1 -1
- klaude_code/core/manager/sub_agent_manager.py +30 -6
- klaude_code/core/prompt.py +15 -13
- klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +0 -1
- klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
- klaude_code/core/prompts/prompt-sub-agent-web.md +48 -0
- klaude_code/core/reminders.py +75 -32
- klaude_code/core/task.py +18 -22
- klaude_code/core/tool/__init__.py +4 -0
- klaude_code/core/tool/report_back_tool.py +84 -0
- klaude_code/core/tool/sub_agent_tool.py +6 -0
- klaude_code/core/tool/tool_runner.py +9 -1
- klaude_code/core/tool/web/web_search_tool.md +23 -0
- klaude_code/core/tool/web/web_search_tool.py +126 -0
- klaude_code/core/turn.py +45 -4
- klaude_code/llm/anthropic/input.py +14 -5
- klaude_code/llm/openrouter/input.py +14 -3
- klaude_code/llm/responses/input.py +19 -0
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/events.py +9 -0
- klaude_code/protocol/model.py +24 -14
- klaude_code/protocol/sub_agent/__init__.py +117 -0
- klaude_code/protocol/sub_agent/explore.py +63 -0
- klaude_code/protocol/sub_agent/oracle.py +91 -0
- klaude_code/protocol/sub_agent/task.py +61 -0
- klaude_code/protocol/sub_agent/web.py +78 -0
- klaude_code/protocol/tools.py +2 -0
- klaude_code/session/export.py +12 -6
- klaude_code/session/session.py +12 -2
- klaude_code/session/templates/export_session.html +111 -36
- klaude_code/ui/modes/repl/completers.py +1 -1
- klaude_code/ui/modes/repl/event_handler.py +65 -8
- klaude_code/ui/modes/repl/renderer.py +11 -9
- klaude_code/ui/renderers/developer.py +18 -7
- klaude_code/ui/renderers/metadata.py +24 -12
- klaude_code/ui/renderers/sub_agent.py +63 -3
- klaude_code/ui/renderers/thinking.py +1 -1
- klaude_code/ui/renderers/tools.py +24 -37
- klaude_code/ui/rich/markdown.py +20 -48
- klaude_code/ui/rich/status.py +61 -17
- klaude_code/ui/rich/theme.py +8 -7
- {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/METADATA +114 -22
- {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/RECORD +57 -48
- klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
- klaude_code/protocol/sub_agent.py +0 -354
- /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
- {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.15.dist-info → klaude_code-1.2.17.dist-info}/entry_points.txt +0 -0
|
@@ -121,7 +121,7 @@ class ActivityState:
|
|
|
121
121
|
for name, count in self._tool_calls.items():
|
|
122
122
|
if not first:
|
|
123
123
|
activity_text.append(", ")
|
|
124
|
-
activity_text.append(name
|
|
124
|
+
activity_text.append(name)
|
|
125
125
|
if count > 1:
|
|
126
126
|
activity_text.append(f" x {count}")
|
|
127
127
|
first = False
|
|
@@ -137,11 +137,13 @@ class SpinnerStatusState:
|
|
|
137
137
|
Composed of two independent layers:
|
|
138
138
|
- base_status: Set by TodoChange, persistent within a turn
|
|
139
139
|
- activity: Current activity (composing or tool_calls), mutually exclusive
|
|
140
|
+
- context_percent: Context usage percentage, updated during task execution
|
|
140
141
|
|
|
141
142
|
Display logic:
|
|
142
143
|
- If activity: show base + activity (if base exists) or activity + "..."
|
|
143
144
|
- Elif base_status: show base_status
|
|
144
145
|
- Else: show "Thinking …"
|
|
146
|
+
- Context percent is appended at the end if available
|
|
145
147
|
"""
|
|
146
148
|
|
|
147
149
|
DEFAULT_STATUS = "Thinking …"
|
|
@@ -149,11 +151,13 @@ class SpinnerStatusState:
|
|
|
149
151
|
def __init__(self) -> None:
|
|
150
152
|
self._base_status: str | None = None
|
|
151
153
|
self._activity = ActivityState()
|
|
154
|
+
self._context_percent: float | None = None
|
|
152
155
|
|
|
153
156
|
def reset(self) -> None:
|
|
154
157
|
"""Reset all layers."""
|
|
155
158
|
self._base_status = None
|
|
156
159
|
self._activity.reset()
|
|
160
|
+
self._context_percent = None
|
|
157
161
|
|
|
158
162
|
def set_base_status(self, status: str | None) -> None:
|
|
159
163
|
"""Set base status from TodoChange."""
|
|
@@ -175,8 +179,16 @@ class SpinnerStatusState:
|
|
|
175
179
|
"""Clear activity state for a new turn."""
|
|
176
180
|
self._activity.reset()
|
|
177
181
|
|
|
182
|
+
def set_context_percent(self, percent: float) -> None:
|
|
183
|
+
"""Set context usage percentage."""
|
|
184
|
+
self._context_percent = percent
|
|
185
|
+
|
|
186
|
+
def get_activity_text(self) -> Text | None:
|
|
187
|
+
"""Get current activity text. Returns None if idle."""
|
|
188
|
+
return self._activity.get_activity_text()
|
|
189
|
+
|
|
178
190
|
def get_status(self) -> Text:
|
|
179
|
-
"""Get current spinner status as rich Text."""
|
|
191
|
+
"""Get current spinner status as rich Text (without context)."""
|
|
180
192
|
activity_text = self._activity.get_activity_text()
|
|
181
193
|
|
|
182
194
|
if self._base_status:
|
|
@@ -184,11 +196,19 @@ class SpinnerStatusState:
|
|
|
184
196
|
if activity_text:
|
|
185
197
|
result.append(" | ")
|
|
186
198
|
result.append_text(activity_text)
|
|
187
|
-
|
|
188
|
-
if activity_text:
|
|
199
|
+
elif activity_text:
|
|
189
200
|
activity_text.append(" …")
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
result = activity_text
|
|
202
|
+
else:
|
|
203
|
+
result = Text(self.DEFAULT_STATUS)
|
|
204
|
+
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
def get_context_text(self) -> Text | None:
|
|
208
|
+
"""Get context usage text for right-aligned display."""
|
|
209
|
+
if self._context_percent is None:
|
|
210
|
+
return None
|
|
211
|
+
return Text(f"{self._context_percent:.1f}%", style=ThemeKey.METADATA_DIM)
|
|
192
212
|
|
|
193
213
|
|
|
194
214
|
class DisplayEventHandler:
|
|
@@ -243,6 +263,8 @@ class DisplayEventHandler:
|
|
|
243
263
|
self._on_task_metadata(e)
|
|
244
264
|
case events.TodoChangeEvent() as e:
|
|
245
265
|
self._on_todo_change(e)
|
|
266
|
+
case events.ContextUsageEvent() as e:
|
|
267
|
+
self._on_context_usage(e)
|
|
246
268
|
case events.TurnEndEvent():
|
|
247
269
|
pass
|
|
248
270
|
case events.ResponseMetadataEvent():
|
|
@@ -377,6 +399,7 @@ class DisplayEventHandler:
|
|
|
377
399
|
self.spinner_status.set_composing(False)
|
|
378
400
|
self._update_spinner()
|
|
379
401
|
await self.stage_manager.transition_to(Stage.WAITING)
|
|
402
|
+
self.renderer.print()
|
|
380
403
|
self.renderer.spinner_start()
|
|
381
404
|
|
|
382
405
|
def _on_tool_call_start(self, event: events.TurnToolCallStartEvent) -> None:
|
|
@@ -407,6 +430,12 @@ class DisplayEventHandler:
|
|
|
407
430
|
self.spinner_status.clear_for_new_turn()
|
|
408
431
|
self._update_spinner()
|
|
409
432
|
|
|
433
|
+
def _on_context_usage(self, event: events.ContextUsageEvent) -> None:
|
|
434
|
+
if self.renderer.is_sub_agent_session(event.session_id):
|
|
435
|
+
return
|
|
436
|
+
self.spinner_status.set_context_percent(event.context_percent)
|
|
437
|
+
self._update_spinner()
|
|
438
|
+
|
|
410
439
|
async def _on_task_finish(self, event: events.TaskFinishEvent) -> None:
|
|
411
440
|
self.renderer.display_task_finish(event)
|
|
412
441
|
if not self.renderer.is_sub_agent_session(event.session_id):
|
|
@@ -454,7 +483,10 @@ class DisplayEventHandler:
|
|
|
454
483
|
|
|
455
484
|
def _update_spinner(self) -> None:
|
|
456
485
|
"""Update spinner text from current status state."""
|
|
457
|
-
self.renderer.spinner_update(
|
|
486
|
+
self.renderer.spinner_update(
|
|
487
|
+
self.spinner_status.get_status(),
|
|
488
|
+
self.spinner_status.get_context_text(),
|
|
489
|
+
)
|
|
458
490
|
|
|
459
491
|
async def _flush_assistant_buffer(self, state: StreamState) -> None:
|
|
460
492
|
if state.is_active:
|
|
@@ -476,6 +508,7 @@ class DisplayEventHandler:
|
|
|
476
508
|
mdstream.update(self.thinking_stream.buffer, final=True)
|
|
477
509
|
self.thinking_stream.finish()
|
|
478
510
|
self.renderer.console.pop_theme()
|
|
511
|
+
self.renderer.print()
|
|
479
512
|
self.renderer.spinner_start()
|
|
480
513
|
|
|
481
514
|
def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
|
|
@@ -512,7 +545,31 @@ class DisplayEventHandler:
|
|
|
512
545
|
if len(todo.content) > 0:
|
|
513
546
|
status_text = todo.content
|
|
514
547
|
status_text = status_text.replace("\n", "")
|
|
515
|
-
|
|
548
|
+
max_length = self._calculate_base_status_max_length()
|
|
549
|
+
return self._truncate_status_text(status_text, max_length=max_length)
|
|
550
|
+
|
|
551
|
+
def _calculate_base_status_max_length(self) -> int:
|
|
552
|
+
"""Calculate max length for base_status based on terminal width.
|
|
553
|
+
|
|
554
|
+
Reserve space for:
|
|
555
|
+
- Spinner glyph + space + context text: 2 chars + context text length 10 chars
|
|
556
|
+
- " | " separator: 3 chars (only if activity text present)
|
|
557
|
+
- Activity text: actual length (only if present)
|
|
558
|
+
- Status hint text (esc to interrupt)
|
|
559
|
+
"""
|
|
560
|
+
terminal_width = self.renderer.console.size.width
|
|
561
|
+
|
|
562
|
+
# Base reserved space: spinner + context + status hint
|
|
563
|
+
reserved_space = 12 + len(const.STATUS_HINT_TEXT)
|
|
564
|
+
|
|
565
|
+
# Add space for activity text if present
|
|
566
|
+
activity_text = self.spinner_status.get_activity_text()
|
|
567
|
+
if activity_text:
|
|
568
|
+
# " | " separator + actual activity text length
|
|
569
|
+
reserved_space += 3 + len(activity_text.plain)
|
|
570
|
+
|
|
571
|
+
max_length = max(10, terminal_width - reserved_space)
|
|
572
|
+
return max_length
|
|
516
573
|
|
|
517
574
|
def _truncate_status_text(self, text: str, max_length: int) -> str:
|
|
518
575
|
if len(text) <= max_length:
|
|
@@ -50,7 +50,7 @@ class REPLRenderer:
|
|
|
50
50
|
|
|
51
51
|
self.session_map: dict[str, SessionStatus] = {}
|
|
52
52
|
self.current_sub_agent_color: Style | None = None
|
|
53
|
-
self.
|
|
53
|
+
self.sub_agent_color_index = 0
|
|
54
54
|
|
|
55
55
|
def register_session(self, session_id: str, sub_agent_state: model.SubAgentState | None = None) -> None:
|
|
56
56
|
session_status = SessionStatus(
|
|
@@ -66,16 +66,16 @@ class REPLRenderer:
|
|
|
66
66
|
def _advance_sub_agent_color_index(self) -> None:
|
|
67
67
|
palette_size = len(self.themes.sub_agent_colors)
|
|
68
68
|
if palette_size == 0:
|
|
69
|
-
self.
|
|
69
|
+
self.sub_agent_color_index = 0
|
|
70
70
|
return
|
|
71
|
-
self.
|
|
71
|
+
self.sub_agent_color_index = (self.sub_agent_color_index + 1) % palette_size
|
|
72
72
|
|
|
73
73
|
def pick_sub_agent_color(self) -> Style:
|
|
74
74
|
self._advance_sub_agent_color_index()
|
|
75
75
|
palette = self.themes.sub_agent_colors
|
|
76
76
|
if not palette:
|
|
77
77
|
return Style()
|
|
78
|
-
return palette[self.
|
|
78
|
+
return palette[self.sub_agent_color_index]
|
|
79
79
|
|
|
80
80
|
def get_session_sub_agent_color(self, session_id: str) -> Style:
|
|
81
81
|
status = self.session_map.get(session_id)
|
|
@@ -100,9 +100,9 @@ class REPLRenderer:
|
|
|
100
100
|
if self.current_sub_agent_color:
|
|
101
101
|
if objects:
|
|
102
102
|
content = objects[0] if len(objects) == 1 else objects
|
|
103
|
-
self.console.print(Quote(content, style=self.current_sub_agent_color))
|
|
103
|
+
self.console.print(Quote(content, style=self.current_sub_agent_color), overflow="ellipsis")
|
|
104
104
|
return
|
|
105
|
-
self.console.print(*objects, style=style, end=end)
|
|
105
|
+
self.console.print(*objects, style=style, end=end, overflow="ellipsis")
|
|
106
106
|
|
|
107
107
|
def display_tool_call(self, e: events.ToolCallEvent) -> None:
|
|
108
108
|
if r_tools.is_sub_agent_tool(e.tool_name):
|
|
@@ -152,6 +152,7 @@ class REPLRenderer:
|
|
|
152
152
|
case events.ThinkingEvent() as e:
|
|
153
153
|
if is_sub_agent:
|
|
154
154
|
continue
|
|
155
|
+
self.display_thinking_prefix()
|
|
155
156
|
self.display_thinking(e.content)
|
|
156
157
|
case events.DeveloperMessageEvent() as e:
|
|
157
158
|
self.display_developer_message(e)
|
|
@@ -233,6 +234,7 @@ class REPLRenderer:
|
|
|
233
234
|
r_sub_agent.render_sub_agent_result(
|
|
234
235
|
event.task_result,
|
|
235
236
|
code_theme=self.themes.code_theme,
|
|
237
|
+
has_structured_output=event.has_structured_output,
|
|
236
238
|
)
|
|
237
239
|
)
|
|
238
240
|
|
|
@@ -262,9 +264,9 @@ class REPLRenderer:
|
|
|
262
264
|
"""Stop the spinner animation."""
|
|
263
265
|
self._spinner.stop()
|
|
264
266
|
|
|
265
|
-
def spinner_update(self, status_text: str | Text) -> None:
|
|
266
|
-
"""Update the spinner status text."""
|
|
267
|
-
self._spinner.update(ShimmerStatusText(status_text, ThemeKey.SPINNER_STATUS_TEXT))
|
|
267
|
+
def spinner_update(self, status_text: str | Text, right_text: Text | None = None) -> None:
|
|
268
|
+
"""Update the spinner status text with optional right-aligned text."""
|
|
269
|
+
self._spinner.update(ShimmerStatusText(status_text, ThemeKey.SPINNER_STATUS_TEXT, right_text))
|
|
268
270
|
|
|
269
271
|
def spinner_renderable(self) -> Spinner:
|
|
270
272
|
"""Return the spinner's renderable for embedding in other components."""
|
|
@@ -67,13 +67,24 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
|
|
|
67
67
|
if e.item.at_files:
|
|
68
68
|
grid = create_grid()
|
|
69
69
|
for at_file in e.item.at_files:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
if at_file.mentioned_in:
|
|
71
|
+
grid.add_row(
|
|
72
|
+
Text(" +", style=ThemeKey.REMINDER),
|
|
73
|
+
Text.assemble(
|
|
74
|
+
(f"{at_file.operation} ", ThemeKey.REMINDER),
|
|
75
|
+
render_path(at_file.path, ThemeKey.REMINDER_BOLD),
|
|
76
|
+
(" mentioned in ", ThemeKey.REMINDER),
|
|
77
|
+
render_path(at_file.mentioned_in, ThemeKey.REMINDER_BOLD),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
grid.add_row(
|
|
82
|
+
Text(" +", style=ThemeKey.REMINDER),
|
|
83
|
+
Text.assemble(
|
|
84
|
+
(f"{at_file.operation} ", ThemeKey.REMINDER),
|
|
85
|
+
render_path(at_file.path, ThemeKey.REMINDER_BOLD),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
77
88
|
parts.append(grid)
|
|
78
89
|
|
|
79
90
|
if uic := e.item.user_image_count:
|
|
@@ -59,20 +59,32 @@ def _render_task_metadata_block(
|
|
|
59
59
|
parts: list[Text] = []
|
|
60
60
|
|
|
61
61
|
if metadata.usage is not None:
|
|
62
|
-
# Tokens: ↑37k
|
|
63
|
-
token_parts: list[
|
|
64
|
-
(
|
|
65
|
-
|
|
62
|
+
# Tokens: ↑ 37k cache 5k ↓ 907 think 45k
|
|
63
|
+
token_parts: list[Text] = [
|
|
64
|
+
Text.assemble(
|
|
65
|
+
("↑ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.input_tokens), ThemeKey.METADATA)
|
|
66
|
+
)
|
|
66
67
|
]
|
|
67
68
|
if metadata.usage.cached_tokens > 0:
|
|
68
|
-
token_parts.append(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
token_parts.append(
|
|
70
|
+
Text.assemble(
|
|
71
|
+
Text("cache ", style=ThemeKey.METADATA_DIM),
|
|
72
|
+
Text(format_number(metadata.usage.cached_tokens), style=ThemeKey.METADATA),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
token_parts.append(
|
|
76
|
+
Text.assemble(
|
|
77
|
+
("↓ ", ThemeKey.METADATA_DIM), (format_number(metadata.usage.output_tokens), ThemeKey.METADATA)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
72
80
|
if metadata.usage.reasoning_tokens > 0:
|
|
73
|
-
token_parts.append(
|
|
74
|
-
|
|
75
|
-
|
|
81
|
+
token_parts.append(
|
|
82
|
+
Text.assemble(
|
|
83
|
+
("think ", ThemeKey.METADATA_DIM),
|
|
84
|
+
(format_number(metadata.usage.reasoning_tokens), ThemeKey.METADATA),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
parts.append(Text(" · ").join(token_parts))
|
|
76
88
|
|
|
77
89
|
# Cost
|
|
78
90
|
if metadata.usage is not None and metadata.usage.total_cost is not None:
|
|
@@ -141,7 +153,7 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
|
|
|
141
153
|
for meta in sorted_items:
|
|
142
154
|
renderables.append(_render_task_metadata_block(meta, is_sub_agent=True, show_context_and_time=False))
|
|
143
155
|
|
|
144
|
-
return
|
|
156
|
+
return Group(*renderables)
|
|
145
157
|
|
|
146
158
|
|
|
147
159
|
def render_welcome(e: events.WelcomeEvent, *, box_style: Box | None = None) -> RenderableType:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
|
+
from typing import Any, cast
|
|
2
3
|
|
|
3
4
|
from rich.console import Group, RenderableType
|
|
5
|
+
from rich.json import JSON
|
|
4
6
|
from rich.panel import Panel
|
|
5
7
|
from rich.style import Style
|
|
6
8
|
from rich.text import Text
|
|
@@ -12,20 +14,71 @@ from klaude_code.ui.rich.markdown import NoInsetMarkdown
|
|
|
12
14
|
from klaude_code.ui.rich.theme import ThemeKey
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
def _compact_schema_value(value: dict[str, Any]) -> str | list[Any] | dict[str, Any]:
|
|
18
|
+
"""Convert a JSON Schema value to compact representation."""
|
|
19
|
+
value_type = value.get("type", "any").lower()
|
|
20
|
+
desc = value.get("description", "")
|
|
21
|
+
|
|
22
|
+
if value_type == "object":
|
|
23
|
+
props = value.get("properties", {})
|
|
24
|
+
return {k: _compact_schema_value(v) for k, v in props.items()}
|
|
25
|
+
elif value_type == "array":
|
|
26
|
+
items = value.get("items", {})
|
|
27
|
+
# If items have no description, use the array's description
|
|
28
|
+
if desc and not items.get("description"):
|
|
29
|
+
items = {**items, "description": desc}
|
|
30
|
+
return [_compact_schema_value(items)]
|
|
31
|
+
else:
|
|
32
|
+
if desc:
|
|
33
|
+
return f"{value_type} // {desc}"
|
|
34
|
+
return value_type
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compact_schema(schema: dict[str, Any]) -> dict[str, Any] | list[Any] | str:
|
|
38
|
+
"""Convert JSON Schema to compact representation for display."""
|
|
39
|
+
return _compact_schema_value(schema)
|
|
40
|
+
|
|
41
|
+
|
|
15
42
|
def render_sub_agent_call(e: model.SubAgentState, style: Style | None = None) -> RenderableType:
|
|
16
43
|
"""Render sub-agent tool call header and prompt body."""
|
|
17
44
|
desc = Text(
|
|
18
45
|
f" {e.sub_agent_desc} ",
|
|
19
46
|
style=Style(color=style.color if style else None, bold=True, reverse=True),
|
|
20
47
|
)
|
|
21
|
-
|
|
48
|
+
elements: list[RenderableType] = [
|
|
22
49
|
Text.assemble((e.sub_agent_type, ThemeKey.TOOL_NAME), " ", desc),
|
|
23
50
|
Text(e.sub_agent_prompt, style=style or ""),
|
|
24
|
-
|
|
51
|
+
]
|
|
52
|
+
if e.output_schema:
|
|
53
|
+
elements.append(Text("\nExpected Output Format JSON:", style=style or ""))
|
|
54
|
+
compact = _compact_schema(e.output_schema)
|
|
55
|
+
schema_text = json.dumps(compact, ensure_ascii=False, indent=2)
|
|
56
|
+
elements.append(JSON(schema_text))
|
|
57
|
+
return Group(*elements)
|
|
25
58
|
|
|
26
59
|
|
|
27
|
-
def render_sub_agent_result(
|
|
60
|
+
def render_sub_agent_result(
|
|
61
|
+
result: str, *, code_theme: str, style: Style | None = None, has_structured_output: bool = False
|
|
62
|
+
) -> RenderableType:
|
|
28
63
|
stripped_result = result.strip()
|
|
64
|
+
|
|
65
|
+
# Use rich JSON for structured output
|
|
66
|
+
if has_structured_output:
|
|
67
|
+
try:
|
|
68
|
+
return Panel.fit(
|
|
69
|
+
Group(
|
|
70
|
+
Text(
|
|
71
|
+
"use /export to view full output",
|
|
72
|
+
style=ThemeKey.TOOL_RESULT,
|
|
73
|
+
),
|
|
74
|
+
JSON(stripped_result),
|
|
75
|
+
),
|
|
76
|
+
border_style=ThemeKey.LINES,
|
|
77
|
+
)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
# Fall back to markdown if not valid JSON
|
|
80
|
+
pass
|
|
81
|
+
|
|
29
82
|
lines = stripped_result.splitlines()
|
|
30
83
|
if len(lines) > const.SUB_AGENT_RESULT_MAX_LINES:
|
|
31
84
|
hidden_count = len(lines) - const.SUB_AGENT_RESULT_MAX_LINES
|
|
@@ -53,6 +106,7 @@ def build_sub_agent_state_from_tool_call(e: events.ToolCallEvent) -> model.SubAg
|
|
|
53
106
|
return None
|
|
54
107
|
description = profile.name
|
|
55
108
|
prompt = ""
|
|
109
|
+
output_schema: dict[str, Any] | None = None
|
|
56
110
|
if e.arguments:
|
|
57
111
|
try:
|
|
58
112
|
payload: dict[str, object] = json.loads(e.arguments)
|
|
@@ -64,8 +118,14 @@ def build_sub_agent_state_from_tool_call(e: events.ToolCallEvent) -> model.SubAg
|
|
|
64
118
|
prompt_value = payload.get("prompt") or payload.get("task")
|
|
65
119
|
if isinstance(prompt_value, str):
|
|
66
120
|
prompt = prompt_value.strip()
|
|
121
|
+
# Extract output_schema if profile supports it
|
|
122
|
+
if profile.output_schema_arg:
|
|
123
|
+
schema_value = payload.get(profile.output_schema_arg)
|
|
124
|
+
if isinstance(schema_value, dict):
|
|
125
|
+
output_schema = cast(dict[str, Any], schema_value)
|
|
67
126
|
return model.SubAgentState(
|
|
68
127
|
sub_agent_type=profile.name,
|
|
69
128
|
sub_agent_desc=description,
|
|
70
129
|
sub_agent_prompt=prompt,
|
|
130
|
+
output_schema=output_schema,
|
|
71
131
|
)
|
|
@@ -16,7 +16,7 @@ def _normalize_thinking_content(content: str) -> str:
|
|
|
16
16
|
content.rstrip()
|
|
17
17
|
.replace("**\n\n", "** \n")
|
|
18
18
|
.replace("\\n\\n\n\n", "") # Weird case of Gemini 3
|
|
19
|
-
.replace("****", "**\n\n**") #
|
|
19
|
+
.replace("****", "**\n\n**") # Remove extra newlines after bold titles
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
|
|
@@ -7,7 +7,7 @@ from rich.padding import Padding
|
|
|
7
7
|
from rich.text import Text
|
|
8
8
|
|
|
9
9
|
from klaude_code import const
|
|
10
|
-
from klaude_code.protocol import events, model
|
|
10
|
+
from klaude_code.protocol import events, model, tools
|
|
11
11
|
from klaude_code.protocol.sub_agent import is_sub_agent_tool as _is_sub_agent_tool
|
|
12
12
|
from klaude_code.ui.renderers import diffs as r_diffs
|
|
13
13
|
from klaude_code.ui.renderers.common import create_grid
|
|
@@ -186,14 +186,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
|
|
|
186
186
|
try:
|
|
187
187
|
json_dict = json.loads(arguments)
|
|
188
188
|
file_path = json_dict.get("file_path")
|
|
189
|
-
|
|
190
|
-
if isinstance(file_path, str):
|
|
191
|
-
abs_path = Path(file_path)
|
|
192
|
-
if not abs_path.is_absolute():
|
|
193
|
-
abs_path = (Path().cwd() / abs_path).resolve()
|
|
194
|
-
if abs_path.exists():
|
|
195
|
-
op_label = "Overwrite"
|
|
196
|
-
tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", (op_label, ThemeKey.TOOL_NAME))
|
|
189
|
+
tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
|
|
197
190
|
arguments_column = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
|
|
198
191
|
except json.JSONDecodeError:
|
|
199
192
|
tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
|
|
@@ -435,35 +428,29 @@ def get_truncation_info(tr: events.ToolResultEvent) -> model.TruncationUIExtra |
|
|
|
435
428
|
return _extract_truncation(tr.ui_extra)
|
|
436
429
|
|
|
437
430
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
"
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
"Bash": ">",
|
|
445
|
-
"apply_patch": "→",
|
|
446
|
-
"TodoWrite": "◎",
|
|
447
|
-
"update_plan": "◎",
|
|
448
|
-
"Mermaid": "⧉",
|
|
449
|
-
"Memory": "★",
|
|
450
|
-
"Skill": "◈",
|
|
451
|
-
}
|
|
431
|
+
def render_report_back_tool_call() -> RenderableType:
|
|
432
|
+
grid = create_grid()
|
|
433
|
+
tool_name_column = Text.assemble(("✔", ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
|
|
434
|
+
grid.add_row(tool_name_column, "")
|
|
435
|
+
return grid
|
|
436
|
+
|
|
452
437
|
|
|
453
438
|
# Tool name to active form mapping (for spinner status)
|
|
454
439
|
_TOOL_ACTIVE_FORM: dict[str, str] = {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
440
|
+
tools.BASH: "Bashing",
|
|
441
|
+
tools.APPLY_PATCH: "Patching",
|
|
442
|
+
tools.EDIT: "Editing",
|
|
443
|
+
tools.MULTI_EDIT: "Editing",
|
|
444
|
+
tools.READ: "Reading",
|
|
445
|
+
tools.WRITE: "Writing",
|
|
446
|
+
tools.TODO_WRITE: "Planning",
|
|
447
|
+
tools.UPDATE_PLAN: "Planning",
|
|
448
|
+
tools.SKILL: "Skilling",
|
|
449
|
+
tools.MERMAID: "Diagramming",
|
|
450
|
+
tools.MEMORY: "Memorizing",
|
|
451
|
+
tools.WEB_FETCH: "Fetching Web",
|
|
452
|
+
tools.WEB_SEARCH: "Searching Web",
|
|
453
|
+
tools.REPORT_BACK: "Reporting",
|
|
467
454
|
}
|
|
468
455
|
|
|
469
456
|
|
|
@@ -490,7 +477,6 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
|
|
|
490
477
|
|
|
491
478
|
Returns a Rich Renderable or None if the tool call should not be rendered.
|
|
492
479
|
"""
|
|
493
|
-
from klaude_code.protocol import tools
|
|
494
480
|
|
|
495
481
|
if is_sub_agent_tool(e.tool_name):
|
|
496
482
|
return None
|
|
@@ -518,6 +504,8 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
|
|
|
518
504
|
return render_memory_tool_call(e.arguments)
|
|
519
505
|
case tools.SKILL:
|
|
520
506
|
return render_generic_tool_call(e.tool_name, e.arguments, "◈")
|
|
507
|
+
case tools.REPORT_BACK:
|
|
508
|
+
return render_report_back_tool_call()
|
|
521
509
|
case _:
|
|
522
510
|
return render_generic_tool_call(e.tool_name, e.arguments)
|
|
523
511
|
|
|
@@ -533,7 +521,6 @@ def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
|
|
|
533
521
|
|
|
534
522
|
Returns a Rich Renderable or None if the tool result should not be rendered.
|
|
535
523
|
"""
|
|
536
|
-
from klaude_code.protocol import tools
|
|
537
524
|
from klaude_code.ui.renderers import errors as r_errors
|
|
538
525
|
|
|
539
526
|
if is_sub_agent_tool(e.tool_name):
|
klaude_code/ui/rich/markdown.py
CHANGED
|
@@ -32,9 +32,9 @@ class NoInsetCodeBlock(CodeBlock):
|
|
|
32
32
|
self.lexer_name,
|
|
33
33
|
theme=self.theme,
|
|
34
34
|
word_wrap=True,
|
|
35
|
-
padding=(0,
|
|
35
|
+
padding=(0, 0),
|
|
36
36
|
)
|
|
37
|
-
yield Panel.fit(syntax, padding=(0, 0),
|
|
37
|
+
yield Panel.fit(syntax, padding=(0, 0), box=box.HORIZONTALS, border_style="markdown.code.border")
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
class ThinkingCodeBlock(CodeBlock):
|
|
@@ -42,7 +42,7 @@ class ThinkingCodeBlock(CodeBlock):
|
|
|
42
42
|
|
|
43
43
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
44
44
|
code = str(self.text).rstrip()
|
|
45
|
-
text = Text(
|
|
45
|
+
text = Text(code, "markdown.code.block")
|
|
46
46
|
yield text
|
|
47
47
|
|
|
48
48
|
|
|
@@ -52,7 +52,10 @@ class LeftHeading(Heading):
|
|
|
52
52
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
53
53
|
text = self.text
|
|
54
54
|
text.justify = "left" # Override justification
|
|
55
|
-
if self.tag == "
|
|
55
|
+
if self.tag == "h1":
|
|
56
|
+
h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
|
|
57
|
+
yield h1_text
|
|
58
|
+
elif self.tag == "h2":
|
|
56
59
|
text.stylize(Style(bold=True, underline=False))
|
|
57
60
|
yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
|
|
58
61
|
else:
|
|
@@ -123,10 +126,6 @@ class MarkdownStream:
|
|
|
123
126
|
self.when: float = 0.0 # Timestamp of last update
|
|
124
127
|
self.min_delay: float = 1.0 / 20 # Minimum time between updates (20fps)
|
|
125
128
|
self.live_window: int = const.MARKDOWN_STREAM_LIVE_WINDOW
|
|
126
|
-
# Track the maximum height the live window has ever reached
|
|
127
|
-
# so we only pad when it shrinks from a previous height,
|
|
128
|
-
# instead of always padding to live_window from the start.
|
|
129
|
-
self._live_window_seen_height: int = 0
|
|
130
129
|
|
|
131
130
|
self.theme = theme
|
|
132
131
|
self.console = console
|
|
@@ -221,18 +220,16 @@ class MarkdownStream:
|
|
|
221
220
|
Markdown going to the console works better in terminal scrollback buffers.
|
|
222
221
|
The live window doesn't play nice with terminal scrollback.
|
|
223
222
|
"""
|
|
224
|
-
# On the first call, start the Live renderer
|
|
225
223
|
if not self._live_started:
|
|
226
224
|
initial_content = self._live_renderable(Text(""), final=False)
|
|
225
|
+
# transient=False keeps final frame on screen after stop()
|
|
227
226
|
self.live = Live(
|
|
228
227
|
initial_content,
|
|
229
228
|
refresh_per_second=1.0 / self.min_delay,
|
|
230
229
|
console=self.console,
|
|
231
230
|
)
|
|
232
231
|
self.live.start()
|
|
233
|
-
# Note: self._live_started is now a property derived from self.live
|
|
234
232
|
|
|
235
|
-
# If live rendering isn't available (e.g., after a final update), stop.
|
|
236
233
|
if self.live is None:
|
|
237
234
|
return
|
|
238
235
|
|
|
@@ -252,61 +249,36 @@ class MarkdownStream:
|
|
|
252
249
|
|
|
253
250
|
num_lines = len(lines)
|
|
254
251
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
if not final:
|
|
258
|
-
num_lines = max(num_lines - self.live_window, 0)
|
|
252
|
+
# Reserve last live_window lines for Live area to keep height stable
|
|
253
|
+
num_lines = max(num_lines - self.live_window, 0)
|
|
259
254
|
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
if final or num_lines > 0:
|
|
263
|
-
# Lines to append to stable area
|
|
255
|
+
# Print new stable lines above Live window
|
|
256
|
+
if num_lines > 0:
|
|
264
257
|
num_printed = len(self.printed)
|
|
265
258
|
to_append_count = num_lines - num_printed
|
|
266
259
|
|
|
267
260
|
if to_append_count > 0:
|
|
268
|
-
# Print new stable lines above Live window
|
|
269
261
|
append_chunk = lines[num_printed:num_lines]
|
|
270
262
|
append_chunk_text = Text.from_ansi("".join(append_chunk))
|
|
271
263
|
live = self.live
|
|
272
264
|
assert live is not None
|
|
273
|
-
live.console.print(append_chunk_text)
|
|
274
|
-
|
|
275
|
-
# Track printed stable lines
|
|
265
|
+
live.console.print(append_chunk_text)
|
|
276
266
|
self.printed = lines[:num_lines]
|
|
277
267
|
|
|
278
|
-
|
|
268
|
+
rest_lines = lines[num_lines:]
|
|
269
|
+
|
|
270
|
+
# Final: render remaining lines without spinner, then stop Live
|
|
279
271
|
if final:
|
|
280
272
|
live = self.live
|
|
281
273
|
assert live is not None
|
|
282
|
-
|
|
274
|
+
rest = "".join(rest_lines)
|
|
275
|
+
rest_text = Text.from_ansi(rest)
|
|
276
|
+
final_renderable = self._live_renderable(rest_text, final=True)
|
|
277
|
+
live.update(final_renderable)
|
|
283
278
|
live.stop()
|
|
284
279
|
self.live = None
|
|
285
280
|
return
|
|
286
281
|
|
|
287
|
-
# Update Live window to prevent timing issues
|
|
288
|
-
# with console.print above. We pad the live region
|
|
289
|
-
# so that its height stays stable when it shrinks
|
|
290
|
-
# from a previously reached height, avoiding spinner jitter.
|
|
291
|
-
rest_lines = lines[num_lines:]
|
|
292
|
-
|
|
293
|
-
if not final:
|
|
294
|
-
current_height = len(rest_lines)
|
|
295
|
-
|
|
296
|
-
# Update the maximum height we've seen so far for this live window.
|
|
297
|
-
if current_height > self._live_window_seen_height:
|
|
298
|
-
# Never exceed configured live_window, even if logic changes later.
|
|
299
|
-
self._live_window_seen_height = min(current_height, self.live_window)
|
|
300
|
-
|
|
301
|
-
target_height = min(self._live_window_seen_height, self.live_window)
|
|
302
|
-
if target_height > 0 and current_height < target_height:
|
|
303
|
-
# Pad only up to the maximum height we've seen so far.
|
|
304
|
-
# This keeps the Live region height stable without overshooting,
|
|
305
|
-
# which can cause the spinner to jump by a line.
|
|
306
|
-
pad_count = target_height - current_height
|
|
307
|
-
# Pad after the existing lines so spinner visually stays at the bottom.
|
|
308
|
-
rest_lines = rest_lines + ["\n"] * pad_count
|
|
309
|
-
|
|
310
282
|
rest = "".join(rest_lines)
|
|
311
283
|
rest = Text.from_ansi(rest)
|
|
312
284
|
live = self.live
|