klaude-code 2.7.0__py3-none-any.whl → 2.8.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 (74) hide show
  1. klaude_code/auth/AGENTS.md +325 -0
  2. klaude_code/auth/__init__.py +17 -1
  3. klaude_code/auth/antigravity/__init__.py +20 -0
  4. klaude_code/auth/antigravity/exceptions.py +17 -0
  5. klaude_code/auth/antigravity/oauth.py +320 -0
  6. klaude_code/auth/antigravity/pkce.py +25 -0
  7. klaude_code/auth/antigravity/token_manager.py +45 -0
  8. klaude_code/auth/base.py +4 -0
  9. klaude_code/auth/claude/oauth.py +29 -9
  10. klaude_code/auth/codex/exceptions.py +4 -0
  11. klaude_code/cli/auth_cmd.py +53 -3
  12. klaude_code/cli/cost_cmd.py +83 -160
  13. klaude_code/cli/list_model.py +50 -0
  14. klaude_code/cli/main.py +2 -2
  15. klaude_code/config/assets/builtin_config.yaml +108 -0
  16. klaude_code/config/builtin_config.py +5 -11
  17. klaude_code/config/config.py +24 -10
  18. klaude_code/const.py +2 -1
  19. klaude_code/core/agent.py +5 -1
  20. klaude_code/core/agent_profile.py +29 -33
  21. klaude_code/core/compaction/AGENTS.md +112 -0
  22. klaude_code/core/compaction/__init__.py +11 -0
  23. klaude_code/core/compaction/compaction.py +705 -0
  24. klaude_code/core/compaction/overflow.py +30 -0
  25. klaude_code/core/compaction/prompts.py +97 -0
  26. klaude_code/core/executor.py +121 -2
  27. klaude_code/core/manager/llm_clients.py +5 -0
  28. klaude_code/core/manager/llm_clients_builder.py +14 -2
  29. klaude_code/core/prompts/prompt-antigravity.md +80 -0
  30. klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
  31. klaude_code/core/reminders.py +7 -2
  32. klaude_code/core/task.py +126 -0
  33. klaude_code/core/tool/file/edit_tool.py +1 -2
  34. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  35. klaude_code/core/turn.py +3 -1
  36. klaude_code/llm/antigravity/__init__.py +3 -0
  37. klaude_code/llm/antigravity/client.py +558 -0
  38. klaude_code/llm/antigravity/input.py +261 -0
  39. klaude_code/llm/registry.py +1 -0
  40. klaude_code/protocol/commands.py +1 -0
  41. klaude_code/protocol/events.py +18 -0
  42. klaude_code/protocol/llm_param.py +1 -0
  43. klaude_code/protocol/message.py +23 -1
  44. klaude_code/protocol/op.py +29 -1
  45. klaude_code/protocol/op_handler.py +10 -0
  46. klaude_code/session/export.py +308 -299
  47. klaude_code/session/session.py +36 -0
  48. klaude_code/session/templates/export_session.html +430 -134
  49. klaude_code/skill/assets/create-plan/SKILL.md +6 -6
  50. klaude_code/tui/command/__init__.py +6 -0
  51. klaude_code/tui/command/compact_cmd.py +32 -0
  52. klaude_code/tui/command/continue_cmd.py +34 -0
  53. klaude_code/tui/command/fork_session_cmd.py +110 -14
  54. klaude_code/tui/command/model_picker.py +5 -1
  55. klaude_code/tui/command/thinking_cmd.py +1 -1
  56. klaude_code/tui/commands.py +6 -0
  57. klaude_code/tui/components/rich/markdown.py +119 -12
  58. klaude_code/tui/components/rich/theme.py +10 -2
  59. klaude_code/tui/components/tools.py +39 -25
  60. klaude_code/tui/components/user_input.py +1 -1
  61. klaude_code/tui/input/__init__.py +5 -2
  62. klaude_code/tui/input/drag_drop.py +6 -57
  63. klaude_code/tui/input/key_bindings.py +10 -0
  64. klaude_code/tui/input/prompt_toolkit.py +19 -6
  65. klaude_code/tui/machine.py +25 -0
  66. klaude_code/tui/renderer.py +68 -4
  67. klaude_code/tui/runner.py +18 -2
  68. klaude_code/tui/terminal/image.py +72 -10
  69. klaude_code/tui/terminal/selector.py +31 -7
  70. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/METADATA +1 -1
  71. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/RECORD +73 -56
  72. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
  73. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/WHEEL +0 -0
  74. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/entry_points.txt +0 -0
@@ -30,6 +30,8 @@ def ensure_commands_loaded() -> None:
30
30
 
31
31
  # Import and register commands in display order
32
32
  from .clear_cmd import ClearCommand
33
+ from .compact_cmd import CompactCommand
34
+ from .continue_cmd import ContinueCommand
33
35
  from .copy_cmd import CopyCommand
34
36
  from .debug_cmd import DebugCommand
35
37
  from .export_cmd import ExportCommand
@@ -45,6 +47,8 @@ def ensure_commands_loaded() -> None:
45
47
  # Register in desired display order
46
48
  register(CopyCommand())
47
49
  register(ExportCommand())
50
+ register(CompactCommand())
51
+ register(ContinueCommand())
48
52
  register(RefreshTerminalCommand())
49
53
  register(ModelCommand())
50
54
  register(SubAgentModelCommand())
@@ -64,6 +68,8 @@ def ensure_commands_loaded() -> None:
64
68
  def __getattr__(name: str) -> object:
65
69
  _commands_map = {
66
70
  "ClearCommand": "clear_cmd",
71
+ "CompactCommand": "compact_cmd",
72
+ "ContinueCommand": "continue_cmd",
67
73
  "CopyCommand": "copy_cmd",
68
74
  "DebugCommand": "debug_cmd",
69
75
  "ExportCommand": "export_cmd",
@@ -0,0 +1,32 @@
1
+ from klaude_code.protocol import commands, message, op
2
+ from klaude_code.tui.command.command_abc import Agent, CommandABC, CommandResult
3
+
4
+
5
+ class CompactCommand(CommandABC):
6
+ @property
7
+ def name(self) -> commands.CommandName:
8
+ return commands.CommandName.COMPACT
9
+
10
+ @property
11
+ def summary(self) -> str:
12
+ return "summarize older context to free up the model window"
13
+
14
+ @property
15
+ def support_addition_params(self) -> bool:
16
+ return True
17
+
18
+ @property
19
+ def placeholder(self) -> str:
20
+ return "optional focus for the summary"
21
+
22
+ async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
23
+ focus = user_input.text.strip() if user_input.text else None
24
+ return CommandResult(
25
+ operations=[
26
+ op.CompactSessionOperation(
27
+ session_id=agent.session.id,
28
+ reason="manual",
29
+ focus=focus or None,
30
+ )
31
+ ]
32
+ )
@@ -0,0 +1,34 @@
1
+ from klaude_code.protocol import commands, events, message, op
2
+
3
+ from .command_abc import Agent, CommandABC, CommandResult
4
+
5
+
6
+ class ContinueCommand(CommandABC):
7
+ """Continue agent execution without adding a new user message."""
8
+
9
+ @property
10
+ def name(self) -> commands.CommandName:
11
+ return commands.CommandName.CONTINUE
12
+
13
+ @property
14
+ def summary(self) -> str:
15
+ return "Continue agent execution (for recovery after interruptions)"
16
+
17
+ async def run(self, agent: Agent, user_input: message.UserInputPayload) -> CommandResult:
18
+ del user_input # unused
19
+
20
+ if agent.session.messages_count == 0:
21
+ return CommandResult(
22
+ events=[
23
+ events.CommandOutputEvent(
24
+ session_id=agent.session.id,
25
+ command_name=self.name,
26
+ content="Cannot continue: no conversation history. Start a conversation first.",
27
+ is_error=True,
28
+ )
29
+ ]
30
+ )
31
+
32
+ return CommandResult(
33
+ operations=[op.ContinueAgentOperation(session_id=agent.session.id)],
34
+ )
@@ -28,10 +28,14 @@ FORK_SELECT_STYLE = merge_styles(
28
28
  class ForkPoint:
29
29
  """A fork point in conversation history."""
30
30
 
31
+ kind: Literal["user", "compaction", "end"]
31
32
  history_index: int # -1 means fork entire conversation
32
- user_message: str
33
33
  tool_call_stats: dict[str, int] # tool_name -> count
34
- last_assistant_summary: str
34
+ user_message: str = ""
35
+ last_assistant_summary: str = ""
36
+ compaction_summary_preview: str = ""
37
+ compaction_first_kept_index: int | None = None
38
+ compaction_tokens_before: int | None = None
35
39
 
36
40
 
37
41
  def _truncate(text: str, max_len: int = 60) -> str:
@@ -42,11 +46,61 @@ def _truncate(text: str, max_len: int = 60) -> str:
42
46
  return text[: max_len - 1] + "…"
43
47
 
44
48
 
49
+ def _first_non_empty_line(text: str) -> str:
50
+ for line in text.splitlines():
51
+ stripped = line.strip()
52
+ if stripped:
53
+ return stripped
54
+ return ""
55
+
56
+
57
+ def _preview_compaction_summary(summary: str) -> str:
58
+ """Return a human-friendly preview line for a CompactionEntry summary.
59
+
60
+ Compaction summaries may start with a fixed prefix line and may contain <summary> tags.
61
+ For UI previews we want something more informative than the prefix.
62
+ """
63
+
64
+ cleaned = summary.replace("<summary>", "\n").replace("</summary>", "\n")
65
+ lines = [line.strip() for line in cleaned.splitlines()]
66
+ prefix = "the conversation history before this point was compacted"
67
+
68
+ def _is_noise(line: str) -> bool:
69
+ if not line:
70
+ return True
71
+ if line.casefold().startswith(prefix):
72
+ return True
73
+ return line in {"---", "----", "-----"}
74
+
75
+ # Prefer the first non-empty line under the "## Goal" section.
76
+ for i, line in enumerate(lines):
77
+ if line == "## Goal":
78
+ for j in range(i + 1, len(lines)):
79
+ candidate = lines[j]
80
+ if _is_noise(candidate):
81
+ continue
82
+ if candidate.startswith("## "):
83
+ break
84
+ return candidate
85
+
86
+ # Otherwise, pick the first non-heading meaningful line.
87
+ for line in lines:
88
+ if _is_noise(line):
89
+ continue
90
+ if line.startswith("## "):
91
+ continue
92
+ return line
93
+
94
+ # Fallback: first non-empty line.
95
+ return _first_non_empty_line(cleaned)
96
+
97
+
45
98
  def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list[ForkPoint]:
46
99
  """Build list of fork points from conversation history.
47
100
 
48
101
  Fork points are:
49
102
  - Each UserMessage position (for UI display, including first which would be empty session)
103
+ - The latest CompactionEntry boundary (just after it)
50
104
  - The end of the conversation (fork entire conversation)
51
105
  """
52
106
  fork_points: list[ForkPoint] = []
@@ -80,24 +134,44 @@ def _build_fork_points(conversation_history: list[message.HistoryEvent]) -> list
80
134
  user_text = message.join_text_parts(user_item.parts)
81
135
  fork_points.append(
82
136
  ForkPoint(
137
+ kind="user",
83
138
  history_index=user_idx,
84
- user_message=user_text or "(empty)",
85
139
  tool_call_stats=tool_stats,
140
+ user_message=user_text or "(empty)",
86
141
  last_assistant_summary=_truncate(last_assistant_content) if last_assistant_content else "",
87
142
  )
88
143
  )
89
144
 
90
- # Add the "fork entire conversation" option at the end
91
- if user_indices:
145
+ # Add a fork point just after the latest compaction entry (if any).
146
+ last_compaction_idx = -1
147
+ last_compaction: message.CompactionEntry | None = None
148
+ for idx in range(len(conversation_history) - 1, -1, -1):
149
+ item = conversation_history[idx]
150
+ if isinstance(item, message.CompactionEntry):
151
+ last_compaction_idx = idx
152
+ last_compaction = item
153
+ break
154
+ if last_compaction is not None:
155
+ # `until_index` is exclusive; `idx + 1` means include the CompactionEntry itself.
156
+ boundary_index = min(len(conversation_history), last_compaction_idx + 1)
157
+ preview = _truncate(_preview_compaction_summary(last_compaction.summary), 70)
92
158
  fork_points.append(
93
159
  ForkPoint(
94
- history_index=-1, # None means fork entire conversation
95
- user_message="", # No specific message, this represents the end
160
+ kind="compaction",
161
+ history_index=boundary_index,
96
162
  tool_call_stats={},
97
- last_assistant_summary="",
163
+ compaction_summary_preview=preview,
164
+ compaction_first_kept_index=last_compaction.first_kept_index,
165
+ compaction_tokens_before=last_compaction.tokens_before,
98
166
  )
99
167
  )
100
168
 
169
+ fork_points.sort(key=lambda fp: fp.history_index)
170
+
171
+ # Add the "fork entire conversation" option at the end
172
+ if fork_points:
173
+ fork_points.append(ForkPoint(kind="end", history_index=-1, tool_call_stats={}))
174
+
101
175
  return fork_points
102
176
 
103
177
 
@@ -107,7 +181,6 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
107
181
 
108
182
  for i, fp in enumerate(fork_points):
109
183
  is_first = i == 0
110
- is_last = i == len(fork_points) - 1
111
184
 
112
185
  # Build the title
113
186
  title_parts: list[tuple[str, str]] = []
@@ -115,12 +188,14 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
115
188
  # First line: separator (with special markers for first/last fork points)
116
189
  if is_first:
117
190
  pass
118
- elif is_last:
191
+ elif fp.kind == "end":
119
192
  title_parts.append(("class:separator", "----- fork from here (entire session) -----\n\n"))
193
+ elif fp.kind == "compaction":
194
+ title_parts.append(("class:separator", "----- fork after compaction -----\n\n"))
120
195
  else:
121
196
  title_parts.append(("class:separator", "----- fork from here -----\n\n"))
122
197
 
123
- if not is_last:
198
+ if fp.kind == "user":
124
199
  # Second line: user message
125
200
  title_parts.append(("class:msg", f"user: {_truncate(fp.user_message, 70)}\n"))
126
201
 
@@ -133,6 +208,15 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
133
208
  if fp.last_assistant_summary:
134
209
  title_parts.append(("class:assistant", f"ai: {fp.last_assistant_summary}\n"))
135
210
 
211
+ elif fp.kind == "compaction":
212
+ kept_from = fp.compaction_first_kept_index
213
+ if kept_from is not None:
214
+ title_parts.append(("class:meta", f"kept: from history index {kept_from}\n"))
215
+ if fp.compaction_tokens_before is not None:
216
+ title_parts.append(("class:meta", f"tokens: {fp.compaction_tokens_before}\n"))
217
+ if fp.compaction_summary_preview:
218
+ title_parts.append(("class:assistant", f"sum: {fp.compaction_summary_preview}\n"))
219
+
136
220
  # Empty line at the end
137
221
  title_parts.append(("class:text", "\n"))
138
222
 
@@ -140,8 +224,16 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
140
224
  SelectItem(
141
225
  title=title_parts,
142
226
  value=fp.history_index,
143
- search_text=fp.user_message if not is_last else "fork entire conversation",
144
- selectable=not is_first,
227
+ search_text=(
228
+ fp.user_message
229
+ if fp.kind == "user"
230
+ else (
231
+ f"compaction {fp.compaction_summary_preview}"
232
+ if fp.kind == "compaction"
233
+ else "fork entire conversation"
234
+ )
235
+ ),
236
+ selectable=not (fp.kind == "user" and is_first),
145
237
  )
146
238
  )
147
239
 
@@ -247,7 +339,11 @@ class ForkSessionCommand(CommandABC):
247
339
  await new_session.wait_for_flush()
248
340
 
249
341
  # Build result message
250
- fork_description = "entire conversation" if selected == -1 else f"up to message index {selected}"
342
+ selected_point = next((fp for fp in fork_points if fp.history_index == selected), None)
343
+ if selected_point is not None and selected_point.kind == "compaction":
344
+ fork_description = "after compaction"
345
+ else:
346
+ fork_description = "entire conversation" if selected == -1 else f"up to message index {selected}"
251
347
 
252
348
  resume_cmd = f"klaude --resume {new_session.id}"
253
349
  copy_to_clipboard(resume_cmd)
@@ -79,7 +79,11 @@ def select_model_interactive(
79
79
  try:
80
80
  items = build_model_select_items(result.filtered_models)
81
81
 
82
- message = f"Select a model (filtered by '{result.filter_hint}'):" if result.filter_hint else "Select a model:"
82
+ total_count = len(result.filtered_models)
83
+ if result.filter_hint:
84
+ message = f"Select a model ({total_count}, filtered by '{result.filter_hint}'):"
85
+ else:
86
+ message = f"Select a model ({total_count}):"
83
87
 
84
88
  initial_value = config.main_model
85
89
  if isinstance(initial_value, str) and initial_value and "@" not in initial_value:
@@ -14,7 +14,7 @@ def _select_thinking_sync(config: llm_param.LLMConfigParameter) -> llm_param.Thi
14
14
  return None
15
15
 
16
16
  items: list[SelectItem[str]] = [
17
- SelectItem(title=[("class:text", opt.label + "\n")], value=opt.value, search_text=opt.label)
17
+ SelectItem(title=[("class:msg", opt.label + "\n")], value=opt.value, search_text=opt.label)
18
18
  for opt in data.options
19
19
  ]
20
20
 
@@ -162,3 +162,9 @@ class TaskClockStart(RenderCommand):
162
162
  @dataclass(frozen=True, slots=True)
163
163
  class TaskClockClear(RenderCommand):
164
164
  pass
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class RenderCompactionSummary(RenderCommand):
169
+ summary: str
170
+ kept_items_brief: tuple[tuple[str, int, str], ...] = () # (item_type, count, preview)
@@ -5,14 +5,17 @@ import io
5
5
  import re
6
6
  import time
7
7
  from collections.abc import Callable
8
+ from pathlib import Path
8
9
  from typing import Any, ClassVar
9
10
 
10
11
  from markdown_it import MarkdownIt
11
12
  from markdown_it.token import Token
12
13
  from rich import box
14
+ from rich._loop import loop_first
13
15
  from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
14
- from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement, TableElement
16
+ from rich.markdown import CodeBlock, Heading, ImageItem, ListItem, Markdown, MarkdownElement, TableElement
15
17
  from rich.rule import Rule
18
+ from rich.segment import Segment
16
19
  from rich.style import Style, StyleType
17
20
  from rich.syntax import Syntax
18
21
  from rich.table import Table
@@ -34,6 +37,9 @@ _THINKING_HTML_BLOCK_RE = re.compile(
34
37
 
35
38
  _HTML_COMMENT_BLOCK_RE = re.compile(r"\A\s*<!--.*?-->\s*\Z", flags=re.DOTALL)
36
39
 
40
+ _CHECKBOX_UNCHECKED_RE = re.compile(r"^\[ \]\s*")
41
+ _CHECKBOX_CHECKED_RE = re.compile(r"^\[x\]\s*", re.IGNORECASE)
42
+
37
43
 
38
44
  class ThinkingHTMLBlock(MarkdownElement):
39
45
  """Render `<thinking>...</thinking>` HTML blocks as Rich Markdown.
@@ -153,6 +159,74 @@ class LeftHeading(Heading):
153
159
  yield text
154
160
 
155
161
 
162
+ class CheckboxListItem(ListItem):
163
+ """A list item that renders checkbox syntax as Unicode symbols."""
164
+
165
+ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
166
+ render_options = options.update(width=options.max_width - 3)
167
+ lines = console.render_lines(self.elements, render_options, style=self.style)
168
+ bullet_style = console.get_style("markdown.item.bullet", default="none")
169
+
170
+ first_line_text = ""
171
+ if lines:
172
+ first_line_text = "".join(seg.text for seg in lines[0] if seg.text)
173
+
174
+ unchecked_match = _CHECKBOX_UNCHECKED_RE.match(first_line_text)
175
+ checked_match = _CHECKBOX_CHECKED_RE.match(first_line_text)
176
+
177
+ if unchecked_match:
178
+ bullet = Segment(" \u2610 ", bullet_style)
179
+ skip_chars = len(unchecked_match.group(0))
180
+ elif checked_match:
181
+ checked_style = console.get_style("markdown.checkbox.checked", default="none")
182
+ bullet = Segment(" \u2713 ", checked_style)
183
+ skip_chars = len(checked_match.group(0))
184
+ else:
185
+ bullet = Segment(" \u2022 ", bullet_style)
186
+ skip_chars = 0
187
+
188
+ padding = Segment(" " * 3, bullet_style)
189
+ new_line = Segment("\n")
190
+
191
+ for first, line in loop_first(lines):
192
+ yield bullet if first else padding
193
+ if first and skip_chars > 0:
194
+ chars_skipped = 0
195
+ for seg in line:
196
+ if seg.text and chars_skipped < skip_chars:
197
+ remaining = skip_chars - chars_skipped
198
+ if len(seg.text) <= remaining:
199
+ chars_skipped += len(seg.text)
200
+ continue
201
+ else:
202
+ yield Segment(seg.text[remaining:], seg.style)
203
+ chars_skipped = skip_chars
204
+ else:
205
+ yield seg
206
+ else:
207
+ yield from line
208
+ yield new_line
209
+
210
+
211
+ class LocalImageItem(ImageItem):
212
+ """Image element that collects local file paths for external rendering."""
213
+
214
+ @classmethod
215
+ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement:
216
+ src = str(token.attrs.get("src", ""))
217
+ instance = cls(src, markdown.hyperlinks)
218
+ if src.startswith("/") and Path(src).exists():
219
+ collected = getattr(markdown, "collected_images", None)
220
+ if collected is not None:
221
+ collected.append(src)
222
+ return instance
223
+
224
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
225
+ if self.destination.startswith("/") and Path(self.destination).exists():
226
+ return
227
+ yield from super().__rich_console__(console, options)
228
+
229
+
156
230
  class NoInsetMarkdown(Markdown):
157
231
  """Markdown with code blocks that have no padding and left-justified headings."""
158
232
 
@@ -164,8 +238,14 @@ class NoInsetMarkdown(Markdown):
164
238
  "hr": Divider,
165
239
  "table_open": MarkdownTable,
166
240
  "html_block": ThinkingHTMLBlock,
241
+ "list_item_open": CheckboxListItem,
242
+ "image": LocalImageItem,
167
243
  }
168
244
 
245
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
246
+ super().__init__(*args, **kwargs)
247
+ self.collected_images: list[str] = []
248
+
169
249
 
170
250
  class ThinkingMarkdown(Markdown):
171
251
  """Markdown for thinking content with grey-styled code blocks and left-justified headings."""
@@ -178,8 +258,14 @@ class ThinkingMarkdown(Markdown):
178
258
  "hr": Divider,
179
259
  "table_open": MarkdownTable,
180
260
  "html_block": ThinkingHTMLBlock,
261
+ "list_item_open": CheckboxListItem,
262
+ "image": LocalImageItem,
181
263
  }
182
264
 
265
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
266
+ super().__init__(*args, **kwargs)
267
+ self.collected_images: list[str] = []
268
+
183
269
 
184
270
  class MarkdownStream:
185
271
  """Block-based streaming Markdown renderer.
@@ -204,6 +290,7 @@ class MarkdownStream:
204
290
  left_margin: int = 0,
205
291
  right_margin: int = MARKDOWN_RIGHT_MARGIN,
206
292
  markdown_class: Callable[..., Markdown] | None = None,
293
+ image_callback: Callable[[str], None] | None = None,
207
294
  ) -> None:
208
295
  """Initialize the markdown stream.
209
296
 
@@ -216,6 +303,7 @@ class MarkdownStream:
216
303
  left_margin (int, optional): Number of columns to reserve on the left side
217
304
  right_margin (int, optional): Number of columns to reserve on the right side
218
305
  markdown_class: Markdown class to use for rendering (defaults to NoInsetMarkdown)
306
+ image_callback: Callback to display local images (called with file path)
219
307
  """
220
308
  self._stable_rendered_lines: list[str] = []
221
309
  self._stable_source_line_count: int = 0
@@ -226,6 +314,8 @@ class MarkdownStream:
226
314
  self.mdargs = {}
227
315
 
228
316
  self._live_sink = live_sink
317
+ self._image_callback = image_callback
318
+ self._displayed_images: set[str] = set()
229
319
 
230
320
  # Streaming control
231
321
  self.when: float = 0.0 # Timestamp of last update
@@ -365,20 +455,24 @@ class MarkdownStream:
365
455
 
366
456
  This is primarily intended for internal debugging and tests.
367
457
  """
458
+ lines, _ = self._render_markdown_to_lines(text, apply_mark=apply_mark)
459
+ return "".join(lines)
368
460
 
369
- return "".join(self._render_markdown_to_lines(text, apply_mark=apply_mark))
370
-
371
- def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> str:
372
- """Render stable prefix to ANSI, preserving inter-block spacing."""
461
+ def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
462
+ """Render stable prefix to ANSI, preserving inter-block spacing.
373
463
 
464
+ Returns:
465
+ tuple: (ANSI string, collected local image paths)
466
+ """
374
467
  if not stable_source:
375
- return ""
468
+ return "", []
376
469
 
377
470
  render_source = stable_source
378
471
  if not final and has_live_suffix:
379
472
  render_source = self._append_nonfinal_sentinel(stable_source)
380
473
 
381
- return self.render_ansi(render_source, apply_mark=True)
474
+ lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
475
+ return "".join(lines), images
382
476
 
383
477
  @staticmethod
384
478
  def normalize_live_ansi_for_boundary(*, stable_ansi: str, live_ansi: str) -> str:
@@ -441,14 +535,14 @@ class MarkdownStream:
441
535
  return stable_source + "\n<!-- -->"
442
536
  return stable_source + "\n\n<!-- -->"
443
537
 
444
- def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> list[str]:
538
+ def _render_markdown_to_lines(self, text: str, *, apply_mark: bool) -> tuple[list[str], list[str]]:
445
539
  """Render markdown text to a list of lines.
446
540
 
447
541
  Args:
448
542
  text (str): Markdown text to render
449
543
 
450
544
  Returns:
451
- list: List of rendered lines with line endings preserved
545
+ tuple: (lines with line endings preserved, collected local image paths)
452
546
  """
453
547
  # Render the markdown to a string buffer
454
548
  string_io = io.StringIO()
@@ -470,6 +564,8 @@ class MarkdownStream:
470
564
  temp_console.print(markdown)
471
565
  output = string_io.getvalue()
472
566
 
567
+ collected_images = getattr(markdown, "collected_images", [])
568
+
473
569
  # Split rendered output into lines, strip trailing spaces, and apply left margin.
474
570
  lines = output.splitlines(keepends=True)
475
571
  indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
@@ -503,7 +599,7 @@ class MarkdownStream:
503
599
  stripped += "\n"
504
600
  processed_lines.append(stripped)
505
601
 
506
- return processed_lines
602
+ return processed_lines, list(collected_images)
507
603
 
508
604
  def __del__(self) -> None:
509
605
  """Destructor to ensure Live display is properly cleaned up."""
@@ -531,15 +627,22 @@ class MarkdownStream:
531
627
  start = time.time()
532
628
 
533
629
  stable_chunk_to_print: str | None = None
630
+ new_images: list[str] = []
534
631
  stable_changed = final or stable_line > self._stable_source_line_count
535
632
  if stable_changed and stable_source:
536
- stable_ansi = self.render_stable_ansi(stable_source, has_live_suffix=bool(live_source), final=final)
633
+ stable_ansi, collected_images = self.render_stable_ansi(
634
+ stable_source, has_live_suffix=bool(live_source), final=final
635
+ )
537
636
  stable_lines = stable_ansi.splitlines(keepends=True)
538
637
  new_lines = stable_lines[len(self._stable_rendered_lines) :]
539
638
  if new_lines:
540
639
  stable_chunk_to_print = "".join(new_lines)
541
640
  self._stable_rendered_lines = stable_lines
542
641
  self._stable_source_line_count = stable_line
642
+ for img in collected_images:
643
+ if img not in self._displayed_images:
644
+ new_images.append(img)
645
+ self._displayed_images.add(img)
543
646
  elif final and not stable_source:
544
647
  self._stable_rendered_lines = []
545
648
  self._stable_source_line_count = stable_line
@@ -547,7 +650,7 @@ class MarkdownStream:
547
650
  live_text_to_set: Text | None = None
548
651
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
549
652
  apply_mark_live = self._stable_source_line_count == 0
550
- live_lines = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
653
+ live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
551
654
 
552
655
  if self._stable_rendered_lines:
553
656
  stable_trailing_blank = 0
@@ -573,6 +676,10 @@ class MarkdownStream:
573
676
  if stable_chunk_to_print:
574
677
  self.console.print(Text.from_ansi(stable_chunk_to_print), end="\n")
575
678
 
679
+ if new_images and self._image_callback:
680
+ for img_path in new_images:
681
+ self._image_callback(img_path)
682
+
576
683
  if final:
577
684
  if self._live_sink is not None:
578
685
  self._live_sink(None)
@@ -95,7 +95,7 @@ DARK_PALETTE = Palette(
95
95
  code_theme="ansi_dark",
96
96
  code_background="#1a1f2a",
97
97
  green_background="#23342c",
98
- blue_grey_background="#313848",
98
+ blue_grey_background="#262d3a",
99
99
  cyan_background="#1a3333",
100
100
  green_sub_background="#1b3928",
101
101
  blue_sub_background="#1a2a3d",
@@ -116,6 +116,7 @@ class ThemeKey(str, Enum):
116
116
  # PANEL
117
117
  SUB_AGENT_RESULT_PANEL = "panel.sub_agent_result"
118
118
  WRITE_MARKDOWN_PANEL = "panel.write_markdown"
119
+ COMPACTION_SUMMARY_PANEL = "panel.compaction_summary"
119
120
  # DIFF
120
121
  DIFF_FILE_NAME = "diff.file_name"
121
122
  DIFF_REMOVE = "diff.remove"
@@ -178,6 +179,8 @@ class ThemeKey(str, Enum):
178
179
  # THINKING
179
180
  THINKING = "thinking"
180
181
  THINKING_BOLD = "thinking.bold"
182
+ # COMPACTION
183
+ COMPACTION_SUMMARY = "compaction.summary"
181
184
  # TODO_ITEM
182
185
  TODO_EXPLANATION = "todo.explanation"
183
186
  TODO_PENDING_MARK = "todo.pending.mark"
@@ -235,6 +238,7 @@ def get_theme(theme: str | None = None) -> Themes:
235
238
  # PANEL
236
239
  ThemeKey.SUB_AGENT_RESULT_PANEL.value: f"on {palette.blue_grey_background}",
237
240
  ThemeKey.WRITE_MARKDOWN_PANEL.value: f"on {palette.green_background}",
241
+ ThemeKey.COMPACTION_SUMMARY_PANEL.value: f"on {palette.blue_grey_background}",
238
242
  # DIFF
239
243
  ThemeKey.DIFF_FILE_NAME.value: palette.blue,
240
244
  ThemeKey.DIFF_REMOVE.value: palette.diff_remove,
@@ -247,7 +251,7 @@ def get_theme(theme: str | None = None) -> Themes:
247
251
  ThemeKey.ERROR.value: palette.red,
248
252
  ThemeKey.ERROR_BOLD.value: "bold " + palette.red,
249
253
  ThemeKey.ERROR_DIM.value: "dim " + palette.red,
250
- ThemeKey.INTERRUPT.value: "reverse bold " + palette.red,
254
+ ThemeKey.INTERRUPT.value: palette.red,
251
255
  # USER_INPUT
252
256
  ThemeKey.USER_INPUT.value: palette.magenta,
253
257
  ThemeKey.USER_INPUT_PROMPT.value: "bold " + palette.magenta,
@@ -296,6 +300,8 @@ def get_theme(theme: str | None = None) -> Themes:
296
300
  # THINKING
297
301
  ThemeKey.THINKING.value: "italic " + palette.grey2,
298
302
  ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
303
+ # COMPACTION
304
+ ThemeKey.COMPACTION_SUMMARY.value: "italic " + palette.grey1,
299
305
  # TODO_ITEM
300
306
  ThemeKey.TODO_EXPLANATION.value: palette.grey1 + " italic",
301
307
  ThemeKey.TODO_PENDING_MARK.value: "bold " + palette.grey1,
@@ -350,6 +356,7 @@ def get_theme(theme: str | None = None) -> Themes:
350
356
  "markdown.link": "underline " + palette.blue,
351
357
  "markdown.link_url": "underline " + palette.blue,
352
358
  "markdown.table.border": palette.grey2,
359
+ "markdown.checkbox.checked": palette.green,
353
360
  }
354
361
  ),
355
362
  thinking_markdown_theme=Theme(
@@ -372,6 +379,7 @@ def get_theme(theme: str | None = None) -> Themes:
372
379
  "markdown.link_url": "underline " + palette.blue,
373
380
  "markdown.strong": "bold italic " + palette.grey1,
374
381
  "markdown.table.border": palette.grey2,
382
+ "markdown.checkbox.checked": palette.green,
375
383
  }
376
384
  ),
377
385
  code_theme=palette.code_theme,