klaude-code 2.6.0__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 (34) hide show
  1. klaude_code/app/runtime.py +1 -1
  2. klaude_code/auth/env.py +19 -15
  3. klaude_code/cli/auth_cmd.py +1 -1
  4. klaude_code/cli/main.py +98 -8
  5. klaude_code/const.py +10 -1
  6. klaude_code/core/reminders.py +4 -5
  7. klaude_code/core/turn.py +1 -1
  8. klaude_code/protocol/commands.py +0 -1
  9. klaude_code/skill/loader.py +12 -13
  10. klaude_code/skill/manager.py +3 -3
  11. klaude_code/tui/command/__init__.py +1 -4
  12. klaude_code/tui/command/copy_cmd.py +1 -1
  13. klaude_code/tui/command/fork_session_cmd.py +4 -4
  14. klaude_code/tui/components/command_output.py +1 -1
  15. klaude_code/tui/components/rich/markdown.py +60 -0
  16. klaude_code/tui/components/rich/theme.py +8 -0
  17. klaude_code/tui/components/user_input.py +38 -27
  18. klaude_code/tui/input/AGENTS.md +44 -0
  19. klaude_code/tui/input/completers.py +10 -14
  20. klaude_code/tui/input/drag_drop.py +197 -0
  21. klaude_code/tui/input/images.py +227 -0
  22. klaude_code/tui/input/key_bindings.py +173 -19
  23. klaude_code/tui/input/paste.py +71 -0
  24. klaude_code/tui/input/prompt_toolkit.py +13 -3
  25. klaude_code/tui/machine.py +1 -1
  26. klaude_code/tui/runner.py +1 -1
  27. klaude_code/tui/terminal/image.py +40 -9
  28. klaude_code/tui/terminal/selector.py +52 -2
  29. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/METADATA +10 -10
  30. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/RECORD +32 -30
  31. klaude_code/tui/command/terminal_setup_cmd.py +0 -248
  32. klaude_code/tui/input/clipboard.py +0 -152
  33. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/WHEEL +0 -0
  34. {klaude_code-2.6.0.dist-info → klaude_code-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -7,8 +7,13 @@ with dependencies injected to avoid circular imports.
7
7
  from __future__ import annotations
8
8
 
9
9
  import contextlib
10
+ import os
10
11
  import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
11
15
  from collections.abc import Callable
16
+ from pathlib import Path
12
17
  from typing import cast
13
18
 
14
19
  from prompt_toolkit.application.current import get_app
@@ -17,11 +22,39 @@ from prompt_toolkit.filters import Always, Condition, Filter
17
22
  from prompt_toolkit.filters.app import has_completions
18
23
  from prompt_toolkit.key_binding import KeyBindings
19
24
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
25
+ from prompt_toolkit.keys import Keys
26
+
27
+ from klaude_code.tui.input.drag_drop import convert_dropped_text
28
+ from klaude_code.tui.input.paste import expand_paste_markers, store_paste
29
+
30
+
31
+ def copy_to_clipboard(text: str) -> None:
32
+ """Copy text to system clipboard using platform-specific commands."""
33
+ try:
34
+ if sys.platform == "darwin":
35
+ subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
36
+ elif sys.platform == "win32":
37
+ subprocess.run(["clip"], input=text.encode("utf-16"), check=True)
38
+ else:
39
+ # Linux: try xclip first, then xsel
40
+ if shutil.which("xclip"):
41
+ subprocess.run(
42
+ ["xclip", "-selection", "clipboard"],
43
+ input=text.encode("utf-8"),
44
+ check=True,
45
+ )
46
+ elif shutil.which("xsel"):
47
+ subprocess.run(
48
+ ["xsel", "--clipboard", "--input"],
49
+ input=text.encode("utf-8"),
50
+ check=True,
51
+ )
52
+ except (OSError, subprocess.SubprocessError):
53
+ pass
20
54
 
21
55
 
22
56
  def create_key_bindings(
23
57
  capture_clipboard_tag: Callable[[], str | None],
24
- copy_to_clipboard: Callable[[str], None],
25
58
  at_token_pattern: re.Pattern[str],
26
59
  *,
27
60
  input_enabled: Filter | None = None,
@@ -31,8 +64,7 @@ def create_key_bindings(
31
64
  """Create REPL key bindings with injected dependencies.
32
65
 
33
66
  Args:
34
- capture_clipboard_tag: Callable to capture clipboard image and return tag
35
- copy_to_clipboard: Callable to copy text to system clipboard
67
+ capture_clipboard_tag: Callable to capture clipboard image and return [image ...] marker
36
68
  at_token_pattern: Pattern to match @token for completion refresh
37
69
 
38
70
  Returns:
@@ -41,6 +73,51 @@ def create_key_bindings(
41
73
  kb = KeyBindings()
42
74
  enabled = input_enabled if input_enabled is not None else Always()
43
75
 
76
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
77
+ swallow_next_control_j = False
78
+
79
+ def _data_requests_newline(data: str) -> bool:
80
+ """Return True when incoming key data should insert a newline.
81
+
82
+ Different terminals and editor-integrated terminals can emit different
83
+ sequences for Shift+Enter/Alt+Enter. We treat these as "insert newline"
84
+ instead of "submit".
85
+ """
86
+
87
+ if not data:
88
+ return False
89
+
90
+ # Pure LF or LF-prefixed sequences (e.g. when modifiers are encoded).
91
+ if data == "\n" or (ord(data[0]) == 10 and len(data) > 1):
92
+ return True
93
+
94
+ # Known escape sequences observed in some terminals.
95
+ if data in {
96
+ "\x1b\r", # Alt+Enter (ESC + CR)
97
+ "\x1b[13;2~", # Shift+Enter (some terminals)
98
+ "\x1b[27;2;13~", # Shift+Enter (xterm "CSI 27" modified keys)
99
+ "\\\r", # Backslash+Enter sentinel (some editor terminals)
100
+ }:
101
+ return True
102
+
103
+ # Any payload that contains both ESC and CR.
104
+ return len(data) > 1 and "\x1b" in data and "\r" in data
105
+
106
+ def _insert_newline(event: KeyPressEvent, *, strip_trailing_backslash: bool = False) -> None:
107
+ buf = event.current_buffer
108
+ if strip_trailing_backslash:
109
+ try:
110
+ doc = buf.document # type: ignore[reportUnknownMemberType]
111
+ if doc.text_before_cursor.endswith("\\"): # type: ignore[reportUnknownMemberType]
112
+ buf.delete_before_cursor() # type: ignore[reportUnknownMemberType]
113
+ except Exception:
114
+ pass
115
+
116
+ with contextlib.suppress(Exception):
117
+ buf.insert_text("\n") # type: ignore[reportUnknownMemberType]
118
+ with contextlib.suppress(Exception):
119
+ event.app.invalidate() # type: ignore[reportUnknownMemberType]
120
+
44
121
  def _can_move_cursor_visually_within_wrapped_line(delta_visible_y: int) -> bool:
45
122
  """Return True when Up/Down should move within a wrapped visual line.
46
123
 
@@ -233,28 +310,85 @@ def create_key_bindings(
233
310
 
234
311
  @kb.add("c-v", filter=enabled)
235
312
  def _(event: KeyPressEvent) -> None:
236
- """Paste image from clipboard as [Image #N]."""
237
- tag = capture_clipboard_tag()
238
- if tag:
313
+ """Paste image from clipboard as an `[image ...]` marker."""
314
+ marker = capture_clipboard_tag()
315
+ if marker:
316
+ with contextlib.suppress(Exception):
317
+ event.current_buffer.insert_text(marker) # pyright: ignore[reportUnknownMemberType]
318
+
319
+ @kb.add(Keys.BracketedPaste, filter=enabled)
320
+ def _(event: KeyPressEvent) -> None:
321
+ """Handle bracketed paste.
322
+
323
+ - Large multi-line pastes are folded into a marker: `[paste #N ...]`.
324
+ - Otherwise, try to convert dropped file URLs/paths into @ tokens or `[image ...]` markers.
325
+ """
326
+
327
+ data = getattr(event, "data", "")
328
+ if not isinstance(data, str) or not data:
329
+ return
330
+
331
+ pasted_lines = data.splitlines()
332
+ line_count = max(1, len(pasted_lines))
333
+ total_chars = len(data)
334
+
335
+ should_fold = line_count > 10 or total_chars > 1000
336
+ if should_fold:
337
+ marker = store_paste(data)
338
+ if marker and not marker.endswith((" ", "\t", "\n")):
339
+ marker += " "
239
340
  with contextlib.suppress(Exception):
240
- event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
341
+ event.current_buffer.insert_text(marker) # pyright: ignore[reportUnknownMemberType]
342
+ return
343
+
344
+ converted = convert_dropped_text(data, cwd=Path.cwd())
345
+ if converted != data and converted and not converted.endswith((" ", "\t", "\n")):
346
+ converted += " "
347
+
348
+ buf = event.current_buffer
349
+ try:
350
+ if buf.selection_state: # type: ignore[reportUnknownMemberType]
351
+ buf.cut_selection() # type: ignore[reportUnknownMemberType]
352
+ except Exception:
353
+ pass
354
+
355
+ with contextlib.suppress(Exception):
356
+ buf.insert_text(converted) # type: ignore[reportUnknownMemberType]
357
+
358
+ @kb.add("escape", "enter", filter=enabled)
359
+ def _(event: KeyPressEvent) -> None:
360
+ """Alt+Enter inserts a newline."""
361
+
362
+ _insert_newline(event)
363
+
364
+ @kb.add("escape", "[", "1", "3", ";", "2", "~", filter=enabled)
365
+ def _(event: KeyPressEvent) -> None:
366
+ """Shift+Enter sequence used by some terminals inserts a newline."""
367
+
368
+ _insert_newline(event)
241
369
 
242
370
  @kb.add("enter", filter=enabled)
243
371
  def _(event: KeyPressEvent) -> None:
372
+ nonlocal swallow_next_control_j
373
+
244
374
  buf = event.current_buffer
245
375
  doc = buf.document # type: ignore
246
376
 
247
- # If VS Code/Windsurf/Cursor sent a "\\" sentinel before Enter (Shift+Enter mapping),
248
- # treat it as a request for a newline instead of submit.
249
- # This allows Shift+Enter to insert a newline in our multiline prompt.
250
- try:
251
- if doc.text_before_cursor.endswith("\\"): # type: ignore[reportUnknownMemberType]
252
- buf.delete_before_cursor() # remove the sentinel backslash # type: ignore[reportUnknownMemberType]
253
- buf.insert_text("\n") # type: ignore[reportUnknownMemberType]
254
- return
255
- except (AttributeError, TypeError):
256
- # Fall through to default behavior if anything goes wrong
257
- pass
377
+ data = getattr(event, "data", "")
378
+ if isinstance(data, str) and _data_requests_newline(data):
379
+ _insert_newline(event)
380
+ return
381
+
382
+ # VS Code-family terminals often implement Shift+Enter via a "\\" sentinel
383
+ # before Enter. Only enable this heuristic under TERM_PROGRAM=vscode.
384
+ if term_program == "vscode":
385
+ try:
386
+ if doc.text_before_cursor.endswith("\\"): # type: ignore[reportUnknownMemberType]
387
+ swallow_next_control_j = True
388
+ _insert_newline(event, strip_trailing_backslash=True)
389
+ return
390
+ except (AttributeError, TypeError):
391
+ pass
258
392
 
259
393
  # When completions are visible, Enter accepts the current selection.
260
394
  # This aligns with common TUI completion UX: navigation doesn't modify
@@ -262,6 +396,21 @@ def create_key_bindings(
262
396
  if not _should_submit_instead_of_accepting_completion(buf) and _accept_current_completion(buf):
263
397
  return
264
398
 
399
+ # Before submitting, expand any folded paste markers so that:
400
+ # - the actual request contains the full pasted content
401
+ # - prompt_toolkit history stores the expanded content
402
+ # Also convert any remaining file:// drops that bypassed bracketed paste.
403
+ try:
404
+ current_text = buf.text # type: ignore[reportUnknownMemberType]
405
+ except Exception:
406
+ current_text = ""
407
+ prepared = expand_paste_markers(current_text)
408
+ prepared = convert_dropped_text(prepared, cwd=Path.cwd())
409
+ if prepared != current_text:
410
+ with contextlib.suppress(Exception):
411
+ buf.text = prepared # type: ignore[reportUnknownMemberType]
412
+ buf.cursor_position = len(prepared) # type: ignore[reportUnknownMemberType]
413
+
265
414
  # If the entire buffer is whitespace-only, insert a newline rather than submitting.
266
415
  if len(buf.text.strip()) == 0: # type: ignore
267
416
  buf.insert_text("\n") # type: ignore
@@ -310,6 +459,11 @@ def create_key_bindings(
310
459
 
311
460
  @kb.add("c-j", filter=enabled)
312
461
  def _(event: KeyPressEvent) -> None:
462
+ nonlocal swallow_next_control_j
463
+ if swallow_next_control_j:
464
+ swallow_next_control_j = False
465
+ return
466
+
313
467
  event.current_buffer.insert_text("\n") # type: ignore
314
468
 
315
469
  @kb.add("c", filter=enabled)
@@ -322,7 +476,7 @@ def create_key_bindings(
322
476
  selected_text: str = doc.text[start:end] # type: ignore[reportUnknownMemberType]
323
477
 
324
478
  if selected_text:
325
- copy_to_clipboard(selected_text) # type: ignore[reportUnknownArgumentType]
479
+ copy_to_clipboard(selected_text)
326
480
  buf.exit_selection() # type: ignore[reportUnknownMemberType]
327
481
  else:
328
482
  buf.insert_text("c") # type: ignore[reportUnknownMemberType]
@@ -0,0 +1,71 @@
1
+ """Fold large multi-line pastes into a short marker.
2
+
3
+ prompt_toolkit already parses terminal bracketed paste mode and exposes the
4
+ pasted payload via a `<bracketed-paste>` key event.
5
+
6
+ We keep the editor buffer small by inserting a marker like:
7
+ - `[paste #3 +42 lines]` (when many lines)
8
+ - `[paste #3 1205 chars]` (when very long)
9
+
10
+ On submit, markers are expanded back to the original pasted content.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ _PASTE_MARKER_RE = re.compile(r"\[paste #(?P<id>\d+)(?: (?P<meta>\+\d+ lines|\d+ chars))?\]")
18
+
19
+
20
+ class PasteBufferState:
21
+ def __init__(self) -> None:
22
+ self._next_id = 1
23
+ self._pastes: dict[int, str] = {}
24
+
25
+ def store(self, text: str) -> str:
26
+ paste_id = self._next_id
27
+ self._next_id += 1
28
+
29
+ lines = text.splitlines()
30
+ line_count = max(1, len(lines))
31
+ total_chars = len(text)
32
+
33
+ if line_count > 10:
34
+ marker = f"[paste #{paste_id} +{line_count} lines]"
35
+ else:
36
+ marker = f"[paste #{paste_id} {total_chars} chars]"
37
+
38
+ self._pastes[paste_id] = text
39
+ return marker
40
+
41
+ def expand_markers(self, text: str) -> str:
42
+ used: set[int] = set()
43
+
44
+ def _replace(m: re.Match[str]) -> str:
45
+ try:
46
+ paste_id = int(m.group("id"))
47
+ except (TypeError, ValueError):
48
+ return m.group(0)
49
+
50
+ content = self._pastes.get(paste_id)
51
+ if content is None:
52
+ return m.group(0)
53
+
54
+ used.add(paste_id)
55
+ return content
56
+
57
+ out = _PASTE_MARKER_RE.sub(_replace, text)
58
+ for pid in used:
59
+ self._pastes.pop(pid, None)
60
+ return out
61
+
62
+
63
+ paste_state = PasteBufferState()
64
+
65
+
66
+ def store_paste(text: str) -> str:
67
+ return paste_state.store(text)
68
+
69
+
70
+ def expand_paste_markers(text: str) -> str:
71
+ return paste_state.expand_markers(text)
@@ -36,9 +36,14 @@ from klaude_code.protocol import llm_param
36
36
  from klaude_code.protocol.commands import CommandInfo
37
37
  from klaude_code.protocol.message import UserInputPayload
38
38
  from klaude_code.tui.components.user_input import USER_MESSAGE_MARK
39
- from klaude_code.tui.input.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
40
39
  from klaude_code.tui.input.completers import AT_TOKEN_PATTERN, create_repl_completer
40
+ from klaude_code.tui.input.drag_drop import convert_dropped_text
41
+ from klaude_code.tui.input.images import (
42
+ capture_clipboard_tag,
43
+ extract_images_from_text,
44
+ )
41
45
  from klaude_code.tui.input.key_bindings import create_key_bindings
46
+ from klaude_code.tui.input.paste import expand_paste_markers
42
47
  from klaude_code.tui.terminal.color import is_light_terminal_background
43
48
  from klaude_code.tui.terminal.selector import SelectItem, SelectOverlay, build_model_select_items
44
49
  from klaude_code.ui.core.input import InputProviderABC
@@ -271,7 +276,6 @@ class PromptToolkitInput(InputProviderABC):
271
276
 
272
277
  kb = create_key_bindings(
273
278
  capture_clipboard_tag=capture_clipboard_tag,
274
- copy_to_clipboard=copy_to_clipboard,
275
279
  at_token_pattern=AT_TOKEN_PATTERN,
276
280
  input_enabled=input_enabled,
277
281
  open_model_picker=self._open_model_picker,
@@ -333,7 +337,7 @@ class PromptToolkitInput(InputProviderABC):
333
337
  pointer="→",
334
338
  use_search_filter=True,
335
339
  search_placeholder="type to search",
336
- list_height=10,
340
+ list_height=20,
337
341
  on_select=self._handle_model_selected,
338
342
  )
339
343
  self._model_picker = model_picker
@@ -669,6 +673,12 @@ class PromptToolkitInput(InputProviderABC):
669
673
  with contextlib.suppress(Exception):
670
674
  self._post_prompt()
671
675
 
676
+ # Expand folded paste markers back into the original content.
677
+ line = expand_paste_markers(line)
678
+
679
+ # Convert drag-and-drop file:// URIs that may have bypassed bracketed paste.
680
+ line = convert_dropped_text(line, cwd=Path.cwd())
681
+
672
682
  # Extract images referenced in the input text
673
683
  images = extract_images_from_text(line)
674
684
 
@@ -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)
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(),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: klaude-code
3
- Version: 2.6.0
3
+ Version: 2.7.0
4
4
  Summary: Minimal code agent CLI
5
5
  Requires-Dist: anthropic>=0.66.0
6
6
  Requires-Dist: chardet>=5.2.0
@@ -9,15 +9,13 @@ Requires-Dist: diff-match-patch>=20241021
9
9
  Requires-Dist: google-genai>=1.56.0
10
10
  Requires-Dist: markdown-it-py>=4.0.0
11
11
  Requires-Dist: openai>=1.102.0
12
- Requires-Dist: pillow>=9.1,<11.0
13
12
  Requires-Dist: prompt-toolkit>=3.0.52
14
13
  Requires-Dist: pydantic>=2.11.7
15
14
  Requires-Dist: pyyaml>=6.0.2
16
15
  Requires-Dist: rich>=14.1.0
17
- Requires-Dist: term-image>=0.7.2
18
16
  Requires-Dist: trafilatura>=2.0.0
19
17
  Requires-Dist: typer>=0.17.3
20
- Requires-Python: >=3.13, <3.14
18
+ Requires-Python: >=3.13
21
19
  Description-Content-Type: text/markdown
22
20
 
23
21
  # Klaude Code
@@ -61,20 +59,22 @@ klaude upgrade
61
59
  ## Usage
62
60
 
63
61
  ```bash
64
- klaude [--model <name>] [--select-model]
62
+ klaude [--model [<name>]] [--continue] [--resume [<id>]]
65
63
  ```
66
64
 
67
65
  **Options:**
68
- - `--model`/`-m`: Preferred model name (exact match picks immediately; otherwise opens the interactive selector filtered by this value).
69
- - `--select-model`/`-s`: Open the interactive model selector at startup (shows all models unless `--model` is also provided).
66
+ - `--model`/`-m`: Choose a model.
67
+ - `--model` (no value): opens the interactive selector.
68
+ - `--model <value>`: resolves `<value>` to a single model; if it can't, it opens the interactive selector filtered by `<value>`.
70
69
  - `--continue`/`-c`: Resume the most recent session.
71
- - `--resume`/`-r`: Select a session to resume for this project.
72
- - `--resume-by-id <id>`: Resume a session by its ID directly.
70
+ - `--resume`/`-r`: Resume a session.
71
+ - `--resume` (no value): select a session to resume for this project.
72
+ - `--resume <id>`: resume a session by its ID directly.
73
73
  - `--vanilla`: Minimal mode with only basic tools (Bash, Read, Edit, Write) and no system prompts.
74
74
 
75
75
  **Model selection behavior:**
76
76
  - Default: uses `main_model` from config.
77
- - `--select-model`: always prompts you to pick.
77
+ - `--model` (no value): always prompts you to pick.
78
78
  - `--model <value>`: tries to resolve `<value>` to a single model; if it can't, it prompts with a filtered list (and falls back to showing all models if there are no matches).
79
79
 
80
80
  **Debug Options:**