klaude-code 2.5.2__py3-none-any.whl → 2.6.0__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 (61) hide show
  1. klaude_code/auth/__init__.py +10 -0
  2. klaude_code/auth/env.py +77 -0
  3. klaude_code/cli/auth_cmd.py +89 -21
  4. klaude_code/cli/config_cmd.py +5 -5
  5. klaude_code/cli/cost_cmd.py +167 -68
  6. klaude_code/cli/main.py +51 -27
  7. klaude_code/cli/self_update.py +7 -7
  8. klaude_code/config/assets/builtin_config.yaml +45 -24
  9. klaude_code/config/builtin_config.py +23 -9
  10. klaude_code/config/config.py +19 -9
  11. klaude_code/config/model_matcher.py +1 -1
  12. klaude_code/const.py +2 -1
  13. klaude_code/core/tool/file/edit_tool.py +1 -1
  14. klaude_code/core/tool/file/read_tool.py +2 -2
  15. klaude_code/core/tool/file/write_tool.py +1 -1
  16. klaude_code/core/turn.py +21 -4
  17. klaude_code/llm/anthropic/client.py +75 -50
  18. klaude_code/llm/anthropic/input.py +20 -9
  19. klaude_code/llm/google/client.py +235 -148
  20. klaude_code/llm/google/input.py +44 -36
  21. klaude_code/llm/openai_compatible/stream.py +114 -100
  22. klaude_code/llm/openrouter/client.py +1 -0
  23. klaude_code/llm/openrouter/reasoning.py +4 -29
  24. klaude_code/llm/partial_message.py +2 -32
  25. klaude_code/llm/responses/client.py +99 -81
  26. klaude_code/llm/responses/input.py +11 -25
  27. klaude_code/llm/stream_parts.py +94 -0
  28. klaude_code/log.py +57 -0
  29. klaude_code/protocol/events.py +214 -0
  30. klaude_code/protocol/sub_agent/image_gen.py +0 -4
  31. klaude_code/session/session.py +51 -18
  32. klaude_code/tui/command/fork_session_cmd.py +14 -23
  33. klaude_code/tui/command/model_picker.py +2 -17
  34. klaude_code/tui/command/resume_cmd.py +2 -18
  35. klaude_code/tui/command/sub_agent_model_cmd.py +5 -19
  36. klaude_code/tui/command/thinking_cmd.py +2 -14
  37. klaude_code/tui/commands.py +0 -5
  38. klaude_code/tui/components/common.py +1 -1
  39. klaude_code/tui/components/metadata.py +21 -21
  40. klaude_code/tui/components/rich/quote.py +36 -8
  41. klaude_code/tui/components/rich/theme.py +2 -0
  42. klaude_code/tui/components/sub_agent.py +6 -0
  43. klaude_code/tui/display.py +11 -1
  44. klaude_code/tui/input/completers.py +11 -7
  45. klaude_code/tui/input/prompt_toolkit.py +3 -1
  46. klaude_code/tui/machine.py +108 -56
  47. klaude_code/tui/renderer.py +4 -65
  48. klaude_code/tui/terminal/selector.py +174 -31
  49. {klaude_code-2.5.2.dist-info → klaude_code-2.6.0.dist-info}/METADATA +23 -31
  50. {klaude_code-2.5.2.dist-info → klaude_code-2.6.0.dist-info}/RECORD +52 -58
  51. klaude_code/cli/session_cmd.py +0 -96
  52. klaude_code/protocol/events/__init__.py +0 -63
  53. klaude_code/protocol/events/base.py +0 -18
  54. klaude_code/protocol/events/chat.py +0 -30
  55. klaude_code/protocol/events/lifecycle.py +0 -23
  56. klaude_code/protocol/events/metadata.py +0 -16
  57. klaude_code/protocol/events/streaming.py +0 -43
  58. klaude_code/protocol/events/system.py +0 -56
  59. klaude_code/protocol/events/tools.py +0 -27
  60. {klaude_code-2.5.2.dist-info → klaude_code-2.6.0.dist-info}/WHEEL +0 -0
  61. {klaude_code-2.5.2.dist-info → klaude_code-2.6.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from klaude_code.protocol import llm_param, message, model
9
+ from klaude_code.protocol.commands import CommandName
10
+
11
+ __all__ = [
12
+ "AssistantImageDeltaEvent",
13
+ "AssistantTextDeltaEvent",
14
+ "AssistantTextEndEvent",
15
+ "AssistantTextStartEvent",
16
+ "CommandOutputEvent",
17
+ "DeveloperMessageEvent",
18
+ "EndEvent",
19
+ "ErrorEvent",
20
+ "Event",
21
+ "InterruptEvent",
22
+ "ReplayEventUnion",
23
+ "ReplayHistoryEvent",
24
+ "ResponseCompleteEvent",
25
+ "ResponseEvent",
26
+ "TaskFinishEvent",
27
+ "TaskMetadataEvent",
28
+ "TaskStartEvent",
29
+ "ThinkingDeltaEvent",
30
+ "ThinkingEndEvent",
31
+ "ThinkingStartEvent",
32
+ "TodoChangeEvent",
33
+ "ToolCallEvent",
34
+ "ToolCallStartEvent",
35
+ "ToolResultEvent",
36
+ "TurnEndEvent",
37
+ "TurnStartEvent",
38
+ "UsageEvent",
39
+ "UserMessageEvent",
40
+ "WelcomeEvent",
41
+ ]
42
+
43
+
44
+ class Event(BaseModel):
45
+ """Base event."""
46
+
47
+ session_id: str
48
+ timestamp: float = Field(default_factory=time.time)
49
+
50
+
51
+ class ResponseEvent(Event):
52
+ """Event associated with a single model response."""
53
+
54
+ response_id: str | None = None
55
+
56
+
57
+ class UserMessageEvent(Event):
58
+ content: str
59
+ images: list[message.ImageURLPart] | None = None
60
+
61
+
62
+ class DeveloperMessageEvent(Event):
63
+ """DeveloperMessages are reminders in user messages or tool results."""
64
+
65
+ item: message.DeveloperMessage
66
+
67
+
68
+ class TodoChangeEvent(Event):
69
+ todos: list[model.TodoItem]
70
+
71
+
72
+ class CommandOutputEvent(Event):
73
+ """Event for command output display. Not persisted to session history."""
74
+
75
+ command_name: CommandName | str
76
+ content: str = ""
77
+ ui_extra: model.ToolResultUIExtra | None = None
78
+ is_error: bool = False
79
+
80
+
81
+ class TaskStartEvent(Event):
82
+ sub_agent_state: model.SubAgentState | None = None
83
+ model_id: str | None = None
84
+
85
+
86
+ class TaskFinishEvent(Event):
87
+ task_result: str
88
+ has_structured_output: bool = False
89
+
90
+
91
+ class TurnStartEvent(Event):
92
+ pass
93
+
94
+
95
+ class TurnEndEvent(Event):
96
+ pass
97
+
98
+
99
+ class UsageEvent(ResponseEvent):
100
+ usage: model.Usage
101
+
102
+
103
+ class TaskMetadataEvent(Event):
104
+ metadata: model.TaskMetadataItem
105
+ cancelled: bool = False
106
+
107
+
108
+ class ThinkingStartEvent(ResponseEvent):
109
+ pass
110
+
111
+
112
+ class ThinkingDeltaEvent(ResponseEvent):
113
+ content: str
114
+
115
+
116
+ class ThinkingEndEvent(ResponseEvent):
117
+ pass
118
+
119
+
120
+ class AssistantTextStartEvent(ResponseEvent):
121
+ pass
122
+
123
+
124
+ class AssistantTextDeltaEvent(ResponseEvent):
125
+ content: str
126
+
127
+
128
+ class AssistantTextEndEvent(ResponseEvent):
129
+ pass
130
+
131
+
132
+ class AssistantImageDeltaEvent(ResponseEvent):
133
+ file_path: str
134
+
135
+
136
+ class ToolCallStartEvent(ResponseEvent):
137
+ tool_call_id: str
138
+ tool_name: str
139
+
140
+
141
+ class ResponseCompleteEvent(ResponseEvent):
142
+ """Final snapshot of the model response."""
143
+
144
+ content: str
145
+ thinking_text: str | None = None
146
+
147
+
148
+ class WelcomeEvent(Event):
149
+ work_dir: str
150
+ llm_config: llm_param.LLMConfigParameter
151
+ show_klaude_code_info: bool = True
152
+ loaded_skills: dict[str, list[str]] = Field(default_factory=dict)
153
+
154
+
155
+ class ErrorEvent(Event):
156
+ error_message: str
157
+ can_retry: bool = False
158
+
159
+
160
+ class InterruptEvent(Event):
161
+ pass
162
+
163
+
164
+ class EndEvent(Event):
165
+ """Global display shutdown."""
166
+
167
+ session_id: str = "__app__"
168
+
169
+
170
+ type ReplayEventUnion = (
171
+ TaskStartEvent
172
+ | TaskFinishEvent
173
+ | TurnStartEvent
174
+ | ThinkingStartEvent
175
+ | ThinkingDeltaEvent
176
+ | ThinkingEndEvent
177
+ | AssistantTextStartEvent
178
+ | AssistantTextDeltaEvent
179
+ | AssistantTextEndEvent
180
+ | AssistantImageDeltaEvent
181
+ | ToolCallEvent
182
+ | ToolResultEvent
183
+ | UserMessageEvent
184
+ | TaskMetadataEvent
185
+ | InterruptEvent
186
+ | DeveloperMessageEvent
187
+ | ErrorEvent
188
+ )
189
+
190
+
191
+ class ReplayHistoryEvent(Event):
192
+ events: list[ReplayEventUnion]
193
+ updated_at: float
194
+ is_load: bool = True
195
+
196
+
197
+ class ToolCallEvent(ResponseEvent):
198
+ tool_call_id: str
199
+ tool_name: str
200
+ arguments: str
201
+
202
+
203
+ class ToolResultEvent(ResponseEvent):
204
+ tool_call_id: str
205
+ tool_name: str
206
+ result: str
207
+ ui_extra: model.ToolResultUIExtra | None = None
208
+ status: Literal["success", "error", "aborted"]
209
+ task_metadata: model.TaskMetadata | None = None
210
+ is_last_in_turn: bool = True
211
+
212
+ @property
213
+ def is_error(self) -> bool:
214
+ return self.status in ("error", "aborted")
@@ -66,10 +66,6 @@ IMAGE_GEN_PARAMETERS: dict[str, Any] = {
66
66
  "enum": ["1K", "2K", "4K"],
67
67
  "description": "Output size for Nano Banana Pro (must use uppercase K).",
68
68
  },
69
- "extra": {
70
- "type": "object",
71
- "description": "Provider/model-specific extra parameters (future-proofing).",
72
- },
73
69
  },
74
70
  "additionalProperties": False,
75
71
  },
@@ -304,24 +304,57 @@ class Session(BaseModel):
304
304
  yield events.TurnStartEvent(session_id=self.id)
305
305
  match it:
306
306
  case message.AssistantMessage() as am:
307
- content = message.join_text_parts(am.parts)
308
- images = [part for part in am.parts if isinstance(part, message.ImageFilePart)]
309
- last_assistant_content = message.format_saved_images(images, content)
310
- thinking_text = "".join(
311
- part.text for part in am.parts if isinstance(part, message.ThinkingTextPart)
312
- )
313
- for image in images:
314
- yield events.AssistantImageDeltaEvent(
315
- file_path=image.file_path,
316
- response_id=am.response_id,
317
- session_id=self.id,
318
- )
319
- yield events.ResponseCompleteEvent(
320
- thinking_text=thinking_text or None,
321
- content=content,
322
- response_id=am.response_id,
323
- session_id=self.id,
324
- )
307
+ all_images = [part for part in am.parts if isinstance(part, message.ImageFilePart)]
308
+ full_content = message.join_text_parts(am.parts)
309
+ last_assistant_content = message.format_saved_images(all_images, full_content)
310
+
311
+ # Reconstruct streaming boundaries from saved parts.
312
+ # This allows replay to reuse the same TUI state machine as live events.
313
+ thinking_open = False
314
+ assistant_open = False
315
+
316
+ for part in am.parts:
317
+ if isinstance(part, message.ThinkingTextPart):
318
+ if assistant_open:
319
+ assistant_open = False
320
+ yield events.AssistantTextEndEvent(response_id=am.response_id, session_id=self.id)
321
+ if not thinking_open:
322
+ thinking_open = True
323
+ yield events.ThinkingStartEvent(response_id=am.response_id, session_id=self.id)
324
+ if part.text:
325
+ yield events.ThinkingDeltaEvent(
326
+ content=part.text,
327
+ response_id=am.response_id,
328
+ session_id=self.id,
329
+ )
330
+ continue
331
+
332
+ if thinking_open:
333
+ thinking_open = False
334
+ yield events.ThinkingEndEvent(response_id=am.response_id, session_id=self.id)
335
+
336
+ if isinstance(part, message.TextPart):
337
+ if not assistant_open:
338
+ assistant_open = True
339
+ yield events.AssistantTextStartEvent(response_id=am.response_id, session_id=self.id)
340
+ if part.text:
341
+ yield events.AssistantTextDeltaEvent(
342
+ content=part.text,
343
+ response_id=am.response_id,
344
+ session_id=self.id,
345
+ )
346
+ elif isinstance(part, message.ImageFilePart):
347
+ yield events.AssistantImageDeltaEvent(
348
+ file_path=part.file_path,
349
+ response_id=am.response_id,
350
+ session_id=self.id,
351
+ )
352
+
353
+ if thinking_open:
354
+ yield events.ThinkingEndEvent(response_id=am.response_id, session_id=self.id)
355
+ if assistant_open:
356
+ yield events.AssistantTextEndEvent(response_id=am.response_id, session_id=self.id)
357
+
325
358
  for part in am.parts:
326
359
  if not isinstance(part, message.ToolCallPart):
327
360
  continue
@@ -3,26 +3,23 @@ import sys
3
3
  from dataclasses import dataclass
4
4
  from typing import Literal
5
5
 
6
- from prompt_toolkit.styles import Style
6
+ from prompt_toolkit.styles import Style, merge_styles
7
7
 
8
8
  from klaude_code.protocol import commands, events, message, model
9
9
  from klaude_code.tui.input.clipboard import copy_to_clipboard
10
- from klaude_code.tui.terminal.selector import SelectItem, select_one
10
+ from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
11
11
 
12
12
  from .command_abc import Agent, CommandABC, CommandResult
13
13
 
14
- FORK_SELECT_STYLE = Style(
14
+ FORK_SELECT_STYLE = merge_styles(
15
15
  [
16
- ("msg", ""),
17
- ("meta", "fg:ansibrightblack"),
18
- ("separator", "fg:ansibrightblack"),
19
- ("assistant", "fg:ansiblue"),
20
- ("pointer", "bold fg:ansigreen"),
21
- ("search_prefix", "fg:ansibrightblack"),
22
- ("search_success", "noinherit fg:ansigreen"),
23
- ("search_none", "noinherit fg:ansired"),
24
- ("question", "bold"),
25
- ("text", ""),
16
+ DEFAULT_PICKER_STYLE,
17
+ Style(
18
+ [
19
+ ("separator", "fg:ansibrightblack"),
20
+ ("assistant", "fg:ansiblue"),
21
+ ]
22
+ ),
26
23
  ]
27
24
  )
28
25
 
@@ -144,6 +141,7 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
144
141
  title=title_parts,
145
142
  value=fp.history_index,
146
143
  search_text=fp.user_message if not is_last else "fork entire conversation",
144
+ selectable=not is_first,
147
145
  )
148
146
  )
149
147
 
@@ -163,6 +161,9 @@ def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | Literal["canc
163
161
 
164
162
  # Default to the last option (fork entire conversation)
165
163
  last_value = items[-1].value
164
+ if last_value is None:
165
+ # Should not happen as we populate all items with int values
166
+ return -1
166
167
 
167
168
  # Non-interactive environments default to forking entire conversation
168
169
  if not sys.stdin.isatty() or not sys.stdout.isatty():
@@ -241,16 +242,6 @@ class ForkSessionCommand(CommandABC):
241
242
  )
242
243
  return CommandResult(events=[event])
243
244
 
244
- # First option (empty session) is just for UI display, not a valid fork point
245
- if selected == fork_points[0].history_index:
246
- event = events.CommandOutputEvent(
247
- session_id=agent.session.id,
248
- command_name=self.name,
249
- content="(cannot fork to empty session)",
250
- is_error=True,
251
- )
252
- return CommandResult(events=[event])
253
-
254
245
  # Perform the fork
255
246
  new_session = agent.session.fork(until_index=selected)
256
247
  await new_session.wait_for_flush()
@@ -72,9 +72,7 @@ def select_model_interactive(
72
72
  return ModelSelectResult(status=ModelSelectStatus.NON_TTY)
73
73
 
74
74
  # Interactive selection
75
- from prompt_toolkit.styles import Style
76
-
77
- from klaude_code.tui.terminal.selector import build_model_select_items, select_one
75
+ from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, build_model_select_items, select_one
78
76
 
79
77
  names = [m.selector for m in result.filtered_models]
80
78
 
@@ -100,20 +98,7 @@ def select_model_interactive(
100
98
  pointer="→",
101
99
  use_search_filter=True,
102
100
  initial_value=initial_value,
103
- style=Style(
104
- [
105
- ("pointer", "ansigreen"),
106
- ("highlighted", "ansigreen"),
107
- ("msg", ""),
108
- ("meta", "fg:ansibrightblack"),
109
- ("text", "ansibrightblack"),
110
- ("question", "bold"),
111
- ("search_prefix", "ansibrightblack"),
112
- # search filter colors at the bottom
113
- ("search_success", "noinherit fg:ansigreen"),
114
- ("search_none", "noinherit fg:ansired"),
115
- ]
116
- ),
101
+ style=DEFAULT_PICKER_STYLE,
117
102
  )
118
103
  if isinstance(selected, str) and selected in names:
119
104
  return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=selected)
@@ -1,28 +1,12 @@
1
1
  import asyncio
2
2
 
3
- from prompt_toolkit.styles import Style
4
-
5
3
  from klaude_code.log import log
6
4
  from klaude_code.protocol import commands, events, message, op
7
5
  from klaude_code.session.selector import build_session_select_options, format_user_messages_display
8
- from klaude_code.tui.terminal.selector import SelectItem, select_one
6
+ from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
9
7
 
10
8
  from .command_abc import Agent, CommandABC, CommandResult
11
9
 
12
- SESSION_SELECT_STYLE = Style(
13
- [
14
- ("msg", "fg:ansibrightblack"),
15
- ("meta", ""),
16
- ("pointer", "bold fg:ansigreen"),
17
- ("highlighted", "fg:ansigreen"),
18
- ("search_prefix", "fg:ansibrightblack"),
19
- ("search_success", "noinherit fg:ansigreen"),
20
- ("search_none", "noinherit fg:ansired"),
21
- ("question", "bold"),
22
- ("text", ""),
23
- ]
24
- )
25
-
26
10
 
27
11
  def select_session_sync() -> str | None:
28
12
  """Interactive session selection (sync version for asyncio.to_thread)."""
@@ -62,7 +46,7 @@ def select_session_sync() -> str | None:
62
46
  message="Select a session to resume:",
63
47
  items=items,
64
48
  pointer="→",
65
- style=SESSION_SELECT_STYLE,
49
+ style=DEFAULT_PICKER_STYLE,
66
50
  )
67
51
  except KeyboardInterrupt:
68
52
  return None
@@ -4,27 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
 
7
- from prompt_toolkit.styles import Style
8
-
9
7
  from klaude_code.config.config import load_config
10
8
  from klaude_code.config.sub_agent_model_helper import SubAgentModelHelper, SubAgentModelInfo
11
9
  from klaude_code.protocol import commands, events, message, op
12
- from klaude_code.tui.terminal.selector import SelectItem, build_model_select_items, select_one
10
+ from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, build_model_select_items, select_one
13
11
 
14
12
  from .command_abc import Agent, CommandABC, CommandResult
15
13
 
16
- SELECT_STYLE = Style(
17
- [
18
- ("instruction", "ansibrightblack"),
19
- ("pointer", "ansigreen"),
20
- ("highlighted", "ansigreen"),
21
- ("text", "ansibrightblack"),
22
- ("question", "bold"),
23
- ("meta", "fg:ansibrightblack"),
24
- ("msg", ""),
25
- ]
26
- )
27
-
28
14
  USE_DEFAULT_BEHAVIOR = "__default__"
29
15
 
30
16
 
@@ -69,8 +55,8 @@ def _select_sub_agent_sync(
69
55
  result = select_one(
70
56
  message="Select sub-agent to configure:",
71
57
  items=items,
72
- pointer="->",
73
- style=SELECT_STYLE,
58
+ pointer="",
59
+ style=DEFAULT_PICKER_STYLE,
74
60
  use_search_filter=False,
75
61
  )
76
62
  return result if isinstance(result, str) else None
@@ -103,8 +89,8 @@ def _select_model_for_sub_agent_sync(
103
89
  result = select_one(
104
90
  message=f"Select model for {sub_agent_type}:",
105
91
  items=all_items,
106
- pointer="->",
107
- style=SELECT_STYLE,
92
+ pointer="",
93
+ style=DEFAULT_PICKER_STYLE,
108
94
  use_search_filter=True,
109
95
  )
110
96
  return result if isinstance(result, str) else None
@@ -1,23 +1,11 @@
1
1
  import asyncio
2
2
 
3
- from prompt_toolkit.styles import Style
4
-
5
3
  from klaude_code.config.thinking import get_thinking_picker_data, parse_thinking_value
6
4
  from klaude_code.protocol import commands, events, llm_param, message, op
7
- from klaude_code.tui.terminal.selector import SelectItem, select_one
5
+ from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
8
6
 
9
7
  from .command_abc import Agent, CommandABC, CommandResult
10
8
 
11
- SELECT_STYLE = Style(
12
- [
13
- ("instruction", "ansibrightblack"),
14
- ("pointer", "ansigreen"),
15
- ("highlighted", "ansigreen"),
16
- ("text", "ansibrightblack"),
17
- ("question", "bold"),
18
- ]
19
- )
20
-
21
9
 
22
10
  def _select_thinking_sync(config: llm_param.LLMConfigParameter) -> llm_param.Thinking | None:
23
11
  """Select thinking level (sync version)."""
@@ -35,7 +23,7 @@ def _select_thinking_sync(config: llm_param.LLMConfigParameter) -> llm_param.Thi
35
23
  message=data.message,
36
24
  items=items,
37
25
  pointer="→",
38
- style=SELECT_STYLE,
26
+ style=DEFAULT_PICKER_STYLE,
39
27
  use_search_filter=False,
40
28
  )
41
29
  if result is None:
@@ -13,11 +13,6 @@ class RenderCommand:
13
13
  pass
14
14
 
15
15
 
16
- @dataclass(frozen=True, slots=True)
17
- class RenderReplayHistory(RenderCommand):
18
- event: events.ReplayHistoryEvent
19
-
20
-
21
16
  @dataclass(frozen=True, slots=True)
22
17
  class RenderWelcome(RenderCommand):
23
18
  event: events.WelcomeEvent
@@ -40,7 +40,7 @@ def truncate_middle(
40
40
  remaining = max(0, len(truncated_lines))
41
41
  return Text(f" … (more {remaining} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED)
42
42
 
43
- lines = text.split("\n")
43
+ lines = [line for line in text.split("\n") if line.strip()]
44
44
  truncated_lines = 0
45
45
  head_lines: list[str] = []
46
46
  tail_lines: list[str] = []
@@ -1,5 +1,4 @@
1
1
  from rich.console import Group, RenderableType
2
- from rich.padding import Padding
3
2
  from rich.text import Text
4
3
 
5
4
  from klaude_code.const import DEFAULT_MAX_TOKENS
@@ -12,14 +11,14 @@ from klaude_code.ui.common import format_number
12
11
  def _render_task_metadata_block(
13
12
  metadata: model.TaskMetadata,
14
13
  *,
15
- is_sub_agent: bool = False,
14
+ mark: Text,
16
15
  show_context_and_time: bool = True,
17
16
  ) -> RenderableType:
18
17
  """Render a single TaskMetadata block.
19
18
 
20
19
  Args:
21
20
  metadata: The TaskMetadata to render.
22
- is_sub_agent: Whether this is a sub-agent block.
21
+ mark: The mark to display in the first column.
23
22
  show_context_and_time: Whether to show context usage percent and time.
24
23
 
25
24
  Returns:
@@ -31,19 +30,15 @@ def _render_task_metadata_block(
31
30
  currency = metadata.usage.currency if metadata.usage else "USD"
32
31
  currency_symbol = "¥" if currency == "CNY" else "$"
33
32
 
34
- # First column: mark only
35
- mark = Text("└", style=ThemeKey.METADATA_DIM) if is_sub_agent else Text("◆", style=ThemeKey.METADATA)
36
-
37
- # Second column: model@provider description / tokens / cost / …
33
+ # Second column: provider/model description / tokens / cost / …
38
34
  content = Text()
39
- content.append_text(Text(metadata.model_name, style=ThemeKey.METADATA_BOLD))
40
35
  if metadata.provider is not None:
41
- content.append_text(Text("@", style=ThemeKey.METADATA)).append_text(
42
- Text(metadata.provider.lower().replace(" ", "-"), style=ThemeKey.METADATA)
43
- )
36
+ content.append_text(Text(metadata.provider.lower().replace(" ", "-"), style=ThemeKey.METADATA))
37
+ content.append_text(Text("/", style=ThemeKey.METADATA_DIM))
38
+ content.append_text(Text(metadata.model_name, style=ThemeKey.METADATA_BOLD))
44
39
  if metadata.description:
45
40
  content.append_text(Text(" ", style=ThemeKey.METADATA)).append_text(
46
- Text(metadata.description, style=ThemeKey.METADATA_DIM)
41
+ Text(metadata.description, style=ThemeKey.METADATA_ITALIC)
47
42
  )
48
43
 
49
44
  # All info parts (tokens, cost, context, etc.)
@@ -63,7 +58,7 @@ def _render_task_metadata_block(
63
58
  token_text.append(" ∿", style=ThemeKey.METADATA_DIM)
64
59
  token_text.append(format_number(metadata.usage.reasoning_tokens), style=ThemeKey.METADATA)
65
60
  if metadata.usage.image_tokens > 0:
66
- token_text.append(" ", style=ThemeKey.METADATA_DIM)
61
+ token_text.append(" ", style=ThemeKey.METADATA_DIM)
67
62
  token_text.append(format_number(metadata.usage.image_tokens), style=ThemeKey.METADATA)
68
63
  parts.append(token_text)
69
64
 
@@ -134,7 +129,7 @@ def _render_task_metadata_block(
134
129
  content.append_text(Text(" ", style=ThemeKey.METADATA_DIM).join(parts))
135
130
 
136
131
  grid.add_row(mark, content)
137
- return grid if not is_sub_agent else Padding(grid, (0, 0, 0, 2))
132
+ return grid
138
133
 
139
134
 
140
135
  def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
@@ -144,16 +139,20 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
144
139
  if e.cancelled:
145
140
  renderables.append(Text())
146
141
 
147
- renderables.append(
148
- _render_task_metadata_block(e.metadata.main_agent, is_sub_agent=False, show_context_and_time=True)
149
- )
142
+ has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
143
+ # Use an extra space for the main agent mark to align with two-character marks (├─, └─)
144
+ main_mark_text = "✓"
145
+ main_mark = Text(main_mark_text, style=ThemeKey.METADATA)
146
+
147
+ renderables.append(_render_task_metadata_block(e.metadata.main_agent, mark=main_mark, show_context_and_time=True))
150
148
 
151
149
  # Render each sub-agent metadata block
152
150
  for meta in e.metadata.sub_agent_task_metadata:
153
- renderables.append(_render_task_metadata_block(meta, is_sub_agent=True, show_context_and_time=True))
151
+ sub_mark = Text(" └", style=ThemeKey.METADATA_DIM)
152
+ renderables.append(_render_task_metadata_block(meta, mark=sub_mark, show_context_and_time=True))
154
153
 
155
154
  # Add total cost line when there are sub-agents
156
- if e.metadata.sub_agent_task_metadata:
155
+ if has_sub_agents:
157
156
  total_cost = 0.0
158
157
  currency = "USD"
159
158
  # Sum up costs from main agent and all sub-agents
@@ -166,12 +165,13 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
166
165
 
167
166
  currency_symbol = "¥" if currency == "CNY" else "$"
168
167
  total_line = Text.assemble(
169
- ("Σ ", ThemeKey.METADATA_DIM),
168
+ ("", ThemeKey.METADATA_DIM),
169
+ (" Σ ", ThemeKey.METADATA_DIM),
170
170
  ("total ", ThemeKey.METADATA_DIM),
171
171
  (currency_symbol, ThemeKey.METADATA_DIM),
172
172
  (f"{total_cost:.4f}", ThemeKey.METADATA_DIM),
173
173
  )
174
174
 
175
- renderables.append(Padding(total_line, (0, 0, 0, 2)))
175
+ renderables.append(total_line)
176
176
 
177
177
  return Group(*renderables)