klaude-code 2.5.3__py3-none-any.whl → 2.7.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 (60) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/__init__.py +10 -0
  3. klaude_code/auth/env.py +81 -0
  4. klaude_code/cli/auth_cmd.py +87 -8
  5. klaude_code/cli/config_cmd.py +5 -5
  6. klaude_code/cli/cost_cmd.py +159 -60
  7. klaude_code/cli/main.py +146 -65
  8. klaude_code/cli/self_update.py +7 -7
  9. klaude_code/config/builtin_config.py +23 -9
  10. klaude_code/config/config.py +19 -9
  11. klaude_code/const.py +10 -1
  12. klaude_code/core/reminders.py +4 -5
  13. klaude_code/core/turn.py +8 -9
  14. klaude_code/llm/google/client.py +12 -0
  15. klaude_code/llm/openai_compatible/stream.py +5 -1
  16. klaude_code/llm/openrouter/client.py +1 -0
  17. klaude_code/protocol/commands.py +0 -1
  18. klaude_code/protocol/events.py +214 -0
  19. klaude_code/protocol/sub_agent/image_gen.py +0 -4
  20. klaude_code/session/session.py +51 -18
  21. klaude_code/skill/loader.py +12 -13
  22. klaude_code/skill/manager.py +3 -3
  23. klaude_code/tui/command/__init__.py +1 -4
  24. klaude_code/tui/command/copy_cmd.py +1 -1
  25. klaude_code/tui/command/fork_session_cmd.py +4 -4
  26. klaude_code/tui/commands.py +0 -5
  27. klaude_code/tui/components/command_output.py +1 -1
  28. klaude_code/tui/components/metadata.py +4 -5
  29. klaude_code/tui/components/rich/markdown.py +60 -0
  30. klaude_code/tui/components/rich/theme.py +8 -0
  31. klaude_code/tui/components/sub_agent.py +6 -0
  32. klaude_code/tui/components/user_input.py +38 -27
  33. klaude_code/tui/display.py +11 -1
  34. klaude_code/tui/input/AGENTS.md +44 -0
  35. klaude_code/tui/input/completers.py +21 -21
  36. klaude_code/tui/input/drag_drop.py +197 -0
  37. klaude_code/tui/input/images.py +227 -0
  38. klaude_code/tui/input/key_bindings.py +173 -19
  39. klaude_code/tui/input/paste.py +71 -0
  40. klaude_code/tui/input/prompt_toolkit.py +13 -3
  41. klaude_code/tui/machine.py +90 -56
  42. klaude_code/tui/renderer.py +1 -62
  43. klaude_code/tui/runner.py +1 -1
  44. klaude_code/tui/terminal/image.py +40 -9
  45. klaude_code/tui/terminal/selector.py +52 -2
  46. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/METADATA +32 -40
  47. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/RECORD +49 -54
  48. klaude_code/cli/session_cmd.py +0 -87
  49. klaude_code/protocol/events/__init__.py +0 -63
  50. klaude_code/protocol/events/base.py +0 -18
  51. klaude_code/protocol/events/chat.py +0 -30
  52. klaude_code/protocol/events/lifecycle.py +0 -23
  53. klaude_code/protocol/events/metadata.py +0 -16
  54. klaude_code/protocol/events/streaming.py +0 -43
  55. klaude_code/protocol/events/system.py +0 -56
  56. klaude_code/protocol/events/tools.py +0 -27
  57. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  58. klaude_code/tui/input/clipboard.py +0 -152
  59. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
  60. {klaude_code-2.5.3.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -19,6 +19,7 @@ from klaude_code.tui.commands import (
19
19
  EmitTmuxSignal,
20
20
  EndAssistantStream,
21
21
  EndThinkingStream,
22
+ PrintBlankLine,
22
23
  PrintRuleLine,
23
24
  RenderAssistantImage,
24
25
  RenderCommand,
@@ -26,7 +27,6 @@ from klaude_code.tui.commands import (
26
27
  RenderDeveloperMessage,
27
28
  RenderError,
28
29
  RenderInterrupt,
29
- RenderReplayHistory,
30
30
  RenderTaskFinish,
31
31
  RenderTaskMetadata,
32
32
  RenderTaskStart,
@@ -160,7 +160,7 @@ class ActivityState:
160
160
  if self._sub_agent_tool_calls:
161
161
  _append_counts(self._sub_agent_tool_calls)
162
162
  if self._tool_calls:
163
- activity_text.append(" , ")
163
+ activity_text.append(", ")
164
164
 
165
165
  if self._tool_calls:
166
166
  _append_counts(self._tool_calls)
@@ -382,17 +382,25 @@ class DisplayStateMachine:
382
382
  self._spinner.set_toast_status(None)
383
383
  return self._spinner_update_commands()
384
384
 
385
+ def begin_replay(self) -> list[RenderCommand]:
386
+ self._spinner.reset()
387
+ return [SpinnerStop(), PrintBlankLine()]
388
+
389
+ def end_replay(self) -> list[RenderCommand]:
390
+ return [SpinnerStop()]
391
+
392
+ def transition_replay(self, event: events.Event) -> list[RenderCommand]:
393
+ return self._transition(event, is_replay=True)
394
+
385
395
  def transition(self, event: events.Event) -> list[RenderCommand]:
396
+ return self._transition(event, is_replay=False)
397
+
398
+ def _transition(self, event: events.Event, *, is_replay: bool) -> list[RenderCommand]:
386
399
  session_id = getattr(event, "session_id", "__app__")
387
400
  s = self._session(session_id)
388
401
  cmds: list[RenderCommand] = []
389
402
 
390
403
  match event:
391
- case events.ReplayHistoryEvent() as e:
392
- cmds.append(RenderReplayHistory(e))
393
- cmds.append(SpinnerStop())
394
- return cmds
395
-
396
404
  case events.WelcomeEvent() as e:
397
405
  cmds.append(RenderWelcome(e))
398
406
  return cmds
@@ -408,13 +416,16 @@ class DisplayStateMachine:
408
416
  s.model_id = e.model_id
409
417
  if not s.is_sub_agent:
410
418
  self._set_primary_if_needed(e.session_id)
411
- cmds.append(TaskClockStart())
419
+ if not is_replay:
420
+ cmds.append(TaskClockStart())
412
421
  else:
413
422
  s.sub_agent_thinking_header = SubAgentThinkingHeaderState()
414
423
 
415
- cmds.append(SpinnerStart())
424
+ if not is_replay:
425
+ cmds.append(SpinnerStart())
416
426
  cmds.append(RenderTaskStart(e))
417
- cmds.extend(self._spinner_update_commands())
427
+ if not is_replay:
428
+ cmds.extend(self._spinner_update_commands())
418
429
  return cmds
419
430
 
420
431
  case events.DeveloperMessageEvent() as e:
@@ -427,9 +438,10 @@ class DisplayStateMachine:
427
438
 
428
439
  case events.TurnStartEvent() as e:
429
440
  cmds.append(RenderTurnStart(e))
430
- self._spinner.clear_for_new_turn()
431
- self._spinner.set_reasoning_status(None)
432
- cmds.extend(self._spinner_update_commands())
441
+ if not is_replay:
442
+ self._spinner.clear_for_new_turn()
443
+ self._spinner.set_reasoning_status(None)
444
+ cmds.extend(self._spinner_update_commands())
433
445
  return cmds
434
446
 
435
447
  case events.ThinkingStartEvent() as e:
@@ -441,9 +453,11 @@ class DisplayStateMachine:
441
453
  s.thinking_tail = ""
442
454
  # Ensure the status reflects that reasoning has started even
443
455
  # before we receive any deltas (or a bold header).
444
- self._spinner.set_reasoning_status(STATUS_THINKING_TEXT)
456
+ if not is_replay:
457
+ self._spinner.set_reasoning_status(STATUS_THINKING_TEXT)
445
458
  cmds.append(StartThinkingStream(session_id=e.session_id))
446
- cmds.extend(self._spinner_update_commands())
459
+ if not is_replay:
460
+ cmds.extend(self._spinner_update_commands())
447
461
  return cmds
448
462
 
449
463
  case events.ThinkingDeltaEvent() as e:
@@ -463,7 +477,7 @@ class DisplayStateMachine:
463
477
 
464
478
  # Update reasoning status for spinner (based on bounded tail).
465
479
  # Only extract headers for models that use markdown bold headers in thinking.
466
- if s.should_extract_reasoning_header:
480
+ if not is_replay and s.should_extract_reasoning_header:
467
481
  s.thinking_tail = (s.thinking_tail + e.content)[-8192:]
468
482
  header = extract_last_bold_header(normalize_thinking_content(s.thinking_tail))
469
483
  if header:
@@ -478,26 +492,31 @@ class DisplayStateMachine:
478
492
  if not self._is_primary(e.session_id):
479
493
  return []
480
494
  s.thinking_stream_active = False
481
- self._spinner.clear_default_reasoning_status()
495
+ if not is_replay:
496
+ self._spinner.clear_default_reasoning_status()
482
497
  cmds.append(EndThinkingStream(session_id=e.session_id))
483
- cmds.append(SpinnerStart())
484
- cmds.extend(self._spinner_update_commands())
498
+ if not is_replay:
499
+ cmds.append(SpinnerStart())
500
+ cmds.extend(self._spinner_update_commands())
485
501
  return cmds
486
502
 
487
503
  case events.AssistantTextStartEvent() as e:
488
504
  if s.is_sub_agent:
489
- self._spinner.set_composing(True)
490
- cmds.extend(self._spinner_update_commands())
505
+ if not is_replay:
506
+ self._spinner.set_composing(True)
507
+ cmds.extend(self._spinner_update_commands())
491
508
  return cmds
492
509
  if not self._is_primary(e.session_id):
493
510
  return []
494
511
 
495
512
  s.assistant_stream_active = True
496
513
  s.assistant_char_count = 0
497
- self._spinner.set_composing(True)
498
- self._spinner.clear_tool_calls()
514
+ if not is_replay:
515
+ self._spinner.set_composing(True)
516
+ self._spinner.clear_tool_calls()
499
517
  cmds.append(StartAssistantStream(session_id=e.session_id))
500
- cmds.extend(self._spinner_update_commands())
518
+ if not is_replay:
519
+ cmds.extend(self._spinner_update_commands())
501
520
  return cmds
502
521
 
503
522
  case events.AssistantTextDeltaEvent() as e:
@@ -507,24 +526,29 @@ class DisplayStateMachine:
507
526
  return []
508
527
 
509
528
  s.assistant_char_count += len(e.content)
510
- self._spinner.set_buffer_length(s.assistant_char_count)
529
+ if not is_replay:
530
+ self._spinner.set_buffer_length(s.assistant_char_count)
511
531
  cmds.append(AppendAssistant(session_id=e.session_id, content=e.content))
512
- cmds.extend(self._spinner_update_commands())
532
+ if not is_replay:
533
+ cmds.extend(self._spinner_update_commands())
513
534
  return cmds
514
535
 
515
536
  case events.AssistantTextEndEvent() as e:
516
537
  if s.is_sub_agent:
517
- self._spinner.set_composing(False)
518
- cmds.extend(self._spinner_update_commands())
538
+ if not is_replay:
539
+ self._spinner.set_composing(False)
540
+ cmds.extend(self._spinner_update_commands())
519
541
  return cmds
520
542
  if not self._is_primary(e.session_id):
521
543
  return []
522
544
 
523
545
  s.assistant_stream_active = False
524
- self._spinner.set_composing(False)
546
+ if not is_replay:
547
+ self._spinner.set_composing(False)
525
548
  cmds.append(EndAssistantStream(session_id=e.session_id))
526
- cmds.append(SpinnerStart())
527
- cmds.extend(self._spinner_update_commands())
549
+ if not is_replay:
550
+ cmds.append(SpinnerStart())
551
+ cmds.extend(self._spinner_update_commands())
528
552
  return cmds
529
553
 
530
554
  case events.AssistantImageDeltaEvent() as e:
@@ -536,9 +560,10 @@ class DisplayStateMachine:
536
560
  return []
537
561
  if not self._is_primary(e.session_id):
538
562
  return []
539
- self._spinner.set_composing(False)
540
- cmds.append(SpinnerStart())
541
- cmds.extend(self._spinner_update_commands())
563
+ if not is_replay:
564
+ self._spinner.set_composing(False)
565
+ cmds.append(SpinnerStart())
566
+ cmds.extend(self._spinner_update_commands())
542
567
  return cmds
543
568
 
544
569
  case events.ToolCallStartEvent() as e:
@@ -553,18 +578,20 @@ class DisplayStateMachine:
553
578
  primary.thinking_stream_active = False
554
579
  cmds.append(EndThinkingStream(session_id=primary.session_id))
555
580
 
556
- self._spinner.set_composing(False)
581
+ if not is_replay:
582
+ self._spinner.set_composing(False)
557
583
 
558
584
  # Skip activity state for fast tools on non-streaming models (e.g., Gemini)
559
585
  # to avoid flash-and-disappear effect
560
- if not s.should_skip_tool_activity(e.tool_name):
586
+ if not is_replay and not s.should_skip_tool_activity(e.tool_name):
561
587
  tool_active_form = get_tool_active_form(e.tool_name)
562
588
  if is_sub_agent_tool(e.tool_name):
563
589
  self._spinner.add_sub_agent_tool_call(e.tool_call_id, tool_active_form)
564
590
  else:
565
591
  self._spinner.add_tool_call(tool_active_form)
566
592
 
567
- cmds.extend(self._spinner_update_commands())
593
+ if not is_replay:
594
+ cmds.extend(self._spinner_update_commands())
568
595
  return cmds
569
596
 
570
597
  case events.ToolCallEvent() as e:
@@ -583,7 +610,7 @@ class DisplayStateMachine:
583
610
  return cmds
584
611
 
585
612
  case events.ToolResultEvent() as e:
586
- if is_sub_agent_tool(e.tool_name):
613
+ if not is_replay and is_sub_agent_tool(e.tool_name):
587
614
  self._spinner.finish_sub_agent_tool_call(e.tool_call_id, get_tool_active_form(e.tool_name))
588
615
  cmds.extend(self._spinner_update_commands())
589
616
 
@@ -601,9 +628,10 @@ class DisplayStateMachine:
601
628
 
602
629
  case events.TodoChangeEvent() as e:
603
630
  todo_text = _extract_active_form_text(e)
604
- self._spinner.set_todo_status(todo_text)
605
- self._spinner.clear_for_new_turn()
606
- cmds.extend(self._spinner_update_commands())
631
+ if not is_replay:
632
+ self._spinner.set_todo_status(todo_text)
633
+ self._spinner.clear_for_new_turn()
634
+ cmds.extend(self._spinner_update_commands())
607
635
  return cmds
608
636
 
609
637
  case events.UsageEvent() as e:
@@ -613,7 +641,7 @@ class DisplayStateMachine:
613
641
  if not self._is_primary(e.session_id):
614
642
  return []
615
643
  context_percent = e.usage.context_usage_percent
616
- if context_percent is not None:
644
+ if not is_replay and context_percent is not None:
617
645
  self._spinner.set_context_percent(context_percent)
618
646
  cmds.extend(self._spinner_update_commands())
619
647
  return cmds
@@ -624,37 +652,43 @@ class DisplayStateMachine:
624
652
  case events.TaskFinishEvent() as e:
625
653
  cmds.append(RenderTaskFinish(e))
626
654
  if not s.is_sub_agent:
627
- cmds.append(TaskClockClear())
628
- self._spinner.reset()
629
- cmds.append(SpinnerStop())
630
- cmds.append(PrintRuleLine())
631
- cmds.append(EmitTmuxSignal())
655
+ if not is_replay:
656
+ cmds.append(TaskClockClear())
657
+ self._spinner.reset()
658
+ cmds.append(SpinnerStop())
659
+ cmds.append(PrintRuleLine())
660
+ cmds.append(EmitTmuxSignal())
632
661
  else:
633
662
  s.sub_agent_thinking_header = None
634
663
  return cmds
635
664
 
636
665
  case events.InterruptEvent() as e:
637
- self._spinner.reset()
638
- cmds.append(SpinnerStop())
666
+ if not is_replay:
667
+ self._spinner.reset()
668
+ cmds.append(SpinnerStop())
639
669
  cmds.append(EndThinkingStream(session_id=e.session_id))
640
670
  cmds.append(EndAssistantStream(session_id=e.session_id))
641
- cmds.append(TaskClockClear())
671
+ if not is_replay:
672
+ cmds.append(TaskClockClear())
642
673
  cmds.append(RenderInterrupt(session_id=e.session_id))
643
674
  return cmds
644
675
 
645
676
  case events.ErrorEvent() as e:
646
- cmds.append(EmitOsc94Error())
677
+ if not is_replay:
678
+ cmds.append(EmitOsc94Error())
647
679
  cmds.append(RenderError(e))
648
- if not e.can_retry:
680
+ if not is_replay and not e.can_retry:
649
681
  self._spinner.reset()
650
682
  cmds.append(SpinnerStop())
651
- cmds.extend(self._spinner_update_commands())
683
+ if not is_replay:
684
+ cmds.extend(self._spinner_update_commands())
652
685
  return cmds
653
686
 
654
687
  case events.EndEvent():
655
- self._spinner.reset()
656
- cmds.append(SpinnerStop())
657
- cmds.append(TaskClockClear())
688
+ if not is_replay:
689
+ self._spinner.reset()
690
+ cmds.append(SpinnerStop())
691
+ cmds.append(TaskClockClear())
658
692
  return cmds
659
693
 
660
694
  case _:
@@ -35,7 +35,6 @@ from klaude_code.tui.commands import (
35
35
  RenderDeveloperMessage,
36
36
  RenderError,
37
37
  RenderInterrupt,
38
- RenderReplayHistory,
39
38
  RenderTaskFinish,
40
39
  RenderTaskMetadata,
41
40
  RenderTaskStart,
@@ -437,67 +436,10 @@ class TUICommandRenderer:
437
436
  Text.assemble(
438
437
  (c_thinking.THINKING_MESSAGE_MARK, ThemeKey.THINKING),
439
438
  " ",
440
- (stripped, ThemeKey.THINKING_BOLD),
439
+ (stripped, ThemeKey.THINKING),
441
440
  )
442
441
  )
443
442
 
444
- async def replay_history(self, history_events: events.ReplayHistoryEvent) -> None:
445
- tool_call_dict: dict[str, events.ToolCallEvent] = {}
446
- self.print()
447
- for event in history_events.events:
448
- event_session_id = getattr(event, "session_id", history_events.session_id)
449
- is_sub_agent = self.is_sub_agent_session(event_session_id)
450
-
451
- with self.session_print_context(event_session_id):
452
- match event:
453
- case events.TaskStartEvent() as e:
454
- self.display_task_start(e)
455
- case events.TurnStartEvent():
456
- self.print()
457
- case events.AssistantImageDeltaEvent() as e:
458
- self.display_image(e.file_path)
459
- case events.ResponseCompleteEvent() as e:
460
- if is_sub_agent:
461
- if self._should_display_sub_agent_thinking_header(event_session_id) and e.thinking_text:
462
- header = c_thinking.extract_last_bold_header(
463
- c_thinking.normalize_thinking_content(e.thinking_text)
464
- )
465
- if header:
466
- self.display_thinking_header(header)
467
- continue
468
- if e.thinking_text:
469
- self.display_thinking(e.thinking_text)
470
- renderable = c_assistant.render_assistant_message(e.content, code_theme=self.themes.code_theme)
471
- if renderable is not None:
472
- self.print(renderable)
473
- self.print()
474
- case events.DeveloperMessageEvent() as e:
475
- self.display_developer_message(e)
476
- case events.UserMessageEvent() as e:
477
- if is_sub_agent:
478
- continue
479
- self.print(c_user_input.render_user_input(e.content))
480
- case events.ToolCallEvent() as e:
481
- tool_call_dict[e.tool_call_id] = e
482
- case events.ToolResultEvent() as e:
483
- tool_call_event = tool_call_dict.get(e.tool_call_id)
484
- if tool_call_event is not None:
485
- self.display_tool_call(tool_call_event)
486
- tool_call_dict.pop(e.tool_call_id, None)
487
- if is_sub_agent:
488
- continue
489
- self.display_tool_call_result(e)
490
- case events.TaskMetadataEvent() as e:
491
- self.print(c_metadata.render_task_metadata(e))
492
- self.print()
493
- case events.InterruptEvent():
494
- self.print()
495
- self.print(c_user_input.render_interrupt())
496
- case events.ErrorEvent() as e:
497
- self.display_error(e)
498
- case events.TaskFinishEvent() as e:
499
- self.display_task_finish(e)
500
-
501
443
  def display_developer_message(self, e: events.DeveloperMessageEvent) -> None:
502
444
  if not c_developer.need_render_developer_message(e):
503
445
  return
@@ -615,9 +557,6 @@ class TUICommandRenderer:
615
557
  async def execute(self, commands: list[RenderCommand]) -> None:
616
558
  for cmd in commands:
617
559
  match cmd:
618
- case RenderReplayHistory(event=event):
619
- await self.replay_history(event)
620
- self.spinner_stop()
621
560
  case RenderWelcome(event=event):
622
561
  self.display_welcome(event)
623
562
  case RenderUserMessage(event=event):
klaude_code/tui/runner.py CHANGED
@@ -312,4 +312,4 @@ async def run_interactive(init_config: AppInitConfig, session_id: str | None = N
312
312
  active_session_id = components.executor.context.current_session_id()
313
313
  if active_session_id and Session.exists(active_session_id):
314
314
  log(f"Session ID: {active_session_id}")
315
- log(f"Resume with: klaude --resume-by-id {active_session_id}")
315
+ log(f"Resume with: klaude --resume {active_session_id}")
@@ -1,34 +1,65 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import sys
4
5
  from pathlib import Path
5
6
  from typing import IO
6
7
 
8
+ # Kitty graphics protocol chunk size (4096 is the recommended max)
9
+ _CHUNK_SIZE = 4096
10
+
7
11
 
8
12
  def print_kitty_image(file_path: str | Path, *, height: int | None = None, file: IO[str] | None = None) -> None:
9
13
  """Print an image to the terminal using Kitty graphics protocol.
10
14
 
11
15
  This intentionally bypasses Rich rendering to avoid interleaving Live refreshes
12
16
  with raw escape sequences.
13
- """
14
17
 
18
+ Args:
19
+ file_path: Path to the image file (PNG recommended).
20
+ height: Display height in terminal rows. If None, uses terminal default.
21
+ file: Output file stream. Defaults to stdout.
22
+ """
15
23
  path = Path(file_path) if isinstance(file_path, str) else file_path
16
24
  if not path.exists():
17
25
  print(f"Image not found: {path}", file=file or sys.stdout, flush=True)
18
26
  return
19
27
 
20
28
  try:
21
- from term_image.image import KittyImage # type: ignore[import-untyped]
22
-
23
- KittyImage.forced_support = True # type: ignore[reportUnknownMemberType]
24
- img = KittyImage.from_file(path) # type: ignore[reportUnknownMemberType]
25
- if height is not None:
26
- img.height = height # type: ignore[reportUnknownMemberType]
27
-
29
+ data = path.read_bytes()
30
+ encoded = base64.standard_b64encode(data).decode("ascii")
28
31
  out = file or sys.stdout
32
+
29
33
  print("", file=out)
30
- print(str(img), file=out)
34
+ _write_kitty_graphics(out, encoded, height=height)
31
35
  print("", file=out)
32
36
  out.flush()
33
37
  except Exception:
34
38
  print(f"Saved image: {path}", file=file or sys.stdout, flush=True)
39
+
40
+
41
+ def _write_kitty_graphics(out: IO[str], encoded_data: str, *, height: int | None = None) -> None:
42
+ """Write Kitty graphics protocol escape sequences.
43
+
44
+ Protocol format: ESC _ G <control>;<payload> ESC \\
45
+ - a=T: direct transmission (data in payload)
46
+ - f=100: PNG format (auto-detected by Kitty)
47
+ - r=N: display height in rows
48
+ - m=1: more data follows, m=0: last chunk
49
+ """
50
+ total_len = len(encoded_data)
51
+
52
+ for i in range(0, total_len, _CHUNK_SIZE):
53
+ chunk = encoded_data[i : i + _CHUNK_SIZE]
54
+ is_last = i + _CHUNK_SIZE >= total_len
55
+
56
+ if i == 0:
57
+ # 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}"
62
+ out.write(f"\033_G{ctrl};{chunk}\033\\")
63
+ else:
64
+ # Subsequent chunks: only m parameter needed
65
+ out.write(f"\033_Gm={0 if is_last else 1};{chunk}\033\\")
@@ -389,7 +389,7 @@ def _build_search_container(
389
389
  frame: bool = True,
390
390
  ) -> tuple[Window, Container]:
391
391
  """Build the search input container with placeholder."""
392
- placeholder_text = f"{search_placeholder} · ↑↓ to select · esc to quit"
392
+ placeholder_text = f"{search_placeholder} · ↑↓ to select · enter/tab to confirm · esc to quit"
393
393
 
394
394
  search_prefix_window = Window(
395
395
  FormattedTextControl([("class:search_prefix", "/ ")]),
@@ -522,6 +522,23 @@ def select_one[T](
522
522
  return
523
523
  event.app.exit(result=value)
524
524
 
525
+ @kb.add(Keys.Tab, eager=True)
526
+ def _(event: KeyPressEvent) -> None:
527
+ """Accept the currently pointed item."""
528
+ indices, _ = _filter_items(items, get_filter_text())
529
+ if not indices:
530
+ event.app.exit(result=None)
531
+ return
532
+
533
+ nonlocal pointed_at
534
+ pointed_at = _coerce_pointed_at_to_selectable(items, indices, pointed_at)
535
+ idx = indices[pointed_at % len(indices)]
536
+ value = items[idx].value
537
+ if value is None:
538
+ event.app.exit(result=None)
539
+ return
540
+ event.app.exit(result=value)
541
+
525
542
  @kb.add(Keys.Escape, eager=True)
526
543
  def _(event: KeyPressEvent) -> None:
527
544
  nonlocal pointed_at
@@ -702,6 +719,28 @@ class SelectOverlay[T]:
702
719
  if hasattr(result, "__await__"):
703
720
  event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
704
721
 
722
+ @kb.add(Keys.Tab, filter=is_open_filter, eager=True)
723
+ def _(event: KeyPressEvent) -> None:
724
+ indices, _ = self._get_visible_indices()
725
+ if not indices:
726
+ self.close()
727
+ return
728
+
729
+ self._pointed_at = _coerce_pointed_at_to_selectable(self._items, indices, self._pointed_at)
730
+ idx = indices[self._pointed_at % len(indices)]
731
+ value = self._items[idx].value
732
+ if value is None:
733
+ self.close()
734
+ return
735
+ self.close()
736
+
737
+ if self._on_select is None:
738
+ return
739
+
740
+ result = self._on_select(value)
741
+ if hasattr(result, "__await__"):
742
+ event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
743
+
705
744
  @kb.add(Keys.Escape, filter=is_open_filter, eager=True)
706
745
  def _(event: KeyPressEvent) -> None:
707
746
  if self._use_search_filter and self._search_buffer is not None and self._search_buffer.text:
@@ -757,9 +796,20 @@ class SelectOverlay[T]:
757
796
  dont_extend_height=Always(),
758
797
  always_hide_cursor=Always(),
759
798
  )
799
+ def get_list_height() -> int:
800
+ # Dynamic height: min of configured height and available terminal space
801
+ # Overhead: header(1) + spacer(1) + search(1) + frame borders(2) + prompt area(3)
802
+ overhead = 8
803
+ try:
804
+ terminal_height = get_app().output.get_size().rows
805
+ available = max(3, terminal_height - overhead)
806
+ return min(self._list_height, available)
807
+ except Exception:
808
+ return self._list_height
809
+
760
810
  list_window = Window(
761
811
  FormattedTextControl(get_choices_tokens),
762
- height=self._list_height,
812
+ height=get_list_height,
763
813
  scroll_offsets=ScrollOffsets(top=0, bottom=2),
764
814
  allow_scroll_beyond_bottom=True,
765
815
  dont_extend_height=Always(),