klaude-code 2.6.0__py3-none-any.whl → 2.7.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 (34) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/env.py +19 -15
  3. klaude_code/cli/auth_cmd.py +1 -1
  4. klaude_code/cli/main.py +98 -8
  5. klaude_code/const.py +10 -1
  6. klaude_code/core/reminders.py +4 -5
  7. klaude_code/core/turn.py +1 -1
  8. klaude_code/protocol/commands.py +0 -1
  9. klaude_code/skill/loader.py +12 -13
  10. klaude_code/skill/manager.py +3 -3
  11. klaude_code/tui/command/__init__.py +1 -4
  12. klaude_code/tui/command/copy_cmd.py +1 -1
  13. klaude_code/tui/command/fork_session_cmd.py +4 -4
  14. klaude_code/tui/components/command_output.py +1 -1
  15. klaude_code/tui/components/rich/markdown.py +60 -0
  16. klaude_code/tui/components/rich/theme.py +8 -0
  17. klaude_code/tui/components/user_input.py +38 -27
  18. klaude_code/tui/input/AGENTS.md +44 -0
  19. klaude_code/tui/input/completers.py +10 -14
  20. klaude_code/tui/input/drag_drop.py +197 -0
  21. klaude_code/tui/input/images.py +227 -0
  22. klaude_code/tui/input/key_bindings.py +173 -19
  23. klaude_code/tui/input/paste.py +71 -0
  24. klaude_code/tui/input/prompt_toolkit.py +13 -3
  25. klaude_code/tui/machine.py +1 -1
  26. klaude_code/tui/runner.py +1 -1
  27. klaude_code/tui/terminal/image.py +40 -9
  28. klaude_code/tui/terminal/selector.py +52 -2
  29. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/METADATA +10 -10
  30. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/RECORD +32 -30
  31. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  32. klaude_code/tui/input/clipboard.py +0 -152
  33. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
  34. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -178,6 +178,6 @@ async def handle_keyboard_interrupt(executor: Executor) -> None:
178
178
  log("Bye!")
179
179
  session_id = executor.context.current_session_id()
180
180
  if session_id and Session.exists(session_id):
181
- log(("Resume with:", "dim"), (f"klaude --resume-by-id {session_id}", "green"))
181
+ log(("Resume with:", "dim"), (f"klaude --resume {session_id}", "green"))
182
182
  with contextlib.suppress(Exception):
183
183
  await executor.submit(op.InterruptOperation(target_session_id=None))
klaude_code/auth/env.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Environment variable configuration stored in klaude-auth.json."""
2
2
 
3
3
  import json
4
- from typing import Any
4
+ from typing import Any, cast
5
5
 
6
6
  from klaude_code.auth.base import KLAUDE_AUTH_FILE
7
7
 
@@ -11,9 +11,9 @@ def _load_store() -> dict[str, Any]:
11
11
  if not KLAUDE_AUTH_FILE.exists():
12
12
  return {}
13
13
  try:
14
- data: Any = json.loads(KLAUDE_AUTH_FILE.read_text())
14
+ data = json.loads(KLAUDE_AUTH_FILE.read_text())
15
15
  if isinstance(data, dict):
16
- return dict(data)
16
+ return cast(dict[str, Any], data)
17
17
  return {}
18
18
  except (json.JSONDecodeError, ValueError):
19
19
  return {}
@@ -25,26 +25,31 @@ def _save_store(data: dict[str, Any]) -> None:
25
25
  KLAUDE_AUTH_FILE.write_text(json.dumps(data, indent=2))
26
26
 
27
27
 
28
+ def _get_env_section(store: dict[str, Any]) -> dict[str, Any] | None:
29
+ """Extract and validate the 'env' section from store."""
30
+ env_section = store.get("env")
31
+ if isinstance(env_section, dict):
32
+ return cast(dict[str, Any], env_section)
33
+ return None
34
+
35
+
28
36
  def get_auth_env(env_var: str) -> str | None:
29
37
  """Get environment variable value from klaude-auth.json 'env' section.
30
38
 
31
39
  This provides a fallback for API keys when real environment variables are not set.
32
40
  Priority: os.environ > klaude-auth.json env
33
41
  """
34
- store = _load_store()
35
- env_section: Any = store.get("env")
36
- if not isinstance(env_section, dict):
42
+ env_section = _get_env_section(_load_store())
43
+ if env_section is None:
37
44
  return None
38
- value: Any = env_section.get(env_var)
45
+ value = env_section.get(env_var)
39
46
  return str(value) if value is not None else None
40
47
 
41
48
 
42
49
  def set_auth_env(env_var: str, value: str) -> None:
43
50
  """Set environment variable value in klaude-auth.json 'env' section."""
44
51
  store = _load_store()
45
- env_section: Any = store.get("env")
46
- if not isinstance(env_section, dict):
47
- env_section = {}
52
+ env_section = _get_env_section(store) or {}
48
53
  env_section[env_var] = value
49
54
  store["env"] = env_section
50
55
  _save_store(store)
@@ -53,8 +58,8 @@ def set_auth_env(env_var: str, value: str) -> None:
53
58
  def delete_auth_env(env_var: str) -> None:
54
59
  """Delete environment variable from klaude-auth.json 'env' section."""
55
60
  store = _load_store()
56
- env_section: Any = store.get("env")
57
- if not isinstance(env_section, dict):
61
+ env_section = _get_env_section(store)
62
+ if env_section is None:
58
63
  return
59
64
  env_section.pop(env_var, None)
60
65
  if len(env_section) == 0:
@@ -70,8 +75,7 @@ def delete_auth_env(env_var: str) -> None:
70
75
 
71
76
  def list_auth_env() -> dict[str, str]:
72
77
  """List all environment variables in klaude-auth.json 'env' section."""
73
- store = _load_store()
74
- env_section: Any = store.get("env")
75
- if not isinstance(env_section, dict):
78
+ env_section = _get_env_section(_load_store())
79
+ if env_section is None:
76
80
  return {}
77
81
  return {k: str(v) for k, v in env_section.items() if v is not None}
@@ -212,7 +212,7 @@ def register_auth_commands(app: typer.Typer) -> None:
212
212
  auth_app = typer.Typer(help="Login/logout", invoke_without_command=True)
213
213
 
214
214
  @auth_app.callback()
215
- def auth_callback(ctx: typer.Context) -> None:
215
+ def auth_callback(ctx: typer.Context) -> None: # pyright: ignore[reportUnusedFunction]
216
216
  """Authentication commands for managing provider logins."""
217
217
  if ctx.invoked_subcommand is None:
218
218
  typer.echo(ctx.get_help())
klaude_code/cli/main.py CHANGED
@@ -1,7 +1,10 @@
1
1
  import asyncio
2
2
  import sys
3
+ from collections.abc import Sequence
4
+ from typing import Any
3
5
 
4
6
  import typer
7
+ from typer.core import TyperGroup
5
8
 
6
9
  from klaude_code.cli.auth_cmd import register_auth_commands
7
10
  from klaude_code.cli.config_cmd import register_config_commands
@@ -40,7 +43,88 @@ def _build_env_help() -> str:
40
43
 
41
44
  ENV_HELP = _build_env_help()
42
45
 
46
+
47
+ def _looks_like_flag(token: str) -> bool:
48
+ return token.startswith("-") and token != "-"
49
+
50
+
51
+ def _preprocess_cli_args(args: list[str]) -> list[str]:
52
+ """Rewrite CLI args to support optional values for selected options.
53
+
54
+ Supported rewrites:
55
+ - --model / -m with no value -> --model-select
56
+ - --resume / -r with value -> --resume-by-id <value>
57
+ """
58
+
59
+ rewritten: list[str] = []
60
+ i = 0
61
+ while i < len(args):
62
+ token = args[i]
63
+
64
+ if token in {"--model", "-m"}:
65
+ next_token = args[i + 1] if i + 1 < len(args) else None
66
+ if next_token is None or next_token == "--" or _looks_like_flag(next_token):
67
+ rewritten.append("--model-select")
68
+ i += 1
69
+ continue
70
+ rewritten.append(token)
71
+ i += 1
72
+ continue
73
+
74
+ if token.startswith("--model="):
75
+ value = token.split("=", 1)[1]
76
+ if value == "":
77
+ rewritten.append("--model-select")
78
+ else:
79
+ rewritten.append(token)
80
+ i += 1
81
+ continue
82
+
83
+ if token in {"--resume", "-r"}:
84
+ next_token = args[i + 1] if i + 1 < len(args) else None
85
+ if next_token is not None and next_token != "--" and not _looks_like_flag(next_token):
86
+ rewritten.extend(["--resume-by-id", next_token])
87
+ i += 2
88
+ continue
89
+ rewritten.append(token)
90
+ i += 1
91
+ continue
92
+
93
+ if token.startswith("--resume="):
94
+ value = token.split("=", 1)[1]
95
+ rewritten.extend(["--resume-by-id", value])
96
+ i += 1
97
+ continue
98
+
99
+ rewritten.append(token)
100
+ i += 1
101
+
102
+ return rewritten
103
+
104
+
105
+ class _PreprocessingTyperGroup(TyperGroup):
106
+ def main(
107
+ self,
108
+ args: Sequence[str] | None = None,
109
+ prog_name: str | None = None,
110
+ complete_var: str | None = None,
111
+ standalone_mode: bool = True,
112
+ windows_expand_args: bool = True,
113
+ **extra: Any,
114
+ ) -> Any:
115
+ click_args = _preprocess_cli_args(list(args) if args is not None else sys.argv[1:])
116
+ return super().main(
117
+ args=click_args,
118
+ prog_name=prog_name,
119
+ complete_var=complete_var,
120
+ standalone_mode=standalone_mode,
121
+ windows_expand_args=windows_expand_args,
122
+ **extra,
123
+ )
124
+
125
+
43
126
  app = typer.Typer(
127
+ cls=_PreprocessingTyperGroup,
44
128
  add_completion=False,
45
129
  pretty_exceptions_enable=False,
46
130
  no_args_is_help=False,
@@ -69,21 +153,27 @@ def main_callback(
69
153
  None,
70
154
  "--model",
71
155
  "-m",
72
- help="Select model by name",
156
+ help="Select model by name; use --model with no value to choose interactively",
73
157
  rich_help_panel="LLM",
74
158
  ),
75
159
  continue_: bool = typer.Option(False, "--continue", "-c", help="Resume latest session"),
76
- resume: bool = typer.Option(False, "--resume", "-r", help="Pick a session to resume"),
160
+ resume: bool = typer.Option(
161
+ False,
162
+ "--resume",
163
+ "-r",
164
+ help="Resume a session; use --resume <id> to resume directly, or --resume to pick interactively",
165
+ ),
77
166
  resume_by_id: str | None = typer.Option(
78
167
  None,
79
168
  "--resume-by-id",
80
169
  help="Resume session by ID",
170
+ hidden=True,
81
171
  ),
82
172
  select_model: bool = typer.Option(
83
173
  False,
84
- "--select-model",
85
- "-s",
86
- help="Choose model interactively",
174
+ "--model-select",
175
+ help="Choose model interactively (same as --model with no value)",
176
+ hidden=True,
87
177
  rich_help_panel="LLM",
88
178
  ),
89
179
  debug: bool = typer.Option(
@@ -107,7 +197,7 @@ def main_callback(
107
197
  banana: bool = typer.Option(
108
198
  False,
109
199
  "--banana",
110
- help="Image generation mode",
200
+ help="Image generation mode (alias for --model banana)",
111
201
  rich_help_panel="LLM",
112
202
  ),
113
203
  version: bool = typer.Option(
@@ -130,11 +220,11 @@ def main_callback(
130
220
 
131
221
  resume_by_id_value = resume_by_id.strip() if resume_by_id is not None else None
132
222
  if resume_by_id_value == "":
133
- log(("Error: --resume-by-id cannot be empty", "red"))
223
+ log(("Error: --resume <id> cannot be empty", "red"))
134
224
  raise typer.Exit(2)
135
225
 
136
226
  if resume_by_id_value is not None and (resume or continue_):
137
- log(("Error: --resume-by-id cannot be combined with --resume/--continue", "red"))
227
+ log(("Error: --resume <id> cannot be combined with --continue or interactive --resume", "red"))
138
228
  raise typer.Exit(2)
139
229
 
140
230
  if resume_by_id_value is not None and not Session.exists(resume_by_id_value):
klaude_code/const.py CHANGED
@@ -7,6 +7,8 @@ that were previously scattered across the codebase.
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import sys
11
+ import tempfile
10
12
  from dataclasses import dataclass
11
13
  from pathlib import Path
12
14
 
@@ -22,6 +24,13 @@ def _get_int_env(name: str, default: int) -> int:
22
24
  return default
23
25
 
24
26
 
27
+ def get_system_temp() -> str:
28
+ """Return system-level temp directory: /tmp on Unix, system temp on Windows."""
29
+ if sys.platform == "win32":
30
+ return tempfile.gettempdir()
31
+ return "/tmp"
32
+
33
+
25
34
  # =============================================================================
26
35
  # Agent / LLM Configuration
27
36
  # =============================================================================
@@ -115,7 +124,7 @@ TOOL_OUTPUT_DISPLAY_TAIL = 10000 # Characters to show from the end of truncated
115
124
  TOOL_OUTPUT_MAX_LINES = 2000 # Maximum lines for tool output before truncation
116
125
  TOOL_OUTPUT_DISPLAY_HEAD_LINES = 1000 # Lines to show from the beginning of truncated output
117
126
  TOOL_OUTPUT_DISPLAY_TAIL_LINES = 1000 # Lines to show from the end of truncated output
118
- TOOL_OUTPUT_TRUNCATION_DIR = "/tmp" # Directory for saving full truncated output
127
+ TOOL_OUTPUT_TRUNCATION_DIR = get_system_temp() # Directory for saving full truncated output
119
128
 
120
129
 
121
130
  # =============================================================================
@@ -17,8 +17,8 @@ from klaude_code.skill import get_skill
17
17
  # Match @ preceded by whitespace, start of line, or → (ReadTool line number arrow)
18
18
  AT_FILE_PATTERN = re.compile(r'(?:(?<!\S)|(?<=\u2192))@("(?P<quoted>[^\"]+)"|(?P<plain>\S+))')
19
19
 
20
- # Match $skill or ¥skill at the beginning of the first line
21
- SKILL_PATTERN = re.compile(r"^[$¥](?P<skill>\S+)")
20
+ # Match $skill or ¥skill inline (at start of line or after whitespace)
21
+ SKILL_PATTERN = re.compile(r"(?:^|\s)[$¥](?P<skill>\S+)")
22
22
 
23
23
 
24
24
  def get_last_new_user_input(session: Session) -> str | None:
@@ -79,14 +79,13 @@ def get_at_patterns_with_source(session: Session) -> list[AtPatternSource]:
79
79
 
80
80
 
81
81
  def get_skill_from_user_input(session: Session) -> str | None:
82
- """Get $skill reference from the first line of last user input."""
82
+ """Get $skill reference from last user input (first match wins)."""
83
83
  for item in reversed(session.conversation_history):
84
84
  if isinstance(item, message.ToolResultMessage):
85
85
  return None
86
86
  if isinstance(item, message.UserMessage):
87
87
  content = message.join_text_parts(item.parts)
88
- first_line = content.split("\n", 1)[0]
89
- m = SKILL_PATTERN.match(first_line)
88
+ m = SKILL_PATTERN.search(content)
90
89
  if m:
91
90
  return m.group("skill")
92
91
  return None
klaude_code/core/turn.py CHANGED
@@ -196,7 +196,7 @@ class TurnExecutor:
196
196
  ):
197
197
  session_ctx.append_history([self._turn_result.assistant_message])
198
198
  # Add continuation prompt to avoid Anthropic thinking block requirement
199
- session_ctx.append_history([message.UserMessage(parts=[message.TextPart(text="continue")])])
199
+ session_ctx.append_history([message.UserMessage(parts=[message.TextPart(text="<system>continue</system>")])])
200
200
  yield events.TurnEndEvent(session_id=session_ctx.session_id)
201
201
  raise TurnError(self._turn_result.stream_error.error)
202
202
 
@@ -20,7 +20,6 @@ class CommandName(str, Enum):
20
20
  COMPACT = "compact"
21
21
  REFRESH_TERMINAL = "refresh-terminal"
22
22
  CLEAR = "clear"
23
- TERMINAL_SETUP = "terminal-setup"
24
23
  EXPORT = "export"
25
24
  EXPORT_ONLINE = "export-online"
26
25
  STATUS = "status"
@@ -209,22 +209,21 @@ class SkillLoader:
209
209
  """Get list of all loaded skill names"""
210
210
  return list(self.loaded_skills.keys())
211
211
 
212
- def get_skills_xml(self) -> str:
213
- """Generate Level 1 metadata in XML format for tool description
212
+ def get_skills_yaml(self) -> str:
213
+ """Generate skill metadata in YAML format for system prompt.
214
214
 
215
215
  Returns:
216
- XML string with all skill metadata
216
+ YAML string with all skill metadata
217
217
  """
218
- xml_parts: list[str] = []
219
- # Prefer showing higher-priority skills first (project > user > system).
218
+ yaml_parts: list[str] = []
220
219
  location_order = {"project": 0, "user": 1, "system": 2}
221
220
  for skill in sorted(self.loaded_skills.values(), key=lambda s: location_order.get(s.location, 3)):
222
- xml_parts.append(
223
- f"""<skill>
224
- <name>{skill.name}</name>
225
- <description>{skill.description}</description>
226
- <scope>{skill.location}</scope>
227
- <location>{skill.skill_path}</location>
228
- </skill>"""
221
+ # Escape description for YAML (handle multi-line and special chars)
222
+ desc = skill.description.replace("\n", " ").strip()
223
+ yaml_parts.append(
224
+ f"- name: {skill.name}\n"
225
+ f" description: {desc}\n"
226
+ f" scope: {skill.location}\n"
227
+ f" location: {skill.skill_path}"
229
228
  )
230
- return "\n".join(xml_parts)
229
+ return "\n".join(yaml_parts)
@@ -80,8 +80,8 @@ def format_available_skills_for_system_prompt() -> str:
80
80
 
81
81
  try:
82
82
  loader = _ensure_initialized()
83
- skills_xml = loader.get_skills_xml().strip()
84
- if not skills_xml:
83
+ skills_yaml = loader.get_skills_yaml().strip()
84
+ if not skills_yaml:
85
85
  return ""
86
86
 
87
87
  return f"""
@@ -102,7 +102,7 @@ Important:
102
102
  The list below is metadata only (name/description/location). The full instructions live in the referenced file.
103
103
 
104
104
  <available_skills>
105
- {skills_xml}
105
+ {skills_yaml}
106
106
  </available_skills>"""
107
107
  except Exception:
108
108
  # Skills are an optional enhancement; do not fail prompt construction if discovery breaks.
@@ -40,7 +40,6 @@ def ensure_commands_loaded() -> None:
40
40
  from .resume_cmd import ResumeCommand
41
41
  from .status_cmd import StatusCommand
42
42
  from .sub_agent_model_cmd import SubAgentModelCommand
43
- from .terminal_setup_cmd import TerminalSetupCommand
44
43
  from .thinking_cmd import ThinkingCommand
45
44
 
46
45
  # Register in desired display order
@@ -55,7 +54,6 @@ def ensure_commands_loaded() -> None:
55
54
  register(StatusCommand())
56
55
  register(ResumeCommand())
57
56
  register(ExportOnlineCommand())
58
- register(TerminalSetupCommand())
59
57
  register(DebugCommand())
60
58
  register(ClearCommand())
61
59
 
@@ -76,7 +74,6 @@ def __getattr__(name: str) -> object:
76
74
  "ResumeCommand": "resume_cmd",
77
75
  "StatusCommand": "status_cmd",
78
76
  "SubAgentModelCommand": "sub_agent_model_cmd",
79
- "TerminalSetupCommand": "terminal_setup_cmd",
80
77
  "ThinkingCommand": "thinking_cmd",
81
78
  }
82
79
  if name in _commands_map:
@@ -91,7 +88,7 @@ __all__ = [
91
88
  # Command classes are lazily loaded via __getattr__
92
89
  # "ClearCommand", "DiffCommand", "HelpCommand", "ModelCommand",
93
90
  # "ExportCommand", "RefreshTerminalCommand", "ReleaseNotesCommand",
94
- # "StatusCommand", "TerminalSetupCommand",
91
+ # "StatusCommand",
95
92
  "CommandABC",
96
93
  "CommandResult",
97
94
  "dispatch_command",
@@ -1,5 +1,5 @@
1
1
  from klaude_code.protocol import commands, events, message
2
- from klaude_code.tui.input.clipboard import copy_to_clipboard
2
+ from klaude_code.tui.input.key_bindings import copy_to_clipboard
3
3
 
4
4
  from .command_abc import Agent, CommandABC, CommandResult
5
5
 
@@ -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
@@ -194,7 +194,7 @@ class ForkSessionCommand(CommandABC):
194
194
 
195
195
  @property
196
196
  def summary(self) -> str:
197
- return "Fork the current session and show a resume-by-id command"
197
+ return "Fork the current session and show a resume command"
198
198
 
199
199
  @property
200
200
  def is_interactive(self) -> bool:
@@ -220,7 +220,7 @@ class ForkSessionCommand(CommandABC):
220
220
  new_session = agent.session.fork()
221
221
  await new_session.wait_for_flush()
222
222
 
223
- resume_cmd = f"klaude --resume-by-id {new_session.id}"
223
+ resume_cmd = f"klaude --resume {new_session.id}"
224
224
  copy_to_clipboard(resume_cmd)
225
225
 
226
226
  event = events.CommandOutputEvent(
@@ -249,7 +249,7 @@ class ForkSessionCommand(CommandABC):
249
249
  # Build result message
250
250
  fork_description = "entire conversation" if selected == -1 else f"up to message index {selected}"
251
251
 
252
- resume_cmd = f"klaude --resume-by-id {new_session.id}"
252
+ resume_cmd = f"klaude --resume {new_session.id}"
253
253
  copy_to_clipboard(resume_cmd)
254
254
 
255
255
  event = events.CommandOutputEvent(
@@ -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
@@ -26,6 +27,63 @@ from klaude_code.const import (
26
27
  )
27
28
  from klaude_code.tui.components.rich.code_panel import CodePanel
28
29
 
30
+ _THINKING_HTML_BLOCK_RE = re.compile(
31
+ r"\A\s*<thinking>\s*\n?(?P<body>.*?)(?:\n\s*)?</thinking>\s*\Z",
32
+ flags=re.IGNORECASE | re.DOTALL,
33
+ )
34
+
35
+ _HTML_COMMENT_BLOCK_RE = re.compile(r"\A\s*<!--.*?-->\s*\Z", flags=re.DOTALL)
36
+
37
+
38
+ class ThinkingHTMLBlock(MarkdownElement):
39
+ """Render `<thinking>...</thinking>` HTML blocks as Rich Markdown.
40
+
41
+ markdown-it-py treats custom tags like `<thinking>` as HTML blocks, and Rich
42
+ Markdown ignores HTML blocks by default. This element restores visibility by
43
+ re-parsing the inner content as Markdown and applying a dedicated style.
44
+
45
+ Non-thinking HTML blocks (including comment sentinels like `<!-- -->`) render
46
+ no visible output, matching Rich's default behavior.
47
+ """
48
+
49
+ new_line: ClassVar[bool] = True
50
+
51
+ @classmethod
52
+ def create(cls, markdown: Markdown, token: Token) -> ThinkingHTMLBlock:
53
+ return cls(content=token.content or "", code_theme=markdown.code_theme)
54
+
55
+ def __init__(self, *, content: str, code_theme: str) -> None:
56
+ self._content = content
57
+ self._code_theme = code_theme
58
+
59
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
60
+ stripped = self._content.strip()
61
+
62
+ # Keep HTML comments invisible. MarkdownStream relies on a comment sentinel
63
+ # (`<!-- -->`) to preserve inter-block spacing in some streaming frames.
64
+ if _HTML_COMMENT_BLOCK_RE.match(stripped):
65
+ return
66
+
67
+ match = _THINKING_HTML_BLOCK_RE.match(stripped)
68
+ if match is None:
69
+ return
70
+
71
+ body = match.group("body").strip("\n")
72
+ if not body.strip():
73
+ return
74
+
75
+ # Render as a single line to avoid the extra blank lines produced by
76
+ # paragraph/block rendering.
77
+ collapsed = " ".join(body.split())
78
+ if not collapsed:
79
+ return
80
+
81
+ text = Text()
82
+ text.append("<thinking>", style="markdown.thinking.tag")
83
+ text.append(collapsed, style="markdown.thinking")
84
+ text.append("</thinking>", style="markdown.thinking.tag")
85
+ yield text
86
+
29
87
 
30
88
  class NoInsetCodeBlock(CodeBlock):
31
89
  """A code block with syntax highlighting and no padding."""
@@ -105,6 +163,7 @@ class NoInsetMarkdown(Markdown):
105
163
  "heading_open": LeftHeading,
106
164
  "hr": Divider,
107
165
  "table_open": MarkdownTable,
166
+ "html_block": ThinkingHTMLBlock,
108
167
  }
109
168
 
110
169
 
@@ -118,6 +177,7 @@ class ThinkingMarkdown(Markdown):
118
177
  "heading_open": LeftHeading,
119
178
  "hr": Divider,
120
179
  "table_open": MarkdownTable,
180
+ "html_block": ThinkingHTMLBlock,
121
181
  }
122
182
 
123
183
 
@@ -331,7 +331,14 @@ def get_theme(theme: str | None = None) -> Themes:
331
331
  markdown_theme=Theme(
332
332
  styles={
333
333
  "markdown.code": palette.purple,
334
+ # Render degraded `<thinking>...</thinking>` blocks inside assistant markdown.
335
+ # This must live in markdown_theme (not just thinking_markdown_theme) because
336
+ # it is used while rendering assistant output.
337
+ "markdown.thinking": "italic " + palette.grey2,
338
+ "markdown.thinking.tag": palette.grey2,
334
339
  "markdown.code.border": palette.grey3,
340
+ # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
341
+ "markdown.code.block": palette.grey1,
335
342
  "markdown.h1": "bold reverse",
336
343
  "markdown.h1.border": palette.grey3,
337
344
  "markdown.h2": "bold underline",
@@ -353,6 +360,7 @@ def get_theme(theme: str | None = None) -> Themes:
353
360
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
354
361
  "markdown.code.block": palette.grey1,
355
362
  "markdown.code.border": palette.grey3,
363
+ "markdown.thinking.tag": palette.grey2 + " dim",
356
364
  "markdown.h1": "bold reverse",
357
365
  "markdown.h1.border": palette.grey3,
358
366
  "markdown.h3": "bold " + palette.grey1,