klaude-code 2.6.0__py3-none-any.whl → 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/AGENTS.md +325 -0
  3. klaude_code/auth/__init__.py +17 -1
  4. klaude_code/auth/antigravity/__init__.py +20 -0
  5. klaude_code/auth/antigravity/exceptions.py +17 -0
  6. klaude_code/auth/antigravity/oauth.py +320 -0
  7. klaude_code/auth/antigravity/pkce.py +25 -0
  8. klaude_code/auth/antigravity/token_manager.py +45 -0
  9. klaude_code/auth/base.py +4 -0
  10. klaude_code/auth/claude/oauth.py +29 -9
  11. klaude_code/auth/codex/exceptions.py +4 -0
  12. klaude_code/auth/env.py +19 -15
  13. klaude_code/cli/auth_cmd.py +54 -4
  14. klaude_code/cli/cost_cmd.py +83 -160
  15. klaude_code/cli/list_model.py +50 -0
  16. klaude_code/cli/main.py +99 -9
  17. klaude_code/config/assets/builtin_config.yaml +108 -0
  18. klaude_code/config/builtin_config.py +5 -11
  19. klaude_code/config/config.py +24 -10
  20. klaude_code/const.py +11 -1
  21. klaude_code/core/agent.py +5 -1
  22. klaude_code/core/agent_profile.py +28 -32
  23. klaude_code/core/compaction/AGENTS.md +112 -0
  24. klaude_code/core/compaction/__init__.py +11 -0
  25. klaude_code/core/compaction/compaction.py +707 -0
  26. klaude_code/core/compaction/overflow.py +30 -0
  27. klaude_code/core/compaction/prompts.py +97 -0
  28. klaude_code/core/executor.py +103 -2
  29. klaude_code/core/manager/llm_clients.py +5 -0
  30. klaude_code/core/manager/llm_clients_builder.py +14 -2
  31. klaude_code/core/prompts/prompt-antigravity.md +80 -0
  32. klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
  33. klaude_code/core/reminders.py +11 -7
  34. klaude_code/core/task.py +126 -0
  35. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  36. klaude_code/core/turn.py +3 -1
  37. klaude_code/llm/antigravity/__init__.py +3 -0
  38. klaude_code/llm/antigravity/client.py +558 -0
  39. klaude_code/llm/antigravity/input.py +261 -0
  40. klaude_code/llm/registry.py +1 -0
  41. klaude_code/protocol/commands.py +0 -1
  42. klaude_code/protocol/events.py +18 -0
  43. klaude_code/protocol/llm_param.py +1 -0
  44. klaude_code/protocol/message.py +23 -1
  45. klaude_code/protocol/op.py +15 -1
  46. klaude_code/protocol/op_handler.py +5 -0
  47. klaude_code/session/session.py +36 -0
  48. klaude_code/skill/assets/create-plan/SKILL.md +6 -6
  49. klaude_code/skill/loader.py +12 -13
  50. klaude_code/skill/manager.py +3 -3
  51. klaude_code/tui/command/__init__.py +4 -4
  52. klaude_code/tui/command/compact_cmd.py +32 -0
  53. klaude_code/tui/command/copy_cmd.py +1 -1
  54. klaude_code/tui/command/fork_session_cmd.py +114 -18
  55. klaude_code/tui/command/model_picker.py +5 -1
  56. klaude_code/tui/command/thinking_cmd.py +1 -1
  57. klaude_code/tui/commands.py +6 -0
  58. klaude_code/tui/components/command_output.py +1 -1
  59. klaude_code/tui/components/rich/markdown.py +117 -1
  60. klaude_code/tui/components/rich/theme.py +18 -2
  61. klaude_code/tui/components/tools.py +39 -25
  62. klaude_code/tui/components/user_input.py +39 -28
  63. klaude_code/tui/input/AGENTS.md +44 -0
  64. klaude_code/tui/input/__init__.py +5 -2
  65. klaude_code/tui/input/completers.py +10 -14
  66. klaude_code/tui/input/drag_drop.py +146 -0
  67. klaude_code/tui/input/images.py +227 -0
  68. klaude_code/tui/input/key_bindings.py +183 -19
  69. klaude_code/tui/input/paste.py +71 -0
  70. klaude_code/tui/input/prompt_toolkit.py +32 -9
  71. klaude_code/tui/machine.py +26 -1
  72. klaude_code/tui/renderer.py +67 -4
  73. klaude_code/tui/runner.py +19 -3
  74. klaude_code/tui/terminal/image.py +103 -10
  75. klaude_code/tui/terminal/selector.py +81 -7
  76. {klaude_code-2.6.0.dist-info → klaude_code-2.8.0.dist-info}/METADATA +10 -10
  77. {klaude_code-2.6.0.dist-info → klaude_code-2.8.0.dist-info}/RECORD +79 -61
  78. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
  79. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  80. klaude_code/tui/input/clipboard.py +0 -152
  81. {klaude_code-2.6.0.dist-info → klaude_code-2.8.0.dist-info}/WHEEL +0 -0
  82. {klaude_code-2.6.0.dist-info → klaude_code-2.8.0.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,7 @@ from typing import Literal
6
6
  from prompt_toolkit.styles import Style, merge_styles
7
7
 
8
8
  from klaude_code.protocol import commands, events, message, model
9
- from klaude_code.tui.input.clipboard import copy_to_clipboard
9
+ from klaude_code.tui.input.key_bindings import copy_to_clipboard
10
10
  from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
11
11
 
12
12
  from .command_abc import Agent, CommandABC, CommandResult
@@ -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
 
@@ -194,7 +286,7 @@ class ForkSessionCommand(CommandABC):
194
286
 
195
287
  @property
196
288
  def summary(self) -> str:
197
- return "Fork the current session and show a resume-by-id command"
289
+ return "Fork the current session and show a resume command"
198
290
 
199
291
  @property
200
292
  def is_interactive(self) -> bool:
@@ -220,7 +312,7 @@ class ForkSessionCommand(CommandABC):
220
312
  new_session = agent.session.fork()
221
313
  await new_session.wait_for_flush()
222
314
 
223
- resume_cmd = f"klaude --resume-by-id {new_session.id}"
315
+ resume_cmd = f"klaude --resume {new_session.id}"
224
316
  copy_to_clipboard(resume_cmd)
225
317
 
226
318
  event = events.CommandOutputEvent(
@@ -247,9 +339,13 @@ 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
- resume_cmd = f"klaude --resume-by-id {new_session.id}"
348
+ resume_cmd = f"klaude --resume {new_session.id}"
253
349
  copy_to_clipboard(resume_cmd)
254
350
 
255
351
  event = events.CommandOutputEvent(
@@ -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)
@@ -50,7 +50,7 @@ def _render_fork_session_output(e: events.CommandOutputEvent) -> RenderableType:
50
50
  grid.add_column(style=ThemeKey.TOOL_RESULT, overflow="fold")
51
51
 
52
52
  grid.add_row(Text("Session forked. Resume command copied to clipboard:", style=ThemeKey.TOOL_RESULT))
53
- grid.add_row(Text(f" klaude --resume-by-id {session_id}", style=ThemeKey.TOOL_RESULT_BOLD))
53
+ grid.add_row(Text(f" klaude --resume {session_id}", style=ThemeKey.TOOL_RESULT_BOLD))
54
54
 
55
55
  return Padding.indent(grid, level=2)
56
56
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import contextlib
4
4
  import io
5
+ import re
5
6
  import time
6
7
  from collections.abc import Callable
7
8
  from typing import Any, ClassVar
@@ -9,9 +10,11 @@ from typing import Any, ClassVar
9
10
  from markdown_it import MarkdownIt
10
11
  from markdown_it.token import Token
11
12
  from rich import box
13
+ from rich._loop import loop_first
12
14
  from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
13
- from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement, TableElement
15
+ from rich.markdown import CodeBlock, Heading, ListItem, Markdown, MarkdownElement, TableElement
14
16
  from rich.rule import Rule
17
+ from rich.segment import Segment
15
18
  from rich.style import Style, StyleType
16
19
  from rich.syntax import Syntax
17
20
  from rich.table import Table
@@ -26,6 +29,66 @@ from klaude_code.const import (
26
29
  )
27
30
  from klaude_code.tui.components.rich.code_panel import CodePanel
28
31
 
32
+ _THINKING_HTML_BLOCK_RE = re.compile(
33
+ r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
34
+ flags=re.IGNORECASE | re.DOTALL,
35
+ )
36
+
37
+ _HTML_COMMENT_BLOCK_RE = re.compile(r"\A\s*<!--.*?-->\s*\Z", flags=re.DOTALL)
38
+
39
+ _CHECKBOX_UNCHECKED_RE = re.compile(r"^\[ \]\s*")
40
+ _CHECKBOX_CHECKED_RE = re.compile(r"^\[x\]\s*", re.IGNORECASE)
41
+
42
+
43
+ class ThinkingHTMLBlock(MarkdownElement):
44
+ """Render `<thinking>...</thinking>` HTML blocks as Rich Markdown.
45
+
46
+ markdown-it-py treats custom tags like `<thinking>` as HTML blocks, and Rich
47
+ Markdown ignores HTML blocks by default. This element restores visibility by
48
+ re-parsing the inner content as Markdown and applying a dedicated style.
49
+
50
+ Non-thinking HTML blocks (including comment sentinels like `<!-- -->`) render
51
+ no visible output, matching Rich's default behavior.
52
+ """
53
+
54
+ new_line: ClassVar[bool] = True
55
+
56
+ @classmethod
57
+ def create(cls, markdown: Markdown, token: Token) -> ThinkingHTMLBlock:
58
+ return cls(content=token.content or "", code_theme=markdown.code_theme)
59
+
60
+ def __init__(self, *, content: str, code_theme: str) -> None:
61
+ self._content = content
62
+ self._code_theme = code_theme
63
+
64
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
65
+ stripped = self._content.strip()
66
+
67
+ # Keep HTML comments invisible. MarkdownStream relies on a comment sentinel
68
+ # (`<!-- -->`) to preserve inter-block spacing in some streaming frames.
69
+ if _HTML_COMMENT_BLOCK_RE.match(stripped):
70
+ return
71
+
72
+ match = _THINKING_HTML_BLOCK_RE.match(stripped)
73
+ if match is None:
74
+ return
75
+
76
+ body = match.group("body").strip("\n")
77
+ if not body.strip():
78
+ return
79
+
80
+ # Render as a single line to avoid the extra blank lines produced by
81
+ # paragraph/block rendering.
82
+ collapsed = " ".join(body.split())
83
+ if not collapsed:
84
+ return
85
+
86
+ text = Text()
87
+ text.append("<thinking>", style="markdown.thinking.tag")
88
+ text.append(collapsed, style="markdown.thinking")
89
+ text.append("</thinking>", style="markdown.thinking.tag")
90
+ yield text
91
+
29
92
 
30
93
  class NoInsetCodeBlock(CodeBlock):
31
94
  """A code block with syntax highlighting and no padding."""
@@ -95,6 +158,55 @@ class LeftHeading(Heading):
95
158
  yield text
96
159
 
97
160
 
161
+ class CheckboxListItem(ListItem):
162
+ """A list item that renders checkbox syntax as Unicode symbols."""
163
+
164
+ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
165
+ render_options = options.update(width=options.max_width - 3)
166
+ lines = console.render_lines(self.elements, render_options, style=self.style)
167
+ bullet_style = console.get_style("markdown.item.bullet", default="none")
168
+
169
+ first_line_text = ""
170
+ if lines:
171
+ first_line_text = "".join(seg.text for seg in lines[0] if seg.text)
172
+
173
+ unchecked_match = _CHECKBOX_UNCHECKED_RE.match(first_line_text)
174
+ checked_match = _CHECKBOX_CHECKED_RE.match(first_line_text)
175
+
176
+ if unchecked_match:
177
+ bullet = Segment(" \u2610 ", bullet_style)
178
+ skip_chars = len(unchecked_match.group(0))
179
+ elif checked_match:
180
+ checked_style = console.get_style("markdown.checkbox.checked", default="none")
181
+ bullet = Segment(" \u2713 ", checked_style)
182
+ skip_chars = len(checked_match.group(0))
183
+ else:
184
+ bullet = Segment(" \u2022 ", bullet_style)
185
+ skip_chars = 0
186
+
187
+ padding = Segment(" " * 3, bullet_style)
188
+ new_line = Segment("\n")
189
+
190
+ for first, line in loop_first(lines):
191
+ yield bullet if first else padding
192
+ if first and skip_chars > 0:
193
+ chars_skipped = 0
194
+ for seg in line:
195
+ if seg.text and chars_skipped < skip_chars:
196
+ remaining = skip_chars - chars_skipped
197
+ if len(seg.text) <= remaining:
198
+ chars_skipped += len(seg.text)
199
+ continue
200
+ else:
201
+ yield Segment(seg.text[remaining:], seg.style)
202
+ chars_skipped = skip_chars
203
+ else:
204
+ yield seg
205
+ else:
206
+ yield from line
207
+ yield new_line
208
+
209
+
98
210
  class NoInsetMarkdown(Markdown):
99
211
  """Markdown with code blocks that have no padding and left-justified headings."""
100
212
 
@@ -105,6 +217,8 @@ class NoInsetMarkdown(Markdown):
105
217
  "heading_open": LeftHeading,
106
218
  "hr": Divider,
107
219
  "table_open": MarkdownTable,
220
+ "html_block": ThinkingHTMLBlock,
221
+ "list_item_open": CheckboxListItem,
108
222
  }
109
223
 
110
224
 
@@ -118,6 +232,8 @@ class ThinkingMarkdown(Markdown):
118
232
  "heading_open": LeftHeading,
119
233
  "hr": Divider,
120
234
  "table_open": MarkdownTable,
235
+ "html_block": ThinkingHTMLBlock,
236
+ "list_item_open": CheckboxListItem,
121
237
  }
122
238
 
123
239
 
@@ -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,
@@ -331,7 +337,14 @@ def get_theme(theme: str | None = None) -> Themes:
331
337
  markdown_theme=Theme(
332
338
  styles={
333
339
  "markdown.code": palette.purple,
340
+ # Render degraded `<thinking>...</thinking>` blocks inside assistant markdown.
341
+ # This must live in markdown_theme (not just thinking_markdown_theme) because
342
+ # it is used while rendering assistant output.
343
+ "markdown.thinking": "italic " + palette.grey2,
344
+ "markdown.thinking.tag": palette.grey2,
334
345
  "markdown.code.border": palette.grey3,
346
+ # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
347
+ "markdown.code.block": palette.grey1,
335
348
  "markdown.h1": "bold reverse",
336
349
  "markdown.h1.border": palette.grey3,
337
350
  "markdown.h2": "bold underline",
@@ -343,6 +356,7 @@ def get_theme(theme: str | None = None) -> Themes:
343
356
  "markdown.link": "underline " + palette.blue,
344
357
  "markdown.link_url": "underline " + palette.blue,
345
358
  "markdown.table.border": palette.grey2,
359
+ "markdown.checkbox.checked": palette.green,
346
360
  }
347
361
  ),
348
362
  thinking_markdown_theme=Theme(
@@ -353,6 +367,7 @@ def get_theme(theme: str | None = None) -> Themes:
353
367
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
354
368
  "markdown.code.block": palette.grey1,
355
369
  "markdown.code.border": palette.grey3,
370
+ "markdown.thinking.tag": palette.grey2 + " dim",
356
371
  "markdown.h1": "bold reverse",
357
372
  "markdown.h1.border": palette.grey3,
358
373
  "markdown.h3": "bold " + palette.grey1,
@@ -364,6 +379,7 @@ def get_theme(theme: str | None = None) -> Themes:
364
379
  "markdown.link_url": "underline " + palette.blue,
365
380
  "markdown.strong": "bold italic " + palette.grey1,
366
381
  "markdown.table.border": palette.grey2,
382
+ "markdown.checkbox.checked": palette.green,
367
383
  }
368
384
  ),
369
385
  code_theme=palette.code_theme,
@@ -263,11 +263,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
263
263
  try:
264
264
  json_dict = json.loads(arguments)
265
265
  file_path = json_dict.get("file_path", "")
266
- # Markdown files show path in result panel, skip here to avoid duplication
267
- if file_path.endswith(".md"):
268
- details: RenderableType | None = None
269
- else:
270
- details = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
266
+ details: RenderableType | None = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
271
267
  except json.JSONDecodeError:
272
268
  details = Text(
273
269
  arguments.strip()[:INVALID_TOOL_CALL_MAX_LENGTH],
@@ -292,24 +288,29 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
292
288
  details = Text("", ThemeKey.TOOL_PARAM)
293
289
 
294
290
  if isinstance(patch_content, str):
295
- update_count = 0
296
- add_count = 0
297
- delete_count = 0
291
+ update_files: list[str] = []
292
+ add_files: list[str] = []
293
+ delete_files: list[str] = []
298
294
  for line in patch_content.splitlines():
299
295
  if line.startswith("*** Update File:"):
300
- update_count += 1
296
+ update_files.append(line[len("*** Update File:") :].strip())
301
297
  elif line.startswith("*** Add File:"):
302
- add_count += 1
298
+ add_files.append(line[len("*** Add File:") :].strip())
303
299
  elif line.startswith("*** Delete File:"):
304
- delete_count += 1
300
+ delete_files.append(line[len("*** Delete File:") :].strip())
305
301
 
306
302
  parts: list[str] = []
307
- if update_count > 0:
308
- parts.append(f"Update File × {update_count}" if update_count > 1 else "Update File")
309
- if add_count > 0:
310
- parts.append(f"Add File × {add_count}" if add_count > 1 else "Add File")
311
- if delete_count > 0:
312
- parts.append(f"Delete File × {delete_count}" if delete_count > 1 else "Delete File")
303
+ if update_files:
304
+ parts.append(f"Update File × {len(update_files)}" if len(update_files) > 1 else "Update File")
305
+ if add_files:
306
+ # For single .md file addition, show filename in parentheses
307
+ if len(add_files) == 1 and add_files[0].endswith(".md"):
308
+ file_name = Path(add_files[0]).name
309
+ parts.append(f"Add File ({file_name})")
310
+ else:
311
+ parts.append(f"Add File × {len(add_files)}" if len(add_files) > 1 else "Add File")
312
+ if delete_files:
313
+ parts.append(f"Delete File × {len(delete_files)}" if len(delete_files) > 1 else "Delete File")
313
314
 
314
315
  if parts:
315
316
  details = Text(", ".join(parts), ThemeKey.TOOL_PARAM)
@@ -593,14 +594,24 @@ def _extract_markdown_doc(ui_extra: model.ToolResultUIExtra | None) -> model.Mar
593
594
 
594
595
 
595
596
  def render_markdown_doc(md_ui: model.MarkdownDocUIExtra, *, code_theme: str) -> RenderableType:
596
- """Render markdown document content in a panel."""
597
- header = render_path(md_ui.file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
598
- return Panel.fit(
599
- Group(header, Text(""), NoInsetMarkdown(md_ui.content, code_theme=code_theme)),
597
+ """Render markdown document content in a panel with 2-char left indent and top margin."""
598
+ import shutil
599
+
600
+ from rich.padding import Padding
601
+
602
+ # Limit panel width to min(100, terminal_width) minus left indent (2)
603
+ terminal_width = shutil.get_terminal_size().columns
604
+ panel_width = min(100, terminal_width) - 2
605
+
606
+ panel = Panel(
607
+ NoInsetMarkdown(md_ui.content, code_theme=code_theme),
600
608
  box=box.SIMPLE,
601
609
  border_style=ThemeKey.LINES,
602
610
  style=ThemeKey.WRITE_MARKDOWN_PANEL,
611
+ width=panel_width,
603
612
  )
613
+ # (top, right, bottom, left) - 1 line top margin, 2-char left indent
614
+ return Padding(panel, (1, 0, 0, 2))
604
615
 
605
616
 
606
617
  def render_tool_result(
@@ -628,11 +639,12 @@ def render_tool_result(
628
639
  rendered: list[RenderableType] = []
629
640
  for item in e.ui_extra.items:
630
641
  if isinstance(item, model.MarkdownDocUIExtra):
642
+ # Markdown docs render without TreeQuote wrap (already has 2-char indent)
631
643
  rendered.append(render_markdown_doc(item, code_theme=code_theme))
632
644
  elif isinstance(item, model.DiffUIExtra):
633
645
  show_file_name = e.tool_name == tools.APPLY_PATCH
634
- rendered.append(r_diffs.render_structured_diff(item, show_file_name=show_file_name))
635
- return wrap(Group(*rendered)) if rendered else None
646
+ rendered.append(wrap(r_diffs.render_structured_diff(item, show_file_name=show_file_name)))
647
+ return Group(*rendered) if rendered else None
636
648
 
637
649
  diff_ui = _extract_diff(e.ui_extra)
638
650
  md_ui = _extract_markdown_doc(e.ui_extra)
@@ -649,11 +661,13 @@ def render_tool_result(
649
661
  return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
650
662
  case tools.WRITE:
651
663
  if md_ui:
652
- return wrap(render_markdown_doc(md_ui, code_theme=code_theme))
664
+ # Markdown docs render without TreeQuote wrap (already has 2-char indent)
665
+ return render_markdown_doc(md_ui, code_theme=code_theme)
653
666
  return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
654
667
  case tools.APPLY_PATCH:
655
668
  if md_ui:
656
- return wrap(render_markdown_doc(md_ui, code_theme=code_theme))
669
+ # Markdown docs render without TreeQuote wrap (already has 2-char indent)
670
+ return render_markdown_doc(md_ui, code_theme=code_theme)
657
671
  if diff_ui:
658
672
  return wrap(r_diffs.render_structured_diff(diff_ui, show_file_name=True))
659
673
  return _render_fallback()