klaude-code 2.8.1__py3-none-any.whl → 2.9.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 (107) hide show
  1. klaude_code/app/runtime.py +2 -1
  2. klaude_code/auth/antigravity/oauth.py +33 -38
  3. klaude_code/auth/antigravity/token_manager.py +0 -18
  4. klaude_code/auth/base.py +53 -0
  5. klaude_code/auth/claude/oauth.py +34 -49
  6. klaude_code/auth/codex/exceptions.py +0 -4
  7. klaude_code/auth/codex/oauth.py +32 -28
  8. klaude_code/auth/codex/token_manager.py +0 -18
  9. klaude_code/cli/cost_cmd.py +128 -39
  10. klaude_code/cli/list_model.py +27 -10
  11. klaude_code/cli/main.py +14 -3
  12. klaude_code/config/assets/builtin_config.yaml +25 -24
  13. klaude_code/config/config.py +47 -25
  14. klaude_code/config/sub_agent_model_helper.py +18 -13
  15. klaude_code/config/thinking.py +0 -8
  16. klaude_code/const.py +1 -1
  17. klaude_code/core/agent_profile.py +11 -56
  18. klaude_code/core/compaction/overflow.py +0 -4
  19. klaude_code/core/executor.py +33 -5
  20. klaude_code/core/manager/llm_clients.py +9 -1
  21. klaude_code/core/prompts/prompt-claude-code.md +4 -4
  22. klaude_code/core/reminders.py +21 -23
  23. klaude_code/core/task.py +1 -5
  24. klaude_code/core/tool/__init__.py +3 -2
  25. klaude_code/core/tool/file/apply_patch.py +0 -27
  26. klaude_code/core/tool/file/read_tool.md +3 -2
  27. klaude_code/core/tool/file/read_tool.py +27 -3
  28. klaude_code/core/tool/offload.py +0 -35
  29. klaude_code/core/tool/shell/bash_tool.py +1 -1
  30. klaude_code/core/tool/sub_agent/__init__.py +6 -0
  31. klaude_code/core/tool/sub_agent/image_gen.md +16 -0
  32. klaude_code/core/tool/sub_agent/image_gen.py +146 -0
  33. klaude_code/core/tool/sub_agent/task.md +20 -0
  34. klaude_code/core/tool/sub_agent/task.py +205 -0
  35. klaude_code/core/tool/tool_registry.py +0 -16
  36. klaude_code/core/turn.py +1 -1
  37. klaude_code/llm/anthropic/input.py +6 -5
  38. klaude_code/llm/antigravity/input.py +14 -7
  39. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  40. klaude_code/llm/google/client.py +8 -6
  41. klaude_code/llm/google/input.py +20 -12
  42. klaude_code/llm/image.py +18 -11
  43. klaude_code/llm/input_common.py +32 -6
  44. klaude_code/llm/json_stable.py +37 -0
  45. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  46. klaude_code/llm/{codex → openai_codex}/client.py +24 -2
  47. klaude_code/llm/openai_codex/prompt_sync.py +237 -0
  48. klaude_code/llm/openai_compatible/client.py +3 -1
  49. klaude_code/llm/openai_compatible/input.py +0 -10
  50. klaude_code/llm/openai_compatible/stream.py +35 -10
  51. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  52. klaude_code/llm/{responses → openai_responses}/input.py +15 -5
  53. klaude_code/llm/registry.py +3 -8
  54. klaude_code/llm/stream_parts.py +3 -1
  55. klaude_code/llm/usage.py +1 -9
  56. klaude_code/protocol/events.py +2 -2
  57. klaude_code/protocol/message.py +3 -2
  58. klaude_code/protocol/model.py +34 -2
  59. klaude_code/protocol/op.py +13 -0
  60. klaude_code/protocol/op_handler.py +5 -0
  61. klaude_code/protocol/sub_agent/AGENTS.md +5 -5
  62. klaude_code/protocol/sub_agent/__init__.py +13 -34
  63. klaude_code/protocol/sub_agent/explore.py +7 -34
  64. klaude_code/protocol/sub_agent/image_gen.py +3 -74
  65. klaude_code/protocol/sub_agent/task.py +3 -47
  66. klaude_code/protocol/sub_agent/web.py +8 -52
  67. klaude_code/protocol/tools.py +2 -0
  68. klaude_code/session/session.py +80 -22
  69. klaude_code/session/store.py +0 -4
  70. klaude_code/skill/assets/deslop/SKILL.md +9 -0
  71. klaude_code/skill/system_skills.py +0 -20
  72. klaude_code/tui/command/fork_session_cmd.py +5 -2
  73. klaude_code/tui/command/resume_cmd.py +9 -2
  74. klaude_code/tui/command/sub_agent_model_cmd.py +85 -18
  75. klaude_code/tui/components/assistant.py +0 -26
  76. klaude_code/tui/components/bash_syntax.py +4 -0
  77. klaude_code/tui/components/command_output.py +3 -1
  78. klaude_code/tui/components/developer.py +3 -0
  79. klaude_code/tui/components/diffs.py +4 -209
  80. klaude_code/tui/components/errors.py +4 -0
  81. klaude_code/tui/components/mermaid_viewer.py +2 -2
  82. klaude_code/tui/components/metadata.py +0 -3
  83. klaude_code/tui/components/rich/markdown.py +120 -87
  84. klaude_code/tui/components/rich/status.py +2 -2
  85. klaude_code/tui/components/rich/theme.py +11 -6
  86. klaude_code/tui/components/sub_agent.py +2 -46
  87. klaude_code/tui/components/thinking.py +0 -33
  88. klaude_code/tui/components/tools.py +65 -21
  89. klaude_code/tui/components/user_input.py +2 -0
  90. klaude_code/tui/input/images.py +21 -18
  91. klaude_code/tui/input/key_bindings.py +2 -2
  92. klaude_code/tui/input/prompt_toolkit.py +49 -49
  93. klaude_code/tui/machine.py +29 -47
  94. klaude_code/tui/renderer.py +48 -33
  95. klaude_code/tui/runner.py +2 -1
  96. klaude_code/tui/terminal/image.py +27 -34
  97. klaude_code/ui/common.py +0 -70
  98. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/METADATA +3 -6
  99. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/RECORD +103 -99
  100. klaude_code/core/tool/sub_agent_tool.py +0 -126
  101. klaude_code/llm/bedrock/__init__.py +0 -3
  102. klaude_code/llm/openai_compatible/tool_call_accumulator.py +0 -108
  103. klaude_code/tui/components/rich/searchable_text.py +0 -68
  104. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  105. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  106. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/WHEEL +0 -0
  107. {klaude_code-2.8.1.dist-info → klaude_code-2.9.1.dist-info}/entry_points.txt +0 -0
@@ -1,185 +1,12 @@
1
- from rich import box
2
- from rich.console import Group, RenderableType
3
- from rich.padding import Padding
4
- from rich.panel import Panel
1
+ from rich.console import RenderableType
5
2
  from rich.text import Text
6
3
 
7
- from klaude_code.const import DIFF_PREFIX_WIDTH, MAX_DIFF_LINES
4
+ from klaude_code.const import DIFF_PREFIX_WIDTH, TAB_EXPAND_WIDTH
8
5
  from klaude_code.protocol import model
9
6
  from klaude_code.tui.components.common import create_grid
10
7
  from klaude_code.tui.components.rich.theme import ThemeKey
11
8
 
12
9
 
13
- def _make_diff_prefix(line: str, new_ln: int | None, width: int) -> tuple[str, int | None]:
14
- kind = line[0]
15
-
16
- number = " " * width
17
- if kind in {"+", " "} and new_ln is not None:
18
- number = f"{new_ln:>{width}}"
19
- new_ln += 1
20
-
21
- if kind == "-":
22
- marker = "-"
23
- elif kind == "+":
24
- marker = "+"
25
- else:
26
- marker = " "
27
-
28
- prefix = f"{number} {marker}"
29
- return prefix, new_ln
30
-
31
-
32
- def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
33
- if diff_text == "":
34
- return Text("")
35
-
36
- lines = diff_text.split("\n")
37
- grid = create_grid()
38
- grid.padding = (0, 0)
39
-
40
- # Track line numbers based on hunk headers
41
- new_ln: int | None = None
42
- # Track if we're in untracked files section
43
- in_untracked_section = False
44
- # Track whether we've already rendered a file header
45
- has_rendered_file_header = False
46
- # Track whether we have rendered actual diff content for the current file
47
- has_rendered_diff_content = False
48
- # Track the "from" file name from --- line (used for deleted files)
49
- from_file_name: str | None = None
50
-
51
- for i, line in enumerate(lines):
52
- # Check for untracked files section header
53
- if line == "git ls-files --others --exclude-standard":
54
- in_untracked_section = True
55
- grid.add_row("", "")
56
- grid.add_row("", Text("Untracked files:", style=ThemeKey.TOOL_MARK))
57
- grid.add_row("", "")
58
- continue
59
-
60
- # Handle untracked files
61
- if in_untracked_section:
62
- # If we hit a new section or empty line, we're done with untracked files
63
- if line.startswith("diff --git") or line.strip() == "":
64
- in_untracked_section = False
65
- elif line.strip(): # Non-empty line in untracked section
66
- file_text = Text(line.strip(), style=ThemeKey.TOOL_PARAM_BOLD)
67
- grid.add_row(
68
- Text(f"{'+':>{DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_PARAM_BOLD),
69
- file_text,
70
- )
71
- continue
72
-
73
- # Capture "from" file name from --- line (needed for deleted files)
74
- if line.startswith("--- "):
75
- raw = line[4:].strip()
76
- if raw != "/dev/null":
77
- from_file_name = raw[2:] if raw.startswith(("a/", "b/")) else raw
78
- continue
79
-
80
- # Parse file name from diff headers
81
- if show_file_name and line.startswith("+++ "):
82
- # Extract file name from +++ header with proper handling of /dev/null
83
- raw = line[4:].strip()
84
- if raw == "/dev/null":
85
- # File was deleted, use the "from" file name
86
- file_name = from_file_name or raw
87
- elif raw.startswith(("a/", "b/")):
88
- file_name = raw[2:]
89
- else:
90
- file_name = raw
91
-
92
- file_text = Text(file_name, style=ThemeKey.DIFF_FILE_NAME)
93
-
94
- # Count actual +/- lines for this file from i+1 onwards
95
- file_additions = 0
96
- file_deletions = 0
97
- for remaining_line in lines[i + 1 :]:
98
- if remaining_line.startswith("diff --git"):
99
- break
100
- elif remaining_line.startswith("+") and not remaining_line.startswith("+++"):
101
- file_additions += 1
102
- elif remaining_line.startswith("-") and not remaining_line.startswith("---"):
103
- file_deletions += 1
104
-
105
- # Create stats text
106
- stats_text = Text()
107
- if file_additions > 0:
108
- stats_text.append(f"+{file_additions}", style=ThemeKey.DIFF_STATS_ADD)
109
- if file_deletions > 0:
110
- if file_additions > 0:
111
- stats_text.append(" ")
112
- stats_text.append(f"-{file_deletions}", style=ThemeKey.DIFF_STATS_REMOVE)
113
-
114
- # Combine file name and stats
115
- file_line = Text(style=ThemeKey.DIFF_FILE_NAME)
116
- file_line.append_text(file_text)
117
- if stats_text.plain:
118
- file_line.append(" (")
119
- file_line.append_text(stats_text)
120
- file_line.append(")")
121
-
122
- if has_rendered_file_header:
123
- grid.add_row("", "")
124
-
125
- if file_additions > 0 and file_deletions == 0:
126
- file_mark = "+"
127
- elif file_deletions > 0 and file_additions == 0:
128
- file_mark = "-"
129
- else:
130
- file_mark = "±"
131
-
132
- grid.add_row(
133
- Text(f"{file_mark:>{DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME),
134
- file_line,
135
- )
136
- has_rendered_file_header = True
137
- has_rendered_diff_content = False
138
- continue
139
-
140
- if line.startswith("diff --git"):
141
- has_rendered_diff_content = False
142
- continue
143
-
144
- # Parse hunk headers to reset counters: @@ -l,s +l,s @@
145
- if line.startswith("@@"):
146
- try:
147
- parts = line.split()
148
- plus = parts[2] # like '+12,4'
149
- new_start = int(plus[1:].split(",")[0])
150
- new_ln = new_start
151
- except (IndexError, ValueError):
152
- new_ln = None
153
- if has_rendered_diff_content:
154
- grid.add_row(Text(f"{'⋮':>{DIFF_PREFIX_WIDTH}}", style=ThemeKey.TOOL_RESULT), "")
155
- continue
156
-
157
- # Skip +++ lines (already handled above)
158
- if line.startswith("+++ "):
159
- continue
160
-
161
- # Only handle unified diff hunk lines; ignore other metadata like
162
- # "diff --git" or "index …" which would otherwise skew counters.
163
- if not line or line[:1] not in {" ", "+", "-"}:
164
- continue
165
-
166
- # Compute line number prefix and style diff content
167
- prefix, new_ln = _make_diff_prefix(line, new_ln, DIFF_PREFIX_WIDTH)
168
-
169
- if line.startswith("-"):
170
- text = Text(line[1:])
171
- text.stylize(ThemeKey.DIFF_REMOVE)
172
- elif line.startswith("+"):
173
- text = Text(line[1:])
174
- text.stylize(ThemeKey.DIFF_ADD)
175
- else:
176
- text = Text(line, style=ThemeKey.TOOL_RESULT)
177
- grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), text)
178
- has_rendered_diff_content = True
179
-
180
- return grid
181
-
182
-
183
10
  def render_structured_diff(ui_extra: model.DiffUIExtra, show_file_name: bool = False) -> RenderableType:
184
11
  files = ui_extra.files
185
12
  if not files:
@@ -204,39 +31,6 @@ def render_structured_diff(ui_extra: model.DiffUIExtra, show_file_name: bool = F
204
31
  return grid
205
32
 
206
33
 
207
- def render_diff_panel(
208
- diff_text: str,
209
- *,
210
- show_file_name: bool = True,
211
- heading: str = "DIFF",
212
- indent: int = 2,
213
- ) -> RenderableType:
214
- lines = diff_text.splitlines()
215
- truncated_notice: Text | None = None
216
- if len(lines) > MAX_DIFF_LINES:
217
- truncated_lines = len(lines) - MAX_DIFF_LINES
218
- diff_text = "\n".join(lines[:MAX_DIFF_LINES])
219
- truncated_notice = Text(f"… truncated {truncated_lines} lines", style=ThemeKey.TOOL_MARK)
220
-
221
- diff_body = render_diff(diff_text, show_file_name=show_file_name)
222
- renderables: list[RenderableType] = [
223
- Text(f" {heading} ", style="bold reverse"),
224
- diff_body,
225
- ]
226
- if truncated_notice is not None:
227
- renderables.extend([Text(""), truncated_notice])
228
-
229
- panel = Panel.fit(
230
- Group(*renderables),
231
- border_style=ThemeKey.LINES,
232
- title_align="center",
233
- box=box.ROUNDED,
234
- )
235
- if indent <= 0:
236
- return panel
237
- return Padding.indent(panel, level=indent)
238
-
239
-
240
34
  def _render_file_header(file_diff: model.DiffFileDiff) -> tuple[Text, Text]:
241
35
  file_text = Text(file_diff.file_path, style=ThemeKey.DIFF_FILE_NAME)
242
36
  stats_text = Text()
@@ -280,7 +74,8 @@ def _render_structured_line(line: model.DiffLine) -> Text:
280
74
  return Text("")
281
75
  text = Text()
282
76
  for span in line.spans:
283
- text.append(span.text, style=_span_style(line.kind, span.op))
77
+ content = span.text.expandtabs(TAB_EXPAND_WIDTH)
78
+ text.append(content, style=_span_style(line.kind, span.op))
284
79
  return text
285
80
 
286
81
 
@@ -9,6 +9,8 @@ def render_error(error_msg: Text) -> RenderableType:
9
9
  """Render error with X mark for error events."""
10
10
  grid = create_grid()
11
11
  error_msg.style = ThemeKey.ERROR
12
+ error_msg.overflow = "ellipsis"
13
+ error_msg.no_wrap = True
12
14
  grid.add_row(Text("✘", style=ThemeKey.ERROR_BOLD), error_msg)
13
15
  return grid
14
16
 
@@ -17,5 +19,7 @@ def render_tool_error(error_msg: Text) -> RenderableType:
17
19
  """Render error with indent for tool results."""
18
20
  grid = create_grid()
19
21
  error_msg.style = ThemeKey.ERROR
22
+ error_msg.overflow = "ellipsis"
23
+ error_msg.no_wrap = True
20
24
  grid.add_row(Text(" "), error_msg)
21
25
  return grid
@@ -16,7 +16,7 @@ _MERMAID_DEFAULT_PNG_SCALE = 2
16
16
 
17
17
 
18
18
  def artifacts_dir() -> Path:
19
- return Path(TOOL_OUTPUT_TRUNCATION_DIR) / "mermaid"
19
+ return Path(TOOL_OUTPUT_TRUNCATION_DIR)
20
20
 
21
21
 
22
22
  def _extract_pako_from_link(link: str) -> str | None:
@@ -72,7 +72,7 @@ def ensure_viewer_file(*, code: str, link: str, tool_call_id: str) -> Path | Non
72
72
  return None
73
73
 
74
74
  safe_id = tool_call_id.replace("/", "_")
75
- path = artifacts_dir() / f"mermaid-viewer-{safe_id}.html"
75
+ path = artifacts_dir() / f"klaude-mermaid-{safe_id}.html"
76
76
  if path.exists():
77
77
  return path
78
78
 
@@ -136,9 +136,6 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
136
136
  """Render task metadata including main agent and sub-agents."""
137
137
  renderables: list[RenderableType] = []
138
138
 
139
- if e.cancelled:
140
- renderables.append(Text())
141
-
142
139
  has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
143
140
  # Use an extra space for the main agent mark to align with two-character marks (├─, └─)
144
141
  main_mark_text = "✓"
@@ -14,7 +14,6 @@ from rich import box
14
14
  from rich._loop import loop_first
15
15
  from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
16
16
  from rich.markdown import CodeBlock, Heading, ImageItem, ListItem, Markdown, MarkdownElement, TableElement
17
- from rich.rule import Rule
18
17
  from rich.segment import Segment
19
18
  from rich.style import Style, StyleType
20
19
  from rich.syntax import Syntax
@@ -120,7 +119,8 @@ class Divider(MarkdownElement):
120
119
 
121
120
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
122
121
  style = console.get_style("markdown.hr", default="none")
123
- yield Rule(style=style, characters="-")
122
+ width = min(options.max_width, 100)
123
+ yield Text("-" * width, style=style)
124
124
 
125
125
 
126
126
  class MarkdownTable(TableElement):
@@ -153,7 +153,8 @@ class LeftHeading(Heading):
153
153
  h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
154
154
  yield h1_text
155
155
  elif self.tag == "h2":
156
- text.stylize(Style(bold=True, underline=False))
156
+ h2_style = console.get_style("markdown.h2", default="bold")
157
+ text.stylize(h2_style + Style(underline=False))
157
158
  yield text
158
159
  else:
159
160
  yield text
@@ -332,11 +333,6 @@ class MarkdownStream:
332
333
  self.right_margin: int = max(right_margin, 0)
333
334
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
334
335
 
335
- @property
336
- def _live_started(self) -> bool:
337
- """Check if Live display has been started (derived from self.live)."""
338
- return self._live_sink is not None
339
-
340
336
  def _get_base_width(self) -> int:
341
337
  return self.console.options.max_width
342
338
 
@@ -399,12 +395,25 @@ class MarkdownStream:
399
395
  return 0
400
396
 
401
397
  top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
402
- if len(top_level) < 2:
398
+ if not top_level:
403
399
  return 0
404
400
 
405
401
  last = top_level[-1]
406
402
  assert last.map is not None
407
403
 
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
+
408
417
  # When the buffer ends mid-line, markdown-it-py can temporarily classify
409
418
  # some lines as a thematic break (hr). For example, a trailing "- --"
410
419
  # parses as an hr, but appending a non-hr character ("- --0") turns it
@@ -421,6 +430,59 @@ class MarkdownStream:
421
430
  start_line = last.map[0]
422
431
  return max(start_line, 0)
423
432
 
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
+
424
486
  def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
425
487
  """Split full markdown into stable and live sources.
426
488
 
@@ -450,15 +512,14 @@ class MarkdownStream:
450
512
  return "", text, 0
451
513
  return stable_source, live_source, stable_line
452
514
 
453
- def render_ansi(self, text: str, *, apply_mark: bool) -> str:
454
- """Render markdown source to an ANSI string.
455
-
456
- This is primarily intended for internal debugging and tests.
457
- """
458
- lines, _ = self._render_markdown_to_lines(text, apply_mark=apply_mark)
459
- return "".join(lines)
460
-
461
- def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
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]]:
462
523
  """Render stable prefix to ANSI, preserving inter-block spacing.
463
524
 
464
525
  Returns:
@@ -468,52 +529,15 @@ class MarkdownStream:
468
529
  return "", []
469
530
 
470
531
  render_source = stable_source
471
- if not final and has_live_suffix:
532
+ if not final and has_live_suffix and not continues_list:
472
533
  render_source = self._append_nonfinal_sentinel(stable_source)
473
534
 
474
535
  lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
475
- return "".join(lines), images
476
536
 
477
- @staticmethod
478
- def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
479
- """Normalize whitespace at the stable/live boundary.
480
-
481
- Some Rich Markdown blocks (e.g. lists) render with a leading blank line.
482
- If the stable prefix already renders a trailing blank line, rendering the
483
- live suffix separately may introduce an extra blank line that wouldn't
484
- appear when rendering the full document.
485
-
486
- This function removes *overlapping* blank lines from the live ANSI when
487
- the stable ANSI already ends with one or more blank lines.
488
-
489
- Important: don't remove *all* leading blank lines from the live suffix.
490
- In some incomplete-block cases, the live render may begin with multiple
491
- blank lines while the full-document render would keep one of them.
492
- """
493
-
494
- stable_lines = stable_ansi.splitlines(keepends=True)
495
- if not stable_lines:
496
- return live_ansi
497
-
498
- stable_trailing_blank = 0
499
- for line in reversed(stable_lines):
500
- if line.strip():
501
- break
502
- stable_trailing_blank += 1
503
- if stable_trailing_blank <= 0:
504
- return live_ansi
505
-
506
- live_lines = live_ansi.splitlines(keepends=True)
507
- live_leading_blank = 0
508
- for line in live_lines:
509
- if line.strip():
510
- break
511
- live_leading_blank += 1
512
-
513
- drop = min(stable_trailing_blank, live_leading_blank)
514
- if drop > 0:
515
- live_lines = live_lines[drop:]
516
- return "".join(live_lines)
537
+ if continues_list:
538
+ while lines and not lines[-1].strip():
539
+ lines.pop()
540
+ return "".join(lines), images
517
541
 
518
542
  def _append_nonfinal_sentinel(self, stable_source: str) -> str:
519
543
  """Make Rich render stable content as if it isn't the last block.
@@ -624,6 +648,8 @@ class MarkdownStream:
624
648
  final=final,
625
649
  )
626
650
 
651
+ continues_list = self._stable_boundary_continues_list(text, stable_line, final=final) and bool(live_source)
652
+
627
653
  start = time.time()
628
654
 
629
655
  stable_chunk_to_print: str | None = None
@@ -631,7 +657,10 @@ class MarkdownStream:
631
657
  stable_changed = final or stable_line > self._stable_source_line_count
632
658
  if stable_changed and stable_source:
633
659
  stable_ansi, collected_images = self.render_stable_ansi(
634
- stable_source, has_live_suffix=bool(live_source), final=final
660
+ stable_source,
661
+ has_live_suffix=bool(live_source),
662
+ final=final,
663
+ continues_list=continues_list,
635
664
  )
636
665
  stable_lines = stable_ansi.splitlines(keepends=True)
637
666
  new_lines = stable_lines[len(self._stable_rendered_lines) :]
@@ -649,30 +678,42 @@ class MarkdownStream:
649
678
 
650
679
  live_text_to_set: Text | None = None
651
680
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
652
- apply_mark_live = self._stable_source_line_count == 0
653
- live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
654
-
655
- if self._stable_rendered_lines:
656
- stable_trailing_blank = 0
657
- for line in reversed(self._stable_rendered_lines):
658
- if line.strip():
659
- break
660
- stable_trailing_blank += 1
661
-
662
- if stable_trailing_blank > 0:
663
- live_leading_blank = 0
664
- for line in live_lines:
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):
665
692
  if line.strip():
666
693
  break
667
- live_leading_blank += 1
694
+ stable_trailing_blank += 1
668
695
 
669
- drop = min(stable_trailing_blank, live_leading_blank)
670
- if drop > 0:
671
- live_lines = live_lines[drop:]
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
672
702
 
673
- live_text_to_set = Text.from_ansi("".join(live_lines))
703
+ drop = min(stable_trailing_blank, live_leading_blank)
704
+ if drop > 0:
705
+ live_lines = live_lines[drop:]
706
+
707
+ live_text_to_set = Text.from_ansi("".join(live_lines))
674
708
 
675
709
  with self._synchronized_output():
710
+ # Update/clear live area first to avoid blank padding when stable block appears
711
+ if final:
712
+ if self._live_sink is not None:
713
+ self._live_sink(None)
714
+ elif live_text_to_set is not None and self._live_sink is not None:
715
+ self._live_sink(live_text_to_set)
716
+
676
717
  if stable_chunk_to_print:
677
718
  self.console.print(Text.from_ansi(stable_chunk_to_print), end="\n")
678
719
 
@@ -680,13 +721,5 @@ class MarkdownStream:
680
721
  for img_path in new_images:
681
722
  self._image_callback(img_path)
682
723
 
683
- if final:
684
- if self._live_sink is not None:
685
- self._live_sink(None)
686
- return
687
-
688
- if live_text_to_set is not None and self._live_sink is not None:
689
- self._live_sink(live_text_to_set)
690
-
691
724
  elapsed = time.time() - start
692
725
  self.min_delay = min(max(elapsed * 6, 1.0 / 30), 0.5)
@@ -277,7 +277,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
277
277
  if cell_len(text.plain) <= max_cells:
278
278
  return text
279
279
 
280
- ellipsis_cells = cell_len(ellipsis)
280
+ ellipsis_cells = cell_len(ellipsis) + 1 # +1 for trailing space
281
281
  if max_cells <= ellipsis_cells:
282
282
  # Not enough space to show any meaningful suffix.
283
283
  clipped = Text(ellipsis, style=text.style)
@@ -307,7 +307,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
307
307
  except Exception:
308
308
  ellipsis_style = suffix.style or text.style
309
309
 
310
- return Text.assemble(Text(ellipsis, style=ellipsis_style), suffix)
310
+ return Text.assemble(Text(ellipsis + " ", style=ellipsis_style), suffix)
311
311
 
312
312
 
313
313
  class ShimmerStatusText:
@@ -21,6 +21,7 @@ class Palette:
21
21
  grey_green: str
22
22
  purple: str
23
23
  lavender: str
24
+ black: str
24
25
  diff_add: str
25
26
  diff_add_char: str
26
27
  diff_remove: str
@@ -55,6 +56,7 @@ LIGHT_PALETTE = Palette(
55
56
  grey_green="#96a096",
56
57
  purple="#5f5fb7",
57
58
  lavender="#7878b0",
59
+ black="#101827",
58
60
  diff_add="#2e5a32 on #dafbe1",
59
61
  diff_add_char="#2e5a32 on #aceebb",
60
62
  diff_remove="#82071e on #ffecec",
@@ -88,6 +90,7 @@ DARK_PALETTE = Palette(
88
90
  grey_green="#6d8672",
89
91
  purple="#afbafe",
90
92
  lavender="#9898b8",
93
+ black="white",
91
94
  diff_add="#c8e6c9 on #1b3928",
92
95
  diff_add_char="#c8e6c9 on #2d6b42",
93
96
  diff_remove="#ffcdd2 on #3d1f23",
@@ -109,6 +112,7 @@ DARK_PALETTE = Palette(
109
112
 
110
113
  class ThemeKey(str, Enum):
111
114
  LINES = "lines"
115
+ LINES_DIM = "lines.dim"
112
116
 
113
117
  # CODE
114
118
  CODE_BACKGROUND = "code_background"
@@ -233,6 +237,7 @@ def get_theme(theme: str | None = None) -> Themes:
233
237
  app_theme=Theme(
234
238
  styles={
235
239
  ThemeKey.LINES.value: palette.grey3,
240
+ ThemeKey.LINES_DIM.value: "dim " + palette.grey3,
236
241
  # CODE
237
242
  ThemeKey.CODE_BACKGROUND.value: f"on {palette.code_background}",
238
243
  # PANEL
@@ -299,9 +304,9 @@ def get_theme(theme: str | None = None) -> Themes:
299
304
  ThemeKey.BASH_HEREDOC_DELIMITER.value: "bold " + palette.grey1,
300
305
  # THINKING
301
306
  ThemeKey.THINKING.value: "italic " + palette.grey2,
302
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
307
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
303
308
  # COMPACTION
304
- ThemeKey.COMPACTION_SUMMARY.value: "italic " + palette.grey1,
309
+ ThemeKey.COMPACTION_SUMMARY.value: palette.grey1,
305
310
  # TODO_ITEM
306
311
  ThemeKey.TODO_EXPLANATION.value: palette.grey1 + " italic",
307
312
  ThemeKey.TODO_PENDING_MARK.value: "bold " + palette.grey1,
@@ -345,9 +350,9 @@ def get_theme(theme: str | None = None) -> Themes:
345
350
  "markdown.code.border": palette.grey3,
346
351
  # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
347
352
  "markdown.code.block": palette.grey1,
348
- "markdown.h1": "bold reverse",
353
+ "markdown.h1": "bold reverse " + palette.black,
349
354
  "markdown.h1.border": palette.grey3,
350
- "markdown.h2": "bold underline",
355
+ "markdown.h2": "bold underline " + palette.black,
351
356
  "markdown.h3": "bold " + palette.grey1,
352
357
  "markdown.h4": "bold " + palette.grey2,
353
358
  "markdown.hr": palette.grey3,
@@ -363,7 +368,8 @@ def get_theme(theme: str | None = None) -> Themes:
363
368
  styles={
364
369
  # THINKING (used for left-side mark in thinking output)
365
370
  ThemeKey.THINKING.value: "italic " + palette.grey2,
366
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
371
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
372
+ "markdown.strong": "italic " + palette.grey1,
367
373
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
368
374
  "markdown.code.block": palette.grey1,
369
375
  "markdown.code.border": palette.grey3,
@@ -377,7 +383,6 @@ def get_theme(theme: str | None = None) -> Themes:
377
383
  "markdown.item.number": palette.grey2,
378
384
  "markdown.link": "underline " + palette.blue,
379
385
  "markdown.link_url": "underline " + palette.blue,
380
- "markdown.strong": "bold italic " + palette.grey1,
381
386
  "markdown.table.border": palette.grey2,
382
387
  "markdown.checkbox.checked": palette.green,
383
388
  }