klaude-code 2.9.0__py3-none-any.whl → 2.10.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 (69) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/antigravity/oauth.py +33 -29
  3. klaude_code/auth/claude/oauth.py +34 -49
  4. klaude_code/cli/cost_cmd.py +4 -4
  5. klaude_code/cli/list_model.py +1 -2
  6. klaude_code/config/assets/builtin_config.yaml +17 -0
  7. klaude_code/const.py +4 -3
  8. klaude_code/core/agent_profile.py +2 -5
  9. klaude_code/core/bash_mode.py +276 -0
  10. klaude_code/core/executor.py +40 -7
  11. klaude_code/core/manager/llm_clients.py +1 -0
  12. klaude_code/core/manager/llm_clients_builder.py +2 -2
  13. klaude_code/core/memory.py +140 -0
  14. klaude_code/core/reminders.py +17 -89
  15. klaude_code/core/task.py +1 -1
  16. klaude_code/core/tool/file/read_tool.py +13 -2
  17. klaude_code/core/tool/shell/bash_tool.py +1 -1
  18. klaude_code/core/turn.py +10 -4
  19. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  20. klaude_code/llm/input_common.py +18 -0
  21. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  22. klaude_code/llm/{codex → openai_codex}/client.py +3 -3
  23. klaude_code/llm/openai_compatible/client.py +3 -1
  24. klaude_code/llm/openai_compatible/stream.py +19 -9
  25. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  26. klaude_code/llm/registry.py +3 -3
  27. klaude_code/llm/stream_parts.py +3 -1
  28. klaude_code/llm/usage.py +1 -1
  29. klaude_code/protocol/events.py +17 -1
  30. klaude_code/protocol/message.py +1 -0
  31. klaude_code/protocol/model.py +14 -1
  32. klaude_code/protocol/op.py +12 -0
  33. klaude_code/protocol/op_handler.py +5 -0
  34. klaude_code/session/session.py +22 -1
  35. klaude_code/tui/command/resume_cmd.py +1 -1
  36. klaude_code/tui/commands.py +15 -0
  37. klaude_code/tui/components/bash_syntax.py +4 -0
  38. klaude_code/tui/components/command_output.py +4 -5
  39. klaude_code/tui/components/developer.py +1 -3
  40. klaude_code/tui/components/diffs.py +3 -2
  41. klaude_code/tui/components/metadata.py +23 -26
  42. klaude_code/tui/components/rich/code_panel.py +31 -16
  43. klaude_code/tui/components/rich/markdown.py +44 -28
  44. klaude_code/tui/components/rich/status.py +2 -2
  45. klaude_code/tui/components/rich/theme.py +28 -16
  46. klaude_code/tui/components/tools.py +23 -0
  47. klaude_code/tui/components/user_input.py +49 -58
  48. klaude_code/tui/components/welcome.py +47 -2
  49. klaude_code/tui/display.py +15 -7
  50. klaude_code/tui/input/completers.py +8 -0
  51. klaude_code/tui/input/key_bindings.py +37 -1
  52. klaude_code/tui/input/prompt_toolkit.py +58 -31
  53. klaude_code/tui/machine.py +87 -49
  54. klaude_code/tui/renderer.py +148 -30
  55. klaude_code/tui/runner.py +22 -0
  56. klaude_code/tui/terminal/image.py +24 -3
  57. klaude_code/tui/terminal/notifier.py +11 -12
  58. klaude_code/tui/terminal/selector.py +1 -1
  59. klaude_code/ui/terminal/title.py +4 -2
  60. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
  61. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/RECORD +67 -66
  62. klaude_code/llm/bedrock/__init__.py +0 -3
  63. klaude_code/tui/components/assistant.py +0 -2
  64. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  65. /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
  66. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  67. /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
  68. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
  69. {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
@@ -18,6 +18,7 @@ from rich.text import Text
18
18
 
19
19
  from klaude_code.const import (
20
20
  MARKDOWN_LEFT_MARGIN,
21
+ MARKDOWN_RIGHT_MARGIN,
21
22
  MARKDOWN_STREAM_LIVE_REPAINT_ENABLED,
22
23
  STATUS_DEFAULT_TEXT,
23
24
  STREAM_MAX_HEIGHT_SHRINK_RESET_LINES,
@@ -25,6 +26,7 @@ from klaude_code.const import (
25
26
  from klaude_code.protocol import events, model, tools
26
27
  from klaude_code.tui.commands import (
27
28
  AppendAssistant,
29
+ AppendBashCommandOutput,
28
30
  AppendThinking,
29
31
  EmitOsc94Error,
30
32
  EmitTmuxSignal,
@@ -33,6 +35,8 @@ from klaude_code.tui.commands import (
33
35
  PrintBlankLine,
34
36
  PrintRuleLine,
35
37
  RenderAssistantImage,
38
+ RenderBashCommandEnd,
39
+ RenderBashCommandStart,
36
40
  RenderCommand,
37
41
  RenderCommandOutput,
38
42
  RenderCompactionSummary,
@@ -56,7 +60,6 @@ from klaude_code.tui.commands import (
56
60
  TaskClockClear,
57
61
  TaskClockStart,
58
62
  )
59
- from klaude_code.tui.components import assistant as c_assistant
60
63
  from klaude_code.tui.components import command_output as c_command_output
61
64
  from klaude_code.tui.components import developer as c_developer
62
65
  from klaude_code.tui.components import errors as c_errors
@@ -67,7 +70,7 @@ from klaude_code.tui.components import thinking as c_thinking
67
70
  from klaude_code.tui.components import tools as c_tools
68
71
  from klaude_code.tui.components import user_input as c_user_input
69
72
  from klaude_code.tui.components import welcome as c_welcome
70
- from klaude_code.tui.components.common import truncate_head
73
+ from klaude_code.tui.components.common import create_grid, truncate_head
71
74
  from klaude_code.tui.components.rich import status as r_status
72
75
  from klaude_code.tui.components.rich.live import CropAboveLive, SingleLine
73
76
  from klaude_code.tui.components.rich.markdown import MarkdownStream, NoInsetMarkdown, ThinkingMarkdown
@@ -165,9 +168,28 @@ class TUICommandRenderer:
165
168
  self._assistant_stream = _StreamState()
166
169
  self._thinking_stream = _StreamState()
167
170
 
171
+ # Replay mode reuses the same event/state machine but does not need streaming UI.
172
+ # When enabled, we avoid bottom Live rendering and defer markdown rendering until
173
+ # the corresponding stream End event.
174
+ self._replay_mode: bool = False
175
+
176
+ self._bash_stream_active: bool = False
177
+ self._bash_last_char_was_newline: bool = True
178
+
168
179
  self._sessions: dict[str, _SessionStatus] = {}
169
180
  self._current_sub_agent_color: Style | None = None
170
181
  self._sub_agent_color_index = 0
182
+ self._sub_agent_thinking_buffers: dict[str, str] = {}
183
+
184
+ def set_replay_mode(self, enabled: bool) -> None:
185
+ """Enable or disable replay rendering mode.
186
+
187
+ Replay mode is optimized for speed and stability:
188
+ - Avoid Rich Live / bottom status rendering.
189
+ - Defer markdown stream rendering until End events.
190
+ """
191
+
192
+ self._replay_mode = enabled
171
193
 
172
194
  # ---------------------------------------------------------------------
173
195
  # Session helpers
@@ -303,7 +325,9 @@ class TUICommandRenderer:
303
325
 
304
326
  def _bottom_renderable(self) -> RenderableType:
305
327
  stream_part: RenderableType = Group()
306
- gap_part: RenderableType = Group()
328
+ # Keep a visible separation between the bottom status line (spinner)
329
+ # and the main terminal output.
330
+ gap_part: RenderableType = Text(" ") if (self._spinner_visible and self._bash_stream_active) else Group()
307
331
 
308
332
  if MARKDOWN_STREAM_LIVE_REPAINT_ENABLED:
309
333
  stream = self._stream_renderable
@@ -325,8 +349,7 @@ class TUICommandRenderer:
325
349
  if pad_lines:
326
350
  stream = Padding(stream, (0, 0, pad_lines, 0))
327
351
  stream_part = stream
328
-
329
- gap_part = Text("") if self._spinner_visible else Group()
352
+ gap_part = Text(" ") if (self._spinner_visible and self._bash_stream_active) else Group()
330
353
 
331
354
  status_part: RenderableType = SingleLine(self._status_spinner) if self._spinner_visible else Group()
332
355
  return Group(stream_part, gap_part, status_part)
@@ -361,17 +384,19 @@ class TUICommandRenderer:
361
384
  mark=c_thinking.THINKING_MESSAGE_MARK,
362
385
  mark_style=ThemeKey.THINKING,
363
386
  left_margin=MARKDOWN_LEFT_MARGIN,
387
+ right_margin=MARKDOWN_RIGHT_MARGIN,
364
388
  markdown_class=ThinkingMarkdown,
365
389
  )
366
390
 
367
391
  def _new_assistant_mdstream(self) -> MarkdownStream:
392
+ live_sink = None if self._replay_mode else self.set_stream_renderable
368
393
  return MarkdownStream(
369
394
  mdargs={"code_theme": self.themes.code_theme},
370
395
  theme=self.themes.markdown_theme,
371
396
  console=self.console,
372
- live_sink=self.set_stream_renderable,
373
- mark=c_assistant.ASSISTANT_MESSAGE_MARK,
397
+ live_sink=live_sink,
374
398
  left_margin=MARKDOWN_LEFT_MARGIN,
399
+ right_margin=MARKDOWN_RIGHT_MARGIN,
375
400
  image_callback=self.display_image,
376
401
  )
377
402
 
@@ -381,6 +406,19 @@ class TUICommandRenderer:
381
406
  def _flush_assistant(self) -> None:
382
407
  self._assistant_stream.render()
383
408
 
409
+ def _render_sub_agent_thinking(self, content: str) -> None:
410
+ """Render sub-agent thinking content as a single block."""
411
+ normalized = c_thinking.normalize_thinking_content(content)
412
+ if not normalized.strip():
413
+ return
414
+ md = ThinkingMarkdown(normalized, code_theme=self.themes.code_theme, style=ThemeKey.THINKING)
415
+ self.console.push_theme(self.themes.thinking_markdown_theme)
416
+ grid = create_grid()
417
+ grid.add_row(Text(c_thinking.THINKING_MESSAGE_MARK, style=ThemeKey.THINKING), md)
418
+ self.print(grid)
419
+ self.console.pop_theme()
420
+ self.print()
421
+
384
422
  # ---------------------------------------------------------------------
385
423
  # Event-specific rendering helpers
386
424
  # ---------------------------------------------------------------------
@@ -447,6 +485,66 @@ class TUICommandRenderer:
447
485
  self.print(c_command_output.render_command_output(e))
448
486
  self.print()
449
487
 
488
+ def display_bash_command_start(self, e: events.BashCommandStartEvent) -> None:
489
+ # The user input line already shows `!cmd`; bash output is streamed as it arrives.
490
+ # We keep minimal rendering here to avoid adding noise.
491
+ self._bash_stream_active = True
492
+ self._bash_last_char_was_newline = True
493
+ if self._spinner_visible:
494
+ self._refresh_bottom_live()
495
+
496
+ def display_bash_command_delta(self, e: events.BashCommandOutputDeltaEvent) -> None:
497
+ if not self._bash_stream_active:
498
+ self._bash_stream_active = True
499
+ if self._spinner_visible:
500
+ self._refresh_bottom_live()
501
+
502
+ content = e.content
503
+ if content == "":
504
+ return
505
+
506
+ # Rich Live refreshes periodically (even when the renderable doesn't change).
507
+ # If we print bash output without a trailing newline while Live is active,
508
+ # the next refresh can overwrite the partial line.
509
+ #
510
+ # To keep streamed bash output stable, temporarily stop the bottom Live
511
+ # during the print, and only resume it once the output is back at a
512
+ # line boundary (i.e. chunk ends with "\n").
513
+ if self._bottom_live is not None:
514
+ with contextlib.suppress(Exception):
515
+ self._bottom_live.stop()
516
+ self._bottom_live = None
517
+
518
+ try:
519
+ # Do not use Renderer.print() here because it forces overflow="ellipsis",
520
+ # which would truncate long command output lines.
521
+ self.console.print(Text(content, style=ThemeKey.TOOL_RESULT), end="", overflow="ignore")
522
+ self._bash_last_char_was_newline = content.endswith("\n")
523
+ finally:
524
+ # Resume the bottom Live only when we're not in the middle of a line,
525
+ # otherwise periodic refresh can clobber the partial line.
526
+ if self._bash_last_char_was_newline and self._spinner_visible:
527
+ self._ensure_bottom_live_started()
528
+ self._refresh_bottom_live()
529
+
530
+ def display_bash_command_end(self, e: events.BashCommandEndEvent) -> None:
531
+ # Stop the bottom Live before finalizing bash output to prevent a refresh
532
+ # from interfering with the final line(s) written to stdout.
533
+ if self._bottom_live is not None:
534
+ with contextlib.suppress(Exception):
535
+ self._bottom_live.stop()
536
+ self._bottom_live = None
537
+
538
+ # Leave a blank line before the next prompt:
539
+ # - If the command output already ended with a newline, print one more "\n".
540
+ # - Otherwise, print "\n\n" to end the line and add one empty line.
541
+ if self._bash_stream_active:
542
+ sep = "\n" if self._bash_last_char_was_newline else "\n\n"
543
+ self.console.print(Text(sep), end="", overflow="ignore")
544
+
545
+ self._bash_stream_active = False
546
+ self._bash_last_char_was_newline = True
547
+
450
548
  def display_welcome(self, event: events.WelcomeEvent) -> None:
451
549
  self.print(c_welcome.render_welcome(event))
452
550
 
@@ -494,7 +592,6 @@ class TUICommandRenderer:
494
592
  if self.is_sub_agent_session(event.session_id):
495
593
  return
496
594
  self.print(c_metadata.render_task_metadata(event))
497
- self.print()
498
595
 
499
596
  def display_task_finish(self, event: events.TaskFinishEvent) -> None:
500
597
  if self.is_sub_agent_session(event.session_id):
@@ -534,9 +631,9 @@ class TUICommandRenderer:
534
631
  )
535
632
  self.console.print(
536
633
  Rule(
537
- Text("Context Compact", style=ThemeKey.COMPACTION_SUMMARY),
634
+ Text("Context Compacted", style=ThemeKey.COMPACTION_SUMMARY),
538
635
  characters="=",
539
- style=ThemeKey.COMPACTION_SUMMARY,
636
+ style=ThemeKey.LINES,
540
637
  )
541
638
  )
542
639
  self.print()
@@ -620,35 +717,56 @@ class TUICommandRenderer:
620
717
  self.display_developer_message(event)
621
718
  case RenderCommandOutput(event=event):
622
719
  self.display_command_output(event)
720
+ case RenderBashCommandStart(event=event):
721
+ self.display_bash_command_start(event)
722
+ case AppendBashCommandOutput(event=event):
723
+ self.display_bash_command_delta(event)
724
+ case RenderBashCommandEnd(event=event):
725
+ self.display_bash_command_end(event)
623
726
  case RenderTurnStart(event=event):
624
727
  self.display_turn_start(event)
625
- case StartThinkingStream():
626
- if not self._thinking_stream.is_active:
728
+ case StartThinkingStream(session_id=session_id):
729
+ if self.is_sub_agent_session(session_id):
730
+ self._sub_agent_thinking_buffers[session_id] = ""
731
+ elif not self._thinking_stream.is_active:
627
732
  self._thinking_stream.start(self._new_thinking_mdstream())
628
- case AppendThinking(content=content):
629
- if self._thinking_stream.is_active:
630
- first_delta = self._thinking_stream.buffer == ""
733
+ case AppendThinking(session_id=session_id, content=content):
734
+ if self.is_sub_agent_session(session_id):
735
+ if session_id in self._sub_agent_thinking_buffers:
736
+ self._sub_agent_thinking_buffers[session_id] += content
737
+ elif self._thinking_stream.is_active:
631
738
  self._thinking_stream.append(content)
632
- if first_delta:
633
- self._thinking_stream.render(transform=c_thinking.normalize_thinking_content)
634
- self._flush_thinking()
635
- case EndThinkingStream():
636
- finalized = self._thinking_stream.finalize(transform=c_thinking.normalize_thinking_content)
637
- if finalized:
638
- self.print()
639
- case StartAssistantStream():
739
+ if not self._replay_mode:
740
+ first_delta = self._thinking_stream.buffer == ""
741
+ if first_delta:
742
+ self._thinking_stream.render(transform=c_thinking.normalize_thinking_content)
743
+ self._flush_thinking()
744
+ case EndThinkingStream(session_id=session_id):
745
+ if self.is_sub_agent_session(session_id):
746
+ buf = self._sub_agent_thinking_buffers.pop(session_id, "")
747
+ if buf.strip():
748
+ with self.session_print_context(session_id):
749
+ self._render_sub_agent_thinking(buf)
750
+ else:
751
+ had_content = bool(self._thinking_stream.buffer.strip())
752
+ finalized = self._thinking_stream.finalize(transform=c_thinking.normalize_thinking_content)
753
+ if finalized and had_content:
754
+ self.print()
755
+ case StartAssistantStream(session_id=_):
640
756
  if not self._assistant_stream.is_active:
641
757
  self._assistant_stream.start(self._new_assistant_mdstream())
642
- case AppendAssistant(content=content):
758
+ case AppendAssistant(session_id=_, content=content):
643
759
  if self._assistant_stream.is_active:
644
- first_delta = self._assistant_stream.buffer == ""
645
760
  self._assistant_stream.append(content)
646
- if first_delta:
647
- self._assistant_stream.render()
648
- self._flush_assistant()
649
- case EndAssistantStream():
761
+ if not self._replay_mode:
762
+ first_delta = self._assistant_stream.buffer == ""
763
+ if first_delta:
764
+ self._assistant_stream.render()
765
+ self._flush_assistant()
766
+ case EndAssistantStream(session_id=_):
767
+ had_content = bool(self._assistant_stream.buffer.strip())
650
768
  finalized = self._assistant_stream.finalize()
651
- if finalized:
769
+ if finalized and had_content:
652
770
  self.print()
653
771
  case RenderThinkingHeader(session_id=session_id, header=header):
654
772
  with self.session_print_context(session_id):
klaude_code/tui/runner.py CHANGED
@@ -65,11 +65,27 @@ async def submit_user_input_payload(
65
65
 
66
66
  submission_id = uuid4().hex
67
67
 
68
+ # Normalize a leading full-width exclamation mark for consistent UI/history.
69
+ # (Bash mode is triggered only when the first character is `!`.)
70
+ text = user_input.text
71
+ if text.startswith("!"):
72
+ text = "!" + text[1:]
73
+ user_input = UserInputPayload(text=text, images=user_input.images)
74
+
68
75
  # Render the raw user input in the TUI even when it resolves to an event-only command.
69
76
  await executor.context.emit_event(
70
77
  events.UserMessageEvent(content=user_input.text, session_id=sid, images=user_input.images)
71
78
  )
72
79
 
80
+ # Bash mode: run a user-entered command without invoking the agent.
81
+ if user_input.text.startswith("!"):
82
+ command = user_input.text[1:].lstrip(" \t")
83
+ if command == "":
84
+ # Enter should be ignored in the input layer for this case; keep a guard here.
85
+ return None
86
+ bash_op = op.RunBashOperation(id=submission_id, session_id=sid, command=command)
87
+ return await executor.submit(bash_op)
88
+
73
89
  cmd_result = await dispatch_command(user_input, agent, submission_id=submission_id)
74
90
  operations: list[op.Operation] = list(cmd_result.operations or [])
75
91
 
@@ -304,6 +320,9 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
304
320
  if is_interactive:
305
321
  with _double_ctrl_c_to_exit_while_running():
306
322
  await components.executor.wait_for(wait_id)
323
+ # Ensure all trailing events (e.g. final deltas / spinner stop) are rendered
324
+ # before handing control back to prompt_toolkit.
325
+ await components.event_queue.join()
307
326
  continue
308
327
 
309
328
  async def _on_esc_interrupt() -> None:
@@ -313,6 +332,9 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
313
332
  try:
314
333
  with _double_ctrl_c_to_exit_while_running():
315
334
  await components.executor.wait_for(wait_id)
335
+ # Ensure all trailing events (e.g. final deltas / spinner stop) are rendered
336
+ # before handing control back to prompt_toolkit.
337
+ await components.event_queue.join()
316
338
  finally:
317
339
  stop_event.set()
318
340
  with contextlib.suppress(Exception):
@@ -17,6 +17,17 @@ _MAX_COLS = 80
17
17
  # Image formats that need conversion to PNG
18
18
  _NEEDS_CONVERSION = {".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
19
19
 
20
+ # Approximate pixels per terminal column (typical for most terminals)
21
+ _PIXELS_PER_COL = 9
22
+
23
+
24
+ def _get_png_width(data: bytes) -> int | None:
25
+ """Extract width from PNG header (IHDR chunk)."""
26
+ # PNG signature (8 bytes) + IHDR length (4 bytes) + "IHDR" (4 bytes) + width (4 bytes)
27
+ if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
28
+ return None
29
+ return int.from_bytes(data[16:20], "big")
30
+
20
31
 
21
32
  def _convert_to_png(path: Path) -> bytes | None:
22
33
  """Convert image to PNG using sips (macOS) or convert (ImageMagick)."""
@@ -63,9 +74,18 @@ def print_kitty_image(file_path: str | Path, *, file: IO[str] | None = None) ->
63
74
  out = file or sys.stdout
64
75
 
65
76
  term_size = shutil.get_terminal_size()
66
- # Only specify columns, let Kitty auto-scale height to preserve aspect ratio
67
77
  target_cols = min(_MAX_COLS, term_size.columns)
68
- size_param = f"c={target_cols}"
78
+
79
+ # Only set column width if image is wider than target, to avoid upscaling small images
80
+ size_param = ""
81
+ img_width = _get_png_width(data)
82
+ if img_width is not None:
83
+ img_cols = img_width // _PIXELS_PER_COL
84
+ if img_cols > target_cols:
85
+ size_param = f"c={target_cols}"
86
+ else:
87
+ # Fallback: always constrain if we can't determine image size
88
+ size_param = f"c={target_cols}"
69
89
  print("", file=out)
70
90
  _write_kitty_graphics(out, encoded, size_param=size_param)
71
91
  print("", file=out)
@@ -92,7 +112,8 @@ def _write_kitty_graphics(out: IO[str], encoded_data: str, *, size_param: str) -
92
112
 
93
113
  if i == 0:
94
114
  # First chunk: include control parameters
95
- ctrl = f"a=T,f=100,{size_param},m={0 if is_last else 1}"
115
+ base_ctrl = f"a=T,f=100,{size_param}" if size_param else "a=T,f=100"
116
+ ctrl = f"{base_ctrl},m={0 if is_last else 1}"
96
117
  out.write(f"\033_G{ctrl};{chunk}\033\\")
97
118
  else:
98
119
  # Subsequent chunks: only m parameter needed
@@ -64,9 +64,9 @@ class TerminalNotifier:
64
64
  return False
65
65
 
66
66
  output = resolve_stream(self.config.stream)
67
- if not self._supports_osc9(output):
67
+ if not self._supports_notification(output):
68
68
  log_debug(
69
- "Terminal notifier skipped: OSC 9 unsupported or not a TTY",
69
+ "Terminal notifier skipped: not a TTY",
70
70
  debug_type=DebugType.TERMINAL,
71
71
  )
72
72
  return False
@@ -74,27 +74,26 @@ class TerminalNotifier:
74
74
  payload = self._render_payload(notification)
75
75
  return self._emit(payload, output)
76
76
 
77
- def _render_payload(self, notification: Notification) -> str:
78
- title = _compact(notification.title)
79
- body = _compact(notification.body) if notification.body else None
80
- if body:
81
- return f"{title} - {body}"
82
- return title
77
+ def _render_payload(self, notification: Notification) -> tuple[str, str]:
78
+ """Return (title, body) for OSC 777 notification."""
79
+ body = _compact(notification.body) if notification.body else _compact(notification.title)
80
+ return ("klaude", body)
83
81
 
84
- def _emit(self, payload: str, output: TextIO) -> bool:
82
+ def _emit(self, payload: tuple[str, str], output: TextIO) -> bool:
85
83
  terminator = BEL if self.config.use_bel else ST
86
- seq = f"\033]9;{payload}{terminator}"
84
+ title, body = payload
85
+ seq = f"\033]777;notify;{title};{body}{terminator}"
87
86
  try:
88
87
  output.write(seq)
89
88
  output.flush()
90
- log_debug("Terminal notifier sent OSC 9 payload", debug_type=DebugType.TERMINAL)
89
+ log_debug("Terminal notifier sent OSC 777 payload", debug_type=DebugType.TERMINAL)
91
90
  return True
92
91
  except Exception as exc:
93
92
  log_debug(f"Terminal notifier send failed: {exc}", debug_type=DebugType.TERMINAL)
94
93
  return False
95
94
 
96
95
  @staticmethod
97
- def _supports_osc9(stream: TextIO) -> bool:
96
+ def _supports_notification(stream: TextIO) -> bool:
98
97
  if sys.platform == "win32":
99
98
  return False
100
99
  if not getattr(stream, "isatty", lambda: False)():
@@ -111,7 +111,7 @@ def build_model_select_items(models: list[Any]) -> list[SelectItem[str]]:
111
111
  meta_str = " · ".join(meta_parts) if meta_parts else ""
112
112
  title: list[tuple[str, str]] = [
113
113
  ("class:meta", f"{model_idx:>{num_width}}. "),
114
- ("class:msg bold", first_line_prefix),
114
+ ("class:msg", first_line_prefix),
115
115
  ("class:msg dim", " → "),
116
116
  # Keep provider/model_id styling attribute-based (dim/bold) so that
117
117
  # the selector's highlight color can still override uniformly.
@@ -26,6 +26,8 @@ def update_terminal_title(model_name: str | None = None) -> None:
26
26
  """Update terminal title with folder name and optional model name."""
27
27
  folder_name = os.path.basename(os.getcwd())
28
28
  if model_name:
29
- set_terminal_title(f"{folder_name}: klaude {model_name}")
29
+ # Strip provider suffix (e.g., opus@openrouter -> opus)
30
+ model_alias = model_name.split("@")[0]
31
+ set_terminal_title(f"klaude [{model_alias}] · {folder_name}")
30
32
  else:
31
- set_terminal_title(f"{folder_name}: klaude")
33
+ set_terminal_title(f"klaude · {folder_name}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: klaude-code
3
- Version: 2.9.0
3
+ Version: 2.10.0
4
4
  Summary: Minimal code agent CLI
5
5
  Requires-Dist: anthropic>=0.66.0
6
6
  Requires-Dist: chardet>=5.2.0