klaude-code 2.9.1__py3-none-any.whl → 2.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. klaude_code/app/runtime.py +5 -1
  2. klaude_code/cli/cost_cmd.py +4 -4
  3. klaude_code/cli/list_model.py +1 -2
  4. klaude_code/cli/main.py +10 -0
  5. klaude_code/config/assets/builtin_config.yaml +15 -14
  6. klaude_code/const.py +4 -3
  7. klaude_code/core/agent_profile.py +23 -0
  8. klaude_code/core/bash_mode.py +276 -0
  9. klaude_code/core/executor.py +40 -7
  10. klaude_code/core/manager/llm_clients.py +1 -0
  11. klaude_code/core/manager/llm_clients_builder.py +2 -2
  12. klaude_code/core/memory.py +140 -0
  13. klaude_code/core/prompts/prompt-sub-agent-web.md +2 -2
  14. klaude_code/core/reminders.py +17 -89
  15. klaude_code/core/tool/offload.py +4 -4
  16. klaude_code/core/tool/web/web_fetch_tool.md +2 -1
  17. klaude_code/core/tool/web/web_fetch_tool.py +1 -1
  18. klaude_code/core/turn.py +9 -4
  19. klaude_code/protocol/events.py +17 -0
  20. klaude_code/protocol/op.py +12 -0
  21. klaude_code/protocol/op_handler.py +5 -0
  22. klaude_code/session/templates/mermaid_viewer.html +85 -0
  23. klaude_code/tui/command/resume_cmd.py +1 -1
  24. klaude_code/tui/commands.py +15 -0
  25. klaude_code/tui/components/command_output.py +4 -5
  26. klaude_code/tui/components/developer.py +1 -3
  27. klaude_code/tui/components/metadata.py +28 -25
  28. klaude_code/tui/components/rich/code_panel.py +31 -16
  29. klaude_code/tui/components/rich/markdown.py +56 -124
  30. klaude_code/tui/components/rich/theme.py +22 -12
  31. klaude_code/tui/components/thinking.py +0 -35
  32. klaude_code/tui/components/tools.py +4 -2
  33. klaude_code/tui/components/user_input.py +49 -59
  34. klaude_code/tui/components/welcome.py +47 -2
  35. klaude_code/tui/display.py +14 -6
  36. klaude_code/tui/input/completers.py +8 -0
  37. klaude_code/tui/input/key_bindings.py +37 -1
  38. klaude_code/tui/input/prompt_toolkit.py +57 -31
  39. klaude_code/tui/machine.py +108 -28
  40. klaude_code/tui/renderer.py +117 -19
  41. klaude_code/tui/runner.py +22 -0
  42. klaude_code/tui/terminal/notifier.py +11 -12
  43. klaude_code/tui/terminal/selector.py +1 -1
  44. klaude_code/ui/terminal/title.py +4 -2
  45. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/METADATA +1 -1
  46. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/RECORD +48 -47
  47. klaude_code/tui/components/assistant.py +0 -2
  48. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/WHEEL +0 -0
  49. {klaude_code-2.9.1.dist-info → klaude_code-2.10.1.dist-info}/entry_points.txt +0 -0
@@ -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,21 @@ 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)
51
- token_text.append(format_number(metadata.usage.input_tokens), style=ThemeKey.METADATA)
50
+ input_tokens = max(metadata.usage.input_tokens - metadata.usage.cached_tokens, 0)
51
+ output_tokens = max(metadata.usage.output_tokens - metadata.usage.reasoning_tokens, 0)
52
+
53
+ token_text.append("↑", style=ThemeKey.METADATA)
54
+ token_text.append(format_number(input_tokens), style=ThemeKey.METADATA)
52
55
  if metadata.usage.cached_tokens > 0:
53
- token_text.append(" ◎", style=ThemeKey.METADATA_DIM)
56
+ token_text.append(" ◎", style=ThemeKey.METADATA)
54
57
  token_text.append(format_number(metadata.usage.cached_tokens), style=ThemeKey.METADATA)
55
- token_text.append(" ↓", style=ThemeKey.METADATA_DIM)
56
- token_text.append(format_number(metadata.usage.output_tokens), style=ThemeKey.METADATA)
58
+ token_text.append(" ↓", style=ThemeKey.METADATA)
59
+ token_text.append(format_number(output_tokens), style=ThemeKey.METADATA)
57
60
  if metadata.usage.reasoning_tokens > 0:
58
- token_text.append(" ∿", style=ThemeKey.METADATA_DIM)
61
+ token_text.append(" ∿", style=ThemeKey.METADATA)
59
62
  token_text.append(format_number(metadata.usage.reasoning_tokens), style=ThemeKey.METADATA)
60
63
  if metadata.usage.image_tokens > 0:
61
- token_text.append(" ⊡", style=ThemeKey.METADATA_DIM)
64
+ token_text.append(" ⊡", style=ThemeKey.METADATA)
62
65
  token_text.append(format_number(metadata.usage.image_tokens), style=ThemeKey.METADATA)
63
66
  parts.append(token_text)
64
67
 
@@ -66,7 +69,7 @@ def _render_task_metadata_block(
66
69
  if metadata.usage is not None and metadata.usage.total_cost is not None:
67
70
  parts.append(
68
71
  Text.assemble(
69
- (currency_symbol, ThemeKey.METADATA_DIM),
72
+ (currency_symbol, ThemeKey.METADATA),
70
73
  (f"{metadata.usage.total_cost:.4f}", ThemeKey.METADATA),
71
74
  )
72
75
  )
@@ -79,9 +82,9 @@ def _render_task_metadata_block(
79
82
  parts.append(
80
83
  Text.assemble(
81
84
  (context_size, ThemeKey.METADATA),
82
- ("/", ThemeKey.METADATA_DIM),
85
+ ("/", ThemeKey.METADATA),
83
86
  (effective_limit_str, ThemeKey.METADATA),
84
- (f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA_DIM),
87
+ (f"({metadata.usage.context_usage_percent:.1f}%)", ThemeKey.METADATA),
85
88
  )
86
89
  )
87
90
 
@@ -90,7 +93,7 @@ def _render_task_metadata_block(
90
93
  parts.append(
91
94
  Text.assemble(
92
95
  (f"{metadata.usage.throughput_tps:.1f}", ThemeKey.METADATA),
93
- ("tps", ThemeKey.METADATA_DIM),
96
+ ("tps", ThemeKey.METADATA),
94
97
  )
95
98
  )
96
99
 
@@ -101,7 +104,7 @@ def _render_task_metadata_block(
101
104
  parts.append(
102
105
  Text.assemble(
103
106
  (ftl_str, ThemeKey.METADATA),
104
- ("-ftl", ThemeKey.METADATA_DIM),
107
+ ("-ftl", ThemeKey.METADATA),
105
108
  )
106
109
  )
107
110
 
@@ -110,7 +113,7 @@ def _render_task_metadata_block(
110
113
  parts.append(
111
114
  Text.assemble(
112
115
  (f"{metadata.task_duration_s:.1f}", ThemeKey.METADATA),
113
- ("s", ThemeKey.METADATA_DIM),
116
+ ("s", ThemeKey.METADATA),
114
117
  )
115
118
  )
116
119
 
@@ -120,13 +123,13 @@ def _render_task_metadata_block(
120
123
  parts.append(
121
124
  Text.assemble(
122
125
  (str(metadata.turn_count), ThemeKey.METADATA),
123
- (suffix, ThemeKey.METADATA_DIM),
126
+ (suffix, ThemeKey.METADATA),
124
127
  )
125
128
  )
126
129
 
127
130
  if parts:
128
- content.append_text(Text(" ", style=ThemeKey.METADATA_DIM))
129
- content.append_text(Text(" ", style=ThemeKey.METADATA_DIM).join(parts))
131
+ content.append_text(Text(" ", style=ThemeKey.METADATA))
132
+ content.append_text(Text(" ", style=ThemeKey.METADATA).join(parts))
130
133
 
131
134
  grid.add_row(mark, content)
132
135
  return grid
@@ -138,14 +141,14 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
138
141
 
139
142
  has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
140
143
  # Use an extra space for the main agent mark to align with two-character marks (├─, └─)
141
- main_mark_text = ""
144
+ main_mark_text = ""
142
145
  main_mark = Text(main_mark_text, style=ThemeKey.METADATA)
143
146
 
144
147
  renderables.append(_render_task_metadata_block(e.metadata.main_agent, mark=main_mark, show_context_and_time=True))
145
148
 
146
149
  # Render each sub-agent metadata block
147
150
  for meta in e.metadata.sub_agent_task_metadata:
148
- sub_mark = Text(" └", style=ThemeKey.METADATA_DIM)
151
+ sub_mark = Text(" └", style=ThemeKey.METADATA)
149
152
  renderables.append(_render_task_metadata_block(meta, mark=sub_mark, show_context_and_time=True))
150
153
 
151
154
  # Add total cost line when there are sub-agents
@@ -162,11 +165,11 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
162
165
 
163
166
  currency_symbol = "¥" if currency == "CNY" else "$"
164
167
  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),
168
+ (" └", ThemeKey.METADATA),
169
+ (" Σ ", ThemeKey.METADATA),
170
+ ("total ", ThemeKey.METADATA),
171
+ (currency_symbol, ThemeKey.METADATA),
172
+ (f"{total_cost:.4f}", ThemeKey.METADATA),
170
173
  )
171
174
 
172
175
  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
@@ -22,12 +22,10 @@ from rich.text import Text
22
22
  from rich.theme import Theme
23
23
 
24
24
  from klaude_code.const import (
25
- MARKDOWN_RIGHT_MARGIN,
26
25
  MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
27
26
  MARKDOWN_STREAM_SYNCHRONIZED_OUTPUT_ENABLED,
28
27
  UI_REFRESH_RATE_FPS,
29
28
  )
30
- from klaude_code.tui.components.rich.code_panel import CodePanel
31
29
 
32
30
  _THINKING_HTML_BLOCK_RE = re.compile(
33
31
  r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
@@ -91,10 +89,15 @@ class ThinkingHTMLBlock(MarkdownElement):
91
89
 
92
90
 
93
91
  class NoInsetCodeBlock(CodeBlock):
94
- """A code block with syntax highlighting and no padding."""
92
+ """A code block with syntax highlighting using markdown fence style."""
95
93
 
96
94
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
97
95
  code = str(self.text).rstrip()
96
+ lang = self.lexer_name if self.lexer_name != "text" else ""
97
+ fence_style = console.get_style("markdown.code.fence", default="none")
98
+ fence_title_style = console.get_style("markdown.code.fence.title", default="none")
99
+
100
+ yield Text.assemble(("```", fence_style), (lang, fence_title_style))
98
101
  syntax = Syntax(
99
102
  code,
100
103
  self.lexer_name,
@@ -102,16 +105,21 @@ class NoInsetCodeBlock(CodeBlock):
102
105
  word_wrap=True,
103
106
  padding=(0, 0),
104
107
  )
105
- yield CodePanel(syntax, border_style="markdown.code.border")
108
+ yield syntax
109
+ yield Text("```", style=fence_style)
106
110
 
107
111
 
108
112
  class ThinkingCodeBlock(CodeBlock):
109
- """A code block for thinking content that uses grey styling instead of syntax highlighting."""
113
+ """A code block for thinking content that uses simple ``` delimiters."""
110
114
 
111
115
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
112
116
  code = str(self.text).rstrip()
113
- text = Text(code, "markdown.code.block")
114
- yield CodePanel(text, border_style="markdown.code.border")
117
+ fence_style = "markdown.code.fence"
118
+ code_style = "markdown.code.block"
119
+ lang = self.lexer_name if self.lexer_name != "text" else ""
120
+ yield Text(f"```{lang}", style=fence_style)
121
+ yield Text(code, style=code_style)
122
+ yield Text("```", style=fence_style)
115
123
 
116
124
 
117
125
  class Divider(MarkdownElement):
@@ -289,7 +297,7 @@ class MarkdownStream:
289
297
  mark: str | None = None,
290
298
  mark_style: StyleType | None = None,
291
299
  left_margin: int = 0,
292
- right_margin: int = MARKDOWN_RIGHT_MARGIN,
300
+ right_margin: int = 0,
293
301
  markdown_class: Callable[..., Markdown] | None = None,
294
302
  image_callback: Callable[[str], None] | None = None,
295
303
  ) -> None:
@@ -325,10 +333,10 @@ class MarkdownStream:
325
333
 
326
334
  self.theme = theme
327
335
  self.console = console
328
- self.mark: str | None = mark
329
- self.mark_style: StyleType | None = mark_style
330
-
331
336
  self.left_margin: int = max(left_margin, 0)
337
+ # Default mark "•" when left_margin >= 2 and no mark specified
338
+ self.mark: str | None = mark if mark is not None else ("•" if self.left_margin >= 2 else None)
339
+ self.mark_style: StyleType | None = mark_style
332
340
 
333
341
  self.right_margin: int = max(right_margin, 0)
334
342
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
@@ -395,25 +403,12 @@ class MarkdownStream:
395
403
  return 0
396
404
 
397
405
  top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
398
- if not top_level:
406
+ if len(top_level) < 2:
399
407
  return 0
400
408
 
401
409
  last = top_level[-1]
402
410
  assert last.map is not None
403
411
 
404
- # Lists are a special case: markdown-it-py treats the whole list as one
405
- # top-level block, which would keep the entire list in the live area
406
- # until the list ends.
407
- #
408
- # For streaming UX, we want all but the final list item to be eligible
409
- # for stabilization, leaving only the *last* item in the live area.
410
- if last.type in {"bullet_list_open", "ordered_list_open"}:
411
- stable_line = self._compute_list_item_boundary(tokens, last)
412
- return max(stable_line, 0)
413
-
414
- if len(top_level) < 2:
415
- return 0
416
-
417
412
  # When the buffer ends mid-line, markdown-it-py can temporarily classify
418
413
  # some lines as a thematic break (hr). For example, a trailing "- --"
419
414
  # parses as an hr, but appending a non-hr character ("- --0") turns it
@@ -430,59 +425,6 @@ class MarkdownStream:
430
425
  start_line = last.map[0]
431
426
  return max(start_line, 0)
432
427
 
433
- def _compute_list_item_boundary(self, tokens: list[Token], list_open: Token) -> int:
434
- """Return the start line of the last list item in a list.
435
-
436
- This allows stabilizing all list items except the final one.
437
- """
438
-
439
- if list_open.map is None:
440
- return 0
441
-
442
- list_start, list_end = list_open.map
443
- item_level = list_open.level + 1
444
-
445
- last_item_start: int | None = None
446
- for token in tokens:
447
- if token.type != "list_item_open":
448
- continue
449
- if token.level != item_level:
450
- continue
451
- if token.map is None:
452
- continue
453
- start_line = token.map[0]
454
- if start_line < list_start or start_line >= list_end:
455
- continue
456
- last_item_start = start_line
457
-
458
- return last_item_start if last_item_start is not None else list_start
459
-
460
- def _stable_boundary_continues_list(self, text: str, stable_line: int, *, final: bool) -> bool:
461
- """Whether the stable/live split point is inside the final top-level list."""
462
-
463
- if final:
464
- return False
465
- if stable_line <= 0:
466
- return False
467
-
468
- try:
469
- tokens = self._parser.parse(text)
470
- except Exception:
471
- return False
472
-
473
- top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
474
- if not top_level:
475
- return False
476
-
477
- last = top_level[-1]
478
- if last.type not in {"bullet_list_open", "ordered_list_open"}:
479
- return False
480
- if last.map is None:
481
- return False
482
-
483
- list_start, list_end = last.map
484
- return list_start < stable_line < list_end
485
-
486
428
  def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
487
429
  """Split full markdown into stable and live sources.
488
430
 
@@ -512,14 +454,7 @@ class MarkdownStream:
512
454
  return "", text, 0
513
455
  return stable_source, live_source, stable_line
514
456
 
515
- def render_stable_ansi(
516
- self,
517
- stable_source: str,
518
- *,
519
- has_live_suffix: bool,
520
- final: bool,
521
- continues_list: bool = False,
522
- ) -> tuple[str, list[str]]:
457
+ def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
523
458
  """Render stable prefix to ANSI, preserving inter-block spacing.
524
459
 
525
460
  Returns:
@@ -529,14 +464,10 @@ class MarkdownStream:
529
464
  return "", []
530
465
 
531
466
  render_source = stable_source
532
- if not final and has_live_suffix and not continues_list:
467
+ if not final and has_live_suffix:
533
468
  render_source = self._append_nonfinal_sentinel(stable_source)
534
469
 
535
470
  lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
536
-
537
- if continues_list:
538
- while lines and not lines[-1].strip():
539
- lines.pop()
540
471
  return "".join(lines), images
541
472
 
542
473
  def _append_nonfinal_sentinel(self, stable_source: str) -> str:
@@ -590,12 +521,17 @@ class MarkdownStream:
590
521
 
591
522
  collected_images = getattr(markdown, "collected_images", [])
592
523
 
593
- # Split rendered output into lines, strip trailing spaces, and apply left margin.
594
524
  lines = output.splitlines(keepends=True)
595
- indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
525
+ use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
526
+
527
+ # Fast path: no margin, no mark -> just rstrip each line
528
+ if self.left_margin == 0 and not use_mark:
529
+ processed_lines = [line.rstrip() + "\n" if line.endswith("\n") else line.rstrip() for line in lines]
530
+ return processed_lines, list(collected_images)
531
+
532
+ indent_prefix = " " * self.left_margin
596
533
  processed_lines: list[str] = []
597
534
  mark_applied = False
598
- use_mark = apply_mark and bool(self.mark) and self.left_margin >= 2
599
535
 
600
536
  # Pre-render styled mark if needed
601
537
  styled_mark: str | None = None
@@ -616,7 +552,7 @@ class MarkdownStream:
616
552
  if use_mark and not mark_applied and stripped:
617
553
  stripped = f"{styled_mark} {stripped}"
618
554
  mark_applied = True
619
- elif indent_prefix:
555
+ else:
620
556
  stripped = indent_prefix + stripped
621
557
 
622
558
  if line.endswith("\n"):
@@ -648,8 +584,6 @@ class MarkdownStream:
648
584
  final=final,
649
585
  )
650
586
 
651
- continues_list = self._stable_boundary_continues_list(text, stable_line, final=final) and bool(live_source)
652
-
653
587
  start = time.time()
654
588
 
655
589
  stable_chunk_to_print: str | None = None
@@ -657,10 +591,7 @@ class MarkdownStream:
657
591
  stable_changed = final or stable_line > self._stable_source_line_count
658
592
  if stable_changed and stable_source:
659
593
  stable_ansi, collected_images = self.render_stable_ansi(
660
- stable_source,
661
- has_live_suffix=bool(live_source),
662
- final=final,
663
- continues_list=continues_list,
594
+ stable_source, has_live_suffix=bool(live_source), final=final
664
595
  )
665
596
  stable_lines = stable_ansi.splitlines(keepends=True)
666
597
  new_lines = stable_lines[len(self._stable_rendered_lines) :]
@@ -678,33 +609,34 @@ class MarkdownStream:
678
609
 
679
610
  live_text_to_set: Text | None = None
680
611
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
681
- if continues_list and self._stable_rendered_lines:
682
- full_lines, _ = self._render_markdown_to_lines(text, apply_mark=True)
683
- skip = min(len(self._stable_rendered_lines), len(full_lines))
684
- live_text_to_set = Text.from_ansi("".join(full_lines[skip:]))
685
- else:
686
- apply_mark = not self._stable_rendered_lines
687
- live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark)
688
-
689
- if self._stable_rendered_lines:
690
- stable_trailing_blank = 0
691
- for line in reversed(self._stable_rendered_lines):
612
+ # Only update live area after we have rendered at least one stable block
613
+ if not self._stable_rendered_lines:
614
+ return
615
+ # When nothing is stable yet, we still want to show incremental output.
616
+ # Apply the mark only for the first (all-live) frame so it stays anchored
617
+ # to the first visible line of the full message.
618
+ apply_mark_to_live = stable_line == 0
619
+ live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_to_live)
620
+
621
+ if self._stable_rendered_lines:
622
+ stable_trailing_blank = 0
623
+ for line in reversed(self._stable_rendered_lines):
624
+ if line.strip():
625
+ break
626
+ stable_trailing_blank += 1
627
+
628
+ if stable_trailing_blank > 0:
629
+ live_leading_blank = 0
630
+ for line in live_lines:
692
631
  if line.strip():
693
632
  break
694
- stable_trailing_blank += 1
695
-
696
- if stable_trailing_blank > 0:
697
- live_leading_blank = 0
698
- for line in live_lines:
699
- if line.strip():
700
- break
701
- live_leading_blank += 1
633
+ live_leading_blank += 1
702
634
 
703
- drop = min(stable_trailing_blank, live_leading_blank)
704
- if drop > 0:
705
- live_lines = live_lines[drop:]
635
+ drop = min(stable_trailing_blank, live_leading_blank)
636
+ if drop > 0:
637
+ live_lines = live_lines[drop:]
706
638
 
707
- live_text_to_set = Text.from_ansi("".join(live_lines))
639
+ live_text_to_set = Text.from_ansi("".join(live_lines))
708
640
 
709
641
  with self._synchronized_output():
710
642
  # Update/clear live area first to avoid blank padding when stable block appears
@@ -39,6 +39,7 @@ class Palette:
39
39
  red_background: str
40
40
  grey_background: str
41
41
  yellow_background: str
42
+ user_message_background: str
42
43
 
43
44
 
44
45
  LIGHT_PALETTE = Palette(
@@ -73,6 +74,7 @@ LIGHT_PALETTE = Palette(
73
74
  red_background="#f9ecec",
74
75
  grey_background="#f0f0f0",
75
76
  yellow_background="#f9f9ec",
77
+ user_message_background="#f0f0f0",
76
78
  )
77
79
 
78
80
  DARK_PALETTE = Palette(
@@ -107,6 +109,7 @@ DARK_PALETTE = Palette(
107
109
  red_background="#3d1f23",
108
110
  grey_background="#2a2d30",
109
111
  yellow_background="#3d3a1a",
112
+ user_message_background="#2a2d30",
110
113
  )
111
114
 
112
115
 
@@ -116,6 +119,7 @@ class ThemeKey(str, Enum):
116
119
 
117
120
  # CODE
118
121
  CODE_BACKGROUND = "code_background"
122
+ CODE_PANEL_TITLE = "code_panel.title"
119
123
 
120
124
  # PANEL
121
125
  SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
@@ -258,18 +262,18 @@ def get_theme(theme: str | None = None) -> Themes:
258
262
  ThemeKey.ERROR_DIM.value: "dim " + palette.red,
259
263
  ThemeKey.INTERRUPT.value: palette.red,
260
264
  # USER_INPUT
261
- ThemeKey.USER_INPUT.value: palette.magenta,
262
- ThemeKey.USER_INPUT_PROMPT.value: "bold " + palette.magenta,
263
- ThemeKey.USER_INPUT_AT_PATTERN.value: palette.purple,
264
- ThemeKey.USER_INPUT_SLASH_COMMAND.value: "bold " + palette.blue,
265
- ThemeKey.USER_INPUT_SKILL.value: "bold " + palette.green,
265
+ ThemeKey.USER_INPUT.value: f"{palette.magenta} on {palette.user_message_background}",
266
+ ThemeKey.USER_INPUT_PROMPT.value: f"bold {palette.magenta} on {palette.user_message_background}",
267
+ ThemeKey.USER_INPUT_AT_PATTERN.value: f"{palette.purple} on {palette.user_message_background}",
268
+ ThemeKey.USER_INPUT_SLASH_COMMAND.value: f"bold {palette.blue} on {palette.user_message_background}",
269
+ ThemeKey.USER_INPUT_SKILL.value: f"bold {palette.green} on {palette.user_message_background}",
266
270
  # ASSISTANT
267
271
  ThemeKey.ASSISTANT_MESSAGE_MARK.value: "bold",
268
272
  # METADATA
269
- ThemeKey.METADATA.value: palette.lavender,
270
- ThemeKey.METADATA_DIM.value: "dim " + palette.lavender,
271
- ThemeKey.METADATA_BOLD.value: "bold " + palette.lavender,
272
- ThemeKey.METADATA_ITALIC.value: "italic " + palette.lavender,
273
+ ThemeKey.METADATA.value: palette.grey1,
274
+ ThemeKey.METADATA_DIM.value: "dim " + palette.grey1,
275
+ ThemeKey.METADATA_BOLD.value: "bold " + palette.grey1,
276
+ ThemeKey.METADATA_ITALIC.value: "italic " + palette.grey1,
273
277
  # STATUS
274
278
  ThemeKey.STATUS_SPINNER.value: palette.blue,
275
279
  ThemeKey.STATUS_TEXT.value: palette.blue,
@@ -347,7 +351,9 @@ def get_theme(theme: str | None = None) -> Themes:
347
351
  # it is used while rendering assistant output.
348
352
  "markdown.thinking": "italic " + palette.grey2,
349
353
  "markdown.thinking.tag": palette.grey2,
350
- "markdown.code.border": palette.grey3,
354
+ "markdown.code.border": palette.grey2,
355
+ "markdown.code.fence": palette.grey2,
356
+ "markdown.code.fence.title": palette.grey1,
351
357
  # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
352
358
  "markdown.code.block": palette.grey1,
353
359
  "markdown.h1": "bold reverse " + palette.black,
@@ -362,6 +368,7 @@ def get_theme(theme: str | None = None) -> Themes:
362
368
  "markdown.link_url": "underline " + palette.blue,
363
369
  "markdown.table.border": palette.grey2,
364
370
  "markdown.checkbox.checked": palette.green,
371
+ "markdown.block_quote": palette.cyan,
365
372
  }
366
373
  ),
367
374
  thinking_markdown_theme=Theme(
@@ -371,8 +378,10 @@ def get_theme(theme: str | None = None) -> Themes:
371
378
  ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
372
379
  "markdown.strong": "italic " + palette.grey1,
373
380
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
374
- "markdown.code.block": palette.grey1,
375
- "markdown.code.border": palette.grey3,
381
+ "markdown.code.block": palette.grey2,
382
+ "markdown.code.fence": palette.grey2,
383
+ "markdown.code.fence.title": palette.grey1,
384
+ "markdown.code.border": palette.grey2,
376
385
  "markdown.thinking.tag": palette.grey2 + " dim",
377
386
  "markdown.h1": "bold reverse",
378
387
  "markdown.h1.border": palette.grey3,
@@ -385,6 +394,7 @@ def get_theme(theme: str | None = None) -> Themes:
385
394
  "markdown.link_url": "underline " + palette.blue,
386
395
  "markdown.table.border": palette.grey2,
387
396
  "markdown.checkbox.checked": palette.green,
397
+ "markdown.block_quote": palette.grey1,
388
398
  }
389
399
  ),
390
400
  code_theme=palette.code_theme,
@@ -26,38 +26,3 @@ def normalize_thinking_content(content: str) -> str:
26
26
  text = text.replace("**\n\n", "** \n")
27
27
 
28
28
  return text
29
-
30
-
31
- def extract_last_bold_header(text: str) -> str | None:
32
- """Extract the latest complete bold header ("**…**") from text.
33
-
34
- We treat a bold segment as a "header" only if it appears at the beginning
35
- of a line (ignoring leading whitespace). This avoids picking up incidental
36
- emphasis inside paragraphs.
37
-
38
- Returns None if no complete bold segment is available yet.
39
- """
40
-
41
- last: str | None = None
42
- i = 0
43
- while True:
44
- start = text.find("**", i)
45
- if start < 0:
46
- break
47
-
48
- line_start = text.rfind("\n", 0, start) + 1
49
- if text[line_start:start].strip():
50
- i = start + 2
51
- continue
52
-
53
- end = text.find("**", start + 2)
54
- if end < 0:
55
- break
56
-
57
- inner = " ".join(text[start + 2 : end].split())
58
- if inner and "\n" not in inner:
59
- last = inner
60
-
61
- i = end + 2
62
-
63
- return last