klaude-code 1.2.6__py3-none-any.whl → 1.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,39 +1,84 @@
1
1
  import re
2
+ from collections.abc import Callable
2
3
 
3
4
  from rich.console import Group, RenderableType
4
5
  from rich.text import Text
5
6
 
6
- from klaude_code.command import is_slash_command_name
7
+ from klaude_code.skill import get_available_skills
7
8
  from klaude_code.ui.renderers.common import create_grid
8
9
  from klaude_code.ui.rich.theme import ThemeKey
9
10
 
11
+ # Module-level command name checker. Set by cli/runtime.py on startup.
12
+ _command_name_checker: Callable[[str], bool] | None = None
13
+
14
+
15
+ def set_command_name_checker(checker: Callable[[str], bool]) -> None:
16
+ """Set the command name validation function (called from runtime layer)."""
17
+ global _command_name_checker
18
+ _command_name_checker = checker
19
+
20
+
21
+ def is_slash_command_name(name: str) -> bool:
22
+ """Check if name is a valid slash command using the injected checker."""
23
+ if _command_name_checker is None:
24
+ return False
25
+ return _command_name_checker(name)
26
+
27
+
28
+ # Match @-file patterns only when they appear at the beginning of the line
29
+ # or immediately after whitespace, to avoid treating mid-word email-like
30
+ # patterns such as foo@bar.com as file references.
31
+ AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
32
+
33
+ # Match $skill or ¥skill pattern at the beginning of the first line
34
+ SKILL_RENDER_PATTERN = re.compile(r"^[$¥](\S+)")
35
+
36
+ USER_MESSAGE_MARK = "❯ "
37
+
10
38
 
11
39
  def render_at_pattern(
12
40
  text: str,
13
41
  at_style: str = ThemeKey.USER_INPUT_AT_PATTERN,
14
42
  other_style: str = ThemeKey.USER_INPUT,
15
43
  ) -> Text:
16
- if "@" in text:
17
- parts = re.split(r"(\s+)", text)
18
- result = Text("")
19
- for s in parts:
20
- if s.startswith("@"):
21
- result.append_text(Text(s, at_style))
22
- else:
23
- result.append_text(Text(s, other_style))
24
- return result
25
- return Text(text, style=other_style)
44
+ if "@" not in text:
45
+ return Text(text, style=other_style)
46
+
47
+ result = Text("")
48
+ last_end = 0
49
+ for match in AT_FILE_RENDER_PATTERN.finditer(text):
50
+ start, end = match.span()
51
+ if start > last_end:
52
+ # Text before the @-pattern
53
+ result.append_text(Text(text[last_end:start], other_style))
54
+ # The @-pattern itself (e.g. @path or @"path with spaces")
55
+ result.append_text(Text(text[start:end], at_style))
56
+ last_end = end
57
+
58
+ if last_end < len(text):
59
+ result.append_text(Text(text[last_end:], other_style))
60
+
61
+ return result
62
+
63
+
64
+ def _is_valid_skill_name(name: str) -> bool:
65
+ """Check if a skill name is valid (exists in loaded skills)."""
66
+ short = name.split(":")[-1] if ":" in name else name
67
+ available_skills = get_available_skills()
68
+ return any(skill_name in (name, short) for skill_name, _, _ in available_skills)
26
69
 
27
70
 
28
71
  def render_user_input(content: str) -> RenderableType:
29
72
  """Render a user message as a group of quoted lines with styles.
30
73
 
31
74
  - Highlights slash command on the first line if recognized
75
+ - Highlights $skill pattern on the first line if recognized
32
76
  - Highlights @file patterns in all lines
33
77
  """
34
78
  lines = content.strip().split("\n")
35
79
  renderables: list[RenderableType] = []
36
80
  has_command = False
81
+ command_style: str | None = None
37
82
  for i, line in enumerate(lines):
38
83
  line_text = render_at_pattern(line)
39
84
 
@@ -41,6 +86,7 @@ def render_user_input(content: str) -> RenderableType:
41
86
  splits = line.split(" ", maxsplit=1)
42
87
  if is_slash_command_name(splits[0][1:]):
43
88
  has_command = True
89
+ command_style = ThemeKey.USER_INPUT_SLASH_COMMAND
44
90
  line_text = Text.assemble(
45
91
  (f"{splits[0]}", ThemeKey.USER_INPUT_SLASH_COMMAND),
46
92
  " ",
@@ -49,13 +95,27 @@ def render_user_input(content: str) -> RenderableType:
49
95
  renderables.append(line_text)
50
96
  continue
51
97
 
98
+ if i == 0 and (line.startswith("$") or line.startswith("¥")):
99
+ m = SKILL_RENDER_PATTERN.match(line)
100
+ if m and _is_valid_skill_name(m.group(1)):
101
+ has_command = True
102
+ command_style = ThemeKey.USER_INPUT_SKILL
103
+ skill_token = m.group(0) # e.g. "$skill-name"
104
+ rest = line[len(skill_token) :]
105
+ line_text = Text.assemble(
106
+ (skill_token, ThemeKey.USER_INPUT_SKILL),
107
+ render_at_pattern(rest) if rest else Text(""),
108
+ )
109
+ renderables.append(line_text)
110
+ continue
111
+
52
112
  renderables.append(line_text)
53
113
  grid = create_grid()
54
114
  grid.padding = (0, 0)
55
115
  mark = (
56
- Text("❯ ", style=ThemeKey.USER_INPUT_PROMPT)
116
+ Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
57
117
  if not has_command
58
- else Text(" ", style=ThemeKey.USER_INPUT_SLASH_COMMAND)
118
+ else Text(" ", style=command_style or ThemeKey.USER_INPUT_SLASH_COMMAND)
59
119
  )
60
120
  grid.add_row(mark, Group(*renderables))
61
121
  return grid
@@ -1 +1,10 @@
1
- # Rich rendering utilities
1
+ """Rich rendering utilities.
2
+
3
+ This package installs a small monkey-patch that improves CJK line breaking in Rich.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .cjk_wrap import install_rich_cjk_wrap_patch
9
+
10
+ install_rich_cjk_wrap_patch()
@@ -0,0 +1,228 @@
1
+ """Monkey-patch Rich wrapping for better CJK line breaks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unicodedata
6
+ from collections.abc import Callable
7
+
8
+
9
+ def _is_cjk_char(ch: str) -> bool:
10
+ return unicodedata.east_asian_width(ch) in ("W", "F")
11
+
12
+
13
+ def _contains_cjk(text: str) -> bool:
14
+ return any(_is_cjk_char(ch) for ch in text)
15
+
16
+
17
+ def _is_ascii_word_char(ch: str) -> bool:
18
+ o = ord(ch)
19
+ return (48 <= o <= 57) or (65 <= o <= 90) or (97 <= o <= 122) or ch in "_."
20
+
21
+
22
+ def _find_prefix_len_for_remaining(word: str, remaining_space: int) -> int:
23
+ """Find a prefix length (in chars) that fits remaining_space.
24
+
25
+ This prefers breakpoints that don't split ASCII word-like runs.
26
+ """
27
+
28
+ if remaining_space <= 0:
29
+ return 0
30
+
31
+ # Local import keeps import-time overhead low.
32
+ from rich.cells import get_character_cell_size
33
+
34
+ total = 0
35
+ best = 0
36
+ n = len(word)
37
+
38
+ for i, ch in enumerate(word):
39
+ total += get_character_cell_size(ch)
40
+ if total > remaining_space:
41
+ break
42
+
43
+ boundary = i + 1
44
+ if boundary >= n:
45
+ best = boundary
46
+ break
47
+
48
+ # Avoid leaving a path separator at the start of the next line.
49
+ if word[boundary] in "/":
50
+ continue
51
+
52
+ # Disallow breaks inside ASCII word runs: ...a|b...
53
+ if _is_ascii_word_char(word[boundary - 1]) and _is_ascii_word_char(word[boundary]):
54
+ continue
55
+
56
+ best = boundary
57
+
58
+ return best
59
+
60
+
61
+ _rich_cjk_wrap_patch_installed = False
62
+
63
+
64
+ def install_rich_cjk_wrap_patch() -> bool:
65
+ """Install a monkey-patch that improves CJK line wrapping in Rich.
66
+
67
+ Rich wraps text by tokenizing on whitespace, which causes long CJK runs to be
68
+ treated as a single "word" and moved to the next line wholesale.
69
+
70
+ This patch keeps ASCII word wrapping behaviour intact, but allows breaking
71
+ CJK-containing tokens at the end of a line to fill remaining space.
72
+
73
+ Returns:
74
+ True if the patch was installed in this process.
75
+ """
76
+
77
+ global _rich_cjk_wrap_patch_installed
78
+ if _rich_cjk_wrap_patch_installed:
79
+ return False
80
+
81
+ import rich._wrap as _wrap
82
+ import rich.text as _text
83
+ from rich._loop import loop_last
84
+ from rich.cells import cell_len, chop_cells
85
+
86
+ _OPEN_TO_CLOSE = {
87
+ "(": ")",
88
+ "(": ")",
89
+ "[": "]",
90
+ "{": "}",
91
+ "“": "”",
92
+ "‘": "’",
93
+ "《": "》",
94
+ "〈": "〉",
95
+ "「": "」",
96
+ "『": "』",
97
+ "【": "】",
98
+ }
99
+
100
+ def _leading_unclosed_delim(word: str) -> str | None:
101
+ stripped = word.lstrip()
102
+ if not stripped:
103
+ return None
104
+
105
+ close_delim = _OPEN_TO_CLOSE.get(stripped[0])
106
+ if close_delim is None:
107
+ return None
108
+
109
+ if close_delim in stripped:
110
+ return None
111
+
112
+ return close_delim
113
+
114
+ def _close_delim_appears_soon(
115
+ word_tokens: list[str],
116
+ *,
117
+ start_index: int,
118
+ close_delim: str,
119
+ max_chars: int = 32,
120
+ max_tokens: int = 4,
121
+ ) -> bool:
122
+ consumed = 0
123
+ for token in word_tokens[start_index + 1 : start_index + 1 + max_tokens]:
124
+ if not token:
125
+ continue
126
+
127
+ close_pos = token.find(close_delim)
128
+ if close_pos != -1 and (consumed + close_pos) < max_chars:
129
+ return True
130
+
131
+ consumed += len(token)
132
+ if consumed >= max_chars:
133
+ return False
134
+
135
+ return False
136
+
137
+ def divide_line_patched(text: str, width: int, fold: bool = True) -> list[int]:
138
+ break_positions: list[int] = []
139
+
140
+ def append(pos: int) -> None:
141
+ if pos and (not break_positions or break_positions[-1] != pos):
142
+ break_positions.append(pos)
143
+
144
+ cell_offset = 0
145
+ _cell_len: Callable[[str], int] = cell_len
146
+
147
+ words = list(_wrap.words(text))
148
+ word_tokens = [w for _s, _e, w in words]
149
+
150
+ for index, (start, _end, word) in enumerate(words):
151
+ next_word: str | None = None
152
+ if index + 1 < len(words):
153
+ next_word = words[index + 1][2]
154
+
155
+ # Heuristic: avoid leaving an unclosed opening delimiter fragment (e.g. "(Deep ")
156
+ # at the end of a line when the next token will wrap.
157
+ word_length = _cell_len(word.rstrip())
158
+ remaining_space = width - cell_offset
159
+ if remaining_space >= word_length and cell_offset and start and next_word is not None:
160
+ cell_offset_with_trailing = cell_offset + _cell_len(word)
161
+ next_length = _cell_len(next_word.rstrip())
162
+ next_will_wrap = next_length > width or (width - cell_offset_with_trailing) < next_length
163
+
164
+ close_delim = _leading_unclosed_delim(word)
165
+ if close_delim is not None and next_will_wrap:
166
+ stripped = word.strip()
167
+ if _cell_len(stripped) <= 16 and _close_delim_appears_soon(
168
+ word_tokens, start_index=index, close_delim=close_delim
169
+ ):
170
+ append(start)
171
+ cell_offset = _cell_len(word)
172
+ continue
173
+
174
+ while True:
175
+ word_length = _cell_len(word.rstrip())
176
+ remaining_space = width - cell_offset
177
+
178
+ if remaining_space >= word_length:
179
+ cell_offset += _cell_len(word)
180
+ break
181
+
182
+ # Prefer splitting CJK-containing tokens to fill remaining space.
183
+ if fold and cell_offset and start and remaining_space > 0 and _contains_cjk(word):
184
+ prefix_len = _find_prefix_len_for_remaining(word, remaining_space)
185
+ if prefix_len:
186
+ break_at = start + prefix_len
187
+ append(break_at)
188
+ word = word[prefix_len:]
189
+ start = break_at
190
+
191
+ # If the remainder fits on the next (empty) line, keep Rich's
192
+ # existing behaviour and move on.
193
+ if _cell_len(word.rstrip()) <= width:
194
+ cell_offset = _cell_len(word)
195
+ break
196
+
197
+ # Otherwise, continue folding the remainder starting on a new line.
198
+ cell_offset = 0
199
+ continue
200
+
201
+ # Fall back to Rich's original logic.
202
+ if word_length > width:
203
+ if fold:
204
+ folded_word = chop_cells(word, width=width)
205
+ for last, line in loop_last(folded_word):
206
+ if start:
207
+ append(start)
208
+ if last:
209
+ cell_offset = _cell_len(line)
210
+ else:
211
+ start += len(line)
212
+ else:
213
+ if start:
214
+ append(start)
215
+ cell_offset = _cell_len(word)
216
+ break
217
+
218
+ if cell_offset and start:
219
+ append(start)
220
+ cell_offset = _cell_len(word)
221
+ break
222
+
223
+ return break_positions
224
+
225
+ _wrap.divide_line = divide_line_patched # pyright: ignore[reportPrivateImportUsage]
226
+ _text.divide_line = divide_line_patched # pyright: ignore[reportPrivateImportUsage]
227
+ _rich_cjk_wrap_patch_installed = True
228
+ return True
@@ -0,0 +1,131 @@
1
+ """A panel that only has top and bottom borders, no left/right borders or padding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.cells import cell_len
8
+ from rich.console import ConsoleRenderable, RichCast
9
+ from rich.jupyter import JupyterMixin
10
+ from rich.measure import Measurement
11
+ from rich.segment import Segment
12
+ from rich.style import StyleType
13
+
14
+ if TYPE_CHECKING:
15
+ from rich.console import Console, ConsoleOptions, RenderResult
16
+
17
+ # Box drawing characters
18
+ TOP_LEFT = "┌" # ┌
19
+ TOP_RIGHT = "┐" # ┐
20
+ BOTTOM_LEFT = "└" # └
21
+ BOTTOM_RIGHT = "┘" # ┘
22
+ HORIZONTAL = "─" # ─
23
+
24
+
25
+ class CodePanel(JupyterMixin):
26
+ """A panel with only top and bottom borders, no left/right borders.
27
+
28
+ This is designed for code blocks where you want easy copy-paste without
29
+ picking up border characters on the sides.
30
+
31
+ Example:
32
+ >>> console.print(CodePanel(Syntax(code, "python")))
33
+
34
+ Renders as:
35
+ ┌──────────────────────────┐
36
+ code line 1
37
+ code line 2
38
+ └──────────────────────────┘
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ renderable: ConsoleRenderable | RichCast | str,
44
+ *,
45
+ border_style: StyleType = "none",
46
+ expand: bool = False,
47
+ padding: int = 1,
48
+ ) -> None:
49
+ """Initialize the CodePanel.
50
+
51
+ Args:
52
+ renderable: A console renderable object.
53
+ border_style: The style of the border. Defaults to "none".
54
+ expand: If True, expand to fill available width. Defaults to False.
55
+ padding: Left/right padding for content. Defaults to 1.
56
+ """
57
+ self.renderable = renderable
58
+ self.border_style = border_style
59
+ self.expand = expand
60
+ self.padding = padding
61
+
62
+ @staticmethod
63
+ def _measure_max_line_cells(lines: list[list[Segment]]) -> int:
64
+ max_cells = 0
65
+ for line in lines:
66
+ plain = "".join(segment.text for segment in line).rstrip()
67
+ max_cells = max(max_cells, cell_len(plain))
68
+ return max_cells
69
+
70
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
71
+ border_style = console.get_style(self.border_style)
72
+ max_width = options.max_width
73
+ pad = self.padding
74
+
75
+ max_content_width = max(max_width - pad * 2, 1)
76
+
77
+ # Measure the content width (account for padding)
78
+ if self.expand:
79
+ content_width = max_content_width
80
+ else:
81
+ probe_options = options.update(width=max_content_width)
82
+ probe_lines = console.render_lines(self.renderable, probe_options, pad=False)
83
+ content_width = self._measure_max_line_cells(probe_lines)
84
+ content_width = max(1, min(content_width, max_content_width))
85
+
86
+ # Render content lines
87
+ child_options = options.update(width=content_width)
88
+ lines = console.render_lines(self.renderable, child_options)
89
+
90
+ # Calculate border width based on content width + padding
91
+ border_width = content_width + pad * 2
92
+
93
+ new_line = Segment.line()
94
+ pad_segment = Segment(" " * pad) if pad > 0 else None
95
+
96
+ # Top border: ┌───...───┐
97
+ top_border = (
98
+ TOP_LEFT + (HORIZONTAL * (border_width - 2)) + TOP_RIGHT if border_width >= 2 else HORIZONTAL * border_width
99
+ )
100
+ yield Segment(top_border, border_style)
101
+ yield new_line
102
+
103
+ # Content lines with padding
104
+ for line in lines:
105
+ if pad_segment:
106
+ yield pad_segment
107
+ yield from line
108
+ if pad_segment:
109
+ yield pad_segment
110
+ yield new_line
111
+
112
+ # Bottom border: └───...───┘
113
+ bottom_border = (
114
+ BOTTOM_LEFT + (HORIZONTAL * (border_width - 2)) + BOTTOM_RIGHT
115
+ if border_width >= 2
116
+ else HORIZONTAL * border_width
117
+ )
118
+ yield Segment(bottom_border, border_style)
119
+ yield new_line
120
+
121
+ def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
122
+ if self.expand:
123
+ return Measurement(options.max_width, options.max_width)
124
+ max_width = options.max_width
125
+ max_content_width = max(max_width - self.padding * 2, 1)
126
+ probe_options = options.update(width=max_content_width)
127
+ probe_lines = console.render_lines(self.renderable, probe_options, pad=False)
128
+ content_width = self._measure_max_line_cells(probe_lines)
129
+ content_width = max(1, min(content_width, max_content_width))
130
+ width = content_width + self.padding * 2
131
+ return Measurement(width, width)
@@ -63,3 +63,20 @@ class CropAboveLive(Live):
63
63
 
64
64
  def update(self, renderable: RenderableType, refresh: bool = True) -> None: # type: ignore[override]
65
65
  super().update(CropAbove(renderable, style=self._crop_style), refresh=refresh)
66
+
67
+
68
+ class SingleLine:
69
+ """Render only the first line of a renderable.
70
+
71
+ This is used to ensure dynamic UI elements (spinners / status) never wrap
72
+ to multiple lines, which would appear as a vertical "jump".
73
+ """
74
+
75
+ def __init__(self, renderable: RenderableType) -> None:
76
+ self.renderable = renderable
77
+
78
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
79
+ line_options = options.update(no_wrap=True, overflow="ellipsis", height=1)
80
+ lines = console.render_lines(self.renderable, line_options, pad=False)
81
+ if lines:
82
+ yield from lines[0]