klaude-code 1.2.22__py3-none-any.whl → 1.2.24__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 (56) hide show
  1. klaude_code/command/prompt-jj-describe.md +32 -0
  2. klaude_code/command/status_cmd.py +1 -1
  3. klaude_code/{const/__init__.py → const.py} +11 -2
  4. klaude_code/core/executor.py +1 -1
  5. klaude_code/core/manager/sub_agent_manager.py +1 -1
  6. klaude_code/core/reminders.py +51 -0
  7. klaude_code/core/task.py +37 -18
  8. klaude_code/core/tool/__init__.py +1 -4
  9. klaude_code/core/tool/file/read_tool.py +23 -1
  10. klaude_code/core/tool/file/write_tool.py +7 -3
  11. klaude_code/core/tool/skill/__init__.py +0 -0
  12. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -39
  13. klaude_code/llm/openai_compatible/client.py +29 -102
  14. klaude_code/llm/openai_compatible/stream.py +272 -0
  15. klaude_code/llm/openrouter/client.py +29 -109
  16. klaude_code/llm/openrouter/{reasoning_handler.py → reasoning.py} +24 -2
  17. klaude_code/protocol/model.py +15 -2
  18. klaude_code/session/export.py +1 -1
  19. klaude_code/session/store.py +4 -2
  20. klaude_code/skill/__init__.py +27 -0
  21. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  22. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  23. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  24. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  25. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  26. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +60 -24
  27. klaude_code/skill/manager.py +70 -0
  28. klaude_code/skill/system_skills.py +192 -0
  29. klaude_code/ui/core/stage_manager.py +0 -3
  30. klaude_code/ui/modes/repl/completers.py +103 -3
  31. klaude_code/ui/modes/repl/event_handler.py +101 -49
  32. klaude_code/ui/modes/repl/input_prompt_toolkit.py +55 -6
  33. klaude_code/ui/modes/repl/renderer.py +24 -17
  34. klaude_code/ui/renderers/assistant.py +7 -2
  35. klaude_code/ui/renderers/developer.py +12 -0
  36. klaude_code/ui/renderers/diffs.py +1 -1
  37. klaude_code/ui/renderers/metadata.py +6 -8
  38. klaude_code/ui/renderers/sub_agent.py +28 -5
  39. klaude_code/ui/renderers/thinking.py +16 -10
  40. klaude_code/ui/renderers/tools.py +83 -34
  41. klaude_code/ui/renderers/user_input.py +32 -2
  42. klaude_code/ui/rich/markdown.py +40 -20
  43. klaude_code/ui/rich/status.py +15 -19
  44. klaude_code/ui/rich/theme.py +70 -17
  45. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/METADATA +18 -13
  46. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/RECORD +49 -45
  47. klaude_code/command/prompt-deslop.md +0 -14
  48. klaude_code/command/prompt-dev-docs-update.md +0 -56
  49. klaude_code/command/prompt-dev-docs.md +0 -46
  50. klaude_code/command/prompt-handoff.md +0 -33
  51. klaude_code/command/prompt-jj-workspace.md +0 -18
  52. klaude_code/core/tool/memory/__init__.py +0 -5
  53. klaude_code/llm/openai_compatible/stream_processor.py +0 -83
  54. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  55. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/WHEEL +0 -0
  56. {klaude_code-1.2.22.dist-info → klaude_code-1.2.24.dist-info}/entry_points.txt +0 -0
@@ -2,8 +2,10 @@ import json
2
2
  from pathlib import Path
3
3
  from typing import Any, cast
4
4
 
5
+ from rich import box
5
6
  from rich.console import Group, RenderableType
6
7
  from rich.padding import Padding
8
+ from rich.panel import Panel
7
9
  from rich.text import Text
8
10
 
9
11
  from klaude_code import const
@@ -11,8 +13,27 @@ from klaude_code.protocol import events, model, tools
11
13
  from klaude_code.protocol.sub_agent import is_sub_agent_tool as _is_sub_agent_tool
12
14
  from klaude_code.ui.renderers import diffs as r_diffs
13
15
  from klaude_code.ui.renderers.common import create_grid, truncate_display
16
+ from klaude_code.ui.rich.markdown import NoInsetMarkdown
14
17
  from klaude_code.ui.rich.theme import ThemeKey
15
18
 
19
+ # Tool markers (Unicode symbols for UI display)
20
+ MARK_GENERIC = "⚒"
21
+ MARK_BASH = "→"
22
+ MARK_PLAN = "▪"
23
+ MARK_READ = "←"
24
+ MARK_EDIT = "±"
25
+ MARK_WRITE = "+"
26
+ MARK_MERMAID = "⧉"
27
+ MARK_WEB_FETCH = "←"
28
+ MARK_WEB_SEARCH = ""
29
+ MARK_DONE = "✔"
30
+ MARK_SKILL = "✪"
31
+
32
+ # Todo status markers
33
+ MARK_TODO_PENDING = "▢"
34
+ MARK_TODO_IN_PROGRESS = "◉"
35
+ MARK_TODO_COMPLETED = "✔"
36
+
16
37
 
17
38
  def is_sub_agent_tool(tool_name: str) -> bool:
18
39
  return _is_sub_agent_tool(tool_name)
@@ -30,7 +51,7 @@ def render_path(path: str, style: str, is_directory: bool = False) -> Text:
30
51
  return Text(path, style=style)
31
52
 
32
53
 
33
- def render_generic_tool_call(tool_name: str, arguments: str, markup: str = "•") -> RenderableType:
54
+ def render_generic_tool_call(tool_name: str, arguments: str, markup: str = MARK_GENERIC) -> RenderableType:
34
55
  grid = create_grid()
35
56
 
36
57
  tool_name_column = Text.assemble((markup, ThemeKey.TOOL_MARK), " ", (tool_name, ThemeKey.TOOL_NAME))
@@ -60,7 +81,7 @@ def render_generic_tool_call(tool_name: str, arguments: str, markup: str = "•"
60
81
 
61
82
  def render_bash_tool_call(arguments: str) -> RenderableType:
62
83
  grid = create_grid()
63
- tool_name_column = Text.assemble((">", ThemeKey.TOOL_MARK), " ", ("Bash", ThemeKey.TOOL_NAME))
84
+ tool_name_column = Text.assemble((MARK_BASH, ThemeKey.TOOL_MARK), " ", ("Bash", ThemeKey.TOOL_NAME))
64
85
 
65
86
  try:
66
87
  payload_raw: Any = json.loads(arguments) if arguments else {}
@@ -103,7 +124,7 @@ def render_bash_tool_call(arguments: str) -> RenderableType:
103
124
 
104
125
  def render_update_plan_tool_call(arguments: str) -> RenderableType:
105
126
  grid = create_grid()
106
- tool_name_column = Text.assemble(("◎", ThemeKey.TOOL_MARK), " ", ("Update Plan", ThemeKey.TOOL_NAME))
127
+ tool_name_column = Text.assemble((MARK_PLAN, ThemeKey.TOOL_MARK), " ", ("Update Plan", ThemeKey.TOOL_NAME))
107
128
  explanation_column = Text("")
108
129
 
109
130
  if arguments:
@@ -160,13 +181,13 @@ def render_read_tool_call(arguments: str) -> RenderableType:
160
181
  style=ThemeKey.INVALID_TOOL_CALL_ARGS,
161
182
  )
162
183
  )
163
- grid.add_row(Text("←", ThemeKey.TOOL_MARK), render_result)
184
+ grid.add_row(Text(MARK_READ, ThemeKey.TOOL_MARK), render_result)
164
185
  return grid
165
186
 
166
187
 
167
188
  def render_edit_tool_call(arguments: str) -> RenderableType:
168
189
  grid = create_grid()
169
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Edit", ThemeKey.TOOL_NAME))
190
+ tool_name_column = Text.assemble((MARK_EDIT, ThemeKey.TOOL_MARK), " ", ("Edit", ThemeKey.TOOL_NAME))
170
191
  try:
171
192
  json_dict = json.loads(arguments)
172
193
  file_path = json_dict.get("file_path")
@@ -185,10 +206,10 @@ def render_write_tool_call(arguments: str) -> RenderableType:
185
206
  try:
186
207
  json_dict = json.loads(arguments)
187
208
  file_path = json_dict.get("file_path")
188
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
209
+ tool_name_column = Text.assemble((MARK_WRITE, ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
189
210
  arguments_column = render_path(file_path, ThemeKey.TOOL_PARAM_FILE_PATH)
190
211
  except json.JSONDecodeError:
191
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
212
+ tool_name_column = Text.assemble((MARK_WRITE, ThemeKey.TOOL_MARK), " ", ("Write", ThemeKey.TOOL_NAME))
192
213
  arguments_column = Text(
193
214
  arguments.strip()[: const.INVALID_TOOL_CALL_MAX_LENGTH],
194
215
  style=ThemeKey.INVALID_TOOL_CALL_ARGS,
@@ -199,7 +220,7 @@ def render_write_tool_call(arguments: str) -> RenderableType:
199
220
 
200
221
  def render_apply_patch_tool_call(arguments: str) -> RenderableType:
201
222
  grid = create_grid()
202
- tool_name_column = Text.assemble(("→", ThemeKey.TOOL_MARK), " ", ("Apply Patch", ThemeKey.TOOL_NAME))
223
+ tool_name_column = Text.assemble((MARK_EDIT, ThemeKey.TOOL_MARK), " ", ("Apply Patch", ThemeKey.TOOL_NAME))
203
224
 
204
225
  try:
205
226
  payload = json.loads(arguments)
@@ -215,9 +236,27 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
215
236
  arguments_column = Text("", ThemeKey.TOOL_PARAM)
216
237
 
217
238
  if isinstance(patch_content, str):
218
- lines = [line for line in patch_content.splitlines() if line and not line.startswith("*** Begin Patch")]
219
- if lines:
220
- arguments_column = Text(lines[0][: const.INVALID_TOOL_CALL_MAX_LENGTH], ThemeKey.TOOL_PARAM)
239
+ update_count = 0
240
+ add_count = 0
241
+ delete_count = 0
242
+ for line in patch_content.splitlines():
243
+ if line.startswith("*** Update File:"):
244
+ update_count += 1
245
+ elif line.startswith("*** Add File:"):
246
+ add_count += 1
247
+ elif line.startswith("*** Delete File:"):
248
+ delete_count += 1
249
+
250
+ parts: list[str] = []
251
+ if update_count > 0:
252
+ parts.append(f"Update File × {update_count}" if update_count > 1 else "Update File")
253
+ if add_count > 0:
254
+ parts.append(f"Add File × {add_count}" if add_count > 1 else "Add File")
255
+ if delete_count > 0:
256
+ parts.append(f"Delete File × {delete_count}" if delete_count > 1 else "Delete File")
257
+
258
+ if parts:
259
+ arguments_column = Text(", ".join(parts), ThemeKey.TOOL_PARAM)
221
260
  else:
222
261
  arguments_column = Text(
223
262
  str(patch_content)[: const.INVALID_TOOL_CALL_MAX_LENGTH],
@@ -229,34 +268,24 @@ def render_apply_patch_tool_call(arguments: str) -> RenderableType:
229
268
 
230
269
 
231
270
  def render_todo(tr: events.ToolResultEvent) -> RenderableType:
232
- if not isinstance(tr.ui_extra, model.TodoListUIExtra):
233
- return Text.assemble(
234
- (" ✘", ThemeKey.ERROR_BOLD),
235
- " ",
236
- Text("(no content)" if tr.ui_extra is None else "(invalid ui_extra)", style=ThemeKey.ERROR),
237
- )
238
-
271
+ assert isinstance(tr.ui_extra, model.TodoListUIExtra)
239
272
  ui_extra = tr.ui_extra.todo_list
240
273
  todo_grid = create_grid()
241
274
  for todo in ui_extra.todos:
242
275
  is_new_completed = todo.content in ui_extra.new_completed
243
276
  match todo.status:
244
277
  case "pending":
245
- mark = "▢"
278
+ mark = MARK_TODO_PENDING
246
279
  mark_style = ThemeKey.TODO_PENDING_MARK
247
280
  text_style = ThemeKey.TODO_PENDING
248
281
  case "in_progress":
249
- mark = "◉"
282
+ mark = MARK_TODO_IN_PROGRESS
250
283
  mark_style = ThemeKey.TODO_IN_PROGRESS_MARK
251
284
  text_style = ThemeKey.TODO_IN_PROGRESS
252
285
  case "completed":
253
- mark = "✔"
286
+ mark = MARK_TODO_COMPLETED
254
287
  mark_style = ThemeKey.TODO_NEW_COMPLETED_MARK if is_new_completed else ThemeKey.TODO_COMPLETED_MARK
255
288
  text_style = ThemeKey.TODO_NEW_COMPLETED if is_new_completed else ThemeKey.TODO_COMPLETED
256
- case _:
257
- mark = "?"
258
- mark_style = ThemeKey.TODO_PENDING_MARK
259
- text_style = ThemeKey.TODO_PENDING
260
289
  text = Text(todo.content)
261
290
  text.stylize(text_style)
262
291
  todo_grid.add_row(Text(mark, style=mark_style), text)
@@ -280,7 +309,7 @@ def _extract_mermaid_link(
280
309
 
281
310
  def render_mermaid_tool_call(arguments: str) -> RenderableType:
282
311
  grid = create_grid()
283
- tool_name_column = Text.assemble(("⧉", ThemeKey.TOOL_MARK), " ", ("Mermaid", ThemeKey.TOOL_NAME))
312
+ tool_name_column = Text.assemble((MARK_MERMAID, ThemeKey.TOOL_MARK), " ", ("Mermaid", ThemeKey.TOOL_NAME))
284
313
  summary = Text("", ThemeKey.TOOL_PARAM)
285
314
 
286
315
  try:
@@ -320,7 +349,7 @@ def _truncate_url(url: str, max_length: int = 400) -> str:
320
349
 
321
350
  def render_web_fetch_tool_call(arguments: str) -> RenderableType:
322
351
  grid = create_grid()
323
- tool_name_column = Text.assemble(("↓", ThemeKey.TOOL_MARK), " ", ("Fetch", ThemeKey.TOOL_NAME))
352
+ tool_name_column = Text.assemble((MARK_WEB_FETCH, ThemeKey.TOOL_MARK), " ", ("Fetch", ThemeKey.TOOL_NAME))
324
353
 
325
354
  try:
326
355
  payload: dict[str, str] = json.loads(arguments)
@@ -341,7 +370,7 @@ def render_web_fetch_tool_call(arguments: str) -> RenderableType:
341
370
 
342
371
  def render_web_search_tool_call(arguments: str) -> RenderableType:
343
372
  grid = create_grid()
344
- tool_name_column = Text.assemble(("◉", ThemeKey.TOOL_MARK), " ", ("Search", ThemeKey.TOOL_NAME))
373
+ tool_name_column = Text.assemble((MARK_WEB_SEARCH, ThemeKey.TOOL_MARK), " ", ("Web Search", ThemeKey.TOOL_NAME))
345
374
 
346
375
  try:
347
376
  payload: dict[str, Any] = json.loads(arguments)
@@ -418,7 +447,7 @@ def get_truncation_info(tr: events.ToolResultEvent) -> model.TruncationUIExtra |
418
447
 
419
448
  def render_report_back_tool_call() -> RenderableType:
420
449
  grid = create_grid()
421
- tool_name_column = Text.assemble(("✔", ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
450
+ tool_name_column = Text.assemble((MARK_DONE, ThemeKey.TOOL_MARK), " ", ("Report Back", ThemeKey.TOOL_NAME))
422
451
  grid.add_row(tool_name_column, "")
423
452
  return grid
424
453
 
@@ -474,19 +503,18 @@ def render_tool_call(e: events.ToolCallEvent) -> RenderableType | None:
474
503
  return render_edit_tool_call(e.arguments)
475
504
  case tools.WRITE:
476
505
  return render_write_tool_call(e.arguments)
477
-
478
506
  case tools.BASH:
479
507
  return render_bash_tool_call(e.arguments)
480
508
  case tools.APPLY_PATCH:
481
509
  return render_apply_patch_tool_call(e.arguments)
482
510
  case tools.TODO_WRITE:
483
- return render_generic_tool_call("Update Todos", "", "◎")
511
+ return render_generic_tool_call("Update Todos", "", MARK_PLAN)
484
512
  case tools.UPDATE_PLAN:
485
513
  return render_update_plan_tool_call(e.arguments)
486
514
  case tools.MERMAID:
487
515
  return render_mermaid_tool_call(e.arguments)
488
516
  case tools.SKILL:
489
- return render_generic_tool_call(e.tool_name, e.arguments, "◈")
517
+ return render_generic_tool_call(e.tool_name, e.arguments, MARK_SKILL)
490
518
  case tools.REPORT_BACK:
491
519
  return render_report_back_tool_call()
492
520
  case tools.WEB_FETCH:
@@ -503,7 +531,23 @@ def _extract_diff(ui_extra: model.ToolResultUIExtra | None) -> model.DiffUIExtra
503
531
  return None
504
532
 
505
533
 
506
- def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
534
+ def _extract_markdown_doc(ui_extra: model.ToolResultUIExtra | None) -> model.MarkdownDocUIExtra | None:
535
+ if isinstance(ui_extra, model.MarkdownDocUIExtra):
536
+ return ui_extra
537
+ return None
538
+
539
+
540
+ def render_markdown_doc(md_ui: model.MarkdownDocUIExtra, *, code_theme: str) -> RenderableType:
541
+ """Render markdown document content in a panel."""
542
+ return Panel.fit(
543
+ NoInsetMarkdown(md_ui.content, code_theme=code_theme),
544
+ box=box.SIMPLE,
545
+ border_style=ThemeKey.LINES,
546
+ style=ThemeKey.WRITE_MARKDOWN_PANEL,
547
+ )
548
+
549
+
550
+ def render_tool_result(e: events.ToolResultEvent, *, code_theme: str = "monokai") -> RenderableType | None:
507
551
  """Unified entry point for rendering tool results.
508
552
 
509
553
  Returns a Rich Renderable or None if the tool result should not be rendered.
@@ -524,11 +568,16 @@ def render_tool_result(e: events.ToolResultEvent) -> RenderableType | None:
524
568
  return Group(render_truncation_info(truncation_info), render_generic_tool_result(e.result))
525
569
 
526
570
  diff_ui = _extract_diff(e.ui_extra)
571
+ md_ui = _extract_markdown_doc(e.ui_extra)
527
572
 
528
573
  match e.tool_name:
529
574
  case tools.READ:
530
575
  return None
531
- case tools.EDIT | tools.WRITE:
576
+ case tools.EDIT:
577
+ return Padding.indent(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""), level=2)
578
+ case tools.WRITE:
579
+ if md_ui:
580
+ return Padding.indent(render_markdown_doc(md_ui, code_theme=code_theme), level=2)
532
581
  return Padding.indent(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""), level=2)
533
582
  case tools.APPLY_PATCH:
534
583
  if diff_ui:
@@ -4,6 +4,7 @@ from rich.console import Group, RenderableType
4
4
  from rich.text import Text
5
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
 
@@ -12,6 +13,11 @@ from klaude_code.ui.rich.theme import ThemeKey
12
13
  # patterns such as foo@bar.com as file references.
13
14
  AT_FILE_RENDER_PATTERN = re.compile(r'(?<!\S)@("([^"]+)"|\S+)')
14
15
 
16
+ # Match $skill or ¥skill pattern at the beginning of the first line
17
+ SKILL_RENDER_PATTERN = re.compile(r"^[$¥](\S+)")
18
+
19
+ USER_MESSAGE_MARK = "❯ "
20
+
15
21
 
16
22
  def render_at_pattern(
17
23
  text: str,
@@ -38,15 +44,24 @@ def render_at_pattern(
38
44
  return result
39
45
 
40
46
 
47
+ def _is_valid_skill_name(name: str) -> bool:
48
+ """Check if a skill name is valid (exists in loaded skills)."""
49
+ short = name.split(":")[-1] if ":" in name else name
50
+ available_skills = get_available_skills()
51
+ return any(skill_name in (name, short) for skill_name, _, _ in available_skills)
52
+
53
+
41
54
  def render_user_input(content: str) -> RenderableType:
42
55
  """Render a user message as a group of quoted lines with styles.
43
56
 
44
57
  - Highlights slash command on the first line if recognized
58
+ - Highlights $skill pattern on the first line if recognized
45
59
  - Highlights @file patterns in all lines
46
60
  """
47
61
  lines = content.strip().split("\n")
48
62
  renderables: list[RenderableType] = []
49
63
  has_command = False
64
+ command_style: str | None = None
50
65
  for i, line in enumerate(lines):
51
66
  line_text = render_at_pattern(line)
52
67
 
@@ -54,6 +69,7 @@ def render_user_input(content: str) -> RenderableType:
54
69
  splits = line.split(" ", maxsplit=1)
55
70
  if is_slash_command_name(splits[0][1:]):
56
71
  has_command = True
72
+ command_style = ThemeKey.USER_INPUT_SLASH_COMMAND
57
73
  line_text = Text.assemble(
58
74
  (f"{splits[0]}", ThemeKey.USER_INPUT_SLASH_COMMAND),
59
75
  " ",
@@ -62,13 +78,27 @@ def render_user_input(content: str) -> RenderableType:
62
78
  renderables.append(line_text)
63
79
  continue
64
80
 
81
+ if i == 0 and (line.startswith("$") or line.startswith("¥")):
82
+ m = SKILL_RENDER_PATTERN.match(line)
83
+ if m and _is_valid_skill_name(m.group(1)):
84
+ has_command = True
85
+ command_style = ThemeKey.USER_INPUT_SKILL
86
+ skill_token = m.group(0) # e.g. "$skill-name"
87
+ rest = line[len(skill_token) :]
88
+ line_text = Text.assemble(
89
+ (skill_token, ThemeKey.USER_INPUT_SKILL),
90
+ render_at_pattern(rest) if rest else Text(""),
91
+ )
92
+ renderables.append(line_text)
93
+ continue
94
+
65
95
  renderables.append(line_text)
66
96
  grid = create_grid()
67
97
  grid.padding = (0, 0)
68
98
  mark = (
69
- Text("❯ ", style=ThemeKey.USER_INPUT_PROMPT)
99
+ Text(USER_MESSAGE_MARK, style=ThemeKey.USER_INPUT_PROMPT)
70
100
  if not has_command
71
- else Text(" ", style=ThemeKey.USER_INPUT_SLASH_COMMAND)
101
+ else Text(" ", style=command_style or ThemeKey.USER_INPUT_SLASH_COMMAND)
72
102
  )
73
103
  grid.add_row(mark, Group(*renderables))
74
104
  return grid
@@ -9,10 +9,10 @@ from typing import Any, ClassVar
9
9
 
10
10
  from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
11
11
  from rich.live import Live
12
- from rich.markdown import CodeBlock, Heading, HorizontalRule, Markdown
12
+ from rich.markdown import CodeBlock, Heading, Markdown, MarkdownElement
13
13
  from rich.rule import Rule
14
14
  from rich.spinner import Spinner
15
- from rich.style import Style
15
+ from rich.style import Style, StyleType
16
16
  from rich.syntax import Syntax
17
17
  from rich.text import Text
18
18
  from rich.theme import Theme
@@ -45,12 +45,12 @@ class ThinkingCodeBlock(CodeBlock):
45
45
  yield CodePanel(text, border_style="markdown.code.border")
46
46
 
47
47
 
48
- class SpacedHorizontalRule(HorizontalRule):
48
+ class Divider(MarkdownElement):
49
49
  """A horizontal rule with an extra blank line below."""
50
50
 
51
51
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
52
- yield from super().__rich_console__(console, options)
53
- yield Text()
52
+ style = console.get_style("markdown.hr", default="none")
53
+ yield Rule(style=style, characters="-")
54
54
 
55
55
 
56
56
  class LeftHeading(Heading):
@@ -64,7 +64,7 @@ class LeftHeading(Heading):
64
64
  yield h1_text
65
65
  elif self.tag == "h2":
66
66
  text.stylize(Style(bold=True, underline=False))
67
- yield Rule(title=text, characters="-", style="markdown.h2.border", align="left")
67
+ yield Rule(title=text, characters="·", style="markdown.h2.border", align="left")
68
68
  else:
69
69
  yield text
70
70
 
@@ -77,7 +77,7 @@ class NoInsetMarkdown(Markdown):
77
77
  "fence": NoInsetCodeBlock,
78
78
  "code_block": NoInsetCodeBlock,
79
79
  "heading_open": LeftHeading,
80
- "hr": SpacedHorizontalRule,
80
+ "hr": Divider,
81
81
  }
82
82
 
83
83
 
@@ -89,7 +89,7 @@ class ThinkingMarkdown(Markdown):
89
89
  "fence": ThinkingCodeBlock,
90
90
  "code_block": ThinkingCodeBlock,
91
91
  "heading_open": LeftHeading,
92
- "hr": SpacedHorizontalRule,
92
+ "hr": Divider,
93
93
  }
94
94
 
95
95
 
@@ -108,7 +108,9 @@ class MarkdownStream:
108
108
  console: Console | None = None,
109
109
  spinner: Spinner | None = None,
110
110
  mark: str | None = None,
111
- indent: int = 0,
111
+ mark_style: StyleType | None = None,
112
+ left_margin: int = 0,
113
+ right_margin: int = const.MARKDOWN_RIGHT_MARGIN,
112
114
  markdown_class: Callable[..., Markdown] | None = None,
113
115
  ) -> None:
114
116
  """Initialize the markdown stream.
@@ -117,8 +119,10 @@ class MarkdownStream:
117
119
  mdargs (dict, optional): Additional arguments to pass to rich Markdown renderer
118
120
  theme (Theme, optional): Theme for rendering markdown
119
121
  console (Console, optional): External console to use for rendering
120
- mark (str | None, optional): Marker shown before the first non-empty line when indent >= 2
121
- indent (int, optional): Number of spaces to indent all rendered lines on the left
122
+ mark (str | None, optional): Marker shown before the first non-empty line when left_margin >= 2
123
+ mark_style (StyleType | None, optional): Style to apply to the mark
124
+ left_margin (int, optional): Number of columns to reserve on the left side
125
+ right_margin (int, optional): Number of columns to reserve on the right side
122
126
  markdown_class: Markdown class to use for rendering (defaults to NoInsetMarkdown)
123
127
  """
124
128
  self.printed: list[str] = [] # Stores lines that have already been printed
@@ -140,7 +144,11 @@ class MarkdownStream:
140
144
  self.console = console
141
145
  self.spinner: Spinner | None = spinner
142
146
  self.mark: str | None = mark
143
- self.indent: int = max(indent, 0)
147
+ self.mark_style: StyleType | None = mark_style
148
+
149
+ self.left_margin: int = max(left_margin, 0)
150
+
151
+ self.right_margin: int = max(right_margin, 0)
144
152
  self.markdown_class: Callable[..., Markdown] = markdown_class or NoInsetMarkdown
145
153
 
146
154
  @property
@@ -160,15 +168,15 @@ class MarkdownStream:
160
168
  # Render the markdown to a string buffer
161
169
  string_io = io.StringIO()
162
170
 
163
- # Determine console width and adjust for left indent so that
164
- # the rendered content plus indent does not exceed the available width.
171
+ # Determine console width and adjust for left margin so that
172
+ # the rendered content plus margins does not exceed the available width.
165
173
  if self.console is not None:
166
174
  base_width = self.console.options.max_width
167
175
  else:
168
176
  probe_console = Console(theme=self.theme)
169
177
  base_width = probe_console.options.max_width
170
178
 
171
- effective_width = max(base_width - self.indent, 1)
179
+ effective_width = max(base_width - self.left_margin - self.right_margin, 1)
172
180
 
173
181
  # Use external console for consistent theming, or create temporary one
174
182
  temp_console = Console(
@@ -182,19 +190,31 @@ class MarkdownStream:
182
190
  temp_console.print(markdown)
183
191
  output = string_io.getvalue()
184
192
 
185
- # Split rendered output into lines, strip trailing spaces, and apply left indent.
193
+ # Split rendered output into lines, strip trailing spaces, and apply left margin.
186
194
  lines = output.splitlines(keepends=True)
187
- indent_prefix = " " * self.indent if self.indent > 0 else ""
195
+ indent_prefix = " " * self.left_margin if self.left_margin > 0 else ""
188
196
  processed_lines: list[str] = []
189
197
  mark_applied = False
190
- use_mark = bool(self.mark) and self.indent >= 2
198
+ use_mark = bool(self.mark) and self.left_margin >= 2
199
+
200
+ # Pre-render styled mark if needed
201
+ styled_mark: str | None = None
202
+ if use_mark and self.mark:
203
+ if self.mark_style:
204
+ mark_text = Text(self.mark, style=self.mark_style)
205
+ mark_buffer = io.StringIO()
206
+ mark_console = Console(file=mark_buffer, force_terminal=True, theme=self.theme)
207
+ mark_console.print(mark_text, end="")
208
+ styled_mark = mark_buffer.getvalue()
209
+ else:
210
+ styled_mark = self.mark
191
211
 
192
212
  for line in lines:
193
213
  stripped = line.rstrip()
194
214
 
195
- # Apply mark to the first non-empty line only when indent is at least 2.
215
+ # Apply mark to the first non-empty line only when left_margin is at least 2.
196
216
  if use_mark and not mark_applied and stripped:
197
- stripped = f"{self.mark} {stripped}"
217
+ stripped = f"{styled_mark} {stripped}"
198
218
  mark_applied = True
199
219
  elif indent_prefix:
200
220
  stripped = indent_prefix + stripped
@@ -22,18 +22,7 @@ BREATHING_SPINNER_NAME = "dots"
22
22
 
23
23
  # Alternating glyphs for the breathing spinner - switches at each "transparent" point
24
24
  _BREATHING_SPINNER_GLYPHS_BASE = [
25
- "",
26
- "✶",
27
- "✲",
28
- "◆",
29
- "❖",
30
- "✧",
31
- "❋",
32
- "✸",
33
- "✻",
34
- "◇",
35
- "✴",
36
- "✷",
25
+ "",
37
26
  ]
38
27
 
39
28
  # Shuffle glyphs on module load for variety across sessions
@@ -114,7 +103,6 @@ def _shimmer_style(console: Console, base_style: Style, intensity: float) -> Sty
114
103
 
115
104
  base_r, base_g, base_b = base_triplet
116
105
  bg_r, bg_g, bg_b = bg_triplet
117
-
118
106
  r = int(bg_r * alpha + base_r * (1.0 - alpha))
119
107
  g = int(bg_g * alpha + base_g * (1.0 - alpha))
120
108
  b = int(bg_b * alpha + base_b * (1.0 - alpha))
@@ -181,9 +169,19 @@ class ShimmerStatusText:
181
169
  Supports optional right-aligned text that stays fixed at the right edge.
182
170
  """
183
171
 
184
- def __init__(self, main_text: str | Text, main_style: ThemeKey, right_text: Text | None = None) -> None:
185
- self._main_text = main_text if isinstance(main_text, Text) else Text(main_text)
186
- self._main_style = main_style
172
+ def __init__(
173
+ self,
174
+ main_text: str | Text,
175
+ right_text: Text | None = None,
176
+ main_style: ThemeKey = ThemeKey.STATUS_TEXT,
177
+ ) -> None:
178
+ if isinstance(main_text, Text):
179
+ text = main_text.copy()
180
+ if not text.style:
181
+ text.style = str(main_style)
182
+ self._main_text = text
183
+ else:
184
+ self._main_text = Text(main_text, style=main_style)
187
185
  self._hint_text = Text(const.STATUS_HINT_TEXT)
188
186
  self._hint_style = ThemeKey.STATUS_HINT
189
187
  self._right_text = right_text
@@ -205,13 +203,11 @@ class ShimmerStatusText:
205
203
  def _render_left_text(self, console: Console) -> Text:
206
204
  """Render the left part with shimmer effect on main text only."""
207
205
  result = Text()
208
- main_style = console.get_style(str(self._main_style))
209
206
  hint_style = console.get_style(str(self._hint_style))
210
207
 
211
208
  # Apply shimmer only to main text
212
209
  for index, (ch, intensity) in enumerate(_shimmer_profile(self._main_text.plain)):
213
- char_style = self._main_text.get_style_at_offset(console, index)
214
- base_style = main_style + char_style
210
+ base_style = self._main_text.get_style_at_offset(console, index)
215
211
  style = _shimmer_style(console, base_style, intensity)
216
212
  result.append(ch, style=style)
217
213