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.
- klaude_code/app/runtime.py +1 -1
- klaude_code/auth/antigravity/oauth.py +33 -29
- klaude_code/auth/claude/oauth.py +34 -49
- klaude_code/cli/cost_cmd.py +4 -4
- klaude_code/cli/list_model.py +1 -2
- klaude_code/config/assets/builtin_config.yaml +17 -0
- klaude_code/const.py +4 -3
- klaude_code/core/agent_profile.py +2 -5
- klaude_code/core/bash_mode.py +276 -0
- klaude_code/core/executor.py +40 -7
- klaude_code/core/manager/llm_clients.py +1 -0
- klaude_code/core/manager/llm_clients_builder.py +2 -2
- klaude_code/core/memory.py +140 -0
- klaude_code/core/reminders.py +17 -89
- klaude_code/core/task.py +1 -1
- klaude_code/core/tool/file/read_tool.py +13 -2
- klaude_code/core/tool/shell/bash_tool.py +1 -1
- klaude_code/core/turn.py +10 -4
- klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
- klaude_code/llm/input_common.py +18 -0
- klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
- klaude_code/llm/{codex → openai_codex}/client.py +3 -3
- klaude_code/llm/openai_compatible/client.py +3 -1
- klaude_code/llm/openai_compatible/stream.py +19 -9
- klaude_code/llm/{responses → openai_responses}/client.py +1 -1
- klaude_code/llm/registry.py +3 -3
- klaude_code/llm/stream_parts.py +3 -1
- klaude_code/llm/usage.py +1 -1
- klaude_code/protocol/events.py +17 -1
- klaude_code/protocol/message.py +1 -0
- klaude_code/protocol/model.py +14 -1
- klaude_code/protocol/op.py +12 -0
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/session.py +22 -1
- klaude_code/tui/command/resume_cmd.py +1 -1
- klaude_code/tui/commands.py +15 -0
- klaude_code/tui/components/bash_syntax.py +4 -0
- klaude_code/tui/components/command_output.py +4 -5
- klaude_code/tui/components/developer.py +1 -3
- klaude_code/tui/components/diffs.py +3 -2
- klaude_code/tui/components/metadata.py +23 -26
- klaude_code/tui/components/rich/code_panel.py +31 -16
- klaude_code/tui/components/rich/markdown.py +44 -28
- klaude_code/tui/components/rich/status.py +2 -2
- klaude_code/tui/components/rich/theme.py +28 -16
- klaude_code/tui/components/tools.py +23 -0
- klaude_code/tui/components/user_input.py +49 -58
- klaude_code/tui/components/welcome.py +47 -2
- klaude_code/tui/display.py +15 -7
- klaude_code/tui/input/completers.py +8 -0
- klaude_code/tui/input/key_bindings.py +37 -1
- klaude_code/tui/input/prompt_toolkit.py +58 -31
- klaude_code/tui/machine.py +87 -49
- klaude_code/tui/renderer.py +148 -30
- klaude_code/tui/runner.py +22 -0
- klaude_code/tui/terminal/image.py +24 -3
- klaude_code/tui/terminal/notifier.py +11 -12
- klaude_code/tui/terminal/selector.py +1 -1
- klaude_code/ui/terminal/title.py +4 -2
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/METADATA +1 -1
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/RECORD +67 -66
- klaude_code/llm/bedrock/__init__.py +0 -3
- klaude_code/tui/components/assistant.py +0 -2
- /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
- /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
- /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
- /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.9.0.dist-info → klaude_code-2.10.0.dist-info}/entry_points.txt +0 -0
klaude_code/tui/renderer.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
|
634
|
+
Text("Context Compacted", style=ThemeKey.COMPACTION_SUMMARY),
|
|
538
635
|
characters="=",
|
|
539
|
-
style=ThemeKey.
|
|
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
|
|
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.
|
|
630
|
-
|
|
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
|
|
633
|
-
self._thinking_stream.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
647
|
-
self._assistant_stream.
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
+
if not self._supports_notification(output):
|
|
68
68
|
log_debug(
|
|
69
|
-
"Terminal notifier skipped:
|
|
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
|
|
79
|
-
body = _compact(notification.body) if notification.body else
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
klaude_code/ui/terminal/title.py
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
33
|
+
set_terminal_title(f"klaude · {folder_name}")
|