klaude-code 2.9.1__py3-none-any.whl → 2.10.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 (40) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/cli/cost_cmd.py +4 -4
  3. klaude_code/cli/list_model.py +1 -2
  4. klaude_code/const.py +4 -3
  5. klaude_code/core/bash_mode.py +276 -0
  6. klaude_code/core/executor.py +40 -7
  7. klaude_code/core/manager/llm_clients.py +1 -0
  8. klaude_code/core/manager/llm_clients_builder.py +2 -2
  9. klaude_code/core/memory.py +140 -0
  10. klaude_code/core/reminders.py +17 -89
  11. klaude_code/core/turn.py +10 -4
  12. klaude_code/protocol/events.py +17 -0
  13. klaude_code/protocol/op.py +12 -0
  14. klaude_code/protocol/op_handler.py +5 -0
  15. klaude_code/tui/command/resume_cmd.py +1 -1
  16. klaude_code/tui/commands.py +15 -0
  17. klaude_code/tui/components/command_output.py +4 -5
  18. klaude_code/tui/components/developer.py +1 -3
  19. klaude_code/tui/components/metadata.py +23 -23
  20. klaude_code/tui/components/rich/code_panel.py +31 -16
  21. klaude_code/tui/components/rich/markdown.py +53 -124
  22. klaude_code/tui/components/rich/theme.py +19 -10
  23. klaude_code/tui/components/tools.py +1 -0
  24. klaude_code/tui/components/user_input.py +48 -59
  25. klaude_code/tui/components/welcome.py +47 -2
  26. klaude_code/tui/display.py +15 -7
  27. klaude_code/tui/input/completers.py +8 -0
  28. klaude_code/tui/input/key_bindings.py +37 -1
  29. klaude_code/tui/input/prompt_toolkit.py +58 -31
  30. klaude_code/tui/machine.py +63 -3
  31. klaude_code/tui/renderer.py +113 -19
  32. klaude_code/tui/runner.py +22 -0
  33. klaude_code/tui/terminal/notifier.py +11 -12
  34. klaude_code/tui/terminal/selector.py +1 -1
  35. klaude_code/ui/terminal/title.py +4 -2
  36. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
  37. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/RECORD +39 -38
  38. klaude_code/tui/components/assistant.py +0 -2
  39. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
  40. {klaude_code-2.9.1.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
@@ -4,9 +4,13 @@ import shlex
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
 
7
- from pydantic import BaseModel
8
-
9
- from klaude_code.const import MEMORY_FILE_NAMES, REMINDER_COOLDOWN_TURNS, TODO_REMINDER_TOOL_CALL_THRESHOLD
7
+ from klaude_code.const import REMINDER_COOLDOWN_TURNS, TODO_REMINDER_TOOL_CALL_THRESHOLD
8
+ from klaude_code.core.memory import (
9
+ Memory,
10
+ discover_memory_files_near_paths,
11
+ format_memories_reminder,
12
+ get_memory_paths,
13
+ )
10
14
  from klaude_code.core.tool import BashTool, ReadTool, build_todo_context
11
15
  from klaude_code.core.tool.context import ToolContext
12
16
  from klaude_code.core.tool.file._utils import hash_text_sha256
@@ -382,28 +386,6 @@ def _compute_file_content_sha256(path: str) -> str | None:
382
386
  return None
383
387
 
384
388
 
385
- def get_memory_paths() -> list[tuple[Path, str]]:
386
- return [
387
- (
388
- Path.home() / ".claude" / "CLAUDE.md",
389
- "user's private global instructions for all projects",
390
- ),
391
- (
392
- Path.home() / ".codex" / "AGENTS.md",
393
- "user's private global instructions for all projects",
394
- ),
395
- (Path.cwd() / "AGENTS.md", "project instructions, checked into the codebase"),
396
- (Path.cwd() / "CLAUDE.md", "project instructions, checked into the codebase"),
397
- (Path.cwd() / ".claude" / "CLAUDE.md", "project instructions, checked into the codebase"),
398
- ]
399
-
400
-
401
- class Memory(BaseModel):
402
- path: str
403
- instruction: str
404
- content: str
405
-
406
-
407
389
  def get_last_user_message_image_paths(session: Session) -> list[str]:
408
390
  """Get image file paths from the last user message in conversation history."""
409
391
  for item in reversed(session.conversation_history):
@@ -502,7 +484,7 @@ def _mark_memory_loaded(session: Session, path: str) -> None:
502
484
 
503
485
  async def memory_reminder(session: Session) -> message.DeveloperMessage | None:
504
486
  """CLAUDE.md AGENTS.md"""
505
- memory_paths = get_memory_paths()
487
+ memory_paths = get_memory_paths(work_dir=session.work_dir)
506
488
  memories: list[Memory] = []
507
489
  for memory_path, instruction in memory_paths:
508
490
  path_str = str(memory_path)
@@ -514,21 +496,12 @@ async def memory_reminder(session: Session) -> message.DeveloperMessage | None:
514
496
  except (PermissionError, UnicodeDecodeError, OSError):
515
497
  continue
516
498
  if len(memories) > 0:
517
- memories_str = "\n\n".join(
518
- [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
519
- )
520
499
  loaded_files = [
521
500
  model.MemoryFileLoaded(path=memory.path, mentioned_patterns=_extract_at_patterns(memory.content))
522
501
  for memory in memories
523
502
  ]
524
503
  return message.DeveloperMessage(
525
- parts=message.text_parts_from_str(
526
- f"""<system-reminder>
527
- Loaded memory files. Follow these instructions. Do not mention them to the user unless explicitly asked.
528
-
529
- {memories_str}
530
- </system-reminder>"""
531
- ),
504
+ parts=message.text_parts_from_str(format_memories_reminder(memories, include_header=True)),
532
505
  ui_extra=model.DeveloperUIExtra(items=[model.MemoryLoadedUIItem(files=loaded_files)]),
533
506
  )
534
507
  return None
@@ -546,68 +519,23 @@ async def last_path_memory_reminder(
546
519
  return None
547
520
 
548
521
  paths = list(session.file_tracker.keys())
549
- memories: list[Memory] = []
550
-
551
- cwd = Path.cwd().resolve()
552
- seen_memory_files: set[str] = set()
553
-
554
- for p_str in paths:
555
- p = Path(p_str)
556
- full = (cwd / p).resolve() if not p.is_absolute() else p.resolve()
557
- try:
558
- _ = full.relative_to(cwd)
559
- except ValueError:
560
- # Not under cwd; skip
561
- continue
562
-
563
- # Determine the deepest directory to scan (file parent or directory itself)
564
- deepest_dir = full if full.is_dir() else full.parent
565
-
566
- # Iterate each directory level from cwd to deepest_dir
567
- try:
568
- rel_parts = deepest_dir.relative_to(cwd).parts
569
- except ValueError:
570
- # Shouldn't happen due to check above, but guard anyway
571
- continue
572
-
573
- current_dir = cwd
574
- for part in rel_parts:
575
- current_dir = current_dir / part
576
- for fname in MEMORY_FILE_NAMES:
577
- mem_path = current_dir / fname
578
- mem_path_str = str(mem_path)
579
- if mem_path_str in seen_memory_files or _is_memory_loaded(session, mem_path_str):
580
- continue
581
- if mem_path.exists() and mem_path.is_file():
582
- try:
583
- text = mem_path.read_text(encoding="utf-8", errors="replace")
584
- except (PermissionError, UnicodeDecodeError, OSError):
585
- continue
586
- _mark_memory_loaded(session, mem_path_str)
587
- seen_memory_files.add(mem_path_str)
588
- memories.append(
589
- Memory(
590
- path=mem_path_str,
591
- instruction="project instructions, discovered near last accessed path",
592
- content=text,
593
- )
594
- )
522
+ memories = discover_memory_files_near_paths(
523
+ paths,
524
+ work_dir=session.work_dir,
525
+ is_memory_loaded=lambda p: _is_memory_loaded(session, p),
526
+ mark_memory_loaded=lambda p: _mark_memory_loaded(session, p),
527
+ )
595
528
 
596
529
  if len(memories) > 0:
597
- memories_str = "\n\n".join(
598
- [f"Contents of {memory.path} ({memory.instruction}):\n\n{memory.content}" for memory in memories]
599
- )
600
530
  loaded_files = [
601
531
  model.MemoryFileLoaded(path=memory.path, mentioned_patterns=_extract_at_patterns(memory.content))
602
532
  for memory in memories
603
533
  ]
604
534
  return message.DeveloperMessage(
605
- parts=message.text_parts_from_str(
606
- f"""<system-reminder>{memories_str}
607
- </system-reminder>"""
608
- ),
535
+ parts=message.text_parts_from_str(format_memories_reminder(memories, include_header=False)),
609
536
  ui_extra=model.DeveloperUIExtra(items=[model.MemoryLoadedUIItem(files=loaded_files)]),
610
537
  )
538
+ return None
611
539
 
612
540
 
613
541
  ALL_REMINDERS = [
klaude_code/core/turn.py CHANGED
@@ -194,11 +194,17 @@ class TurnExecutor:
194
194
  and self._turn_result.assistant_message is not None
195
195
  and self._turn_result.assistant_message.parts
196
196
  ):
197
- session_ctx.append_history([self._turn_result.assistant_message])
198
- # Add continuation prompt to avoid Anthropic thinking block requirement
199
- session_ctx.append_history(
200
- [message.UserMessage(parts=[message.TextPart(text="<system>continue</system>")])]
197
+ # Discard partial message if it only contains thinking parts
198
+ has_non_thinking = any(
199
+ not isinstance(part, message.ThinkingTextPart)
200
+ for part in self._turn_result.assistant_message.parts
201
201
  )
202
+ if has_non_thinking:
203
+ session_ctx.append_history([self._turn_result.assistant_message])
204
+ # Add continuation prompt to avoid Anthropic thinking block requirement
205
+ session_ctx.append_history(
206
+ [message.UserMessage(parts=[message.TextPart(text="<system>continue</system>")])]
207
+ )
202
208
  yield events.TurnEndEvent(session_id=session_ctx.session_id)
203
209
  raise TurnError(self._turn_result.stream_error.error)
204
210
 
@@ -14,6 +14,9 @@ __all__ = [
14
14
  "AssistantTextDeltaEvent",
15
15
  "AssistantTextEndEvent",
16
16
  "AssistantTextStartEvent",
17
+ "BashCommandEndEvent",
18
+ "BashCommandOutputDeltaEvent",
19
+ "BashCommandStartEvent",
17
20
  "CommandOutputEvent",
18
21
  "CompactionEndEvent",
19
22
  "CompactionStartEvent",
@@ -81,6 +84,19 @@ class CommandOutputEvent(Event):
81
84
  is_error: bool = False
82
85
 
83
86
 
87
+ class BashCommandStartEvent(Event):
88
+ command: str
89
+
90
+
91
+ class BashCommandOutputDeltaEvent(Event):
92
+ content: str
93
+
94
+
95
+ class BashCommandEndEvent(Event):
96
+ exit_code: int | None = None
97
+ cancelled: bool = False
98
+
99
+
84
100
  class TaskStartEvent(Event):
85
101
  sub_agent_state: model.SubAgentState | None = None
86
102
  model_id: str | None = None
@@ -166,6 +182,7 @@ class WelcomeEvent(Event):
166
182
  llm_config: llm_param.LLMConfigParameter
167
183
  show_klaude_code_info: bool = True
168
184
  loaded_skills: dict[str, list[str]] = Field(default_factory=dict)
185
+ loaded_memories: dict[str, list[str]] = Field(default_factory=dict)
169
186
 
170
187
 
171
188
  class ErrorEvent(Event):
@@ -24,6 +24,7 @@ class OperationType(Enum):
24
24
  """Enumeration of supported operation types."""
25
25
 
26
26
  RUN_AGENT = "run_agent"
27
+ RUN_BASH = "run_bash"
27
28
  CONTINUE_AGENT = "continue_agent"
28
29
  COMPACT_SESSION = "compact_session"
29
30
  CHANGE_MODEL = "change_model"
@@ -60,6 +61,17 @@ class RunAgentOperation(Operation):
60
61
  await handler.handle_run_agent(self)
61
62
 
62
63
 
64
+ class RunBashOperation(Operation):
65
+ """Operation for running a user-entered bash-mode command."""
66
+
67
+ type: OperationType = OperationType.RUN_BASH
68
+ session_id: str
69
+ command: str
70
+
71
+ async def execute(self, handler: OperationHandler) -> None:
72
+ await handler.handle_run_bash(self)
73
+
74
+
63
75
  class ContinueAgentOperation(Operation):
64
76
  """Operation for continuing an agent task without adding a new user message.
65
77
 
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
22
22
  InterruptOperation,
23
23
  ResumeSessionOperation,
24
24
  RunAgentOperation,
25
+ RunBashOperation,
25
26
  )
26
27
 
27
28
 
@@ -32,6 +33,10 @@ class OperationHandler(Protocol):
32
33
  """Handle a run agent operation."""
33
34
  ...
34
35
 
36
+ async def handle_run_bash(self, operation: RunBashOperation) -> None:
37
+ """Handle a bash-mode command execution operation."""
38
+ ...
39
+
35
40
  async def handle_continue_agent(self, operation: ContinueAgentOperation) -> None:
36
41
  """Handle a continue agent operation (resume without adding user message)."""
37
42
  ...
@@ -34,7 +34,7 @@ def select_session_sync(session_ids: list[str] | None = None) -> str | None:
34
34
  if msg == "⋮":
35
35
  title.append(("class:msg", f" {msg}\n"))
36
36
  else:
37
- prefix = "└─" if is_last else "├─"
37
+ prefix = "╰─" if is_last else "├─"
38
38
  title.append(("fg:ansibrightblack dim", f" {prefix} "))
39
39
  title.append(("class:msg", f"{msg}\n"))
40
40
  title.append(("", "\n"))
@@ -38,6 +38,21 @@ class RenderCommandOutput(RenderCommand):
38
38
  event: events.CommandOutputEvent
39
39
 
40
40
 
41
+ @dataclass(frozen=True, slots=True)
42
+ class RenderBashCommandStart(RenderCommand):
43
+ event: events.BashCommandStartEvent
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AppendBashCommandOutput(RenderCommand):
48
+ event: events.BashCommandOutputDeltaEvent
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class RenderBashCommandEnd(RenderCommand):
53
+ event: events.BashCommandEndEvent
54
+
55
+
41
56
  @dataclass(frozen=True, slots=True)
42
57
  class RenderTurnStart(RenderCommand):
43
58
  event: events.TurnStartEvent
@@ -1,5 +1,4 @@
1
1
  from rich.console import RenderableType
2
- from rich.padding import Padding
3
2
  from rich.table import Table
4
3
  from rich.text import Text
5
4
 
@@ -19,7 +18,7 @@ def render_command_output(e: events.CommandOutputEvent) -> RenderableType:
19
18
  case _:
20
19
  content = e.content or "(no content)"
21
20
  style = ThemeKey.TOOL_RESULT if not e.is_error else ThemeKey.ERROR
22
- return Padding.indent(truncate_middle(content, base_style=style), level=2)
21
+ return truncate_middle(content, base_style=style)
23
22
 
24
23
 
25
24
  def _format_tokens(tokens: int) -> str:
@@ -44,7 +43,7 @@ def _format_cost(cost: float | None, currency: str = "USD") -> str:
44
43
  def _render_fork_session_output(e: events.CommandOutputEvent) -> RenderableType:
45
44
  """Render fork session output with usage instructions."""
46
45
  if not isinstance(e.ui_extra, model.SessionIdUIExtra):
47
- return Padding.indent(Text(e.content, style=ThemeKey.TOOL_RESULT), level=2)
46
+ return Text(e.content, style=ThemeKey.TOOL_RESULT)
48
47
 
49
48
  grid = Table.grid(padding=(0, 1))
50
49
  session_id = e.ui_extra.session_id
@@ -54,7 +53,7 @@ def _render_fork_session_output(e: events.CommandOutputEvent) -> RenderableType:
54
53
  grid.add_row(Text("Session forked. Resume command copied to clipboard:", style=ThemeKey.TOOL_RESULT))
55
54
  grid.add_row(Text(f" klaude -r {short_id}", style=ThemeKey.TOOL_RESULT_BOLD))
56
55
 
57
- return Padding.indent(grid, level=2)
56
+ return grid
58
57
 
59
58
 
60
59
  def _render_status_output(e: events.CommandOutputEvent) -> RenderableType:
@@ -95,4 +94,4 @@ def _render_status_output(e: events.CommandOutputEvent) -> RenderableType:
95
94
  usage_detail = "(no usage data)"
96
95
  table.add_row(f"{model_label}:", usage_detail)
97
96
 
98
- return Padding.indent(table, level=2)
97
+ return table
@@ -6,7 +6,7 @@ from klaude_code.tui.components.common import create_grid
6
6
  from klaude_code.tui.components.rich.theme import ThemeKey
7
7
  from klaude_code.tui.components.tools import render_path
8
8
 
9
- REMINDER_BULLET = " ⧉"
9
+ REMINDER_BULLET = "⧉"
10
10
 
11
11
 
12
12
  def need_render_developer_message(e: events.DeveloperMessageEvent) -> bool:
@@ -56,8 +56,6 @@ def render_developer_message(e: events.DeveloperMessageEvent) -> RenderableType:
56
56
  text = "Todo hasn't been updated recently"
57
57
  case "empty":
58
58
  text = "Todo list is empty"
59
- case _:
60
- text = "Todo reminder"
61
59
  grid = create_grid()
62
60
  grid.add_row(
63
61
  Text(REMINDER_BULLET, style=ThemeKey.REMINDER),
@@ -34,8 +34,8 @@ def _render_task_metadata_block(
34
34
  content = Text()
35
35
  if metadata.provider is not None:
36
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))
37
+ content.append_text(Text("/", style=ThemeKey.METADATA))
38
+ content.append_text(Text(metadata.model_name, style=ThemeKey.METADATA))
39
39
  if metadata.description:
40
40
  content.append_text(Text(" ", style=ThemeKey.METADATA)).append_text(
41
41
  Text(metadata.description, style=ThemeKey.METADATA_ITALIC)
@@ -47,18 +47,18 @@ def _render_task_metadata_block(
47
47
  if metadata.usage is not None:
48
48
  # Tokens: ↑37k ◎5k ↓907 ∿45k ⌗ 100
49
49
  token_text = Text()
50
- token_text.append("↑", style=ThemeKey.METADATA_DIM)
50
+ token_text.append("↑", style=ThemeKey.METADATA)
51
51
  token_text.append(format_number(metadata.usage.input_tokens), style=ThemeKey.METADATA)
52
52
  if metadata.usage.cached_tokens > 0:
53
- token_text.append(" ◎", style=ThemeKey.METADATA_DIM)
53
+ token_text.append(" ◎", style=ThemeKey.METADATA)
54
54
  token_text.append(format_number(metadata.usage.cached_tokens), style=ThemeKey.METADATA)
55
- token_text.append(" ↓", style=ThemeKey.METADATA_DIM)
55
+ token_text.append(" ↓", style=ThemeKey.METADATA)
56
56
  token_text.append(format_number(metadata.usage.output_tokens), style=ThemeKey.METADATA)
57
57
  if metadata.usage.reasoning_tokens > 0:
58
- token_text.append(" ∿", style=ThemeKey.METADATA_DIM)
58
+ token_text.append(" ∿", style=ThemeKey.METADATA)
59
59
  token_text.append(format_number(metadata.usage.reasoning_tokens), style=ThemeKey.METADATA)
60
60
  if metadata.usage.image_tokens > 0:
61
- token_text.append(" ⊡", style=ThemeKey.METADATA_DIM)
61
+ token_text.append(" ⊡", style=ThemeKey.METADATA)
62
62
  token_text.append(format_number(metadata.usage.image_tokens), style=ThemeKey.METADATA)
63
63
  parts.append(token_text)
64
64
 
@@ -66,7 +66,7 @@ def _render_task_metadata_block(
66
66
  if metadata.usage is not None and metadata.usage.total_cost is not None:
67
67
  parts.append(
68
68
  Text.assemble(
69
- (currency_symbol, ThemeKey.METADATA_DIM),
69
+ (currency_symbol, ThemeKey.METADATA),
70
70
  (f"{metadata.usage.total_cost:.4f}", ThemeKey.METADATA),
71
71
  )
72
72
  )
@@ -79,9 +79,9 @@ def _render_task_metadata_block(
79
79
  parts.append(
80
80
  Text.assemble(
81
81
  (context_size, ThemeKey.METADATA),
82
- ("/", ThemeKey.METADATA_DIM),
82
+ ("/", ThemeKey.METADATA),
83
83
  (effective_limit_str, ThemeKey.METADATA),
84
- (f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA_DIM),
84
+ (f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA),
85
85
  )
86
86
  )
87
87
 
@@ -90,7 +90,7 @@ def _render_task_metadata_block(
90
90
  parts.append(
91
91
  Text.assemble(
92
92
  (f"{metadata.usage.throughput_tps:.1f}", ThemeKey.METADATA),
93
- ("tps", ThemeKey.METADATA_DIM),
93
+ ("tps", ThemeKey.METADATA),
94
94
  )
95
95
  )
96
96
 
@@ -101,7 +101,7 @@ def _render_task_metadata_block(
101
101
  parts.append(
102
102
  Text.assemble(
103
103
  (ftl_str, ThemeKey.METADATA),
104
- ("-ftl", ThemeKey.METADATA_DIM),
104
+ ("-ftl", ThemeKey.METADATA),
105
105
  )
106
106
  )
107
107
 
@@ -110,7 +110,7 @@ def _render_task_metadata_block(
110
110
  parts.append(
111
111
  Text.assemble(
112
112
  (f"{metadata.task_duration_s:.1f}", ThemeKey.METADATA),
113
- ("s", ThemeKey.METADATA_DIM),
113
+ ("s", ThemeKey.METADATA),
114
114
  )
115
115
  )
116
116
 
@@ -120,13 +120,13 @@ def _render_task_metadata_block(
120
120
  parts.append(
121
121
  Text.assemble(
122
122
  (str(metadata.turn_count), ThemeKey.METADATA),
123
- (suffix, ThemeKey.METADATA_DIM),
123
+ (suffix, ThemeKey.METADATA),
124
124
  )
125
125
  )
126
126
 
127
127
  if parts:
128
- content.append_text(Text(" ", style=ThemeKey.METADATA_DIM))
129
- content.append_text(Text(" ", style=ThemeKey.METADATA_DIM).join(parts))
128
+ content.append_text(Text(" ", style=ThemeKey.METADATA))
129
+ content.append_text(Text(" ", style=ThemeKey.METADATA).join(parts))
130
130
 
131
131
  grid.add_row(mark, content)
132
132
  return grid
@@ -138,14 +138,14 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
138
138
 
139
139
  has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
140
140
  # Use an extra space for the main agent mark to align with two-character marks (├─, └─)
141
- main_mark_text = ""
141
+ main_mark_text = ""
142
142
  main_mark = Text(main_mark_text, style=ThemeKey.METADATA)
143
143
 
144
144
  renderables.append(_render_task_metadata_block(e.metadata.main_agent, mark=main_mark, show_context_and_time=True))
145
145
 
146
146
  # Render each sub-agent metadata block
147
147
  for meta in e.metadata.sub_agent_task_metadata:
148
- sub_mark = Text(" └", style=ThemeKey.METADATA_DIM)
148
+ sub_mark = Text(" └", style=ThemeKey.METADATA)
149
149
  renderables.append(_render_task_metadata_block(meta, mark=sub_mark, show_context_and_time=True))
150
150
 
151
151
  # Add total cost line when there are sub-agents
@@ -162,11 +162,11 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
162
162
 
163
163
  currency_symbol = "¥" if currency == "CNY" else "$"
164
164
  total_line = Text.assemble(
165
- (" └", ThemeKey.METADATA_DIM),
166
- (" Σ ", ThemeKey.METADATA_DIM),
167
- ("total ", ThemeKey.METADATA_DIM),
168
- (currency_symbol, ThemeKey.METADATA_DIM),
169
- (f"{total_cost:.4f}", ThemeKey.METADATA_DIM),
165
+ (" └", ThemeKey.METADATA),
166
+ (" Σ ", ThemeKey.METADATA),
167
+ ("total ", ThemeKey.METADATA),
168
+ (currency_symbol, ThemeKey.METADATA),
169
+ (f"{total_cost:.4f}", ThemeKey.METADATA),
170
170
  )
171
171
 
172
172
  renderables.append(total_line)
@@ -14,12 +14,12 @@ from rich.style import StyleType
14
14
  if TYPE_CHECKING:
15
15
  from rich.console import Console, ConsoleOptions, RenderResult
16
16
 
17
- # Box drawing characters
18
- TOP_LEFT = "" # ┌
19
- TOP_RIGHT = "" # ┐
20
- BOTTOM_LEFT = "" # └
21
- BOTTOM_RIGHT = "" # ┘
22
- HORIZONTAL = "─" # ─
17
+ # Box drawing characters (rounded corners)
18
+ TOP_LEFT = ""
19
+ TOP_RIGHT = ""
20
+ BOTTOM_LEFT = ""
21
+ BOTTOM_RIGHT = ""
22
+ HORIZONTAL = "─"
23
23
 
24
24
 
25
25
  class CodePanel(JupyterMixin):
@@ -32,10 +32,10 @@ class CodePanel(JupyterMixin):
32
32
  >>> console.print(CodePanel(Syntax(code, "python")))
33
33
 
34
34
  Renders as:
35
- ┌──────────────────────────┐
35
+ ╭──────────────────────────╮
36
36
  code line 1
37
37
  code line 2
38
- └──────────────────────────┘
38
+ ╰──────────────────────────╯
39
39
  """
40
40
 
41
41
  def __init__(
@@ -44,7 +44,9 @@ class CodePanel(JupyterMixin):
44
44
  *,
45
45
  border_style: StyleType = "none",
46
46
  expand: bool = False,
47
- padding: int = 1,
47
+ padding: int = 0,
48
+ title: str | None = None,
49
+ title_style: StyleType = "none",
48
50
  ) -> None:
49
51
  """Initialize the CodePanel.
50
52
 
@@ -52,12 +54,16 @@ class CodePanel(JupyterMixin):
52
54
  renderable: A console renderable object.
53
55
  border_style: The style of the border. Defaults to "none".
54
56
  expand: If True, expand to fill available width. Defaults to False.
55
- padding: Left/right padding for content. Defaults to 1.
57
+ padding: Left/right padding for content. Defaults to 0.
58
+ title: Optional title to display in the top border. Defaults to None.
59
+ title_style: The style of the title. Defaults to "none".
56
60
  """
57
61
  self.renderable = renderable
58
62
  self.border_style = border_style
59
63
  self.expand = expand
60
64
  self.padding = padding
65
+ self.title = title
66
+ self.title_style = title_style
61
67
 
62
68
  @staticmethod
63
69
  def _measure_max_line_cells(lines: list[list[Segment]]) -> int:
@@ -93,11 +99,20 @@ class CodePanel(JupyterMixin):
93
99
  new_line = Segment.line()
94
100
  pad_segment = Segment(" " * pad) if pad > 0 else None
95
101
 
96
- # Top border: ┌───...───┐
97
- top_border = (
98
- TOP_LEFT + (HORIZONTAL * (border_width - 2)) + TOP_RIGHT if border_width >= 2 else HORIZONTAL * border_width
99
- )
100
- yield Segment(top_border, border_style)
102
+ # Top border: ╭───...───╮ or ╭ title ───...───╮
103
+ if self.title and border_width >= len(self.title) + 4:
104
+ title_part = f" {self.title} "
105
+ title_style = console.get_style(self.title_style)
106
+ remaining = border_width - 2 - len(title_part)
107
+ yield Segment(TOP_LEFT, border_style)
108
+ yield Segment(title_part, title_style)
109
+ yield Segment((HORIZONTAL * remaining) + TOP_RIGHT, border_style)
110
+ elif border_width >= 2:
111
+ top_border = TOP_LEFT + (HORIZONTAL * (border_width - 2)) + TOP_RIGHT
112
+ yield Segment(top_border, border_style)
113
+ else:
114
+ top_border = HORIZONTAL * border_width
115
+ yield Segment(top_border, border_style)
101
116
  yield new_line
102
117
 
103
118
  # Content lines with padding
@@ -109,7 +124,7 @@ class CodePanel(JupyterMixin):
109
124
  yield pad_segment
110
125
  yield new_line
111
126
 
112
- # Bottom border: └───...───┘
127
+ # Bottom border: ╰───...───╯
113
128
  bottom_border = (
114
129
  BOTTOM_LEFT + (HORIZONTAL * (border_width - 2)) + BOTTOM_RIGHT
115
130
  if border_width >= 2