klaude-code 2.9.0__py3-none-any.whl → 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. klaude_code/auth/antigravity/oauth.py +33 -29
  2. klaude_code/auth/claude/oauth.py +34 -49
  3. klaude_code/config/assets/builtin_config.yaml +17 -0
  4. klaude_code/core/agent_profile.py +2 -5
  5. klaude_code/core/task.py +1 -1
  6. klaude_code/core/tool/file/read_tool.py +13 -2
  7. klaude_code/core/tool/shell/bash_tool.py +1 -1
  8. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  9. klaude_code/llm/input_common.py +18 -0
  10. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  11. klaude_code/llm/{codex → openai_codex}/client.py +3 -3
  12. klaude_code/llm/openai_compatible/client.py +3 -1
  13. klaude_code/llm/openai_compatible/stream.py +19 -9
  14. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  15. klaude_code/llm/registry.py +3 -3
  16. klaude_code/llm/stream_parts.py +3 -1
  17. klaude_code/llm/usage.py +1 -1
  18. klaude_code/protocol/events.py +0 -1
  19. klaude_code/protocol/message.py +1 -0
  20. klaude_code/protocol/model.py +14 -1
  21. klaude_code/session/session.py +22 -1
  22. klaude_code/tui/components/bash_syntax.py +4 -0
  23. klaude_code/tui/components/diffs.py +3 -2
  24. klaude_code/tui/components/metadata.py +0 -3
  25. klaude_code/tui/components/rich/markdown.py +120 -33
  26. klaude_code/tui/components/rich/status.py +2 -2
  27. klaude_code/tui/components/rich/theme.py +9 -6
  28. klaude_code/tui/components/tools.py +22 -0
  29. klaude_code/tui/components/user_input.py +2 -0
  30. klaude_code/tui/machine.py +25 -47
  31. klaude_code/tui/renderer.py +37 -13
  32. klaude_code/tui/terminal/image.py +24 -3
  33. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/METADATA +1 -1
  34. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/RECORD +40 -40
  35. klaude_code/llm/bedrock/__init__.py +0 -3
  36. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  37. /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
  38. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  39. /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
  40. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/WHEEL +0 -0
  41. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  from rich.console import RenderableType
2
2
  from rich.text import Text
3
3
 
4
- from klaude_code.const import DIFF_PREFIX_WIDTH
4
+ from klaude_code.const import DIFF_PREFIX_WIDTH, TAB_EXPAND_WIDTH
5
5
  from klaude_code.protocol import model
6
6
  from klaude_code.tui.components.common import create_grid
7
7
  from klaude_code.tui.components.rich.theme import ThemeKey
@@ -74,7 +74,8 @@ def _render_structured_line(line: model.DiffLine) -> Text:
74
74
  return Text("")
75
75
  text = Text()
76
76
  for span in line.spans:
77
- text.append(span.text, style=_span_style(line.kind, span.op))
77
+ content = span.text.expandtabs(TAB_EXPAND_WIDTH)
78
+ text.append(content, style=_span_style(line.kind, span.op))
78
79
  return text
79
80
 
80
81
 
@@ -136,9 +136,6 @@ def render_task_metadata(e: events.TaskMetadataEvent) -> RenderableType:
136
136
  """Render task metadata including main agent and sub-agents."""
137
137
  renderables: list[RenderableType] = []
138
138
 
139
- if e.cancelled:
140
- renderables.append(Text())
141
-
142
139
  has_sub_agents = len(e.metadata.sub_agent_task_metadata) > 0
143
140
  # Use an extra space for the main agent mark to align with two-character marks (├─, └─)
144
141
  main_mark_text = "✓"
@@ -14,7 +14,6 @@ from rich import box
14
14
  from rich._loop import loop_first
15
15
  from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
16
16
  from rich.markdown import CodeBlock, Heading, ImageItem, ListItem, Markdown, MarkdownElement, TableElement
17
- from rich.rule import Rule
18
17
  from rich.segment import Segment
19
18
  from rich.style import Style, StyleType
20
19
  from rich.syntax import Syntax
@@ -120,7 +119,8 @@ class Divider(MarkdownElement):
120
119
 
121
120
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
122
121
  style = console.get_style("markdown.hr", default="none")
123
- yield Rule(style=style, characters="-")
122
+ width = min(options.max_width, 100)
123
+ yield Text("-" * width, style=style)
124
124
 
125
125
 
126
126
  class MarkdownTable(TableElement):
@@ -153,7 +153,8 @@ class LeftHeading(Heading):
153
153
  h1_text = text.assemble((" ", "markdown.h1"), text, (" ", "markdown.h1"))
154
154
  yield h1_text
155
155
  elif self.tag == "h2":
156
- text.stylize(Style(bold=True, underline=False))
156
+ h2_style = console.get_style("markdown.h2", default="bold")
157
+ text.stylize(h2_style + Style(underline=False))
157
158
  yield text
158
159
  else:
159
160
  yield text
@@ -394,12 +395,25 @@ class MarkdownStream:
394
395
  return 0
395
396
 
396
397
  top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
397
- if len(top_level) < 2:
398
+ if not top_level:
398
399
  return 0
399
400
 
400
401
  last = top_level[-1]
401
402
  assert last.map is not None
402
403
 
404
+ # Lists are a special case: markdown-it-py treats the whole list as one
405
+ # top-level block, which would keep the entire list in the live area
406
+ # until the list ends.
407
+ #
408
+ # For streaming UX, we want all but the final list item to be eligible
409
+ # for stabilization, leaving only the *last* item in the live area.
410
+ if last.type in {"bullet_list_open", "ordered_list_open"}:
411
+ stable_line = self._compute_list_item_boundary(tokens, last)
412
+ return max(stable_line, 0)
413
+
414
+ if len(top_level) < 2:
415
+ return 0
416
+
403
417
  # When the buffer ends mid-line, markdown-it-py can temporarily classify
404
418
  # some lines as a thematic break (hr). For example, a trailing "- --"
405
419
  # parses as an hr, but appending a non-hr character ("- --0") turns it
@@ -416,6 +430,59 @@ class MarkdownStream:
416
430
  start_line = last.map[0]
417
431
  return max(start_line, 0)
418
432
 
433
+ def _compute_list_item_boundary(self, tokens: list[Token], list_open: Token) -> int:
434
+ """Return the start line of the last list item in a list.
435
+
436
+ This allows stabilizing all list items except the final one.
437
+ """
438
+
439
+ if list_open.map is None:
440
+ return 0
441
+
442
+ list_start, list_end = list_open.map
443
+ item_level = list_open.level + 1
444
+
445
+ last_item_start: int | None = None
446
+ for token in tokens:
447
+ if token.type != "list_item_open":
448
+ continue
449
+ if token.level != item_level:
450
+ continue
451
+ if token.map is None:
452
+ continue
453
+ start_line = token.map[0]
454
+ if start_line < list_start or start_line >= list_end:
455
+ continue
456
+ last_item_start = start_line
457
+
458
+ return last_item_start if last_item_start is not None else list_start
459
+
460
+ def _stable_boundary_continues_list(self, text: str, stable_line: int, *, final: bool) -> bool:
461
+ """Whether the stable/live split point is inside the final top-level list."""
462
+
463
+ if final:
464
+ return False
465
+ if stable_line <= 0:
466
+ return False
467
+
468
+ try:
469
+ tokens = self._parser.parse(text)
470
+ except Exception:
471
+ return False
472
+
473
+ top_level: list[Token] = [token for token in tokens if token.level == 0 and token.map is not None]
474
+ if not top_level:
475
+ return False
476
+
477
+ last = top_level[-1]
478
+ if last.type not in {"bullet_list_open", "ordered_list_open"}:
479
+ return False
480
+ if last.map is None:
481
+ return False
482
+
483
+ list_start, list_end = last.map
484
+ return list_start < stable_line < list_end
485
+
419
486
  def split_blocks(self, text: str, *, min_stable_line: int = 0, final: bool = False) -> tuple[str, str, int]:
420
487
  """Split full markdown into stable and live sources.
421
488
 
@@ -445,7 +512,14 @@ class MarkdownStream:
445
512
  return "", text, 0
446
513
  return stable_source, live_source, stable_line
447
514
 
448
- def render_stable_ansi(self, stable_source: str, *, has_live_suffix: bool, final: bool) -> tuple[str, list[str]]:
515
+ def render_stable_ansi(
516
+ self,
517
+ stable_source: str,
518
+ *,
519
+ has_live_suffix: bool,
520
+ final: bool,
521
+ continues_list: bool = False,
522
+ ) -> tuple[str, list[str]]:
449
523
  """Render stable prefix to ANSI, preserving inter-block spacing.
450
524
 
451
525
  Returns:
@@ -455,10 +529,14 @@ class MarkdownStream:
455
529
  return "", []
456
530
 
457
531
  render_source = stable_source
458
- if not final and has_live_suffix:
532
+ if not final and has_live_suffix and not continues_list:
459
533
  render_source = self._append_nonfinal_sentinel(stable_source)
460
534
 
461
535
  lines, images = self._render_markdown_to_lines(render_source, apply_mark=True)
536
+
537
+ if continues_list:
538
+ while lines and not lines[-1].strip():
539
+ lines.pop()
462
540
  return "".join(lines), images
463
541
 
464
542
  def _append_nonfinal_sentinel(self, stable_source: str) -> str:
@@ -570,6 +648,8 @@ class MarkdownStream:
570
648
  final=final,
571
649
  )
572
650
 
651
+ continues_list = self._stable_boundary_continues_list(text, stable_line, final=final) and bool(live_source)
652
+
573
653
  start = time.time()
574
654
 
575
655
  stable_chunk_to_print: str | None = None
@@ -577,7 +657,10 @@ class MarkdownStream:
577
657
  stable_changed = final or stable_line > self._stable_source_line_count
578
658
  if stable_changed and stable_source:
579
659
  stable_ansi, collected_images = self.render_stable_ansi(
580
- stable_source, has_live_suffix=bool(live_source), final=final
660
+ stable_source,
661
+ has_live_suffix=bool(live_source),
662
+ final=final,
663
+ continues_list=continues_list,
581
664
  )
582
665
  stable_lines = stable_ansi.splitlines(keepends=True)
583
666
  new_lines = stable_lines[len(self._stable_rendered_lines) :]
@@ -595,30 +678,42 @@ class MarkdownStream:
595
678
 
596
679
  live_text_to_set: Text | None = None
597
680
  if not final and MARKDOWN_STREAM_LIVE_REPAINT_ENABLED and self._live_sink is not None:
598
- apply_mark_live = self._stable_source_line_count == 0
599
- live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark_live)
600
-
601
- if self._stable_rendered_lines:
602
- stable_trailing_blank = 0
603
- for line in reversed(self._stable_rendered_lines):
604
- if line.strip():
605
- break
606
- stable_trailing_blank += 1
607
-
608
- if stable_trailing_blank > 0:
609
- live_leading_blank = 0
610
- for line in live_lines:
681
+ if continues_list and self._stable_rendered_lines:
682
+ full_lines, _ = self._render_markdown_to_lines(text, apply_mark=True)
683
+ skip = min(len(self._stable_rendered_lines), len(full_lines))
684
+ live_text_to_set = Text.from_ansi("".join(full_lines[skip:]))
685
+ else:
686
+ apply_mark = not self._stable_rendered_lines
687
+ live_lines, _ = self._render_markdown_to_lines(live_source, apply_mark=apply_mark)
688
+
689
+ if self._stable_rendered_lines:
690
+ stable_trailing_blank = 0
691
+ for line in reversed(self._stable_rendered_lines):
611
692
  if line.strip():
612
693
  break
613
- live_leading_blank += 1
694
+ stable_trailing_blank += 1
614
695
 
615
- drop = min(stable_trailing_blank, live_leading_blank)
616
- if drop > 0:
617
- live_lines = live_lines[drop:]
696
+ if stable_trailing_blank > 0:
697
+ live_leading_blank = 0
698
+ for line in live_lines:
699
+ if line.strip():
700
+ break
701
+ live_leading_blank += 1
618
702
 
619
- live_text_to_set = Text.from_ansi("".join(live_lines))
703
+ drop = min(stable_trailing_blank, live_leading_blank)
704
+ if drop > 0:
705
+ live_lines = live_lines[drop:]
706
+
707
+ live_text_to_set = Text.from_ansi("".join(live_lines))
620
708
 
621
709
  with self._synchronized_output():
710
+ # Update/clear live area first to avoid blank padding when stable block appears
711
+ if final:
712
+ if self._live_sink is not None:
713
+ self._live_sink(None)
714
+ elif live_text_to_set is not None and self._live_sink is not None:
715
+ self._live_sink(live_text_to_set)
716
+
622
717
  if stable_chunk_to_print:
623
718
  self.console.print(Text.from_ansi(stable_chunk_to_print), end="\n")
624
719
 
@@ -626,13 +721,5 @@ class MarkdownStream:
626
721
  for img_path in new_images:
627
722
  self._image_callback(img_path)
628
723
 
629
- if final:
630
- if self._live_sink is not None:
631
- self._live_sink(None)
632
- return
633
-
634
- if live_text_to_set is not None and self._live_sink is not None:
635
- self._live_sink(live_text_to_set)
636
-
637
724
  elapsed = time.time() - start
638
725
  self.min_delay = min(max(elapsed * 6, 1.0 / 30), 0.5)
@@ -277,7 +277,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
277
277
  if cell_len(text.plain) <= max_cells:
278
278
  return text
279
279
 
280
- ellipsis_cells = cell_len(ellipsis)
280
+ ellipsis_cells = cell_len(ellipsis) + 1 # +1 for trailing space
281
281
  if max_cells <= ellipsis_cells:
282
282
  # Not enough space to show any meaningful suffix.
283
283
  clipped = Text(ellipsis, style=text.style)
@@ -307,7 +307,7 @@ def truncate_left(text: Text, max_cells: int, *, console: Console, ellipsis: str
307
307
  except Exception:
308
308
  ellipsis_style = suffix.style or text.style
309
309
 
310
- return Text.assemble(Text(ellipsis, style=ellipsis_style), suffix)
310
+ return Text.assemble(Text(ellipsis + " ", style=ellipsis_style), suffix)
311
311
 
312
312
 
313
313
  class ShimmerStatusText:
@@ -21,6 +21,7 @@ class Palette:
21
21
  grey_green: str
22
22
  purple: str
23
23
  lavender: str
24
+ black: str
24
25
  diff_add: str
25
26
  diff_add_char: str
26
27
  diff_remove: str
@@ -55,6 +56,7 @@ LIGHT_PALETTE = Palette(
55
56
  grey_green="#96a096",
56
57
  purple="#5f5fb7",
57
58
  lavender="#7878b0",
59
+ black="#101827",
58
60
  diff_add="#2e5a32 on #dafbe1",
59
61
  diff_add_char="#2e5a32 on #aceebb",
60
62
  diff_remove="#82071e on #ffecec",
@@ -88,6 +90,7 @@ DARK_PALETTE = Palette(
88
90
  grey_green="#6d8672",
89
91
  purple="#afbafe",
90
92
  lavender="#9898b8",
93
+ black="white",
91
94
  diff_add="#c8e6c9 on #1b3928",
92
95
  diff_add_char="#c8e6c9 on #2d6b42",
93
96
  diff_remove="#ffcdd2 on #3d1f23",
@@ -301,9 +304,9 @@ def get_theme(theme: str | None = None) -> Themes:
301
304
  ThemeKey.BASH_HEREDOC_DELIMITER.value: "bold " + palette.grey1,
302
305
  # THINKING
303
306
  ThemeKey.THINKING.value: "italic " + palette.grey2,
304
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
307
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
305
308
  # COMPACTION
306
- ThemeKey.COMPACTION_SUMMARY.value: "italic " + palette.grey1,
309
+ ThemeKey.COMPACTION_SUMMARY.value: palette.grey1,
307
310
  # TODO_ITEM
308
311
  ThemeKey.TODO_EXPLANATION.value: palette.grey1 + " italic",
309
312
  ThemeKey.TODO_PENDING_MARK.value: "bold " + palette.grey1,
@@ -347,9 +350,9 @@ def get_theme(theme: str | None = None) -> Themes:
347
350
  "markdown.code.border": palette.grey3,
348
351
  # Used by ThinkingMarkdown when rendering `<thinking>` blocks.
349
352
  "markdown.code.block": palette.grey1,
350
- "markdown.h1": "bold reverse",
353
+ "markdown.h1": "bold reverse " + palette.black,
351
354
  "markdown.h1.border": palette.grey3,
352
- "markdown.h2": "bold underline",
355
+ "markdown.h2": "bold underline " + palette.black,
353
356
  "markdown.h3": "bold " + palette.grey1,
354
357
  "markdown.h4": "bold " + palette.grey2,
355
358
  "markdown.hr": palette.grey3,
@@ -365,7 +368,8 @@ def get_theme(theme: str | None = None) -> Themes:
365
368
  styles={
366
369
  # THINKING (used for left-side mark in thinking output)
367
370
  ThemeKey.THINKING.value: "italic " + palette.grey2,
368
- ThemeKey.THINKING_BOLD.value: "bold italic " + palette.grey1,
371
+ ThemeKey.THINKING_BOLD.value: "italic " + palette.grey1,
372
+ "markdown.strong": "italic " + palette.grey1,
369
373
  "markdown.code": palette.grey1 + " italic on " + palette.code_background,
370
374
  "markdown.code.block": palette.grey1,
371
375
  "markdown.code.border": palette.grey3,
@@ -379,7 +383,6 @@ def get_theme(theme: str | None = None) -> Themes:
379
383
  "markdown.item.number": palette.grey2,
380
384
  "markdown.link": "underline " + palette.blue,
381
385
  "markdown.link_url": "underline " + palette.blue,
382
- "markdown.strong": "bold italic " + palette.grey1,
383
386
  "markdown.table.border": palette.grey2,
384
387
  "markdown.checkbox.checked": palette.green,
385
388
  }
@@ -10,8 +10,10 @@ from rich.text import Text
10
10
 
11
11
  from klaude_code.const import (
12
12
  BASH_OUTPUT_PANEL_THRESHOLD,
13
+ DIFF_PREFIX_WIDTH,
13
14
  INVALID_TOOL_CALL_MAX_LENGTH,
14
15
  QUERY_DISPLAY_TRUNCATE_LENGTH,
16
+ TAB_EXPAND_WIDTH,
15
17
  URL_TRUNCATE_MAX_LENGTH,
16
18
  WEB_SEARCH_DEFAULT_MAX_RESULTS,
17
19
  )
@@ -387,6 +389,24 @@ def render_generic_tool_result(result: str, *, is_error: bool = False) -> Render
387
389
  return text
388
390
 
389
391
 
392
+ def render_read_preview(ui_extra: model.ReadPreviewUIExtra) -> RenderableType:
393
+ """Render read preview with line numbers aligned to diff style."""
394
+ grid = create_grid()
395
+ grid.padding = (0, 0)
396
+
397
+ for line in ui_extra.lines:
398
+ prefix = f"{line.line_no:>{DIFF_PREFIX_WIDTH}} "
399
+ content = line.content.expandtabs(TAB_EXPAND_WIDTH)
400
+ grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), Text(content, ThemeKey.TOOL_RESULT))
401
+
402
+ if ui_extra.remaining_lines > 0:
403
+ remaining_prefix = f"{'⋮':>{DIFF_PREFIX_WIDTH}} "
404
+ remaining_text = Text(f"(more {ui_extra.remaining_lines} lines)", ThemeKey.TOOL_RESULT_TRUNCATED)
405
+ grid.add_row(Text(remaining_prefix, ThemeKey.TOOL_RESULT_TRUNCATED), remaining_text)
406
+
407
+ return grid
408
+
409
+
390
410
  def _extract_mermaid_link(
391
411
  ui_extra: model.ToolResultUIExtra | None,
392
412
  ) -> model.MermaidLinkUIExtra | None:
@@ -678,6 +698,8 @@ def render_tool_result(
678
698
 
679
699
  match e.tool_name:
680
700
  case tools.READ:
701
+ if isinstance(e.ui_extra, model.ReadPreviewUIExtra):
702
+ return wrap(render_read_preview(e.ui_extra))
681
703
  return None
682
704
  case tools.EDIT:
683
705
  return wrap(r_diffs.render_structured_diff(diff_ui) if diff_ui else Text(""))
@@ -3,6 +3,7 @@ import re
3
3
  from rich.console import Group, RenderableType
4
4
  from rich.text import Text
5
5
 
6
+ from klaude_code.const import TAB_EXPAND_WIDTH
6
7
  from klaude_code.skill import get_available_skills
7
8
  from klaude_code.tui.components.common import create_grid
8
9
  from klaude_code.tui.components.rich.theme import ThemeKey
@@ -82,6 +83,7 @@ def render_user_input(content: str) -> RenderableType:
82
83
  lines = content.strip().split("\n")
83
84
  renderables: list[RenderableType] = []
84
85
  for i, line in enumerate(lines):
86
+ line = line.expandtabs(TAB_EXPAND_WIDTH)
85
87
  # Handle slash command on first line
86
88
  if i == 0 and line.startswith("/"):
87
89
  splits = line.split(" ", maxsplit=1)
@@ -32,7 +32,6 @@ from klaude_code.tui.commands import (
32
32
  RenderTaskFinish,
33
33
  RenderTaskMetadata,
34
34
  RenderTaskStart,
35
- RenderThinkingHeader,
36
35
  RenderToolCall,
37
36
  RenderToolResult,
38
37
  RenderTurnStart,
@@ -68,25 +67,6 @@ FAST_TOOLS: frozenset[str] = frozenset(
68
67
  )
69
68
 
70
69
 
71
- @dataclass
72
- class SubAgentThinkingHeaderState:
73
- buffer: str = ""
74
- last_header: str | None = None
75
-
76
- def append_and_extract_new_header(self, content: str) -> str | None:
77
- self.buffer += content
78
-
79
- max_chars = 8192
80
- if len(self.buffer) > max_chars:
81
- self.buffer = self.buffer[-max_chars:]
82
-
83
- header = extract_last_bold_header(normalize_thinking_content(self.buffer))
84
- if header and header != self.last_header:
85
- self.last_header = header
86
- return header
87
- return None
88
-
89
-
90
70
  class ActivityState:
91
71
  """Tracks composing/tool activity for spinner display."""
92
72
 
@@ -110,7 +90,10 @@ class ActivityState:
110
90
 
111
91
  def add_sub_agent_tool_call(self, tool_call_id: str, tool_name: str) -> None:
112
92
  if tool_call_id in self._sub_agent_tool_calls_by_id:
113
- return
93
+ old_tool_name = self._sub_agent_tool_calls_by_id[tool_call_id]
94
+ self._sub_agent_tool_calls[old_tool_name] = self._sub_agent_tool_calls.get(old_tool_name, 0) - 1
95
+ if self._sub_agent_tool_calls[old_tool_name] <= 0:
96
+ self._sub_agent_tool_calls.pop(old_tool_name, None)
114
97
  self._sub_agent_tool_calls_by_id[tool_call_id] = tool_name
115
98
  self._sub_agent_tool_calls[tool_name] = self._sub_agent_tool_calls.get(tool_name, 0) + 1
116
99
 
@@ -303,7 +286,6 @@ class SpinnerStatusState:
303
286
  class _SessionState:
304
287
  session_id: str
305
288
  sub_agent_state: model.SubAgentState | None = None
306
- sub_agent_thinking_header: SubAgentThinkingHeaderState | None = None
307
289
  model_id: str | None = None
308
290
  assistant_stream_active: bool = False
309
291
  thinking_stream_active: bool = False
@@ -418,8 +400,6 @@ class DisplayStateMachine:
418
400
  self._set_primary_if_needed(e.session_id)
419
401
  if not is_replay:
420
402
  cmds.append(TaskClockStart())
421
- else:
422
- s.sub_agent_thinking_header = SubAgentThinkingHeaderState()
423
403
 
424
404
  if not is_replay:
425
405
  cmds.append(SpinnerStart())
@@ -465,7 +445,11 @@ class DisplayStateMachine:
465
445
 
466
446
  case events.ThinkingStartEvent() as e:
467
447
  if s.is_sub_agent:
468
- return []
448
+ if not s.should_show_sub_agent_thinking_header:
449
+ return []
450
+ s.thinking_stream_active = True
451
+ cmds.append(StartThinkingStream(session_id=e.session_id))
452
+ return cmds
469
453
  if not self._is_primary(e.session_id):
470
454
  return []
471
455
  s.thinking_stream_active = True
@@ -483,11 +467,7 @@ class DisplayStateMachine:
483
467
  if s.is_sub_agent:
484
468
  if not s.should_show_sub_agent_thinking_header:
485
469
  return []
486
- if s.sub_agent_thinking_header is None:
487
- s.sub_agent_thinking_header = SubAgentThinkingHeaderState()
488
- header = s.sub_agent_thinking_header.append_and_extract_new_header(e.content)
489
- if header:
490
- cmds.append(RenderThinkingHeader(session_id=e.session_id, header=header))
470
+ cmds.append(AppendThinking(session_id=e.session_id, content=e.content))
491
471
  return cmds
492
472
 
493
473
  if not self._is_primary(e.session_id):
@@ -507,7 +487,11 @@ class DisplayStateMachine:
507
487
 
508
488
  case events.ThinkingEndEvent() as e:
509
489
  if s.is_sub_agent:
510
- return []
490
+ if not s.should_show_sub_agent_thinking_header:
491
+ return []
492
+ s.thinking_stream_active = False
493
+ cmds.append(EndThinkingStream(session_id=e.session_id))
494
+ return cmds
511
495
  if not self._is_primary(e.session_id):
512
496
  return []
513
497
  s.thinking_stream_active = False
@@ -603,14 +587,11 @@ class DisplayStateMachine:
603
587
  # Skip activity state for fast tools on non-streaming models (e.g., Gemini)
604
588
  # to avoid flash-and-disappear effect
605
589
  if not is_replay and not s.should_skip_tool_activity(e.tool_name):
606
- if e.tool_name == tools.TASK:
607
- pass
590
+ tool_active_form = get_tool_active_form(e.tool_name)
591
+ if is_sub_agent_tool(e.tool_name):
592
+ self._spinner.add_sub_agent_tool_call(e.tool_call_id, tool_active_form)
608
593
  else:
609
- tool_active_form = get_tool_active_form(e.tool_name)
610
- if is_sub_agent_tool(e.tool_name):
611
- self._spinner.add_sub_agent_tool_call(e.tool_call_id, tool_active_form)
612
- else:
613
- self._spinner.add_tool_call(tool_active_form)
594
+ self._spinner.add_tool_call(tool_active_form)
614
595
 
615
596
  if not is_replay:
616
597
  cmds.extend(self._spinner_update_commands())
@@ -679,15 +660,12 @@ class DisplayStateMachine:
679
660
  case events.TaskFinishEvent() as e:
680
661
  s.task_active = False
681
662
  cmds.append(RenderTaskFinish(e))
682
- if not s.is_sub_agent:
683
- if not is_replay:
684
- cmds.append(TaskClockClear())
685
- self._spinner.reset()
686
- cmds.append(SpinnerStop())
687
- cmds.append(PrintRuleLine())
688
- cmds.append(EmitTmuxSignal())
689
- else:
690
- s.sub_agent_thinking_header = None
663
+ if not s.is_sub_agent and not is_replay:
664
+ cmds.append(TaskClockClear())
665
+ self._spinner.reset()
666
+ cmds.append(SpinnerStop())
667
+ cmds.append(PrintRuleLine())
668
+ cmds.append(EmitTmuxSignal())
691
669
  return cmds
692
670
 
693
671
  case events.InterruptEvent() as e:
@@ -67,7 +67,7 @@ from klaude_code.tui.components import thinking as c_thinking
67
67
  from klaude_code.tui.components import tools as c_tools
68
68
  from klaude_code.tui.components import user_input as c_user_input
69
69
  from klaude_code.tui.components import welcome as c_welcome
70
- from klaude_code.tui.components.common import truncate_head
70
+ from klaude_code.tui.components.common import create_grid, truncate_head
71
71
  from klaude_code.tui.components.rich import status as r_status
72
72
  from klaude_code.tui.components.rich.live import CropAboveLive, SingleLine
73
73
  from klaude_code.tui.components.rich.markdown import MarkdownStream, NoInsetMarkdown, ThinkingMarkdown
@@ -168,6 +168,7 @@ class TUICommandRenderer:
168
168
  self._sessions: dict[str, _SessionStatus] = {}
169
169
  self._current_sub_agent_color: Style | None = None
170
170
  self._sub_agent_color_index = 0
171
+ self._sub_agent_thinking_buffers: dict[str, str] = {}
171
172
 
172
173
  # ---------------------------------------------------------------------
173
174
  # Session helpers
@@ -325,8 +326,7 @@ class TUICommandRenderer:
325
326
  if pad_lines:
326
327
  stream = Padding(stream, (0, 0, pad_lines, 0))
327
328
  stream_part = stream
328
-
329
- gap_part = Text("") if self._spinner_visible else Group()
329
+ gap_part = Text("")
330
330
 
331
331
  status_part: RenderableType = SingleLine(self._status_spinner) if self._spinner_visible else Group()
332
332
  return Group(stream_part, gap_part, status_part)
@@ -381,6 +381,19 @@ class TUICommandRenderer:
381
381
  def _flush_assistant(self) -> None:
382
382
  self._assistant_stream.render()
383
383
 
384
+ def _render_sub_agent_thinking(self, content: str) -> None:
385
+ """Render sub-agent thinking content as a single block."""
386
+ normalized = c_thinking.normalize_thinking_content(content)
387
+ if not normalized.strip():
388
+ return
389
+ md = ThinkingMarkdown(normalized, code_theme=self.themes.code_theme, style=ThemeKey.THINKING)
390
+ self.console.push_theme(self.themes.thinking_markdown_theme)
391
+ grid = create_grid()
392
+ grid.add_row(Text(c_thinking.THINKING_MESSAGE_MARK, style=ThemeKey.THINKING), md)
393
+ self.print(grid)
394
+ self.console.pop_theme()
395
+ self.print()
396
+
384
397
  # ---------------------------------------------------------------------
385
398
  # Event-specific rendering helpers
386
399
  # ---------------------------------------------------------------------
@@ -534,9 +547,9 @@ class TUICommandRenderer:
534
547
  )
535
548
  self.console.print(
536
549
  Rule(
537
- Text("Context Compact", style=ThemeKey.COMPACTION_SUMMARY),
550
+ Text("Context Compacted", style=ThemeKey.COMPACTION_SUMMARY),
538
551
  characters="=",
539
- style=ThemeKey.COMPACTION_SUMMARY,
552
+ style=ThemeKey.LINES,
540
553
  )
541
554
  )
542
555
  self.print()
@@ -622,20 +635,31 @@ class TUICommandRenderer:
622
635
  self.display_command_output(event)
623
636
  case RenderTurnStart(event=event):
624
637
  self.display_turn_start(event)
625
- case StartThinkingStream():
626
- if not self._thinking_stream.is_active:
638
+ case StartThinkingStream(session_id=session_id):
639
+ if self.is_sub_agent_session(session_id):
640
+ self._sub_agent_thinking_buffers[session_id] = ""
641
+ elif not self._thinking_stream.is_active:
627
642
  self._thinking_stream.start(self._new_thinking_mdstream())
628
- case AppendThinking(content=content):
629
- if self._thinking_stream.is_active:
643
+ case AppendThinking(session_id=session_id, content=content):
644
+ if self.is_sub_agent_session(session_id):
645
+ if session_id in self._sub_agent_thinking_buffers:
646
+ self._sub_agent_thinking_buffers[session_id] += content
647
+ elif self._thinking_stream.is_active:
630
648
  first_delta = self._thinking_stream.buffer == ""
631
649
  self._thinking_stream.append(content)
632
650
  if first_delta:
633
651
  self._thinking_stream.render(transform=c_thinking.normalize_thinking_content)
634
652
  self._flush_thinking()
635
- case EndThinkingStream():
636
- finalized = self._thinking_stream.finalize(transform=c_thinking.normalize_thinking_content)
637
- if finalized:
638
- self.print()
653
+ case EndThinkingStream(session_id=session_id):
654
+ if self.is_sub_agent_session(session_id):
655
+ buf = self._sub_agent_thinking_buffers.pop(session_id, "")
656
+ if buf.strip():
657
+ with self.session_print_context(session_id):
658
+ self._render_sub_agent_thinking(buf)
659
+ else:
660
+ finalized = self._thinking_stream.finalize(transform=c_thinking.normalize_thinking_content)
661
+ if finalized:
662
+ self.print()
639
663
  case StartAssistantStream():
640
664
  if not self._assistant_stream.is_active:
641
665
  self._assistant_stream.start(self._new_assistant_mdstream())