klaude-code 2.7.0__py3-none-any.whl → 2.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/auth/AGENTS.md +325 -0
- klaude_code/auth/__init__.py +17 -1
- klaude_code/auth/antigravity/__init__.py +20 -0
- klaude_code/auth/antigravity/exceptions.py +17 -0
- klaude_code/auth/antigravity/oauth.py +320 -0
- klaude_code/auth/antigravity/pkce.py +25 -0
- klaude_code/auth/antigravity/token_manager.py +45 -0
- klaude_code/auth/base.py +4 -0
- klaude_code/auth/claude/oauth.py +29 -9
- klaude_code/auth/codex/exceptions.py +4 -0
- klaude_code/cli/auth_cmd.py +53 -3
- klaude_code/cli/cost_cmd.py +83 -160
- klaude_code/cli/list_model.py +50 -0
- klaude_code/cli/main.py +1 -1
- klaude_code/config/assets/builtin_config.yaml +108 -0
- klaude_code/config/builtin_config.py +5 -11
- klaude_code/config/config.py +24 -10
- klaude_code/const.py +1 -0
- klaude_code/core/agent.py +5 -1
- klaude_code/core/agent_profile.py +28 -32
- klaude_code/core/compaction/AGENTS.md +112 -0
- klaude_code/core/compaction/__init__.py +11 -0
- klaude_code/core/compaction/compaction.py +707 -0
- klaude_code/core/compaction/overflow.py +30 -0
- klaude_code/core/compaction/prompts.py +97 -0
- klaude_code/core/executor.py +103 -2
- klaude_code/core/manager/llm_clients.py +5 -0
- klaude_code/core/manager/llm_clients_builder.py +14 -2
- klaude_code/core/prompts/prompt-antigravity.md +80 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
- klaude_code/core/reminders.py +7 -2
- klaude_code/core/task.py +126 -0
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/turn.py +3 -1
- klaude_code/llm/antigravity/__init__.py +3 -0
- klaude_code/llm/antigravity/client.py +558 -0
- klaude_code/llm/antigravity/input.py +261 -0
- klaude_code/llm/registry.py +1 -0
- klaude_code/protocol/events.py +18 -0
- klaude_code/protocol/llm_param.py +1 -0
- klaude_code/protocol/message.py +23 -1
- klaude_code/protocol/op.py +15 -1
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/session.py +36 -0
- klaude_code/skill/assets/create-plan/SKILL.md +6 -6
- klaude_code/tui/command/__init__.py +3 -0
- klaude_code/tui/command/compact_cmd.py +32 -0
- klaude_code/tui/command/fork_session_cmd.py +110 -14
- klaude_code/tui/command/model_picker.py +5 -1
- klaude_code/tui/command/thinking_cmd.py +1 -1
- klaude_code/tui/commands.py +6 -0
- klaude_code/tui/components/rich/markdown.py +57 -1
- klaude_code/tui/components/rich/theme.py +10 -2
- klaude_code/tui/components/tools.py +39 -25
- klaude_code/tui/components/user_input.py +1 -1
- klaude_code/tui/input/__init__.py +5 -2
- klaude_code/tui/input/drag_drop.py +6 -57
- klaude_code/tui/input/key_bindings.py +10 -0
- klaude_code/tui/input/prompt_toolkit.py +19 -6
- klaude_code/tui/machine.py +25 -0
- klaude_code/tui/renderer.py +67 -4
- klaude_code/tui/runner.py +18 -2
- klaude_code/tui/terminal/image.py +72 -10
- klaude_code/tui/terminal/selector.py +31 -7
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/METADATA +1 -1
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/RECORD +68 -52
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -53,6 +53,7 @@ class REPLStatusSnapshot(NamedTuple):
|
|
|
53
53
|
"""Snapshot of REPL status for bottom toolbar display."""
|
|
54
54
|
|
|
55
55
|
update_message: str | None = None
|
|
56
|
+
debug_log_path: str | None = None
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
COMPLETION_SELECTED_DARK_BG = "ansigreen"
|
|
@@ -434,7 +435,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
434
435
|
original_height_int = original_height_value if isinstance(original_height_value, int) else None
|
|
435
436
|
|
|
436
437
|
if picker_open or completion_open:
|
|
437
|
-
target_rows =
|
|
438
|
+
target_rows = 24 if picker_open else 14
|
|
438
439
|
|
|
439
440
|
# Cap to the current terminal size.
|
|
440
441
|
# Leave a small buffer to avoid triggering "Window too small".
|
|
@@ -531,7 +532,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
531
532
|
return [], None
|
|
532
533
|
|
|
533
534
|
items: list[SelectItem[str]] = [
|
|
534
|
-
SelectItem(title=[("class:
|
|
535
|
+
SelectItem(title=[("class:msg", opt.label + "\n")], value=opt.value, search_text=opt.label)
|
|
535
536
|
for opt in data.options
|
|
536
537
|
]
|
|
537
538
|
return items, data.current_value
|
|
@@ -573,24 +574,36 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
573
574
|
doing any blocking IO here.
|
|
574
575
|
"""
|
|
575
576
|
update_message: str | None = None
|
|
577
|
+
debug_log_path: str | None = None
|
|
576
578
|
if self._status_provider is not None:
|
|
577
579
|
try:
|
|
578
580
|
status = self._status_provider()
|
|
579
581
|
update_message = status.update_message
|
|
582
|
+
debug_log_path = status.debug_log_path
|
|
580
583
|
except (AttributeError, RuntimeError):
|
|
581
|
-
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
# Priority: update_message > debug_log_path
|
|
587
|
+
display_text: str | None = None
|
|
588
|
+
text_style: str = ""
|
|
589
|
+
if update_message:
|
|
590
|
+
display_text = update_message
|
|
591
|
+
text_style = "#ansiyellow"
|
|
592
|
+
elif debug_log_path:
|
|
593
|
+
display_text = f"Debug log: {debug_log_path}"
|
|
594
|
+
text_style = "fg:ansibrightblack"
|
|
582
595
|
|
|
583
596
|
# If nothing to show, return a blank line to actively clear any previously
|
|
584
597
|
# rendered content. (When `bottom_toolbar` is a callable, prompt_toolkit
|
|
585
598
|
# will still reserve the toolbar line.)
|
|
586
|
-
if not
|
|
599
|
+
if not display_text:
|
|
587
600
|
try:
|
|
588
601
|
terminal_width = shutil.get_terminal_size().columns
|
|
589
602
|
except (OSError, ValueError):
|
|
590
603
|
terminal_width = 0
|
|
591
604
|
return FormattedText([("", " " * max(0, terminal_width))])
|
|
592
605
|
|
|
593
|
-
left_text = " " +
|
|
606
|
+
left_text = " " + display_text
|
|
594
607
|
try:
|
|
595
608
|
terminal_width = shutil.get_terminal_size().columns
|
|
596
609
|
padding = " " * max(0, terminal_width - len(left_text))
|
|
@@ -598,7 +611,7 @@ class PromptToolkitInput(InputProviderABC):
|
|
|
598
611
|
padding = ""
|
|
599
612
|
|
|
600
613
|
toolbar_text = left_text + padding
|
|
601
|
-
return FormattedText([(
|
|
614
|
+
return FormattedText([(text_style, toolbar_text)])
|
|
602
615
|
|
|
603
616
|
# -------------------------------------------------------------------------
|
|
604
617
|
# Placeholder
|
klaude_code/tui/machine.py
CHANGED
|
@@ -6,6 +6,7 @@ from rich.text import Text
|
|
|
6
6
|
|
|
7
7
|
from klaude_code.const import (
|
|
8
8
|
SIGINT_DOUBLE_PRESS_EXIT_TEXT,
|
|
9
|
+
STATUS_COMPACTING_TEXT,
|
|
9
10
|
STATUS_COMPOSING_TEXT,
|
|
10
11
|
STATUS_DEFAULT_TEXT,
|
|
11
12
|
STATUS_SHOW_BUFFER_LENGTH,
|
|
@@ -24,6 +25,7 @@ from klaude_code.tui.commands import (
|
|
|
24
25
|
RenderAssistantImage,
|
|
25
26
|
RenderCommand,
|
|
26
27
|
RenderCommandOutput,
|
|
28
|
+
RenderCompactionSummary,
|
|
27
29
|
RenderDeveloperMessage,
|
|
28
30
|
RenderError,
|
|
29
31
|
RenderInterrupt,
|
|
@@ -311,6 +313,7 @@ class _SessionState:
|
|
|
311
313
|
thinking_stream_active: bool = False
|
|
312
314
|
assistant_char_count: int = 0
|
|
313
315
|
thinking_tail: str = ""
|
|
316
|
+
task_active: bool = False
|
|
314
317
|
|
|
315
318
|
@property
|
|
316
319
|
def is_sub_agent(self) -> bool:
|
|
@@ -414,6 +417,7 @@ class DisplayStateMachine:
|
|
|
414
417
|
case events.TaskStartEvent() as e:
|
|
415
418
|
s.sub_agent_state = e.sub_agent_state
|
|
416
419
|
s.model_id = e.model_id
|
|
420
|
+
s.task_active = True
|
|
417
421
|
if not s.is_sub_agent:
|
|
418
422
|
self._set_primary_if_needed(e.session_id)
|
|
419
423
|
if not is_replay:
|
|
@@ -428,6 +432,25 @@ class DisplayStateMachine:
|
|
|
428
432
|
cmds.extend(self._spinner_update_commands())
|
|
429
433
|
return cmds
|
|
430
434
|
|
|
435
|
+
case events.CompactionStartEvent():
|
|
436
|
+
if not is_replay:
|
|
437
|
+
self._spinner.set_reasoning_status(STATUS_COMPACTING_TEXT)
|
|
438
|
+
if not s.task_active:
|
|
439
|
+
cmds.append(SpinnerStart())
|
|
440
|
+
cmds.extend(self._spinner_update_commands())
|
|
441
|
+
return cmds
|
|
442
|
+
|
|
443
|
+
case events.CompactionEndEvent() as e:
|
|
444
|
+
if not is_replay:
|
|
445
|
+
self._spinner.set_reasoning_status(None)
|
|
446
|
+
if not s.task_active:
|
|
447
|
+
cmds.append(SpinnerStop())
|
|
448
|
+
cmds.extend(self._spinner_update_commands())
|
|
449
|
+
if e.summary and not e.aborted:
|
|
450
|
+
kept_brief = tuple((item.item_type, item.count, item.preview) for item in e.kept_items_brief)
|
|
451
|
+
cmds.append(RenderCompactionSummary(summary=e.summary, kept_items_brief=kept_brief))
|
|
452
|
+
return cmds
|
|
453
|
+
|
|
431
454
|
case events.DeveloperMessageEvent() as e:
|
|
432
455
|
cmds.append(RenderDeveloperMessage(e))
|
|
433
456
|
return cmds
|
|
@@ -650,6 +673,7 @@ class DisplayStateMachine:
|
|
|
650
673
|
return []
|
|
651
674
|
|
|
652
675
|
case events.TaskFinishEvent() as e:
|
|
676
|
+
s.task_active = False
|
|
653
677
|
cmds.append(RenderTaskFinish(e))
|
|
654
678
|
if not s.is_sub_agent:
|
|
655
679
|
if not is_replay:
|
|
@@ -666,6 +690,7 @@ class DisplayStateMachine:
|
|
|
666
690
|
if not is_replay:
|
|
667
691
|
self._spinner.reset()
|
|
668
692
|
cmds.append(SpinnerStop())
|
|
693
|
+
s.task_active = False
|
|
669
694
|
cmds.append(EndThinkingStream(session_id=e.session_id))
|
|
670
695
|
cmds.append(EndAssistantStream(session_id=e.session_id))
|
|
671
696
|
if not is_replay:
|
klaude_code/tui/renderer.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
import shutil
|
|
4
5
|
from collections.abc import Callable, Iterator
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
10
|
+
from rich import box
|
|
9
11
|
from rich.console import Console, Group, RenderableType
|
|
10
12
|
from rich.padding import Padding
|
|
13
|
+
from rich.panel import Panel
|
|
11
14
|
from rich.rule import Rule
|
|
12
15
|
from rich.spinner import Spinner
|
|
13
16
|
from rich.style import Style, StyleType
|
|
@@ -32,6 +35,7 @@ from klaude_code.tui.commands import (
|
|
|
32
35
|
RenderAssistantImage,
|
|
33
36
|
RenderCommand,
|
|
34
37
|
RenderCommandOutput,
|
|
38
|
+
RenderCompactionSummary,
|
|
35
39
|
RenderDeveloperMessage,
|
|
36
40
|
RenderError,
|
|
37
41
|
RenderInterrupt,
|
|
@@ -66,7 +70,7 @@ from klaude_code.tui.components import welcome as c_welcome
|
|
|
66
70
|
from klaude_code.tui.components.common import truncate_head
|
|
67
71
|
from klaude_code.tui.components.rich import status as r_status
|
|
68
72
|
from klaude_code.tui.components.rich.live import CropAboveLive, SingleLine
|
|
69
|
-
from klaude_code.tui.components.rich.markdown import MarkdownStream, ThinkingMarkdown
|
|
73
|
+
from klaude_code.tui.components.rich.markdown import MarkdownStream, NoInsetMarkdown, ThinkingMarkdown
|
|
70
74
|
from klaude_code.tui.components.rich.quote import Quote
|
|
71
75
|
from klaude_code.tui.components.rich.status import BreathingSpinner, ShimmerStatusText
|
|
72
76
|
from klaude_code.tui.components.rich.theme import ThemeKey, get_theme
|
|
@@ -410,7 +414,7 @@ class TUICommandRenderer:
|
|
|
410
414
|
session_id=e.session_id,
|
|
411
415
|
)
|
|
412
416
|
if image_path is not None:
|
|
413
|
-
self.display_image(str(image_path)
|
|
417
|
+
self.display_image(str(image_path))
|
|
414
418
|
|
|
415
419
|
renderable = c_tools.render_tool_result(e, code_theme=self.themes.code_theme, session_id=e.session_id)
|
|
416
420
|
if renderable is not None:
|
|
@@ -472,7 +476,7 @@ class TUICommandRenderer:
|
|
|
472
476
|
if not self.is_sub_agent_session(event.session_id):
|
|
473
477
|
self.print()
|
|
474
478
|
|
|
475
|
-
def display_image(self, file_path: str
|
|
479
|
+
def display_image(self, file_path: str) -> None:
|
|
476
480
|
# Suspend the Live status bar while emitting raw terminal output.
|
|
477
481
|
had_live = self._bottom_live is not None
|
|
478
482
|
was_spinner_visible = self._spinner_visible
|
|
@@ -485,7 +489,7 @@ class TUICommandRenderer:
|
|
|
485
489
|
self._bottom_live = None
|
|
486
490
|
|
|
487
491
|
try:
|
|
488
|
-
print_kitty_image(file_path,
|
|
492
|
+
print_kitty_image(file_path, file=self.console.file)
|
|
489
493
|
finally:
|
|
490
494
|
if resume_live:
|
|
491
495
|
if was_spinner_visible:
|
|
@@ -524,6 +528,63 @@ class TUICommandRenderer:
|
|
|
524
528
|
else:
|
|
525
529
|
self.print(c_errors.render_error(Text(event.error_message)))
|
|
526
530
|
|
|
531
|
+
def display_compaction_summary(self, summary: str, kept_items_brief: tuple[tuple[str, int, str], ...] = ()) -> None:
|
|
532
|
+
stripped = summary.strip()
|
|
533
|
+
if not stripped:
|
|
534
|
+
return
|
|
535
|
+
stripped = (
|
|
536
|
+
stripped.replace("<summary>", "")
|
|
537
|
+
.replace("</summary>", "")
|
|
538
|
+
.replace("<read_files>", "")
|
|
539
|
+
.replace("</read_files>", "")
|
|
540
|
+
.replace("<modified-files>", "")
|
|
541
|
+
.replace("</modified-files>", "")
|
|
542
|
+
)
|
|
543
|
+
self.console.print(
|
|
544
|
+
Rule(
|
|
545
|
+
Text("Context Compact", style=ThemeKey.COMPACTION_SUMMARY),
|
|
546
|
+
characters="=",
|
|
547
|
+
style=ThemeKey.COMPACTION_SUMMARY,
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
self.print()
|
|
551
|
+
|
|
552
|
+
# Limit panel width to min(100, terminal_width) minus left indent (2)
|
|
553
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
554
|
+
panel_width = min(100, terminal_width) - 2
|
|
555
|
+
|
|
556
|
+
self.console.push_theme(self.themes.markdown_theme)
|
|
557
|
+
panel = Panel(
|
|
558
|
+
NoInsetMarkdown(stripped, code_theme=self.themes.code_theme, style=ThemeKey.COMPACTION_SUMMARY),
|
|
559
|
+
box=box.SIMPLE,
|
|
560
|
+
border_style=ThemeKey.LINES,
|
|
561
|
+
style=ThemeKey.COMPACTION_SUMMARY_PANEL,
|
|
562
|
+
width=panel_width,
|
|
563
|
+
)
|
|
564
|
+
self.print(Padding(panel, (0, 0, 0, MARKDOWN_LEFT_MARGIN)))
|
|
565
|
+
self.console.pop_theme()
|
|
566
|
+
|
|
567
|
+
if kept_items_brief:
|
|
568
|
+
# Collect tool call counts (skip User/Assistant entries)
|
|
569
|
+
tool_counts: dict[str, int] = {}
|
|
570
|
+
for item_type, count, _ in kept_items_brief:
|
|
571
|
+
if item_type not in ("User", "Assistant"):
|
|
572
|
+
tool_counts[item_type] = tool_counts.get(item_type, 0) + count
|
|
573
|
+
|
|
574
|
+
if tool_counts:
|
|
575
|
+
parts: list[str] = []
|
|
576
|
+
for tool_type, tool_count in tool_counts.items():
|
|
577
|
+
if tool_count > 1:
|
|
578
|
+
parts.append(f"{tool_type} x {tool_count}")
|
|
579
|
+
else:
|
|
580
|
+
parts.append(tool_type)
|
|
581
|
+
line = Text()
|
|
582
|
+
line.append("\n Kept uncompacted: ", style=ThemeKey.COMPACTION_SUMMARY)
|
|
583
|
+
line.append(", ".join(parts), style=ThemeKey.COMPACTION_SUMMARY)
|
|
584
|
+
self.print(line)
|
|
585
|
+
|
|
586
|
+
self.print()
|
|
587
|
+
|
|
527
588
|
# ---------------------------------------------------------------------
|
|
528
589
|
# Notifications
|
|
529
590
|
# ---------------------------------------------------------------------
|
|
@@ -617,6 +678,8 @@ class TUICommandRenderer:
|
|
|
617
678
|
self.display_interrupt()
|
|
618
679
|
case RenderError(event=event):
|
|
619
680
|
self.display_error(event)
|
|
681
|
+
case RenderCompactionSummary(summary=summary, kept_items_brief=kept_items_brief):
|
|
682
|
+
self.display_compaction_summary(summary, kept_items_brief)
|
|
620
683
|
case SpinnerStart():
|
|
621
684
|
self.spinner_start()
|
|
622
685
|
case SpinnerStop():
|
klaude_code/tui/runner.py
CHANGED
|
@@ -16,8 +16,9 @@ from klaude_code.app.runtime import (
|
|
|
16
16
|
)
|
|
17
17
|
from klaude_code.config import load_config
|
|
18
18
|
from klaude_code.const import SIGINT_DOUBLE_PRESS_EXIT_TEXT
|
|
19
|
+
from klaude_code.core.compaction import should_compact_threshold
|
|
19
20
|
from klaude_code.core.executor import Executor
|
|
20
|
-
from klaude_code.log import log
|
|
21
|
+
from klaude_code.log import get_current_log_file, log
|
|
21
22
|
from klaude_code.protocol import events, llm_param, op
|
|
22
23
|
from klaude_code.protocol.message import UserInputPayload
|
|
23
24
|
from klaude_code.session.session import Session
|
|
@@ -80,6 +81,19 @@ async def submit_user_input_payload(
|
|
|
80
81
|
for evt in cmd_result.events:
|
|
81
82
|
await executor.context.emit_event(evt)
|
|
82
83
|
|
|
84
|
+
if run_ops and should_compact_threshold(
|
|
85
|
+
session=agent.session,
|
|
86
|
+
config=None,
|
|
87
|
+
llm_config=agent.profile.llm_client.get_llm_config(),
|
|
88
|
+
):
|
|
89
|
+
await executor.submit_and_wait(
|
|
90
|
+
op.CompactSessionOperation(
|
|
91
|
+
session_id=agent.session.id,
|
|
92
|
+
reason="threshold",
|
|
93
|
+
will_retry=False,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
83
97
|
submitted_ids: list[str] = []
|
|
84
98
|
for operation_item in operations:
|
|
85
99
|
submitted_ids.append(await executor.submit(operation_item))
|
|
@@ -124,7 +138,9 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
|
|
|
124
138
|
|
|
125
139
|
def _status_provider() -> REPLStatusSnapshot:
|
|
126
140
|
update_message = get_update_message()
|
|
127
|
-
|
|
141
|
+
debug_log = get_current_log_file()
|
|
142
|
+
debug_log_path = str(debug_log) if debug_log else None
|
|
143
|
+
return build_repl_status_snapshot(update_message, debug_log_path=debug_log_path)
|
|
128
144
|
|
|
129
145
|
def _stop_rich_bottom_ui() -> None:
|
|
130
146
|
active_display = components.display
|
|
@@ -1,23 +1,64 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import shutil
|
|
5
|
+
import struct
|
|
6
|
+
import subprocess
|
|
4
7
|
import sys
|
|
8
|
+
import tempfile
|
|
5
9
|
from pathlib import Path
|
|
6
10
|
from typing import IO
|
|
7
11
|
|
|
8
12
|
# Kitty graphics protocol chunk size (4096 is the recommended max)
|
|
9
13
|
_CHUNK_SIZE = 4096
|
|
10
14
|
|
|
15
|
+
# Max columns for non-wide images
|
|
16
|
+
_MAX_COLS = 120
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
# Image formats that need conversion to PNG
|
|
19
|
+
_NEEDS_CONVERSION = {".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _convert_to_png(path: Path) -> bytes | None:
|
|
23
|
+
"""Convert image to PNG using sips (macOS) or convert (ImageMagick)."""
|
|
24
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp:
|
|
25
|
+
tmp_path = tmp.name
|
|
26
|
+
# Try sips first (macOS built-in)
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["sips", "-s", "format", "png", str(path), "--out", tmp_path],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
)
|
|
31
|
+
if result.returncode == 0:
|
|
32
|
+
return Path(tmp_path).read_bytes()
|
|
33
|
+
# Fallback to ImageMagick convert
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["convert", str(path), tmp_path],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
)
|
|
38
|
+
if result.returncode == 0:
|
|
39
|
+
return Path(tmp_path).read_bytes()
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_png_dimensions(data: bytes) -> tuple[int, int] | None:
|
|
44
|
+
"""Extract width and height from PNG file header."""
|
|
45
|
+
# PNG: 8-byte signature + IHDR chunk (4 len + 4 type + 4 width + 4 height)
|
|
46
|
+
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
47
|
+
return None
|
|
48
|
+
width, height = struct.unpack(">II", data[16:24])
|
|
49
|
+
return width, height
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_kitty_image(file_path: str | Path, *, file: IO[str] | None = None) -> None:
|
|
13
53
|
"""Print an image to the terminal using Kitty graphics protocol.
|
|
14
54
|
|
|
15
55
|
This intentionally bypasses Rich rendering to avoid interleaving Live refreshes
|
|
16
|
-
with raw escape sequences.
|
|
56
|
+
with raw escape sequences. Image size adapts based on aspect ratio:
|
|
57
|
+
- Landscape images: fill terminal width
|
|
58
|
+
- Portrait images: limit height to avoid oversized display
|
|
17
59
|
|
|
18
60
|
Args:
|
|
19
61
|
file_path: Path to the image file (PNG recommended).
|
|
20
|
-
height: Display height in terminal rows. If None, uses terminal default.
|
|
21
62
|
file: Output file stream. Defaults to stdout.
|
|
22
63
|
"""
|
|
23
64
|
path = Path(file_path) if isinstance(file_path, str) else file_path
|
|
@@ -26,24 +67,48 @@ def print_kitty_image(file_path: str | Path, *, height: int | None = None, file:
|
|
|
26
67
|
return
|
|
27
68
|
|
|
28
69
|
try:
|
|
29
|
-
|
|
70
|
+
# Convert non-PNG formats to PNG for Kitty graphics protocol compatibility
|
|
71
|
+
if path.suffix.lower() in _NEEDS_CONVERSION:
|
|
72
|
+
data = _convert_to_png(path)
|
|
73
|
+
if data is None:
|
|
74
|
+
print(f"Saved image: {path}", file=file or sys.stdout, flush=True)
|
|
75
|
+
return
|
|
76
|
+
else:
|
|
77
|
+
data = path.read_bytes()
|
|
78
|
+
|
|
30
79
|
encoded = base64.standard_b64encode(data).decode("ascii")
|
|
31
80
|
out = file or sys.stdout
|
|
32
81
|
|
|
82
|
+
term_size = shutil.get_terminal_size()
|
|
83
|
+
dimensions = _get_png_dimensions(data)
|
|
84
|
+
|
|
85
|
+
# Determine sizing strategy based on aspect ratio
|
|
86
|
+
if dimensions is not None:
|
|
87
|
+
img_width, img_height = dimensions
|
|
88
|
+
if img_width > 2 * img_height:
|
|
89
|
+
# Wide landscape (width > 2x height): fill terminal width
|
|
90
|
+
size_param = f"c={term_size.columns}"
|
|
91
|
+
else:
|
|
92
|
+
# Other images: limit width to 80% of terminal
|
|
93
|
+
size_param = f"c={min(_MAX_COLS, term_size.columns * 4 // 5)}"
|
|
94
|
+
else:
|
|
95
|
+
# Fallback: limit width to 80% of terminal
|
|
96
|
+
size_param = f"c={min(_MAX_COLS, term_size.columns * 4 // 5)}"
|
|
33
97
|
print("", file=out)
|
|
34
|
-
_write_kitty_graphics(out, encoded,
|
|
98
|
+
_write_kitty_graphics(out, encoded, size_param=size_param)
|
|
35
99
|
print("", file=out)
|
|
36
100
|
out.flush()
|
|
37
101
|
except Exception:
|
|
38
102
|
print(f"Saved image: {path}", file=file or sys.stdout, flush=True)
|
|
39
103
|
|
|
40
104
|
|
|
41
|
-
def _write_kitty_graphics(out: IO[str], encoded_data: str, *,
|
|
105
|
+
def _write_kitty_graphics(out: IO[str], encoded_data: str, *, size_param: str) -> None:
|
|
42
106
|
"""Write Kitty graphics protocol escape sequences.
|
|
43
107
|
|
|
44
108
|
Protocol format: ESC _ G <control>;<payload> ESC \\
|
|
45
109
|
- a=T: direct transmission (data in payload)
|
|
46
110
|
- f=100: PNG format (auto-detected by Kitty)
|
|
111
|
+
- c=N: display width in columns
|
|
47
112
|
- r=N: display height in rows
|
|
48
113
|
- m=1: more data follows, m=0: last chunk
|
|
49
114
|
"""
|
|
@@ -55,10 +120,7 @@ def _write_kitty_graphics(out: IO[str], encoded_data: str, *, height: int | None
|
|
|
55
120
|
|
|
56
121
|
if i == 0:
|
|
57
122
|
# First chunk: include control parameters
|
|
58
|
-
ctrl = "a=T,f=100"
|
|
59
|
-
if height is not None:
|
|
60
|
-
ctrl += f",r={height}"
|
|
61
|
-
ctrl += f",m={0 if is_last else 1}"
|
|
123
|
+
ctrl = f"a=T,f=100,{size_param},m={0 if is_last else 1}"
|
|
62
124
|
out.write(f"\033_G{ctrl};{chunk}\033\\")
|
|
63
125
|
else:
|
|
64
126
|
# Subsequent chunks: only m parameter needed
|
|
@@ -81,13 +81,13 @@ def build_model_select_items(models: list[Any]) -> list[SelectItem[str]]:
|
|
|
81
81
|
|
|
82
82
|
items: list[SelectItem[str]] = []
|
|
83
83
|
model_idx = 0
|
|
84
|
-
separator_base_len =
|
|
84
|
+
separator_base_len = 80
|
|
85
85
|
for provider, provider_models in grouped.items():
|
|
86
86
|
provider_text = provider.lower()
|
|
87
87
|
count_text = f"({len(provider_models)})"
|
|
88
88
|
header_len = len(provider_text) + 1 + len(count_text)
|
|
89
89
|
separator_len = separator_base_len + max_header_len - header_len
|
|
90
|
-
separator = "
|
|
90
|
+
separator = "-" * separator_len
|
|
91
91
|
items.append(
|
|
92
92
|
SelectItem(
|
|
93
93
|
title=[
|
|
@@ -574,7 +574,10 @@ def select_one[T](
|
|
|
574
574
|
)
|
|
575
575
|
list_window = Window(
|
|
576
576
|
FormattedTextControl(get_choices_tokens),
|
|
577
|
-
|
|
577
|
+
# Keep 1 line of context above the cursor so non-selectable header rows
|
|
578
|
+
# (e.g. provider group labels) remain visible when wrapping back to the
|
|
579
|
+
# first selectable item in a scrolled list.
|
|
580
|
+
scroll_offsets=ScrollOffsets(top=1, bottom=2),
|
|
578
581
|
allow_scroll_beyond_bottom=True,
|
|
579
582
|
dont_extend_height=Always(),
|
|
580
583
|
always_hide_cursor=Always(),
|
|
@@ -796,6 +799,7 @@ class SelectOverlay[T]:
|
|
|
796
799
|
dont_extend_height=Always(),
|
|
797
800
|
always_hide_cursor=Always(),
|
|
798
801
|
)
|
|
802
|
+
|
|
799
803
|
def get_list_height() -> int:
|
|
800
804
|
# Dynamic height: min of configured height and available terminal space
|
|
801
805
|
# Overhead: header(1) + spacer(1) + search(1) + frame borders(2) + prompt area(3)
|
|
@@ -803,15 +807,35 @@ class SelectOverlay[T]:
|
|
|
803
807
|
try:
|
|
804
808
|
terminal_height = get_app().output.get_size().rows
|
|
805
809
|
available = max(3, terminal_height - overhead)
|
|
806
|
-
|
|
810
|
+
cap = min(self._list_height, available)
|
|
807
811
|
except Exception:
|
|
808
|
-
|
|
812
|
+
cap = self._list_height
|
|
813
|
+
|
|
814
|
+
# Shrink list height when content is shorter than the configured cap.
|
|
815
|
+
# This is especially helpful for small pickers (e.g. thinking level)
|
|
816
|
+
# where a fixed list_height would otherwise render extra blank rows.
|
|
817
|
+
indices, _ = self._get_visible_indices()
|
|
818
|
+
if not indices:
|
|
819
|
+
return max(1, cap)
|
|
820
|
+
|
|
821
|
+
visible_lines = 0
|
|
822
|
+
for idx in indices:
|
|
823
|
+
item = self._items[idx]
|
|
824
|
+
newlines = sum(text.count("\n") for _style, text in item.title)
|
|
825
|
+
visible_lines += max(1, newlines)
|
|
826
|
+
if visible_lines >= cap:
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
return max(1, min(cap, visible_lines))
|
|
809
830
|
|
|
810
831
|
list_window = Window(
|
|
811
832
|
FormattedTextControl(get_choices_tokens),
|
|
812
833
|
height=get_list_height,
|
|
813
|
-
|
|
814
|
-
|
|
834
|
+
# See select_one(): keep header rows visible when wrapping.
|
|
835
|
+
# For embedded overlays, avoid reserving extra blank lines near the
|
|
836
|
+
# bottom when the list height is tight (e.g. short pickers).
|
|
837
|
+
scroll_offsets=ScrollOffsets(top=1, bottom=0),
|
|
838
|
+
allow_scroll_beyond_bottom=False,
|
|
815
839
|
dont_extend_height=Always(),
|
|
816
840
|
always_hide_cursor=Always(),
|
|
817
841
|
)
|