klaude-code 1.4.3__py3-none-any.whl → 1.6.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 (37) hide show
  1. klaude_code/cli/main.py +22 -11
  2. klaude_code/cli/runtime.py +171 -34
  3. klaude_code/command/__init__.py +4 -0
  4. klaude_code/command/fork_session_cmd.py +220 -2
  5. klaude_code/command/help_cmd.py +2 -1
  6. klaude_code/command/model_cmd.py +3 -5
  7. klaude_code/command/model_select.py +84 -0
  8. klaude_code/command/refresh_cmd.py +4 -4
  9. klaude_code/command/registry.py +23 -0
  10. klaude_code/command/resume_cmd.py +62 -2
  11. klaude_code/command/thinking_cmd.py +30 -199
  12. klaude_code/config/select_model.py +47 -97
  13. klaude_code/config/thinking.py +255 -0
  14. klaude_code/core/executor.py +53 -63
  15. klaude_code/llm/usage.py +1 -1
  16. klaude_code/protocol/commands.py +11 -0
  17. klaude_code/protocol/op.py +15 -0
  18. klaude_code/session/__init__.py +2 -2
  19. klaude_code/session/selector.py +65 -65
  20. klaude_code/session/session.py +18 -12
  21. klaude_code/ui/modes/repl/completers.py +27 -15
  22. klaude_code/ui/modes/repl/event_handler.py +24 -33
  23. klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
  24. klaude_code/ui/modes/repl/key_bindings.py +30 -10
  25. klaude_code/ui/modes/repl/renderer.py +1 -1
  26. klaude_code/ui/renderers/developer.py +2 -2
  27. klaude_code/ui/renderers/metadata.py +11 -6
  28. klaude_code/ui/renderers/user_input.py +18 -1
  29. klaude_code/ui/rich/markdown.py +41 -9
  30. klaude_code/ui/rich/status.py +83 -22
  31. klaude_code/ui/rich/theme.py +2 -2
  32. klaude_code/ui/terminal/notifier.py +42 -0
  33. klaude_code/ui/terminal/selector.py +488 -136
  34. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
  35. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
  36. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
  37. {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/entry_points.txt +0 -0
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import contextlib
4
4
  import sys
5
+ from collections.abc import Callable, Coroutine
5
6
  from dataclasses import dataclass
6
7
  from functools import partial
8
+ from typing import Any, cast
7
9
 
8
10
  from prompt_toolkit.application import Application
9
11
  from prompt_toolkit.application.current import get_app
@@ -27,6 +29,248 @@ class SelectItem[T]:
27
29
  search_text: str
28
30
 
29
31
 
32
+ # ---------------------------------------------------------------------------
33
+ # Model selection items builder
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ def build_model_select_items(models: list[Any]) -> list[SelectItem[str]]:
38
+ """Build SelectItem list from ModelEntry objects.
39
+
40
+ Args:
41
+ models: List of ModelEntry objects (from config.iter_model_entries).
42
+
43
+ Returns:
44
+ List of SelectItem[str] with model_name as the value.
45
+ """
46
+ if not models:
47
+ return []
48
+
49
+ max_model_name_length = max(len(m.model_name) for m in models)
50
+ num_width = len(str(len(models)))
51
+
52
+ def _thinking_info(m: Any) -> str:
53
+ thinking = m.model_params.thinking
54
+ if not thinking:
55
+ return ""
56
+ if thinking.reasoning_effort:
57
+ return f"reasoning {thinking.reasoning_effort}"
58
+ if thinking.budget_tokens:
59
+ return f"thinking budget {thinking.budget_tokens}"
60
+ return "thinking (configured)"
61
+
62
+ items: list[SelectItem[str]] = []
63
+ for idx, m in enumerate(models, 1):
64
+ model_id = m.model_params.model or "N/A"
65
+ first_line_prefix = f"{m.model_name:<{max_model_name_length}} → "
66
+ thinking_info = _thinking_info(m)
67
+ meta_parts: list[str] = [m.provider]
68
+ if thinking_info:
69
+ meta_parts.append(thinking_info)
70
+ if m.model_params.verbosity:
71
+ meta_parts.append(f"verbosity {m.model_params.verbosity}")
72
+ meta_str = " · ".join(meta_parts)
73
+ title = [
74
+ ("class:meta", f"{idx:>{num_width}}. "),
75
+ ("class:msg", first_line_prefix),
76
+ ("class:msg bold", model_id),
77
+ ("class:meta", f" {meta_str}\n"),
78
+ ]
79
+ search_text = f"{m.model_name} {model_id} {m.provider}"
80
+ items.append(SelectItem(title=title, value=m.model_name, search_text=search_text))
81
+
82
+ return items
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Shared helpers for select_one() and SelectOverlay
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def _restyle_title(title: list[tuple[str, str]], cls: str) -> list[tuple[str, str]]:
91
+ """Re-apply a style class while keeping text attributes like bold/italic."""
92
+ keep_attrs = {"bold", "italic", "underline", "reverse", "blink", "strike"}
93
+ restyled: list[tuple[str, str]] = []
94
+ for old_style, text in title:
95
+ attrs = [tok for tok in old_style.split() if tok in keep_attrs]
96
+ style = f"{cls} {' '.join(attrs)}".strip()
97
+ restyled.append((style, text))
98
+ return restyled
99
+
100
+
101
+ def _indent_multiline_tokens(
102
+ tokens: list[tuple[str, str]],
103
+ indent: str,
104
+ *,
105
+ indent_style: str = "class:text",
106
+ ) -> list[tuple[str, str]]:
107
+ """Indent continuation lines inside formatted tokens.
108
+
109
+ This is needed when an item's title contains embedded newlines. The selector
110
+ prefixes each *item* with the pointer padding, but continuation lines inside
111
+ a single item would otherwise start at column 0.
112
+ """
113
+ if not tokens or all("\n" not in text for _style, text in tokens):
114
+ return tokens
115
+
116
+ def _has_non_newline_text(s: str) -> bool:
117
+ return bool(s.replace("\n", ""))
118
+
119
+ has_text_after_token: list[bool] = [False] * len(tokens)
120
+ remaining = False
121
+ for i in range(len(tokens) - 1, -1, -1):
122
+ has_text_after_token[i] = remaining
123
+ remaining = remaining or _has_non_newline_text(tokens[i][1])
124
+
125
+ out: list[tuple[str, str]] = []
126
+ for token_index, (style, text) in enumerate(tokens):
127
+ if "\n" not in text:
128
+ out.append((style, text))
129
+ continue
130
+
131
+ parts = text.split("\n")
132
+ for part_index, part in enumerate(parts):
133
+ if part:
134
+ out.append((style, part))
135
+
136
+ # If this was a newline, re-add it.
137
+ if part_index < len(parts) - 1:
138
+ out.append((style, "\n"))
139
+
140
+ # Only indent when there is more text remaining within this item.
141
+ has_text_later_in_token = any(p for p in parts[part_index + 1 :])
142
+ if has_text_later_in_token or has_text_after_token[token_index]:
143
+ out.append((indent_style, indent))
144
+
145
+ return out
146
+
147
+
148
+ def _filter_items[T](
149
+ items: list[SelectItem[T]],
150
+ filter_text: str,
151
+ ) -> tuple[list[int], bool]:
152
+ """Return visible item indices and whether any matched the filter."""
153
+ if not items:
154
+ return [], True
155
+ if not filter_text:
156
+ return list(range(len(items))), True
157
+
158
+ needle = filter_text.lower()
159
+ matched = [i for i, it in enumerate(items) if needle in it.search_text.lower()]
160
+ if matched:
161
+ return matched, True
162
+ return list(range(len(items))), False
163
+
164
+
165
+ def _build_choices_tokens[T](
166
+ items: list[SelectItem[T]],
167
+ visible_indices: list[int],
168
+ pointed_at: int,
169
+ pointer: str,
170
+ *,
171
+ highlight_pointed_item: bool = True,
172
+ ) -> list[tuple[str, str]]:
173
+ """Build formatted tokens for the choice list."""
174
+ if not visible_indices:
175
+ return [("class:text", "(no items)\n")]
176
+
177
+ tokens: list[tuple[str, str]] = []
178
+ pointer_pad = " " * (2 + len(pointer))
179
+ pointed_prefix = f" {pointer} "
180
+
181
+ for pos, idx in enumerate(visible_indices):
182
+ is_pointed = pos == pointed_at
183
+ if is_pointed:
184
+ tokens.append(("class:pointer", pointed_prefix))
185
+ tokens.append(("[SetCursorPosition]", ""))
186
+ else:
187
+ tokens.append(("class:text", pointer_pad))
188
+
189
+ if is_pointed and highlight_pointed_item:
190
+ title_tokens = _restyle_title(items[idx].title, "class:highlighted")
191
+ else:
192
+ title_tokens = items[idx].title
193
+
194
+ title_tokens = _indent_multiline_tokens(title_tokens, pointer_pad)
195
+ tokens.extend(title_tokens)
196
+
197
+ return tokens
198
+
199
+
200
+ def _build_rounded_frame(body: Container) -> HSplit:
201
+ """Build a rounded border frame around the given container."""
202
+ border = partial(Window, style="class:frame.border", height=1)
203
+ top = VSplit(
204
+ [
205
+ border(width=1, char="╭"),
206
+ border(char="─"),
207
+ border(width=1, char="╮"),
208
+ ],
209
+ height=1,
210
+ padding=0,
211
+ )
212
+ middle = VSplit(
213
+ [
214
+ border(width=1, char="│"),
215
+ body,
216
+ border(width=1, char="│"),
217
+ ],
218
+ padding=0,
219
+ )
220
+ bottom = VSplit(
221
+ [
222
+ border(width=1, char="╰"),
223
+ border(char="─"),
224
+ border(width=1, char="╯"),
225
+ ],
226
+ height=1,
227
+ padding=0,
228
+ )
229
+ return HSplit([top, middle, bottom], padding=0, style="class:frame")
230
+
231
+
232
+ def _build_search_container(
233
+ search_buffer: Buffer,
234
+ search_placeholder: str,
235
+ ) -> tuple[Window, Container]:
236
+ """Build the search input container with placeholder."""
237
+ placeholder_text = f"{search_placeholder} · ↑↓ to select · esc to quit"
238
+
239
+ search_prefix_window = Window(
240
+ FormattedTextControl([("class:search_prefix", "/ ")]),
241
+ width=2,
242
+ height=1,
243
+ dont_extend_height=Always(),
244
+ always_hide_cursor=Always(),
245
+ )
246
+ input_window = Window(
247
+ BufferControl(buffer=search_buffer),
248
+ height=1,
249
+ dont_extend_height=Always(),
250
+ style="class:search_input",
251
+ )
252
+ placeholder_window = ConditionalContainer(
253
+ content=Window(
254
+ FormattedTextControl([("class:search_placeholder", placeholder_text)]),
255
+ height=1,
256
+ dont_extend_height=Always(),
257
+ always_hide_cursor=Always(),
258
+ ),
259
+ filter=Condition(lambda: search_buffer.text == ""),
260
+ )
261
+ search_input_container = FloatContainer(
262
+ content=input_window,
263
+ floats=[Float(content=placeholder_window, top=0, left=0)],
264
+ )
265
+ framed = _build_rounded_frame(VSplit([search_prefix_window, search_input_container], padding=0))
266
+ return input_window, framed
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # select_one: standalone single-choice selector
271
+ # ---------------------------------------------------------------------------
272
+
273
+
30
274
  def select_one[T](
31
275
  *,
32
276
  message: str,
@@ -36,15 +280,9 @@ def select_one[T](
36
280
  use_search_filter: bool = True,
37
281
  initial_value: T | None = None,
38
282
  search_placeholder: str = "type to search",
283
+ highlight_pointed_item: bool = True,
39
284
  ) -> T | None:
40
- """Terminal single-choice selector based on prompt_toolkit.
41
-
42
- Features:
43
- - Search-as-you-type filter (optional)
44
- - Multi-line titles (via formatted text fragments)
45
- - Highlight entire pointed item via `class:highlighted`
46
- """
47
-
285
+ """Terminal single-choice selector based on prompt_toolkit."""
48
286
  if not items:
49
287
  return None
50
288
 
@@ -54,61 +292,28 @@ def select_one[T](
54
292
 
55
293
  pointed_at = 0
56
294
 
57
- search_buffer: Buffer | None = None
58
- if use_search_filter:
59
- search_buffer = Buffer()
60
-
61
- def visible_indices() -> tuple[list[int], bool]:
62
- filter_text = search_buffer.text if (use_search_filter and search_buffer is not None) else ""
63
- if not filter_text:
64
- return list(range(len(items))), True
65
-
66
- needle = filter_text.lower()
67
- matched = [i for i, it in enumerate(items) if needle in it.search_text.lower()]
68
- if matched:
69
- return matched, True
70
- return list(range(len(items))), False
71
-
72
- def _restyle_title(title: list[tuple[str, str]], cls: str) -> list[tuple[str, str]]:
73
- # Keep simple text attributes like bold/italic while overriding colors via `cls`.
74
- keep_attrs = {"bold", "italic", "underline", "reverse", "blink", "strike"}
75
- restyled: list[tuple[str, str]] = []
76
- for old_style, text in title:
77
- attrs = [tok for tok in old_style.split() if tok in keep_attrs]
78
- style = f"{cls} {' '.join(attrs)}".strip()
79
- restyled.append((style, text))
80
- return restyled
295
+ search_buffer: Buffer | None = Buffer() if use_search_filter else None
296
+
297
+ def get_filter_text() -> str:
298
+ return search_buffer.text if (use_search_filter and search_buffer is not None) else ""
81
299
 
82
300
  def get_header_tokens() -> list[tuple[str, str]]:
83
301
  return [("class:question", message + " ")]
84
302
 
85
303
  def get_choices_tokens() -> list[tuple[str, str]]:
86
304
  nonlocal pointed_at
87
- indices, _found = visible_indices()
88
- if not indices:
89
- return [("class:text", "(no items)\n")]
90
-
91
- pointed_at %= len(indices)
92
- tokens: list[tuple[str, str]] = []
93
-
94
- pointer_pad = " " * (2 + len(pointer))
95
- pointed_prefix = f" {pointer} "
96
-
97
- for pos, idx in enumerate(indices):
98
- is_pointed = pos == pointed_at
99
-
100
- if is_pointed:
101
- tokens.append(("class:pointer", pointed_prefix))
102
- tokens.append(("[SetCursorPosition]", ""))
103
- else:
104
- tokens.append(("class:text", pointer_pad))
105
-
106
- title_tokens = _restyle_title(items[idx].title, "class:highlighted") if is_pointed else items[idx].title
107
- tokens.extend(title_tokens)
108
-
109
- return tokens
305
+ indices, _ = _filter_items(items, get_filter_text())
306
+ if indices:
307
+ pointed_at %= len(indices)
308
+ return _build_choices_tokens(
309
+ items,
310
+ indices,
311
+ pointed_at,
312
+ pointer,
313
+ highlight_pointed_item=highlight_pointed_item,
314
+ )
110
315
 
111
- def _on_search_changed(_buf: Buffer) -> None:
316
+ def on_search_changed(_buf: Buffer) -> None:
112
317
  nonlocal pointed_at
113
318
  pointed_at = 0
114
319
  with contextlib.suppress(Exception):
@@ -118,40 +323,32 @@ def select_one[T](
118
323
 
119
324
  @kb.add(Keys.ControlC, eager=True)
120
325
  @kb.add(Keys.ControlQ, eager=True)
121
- def _cancel(event: KeyPressEvent) -> None:
326
+ def _(event: KeyPressEvent) -> None:
122
327
  event.app.exit(result=None)
123
328
 
124
- _ = _cancel # registered via decorator
125
-
126
329
  @kb.add(Keys.Down, eager=True)
127
- def _down(event: KeyPressEvent) -> None:
330
+ def _(event: KeyPressEvent) -> None:
128
331
  nonlocal pointed_at
129
332
  pointed_at += 1
130
333
  event.app.invalidate()
131
334
 
132
- _ = _down # registered via decorator
133
-
134
335
  @kb.add(Keys.Up, eager=True)
135
- def _up(event: KeyPressEvent) -> None:
336
+ def _(event: KeyPressEvent) -> None:
136
337
  nonlocal pointed_at
137
338
  pointed_at -= 1
138
339
  event.app.invalidate()
139
340
 
140
- _ = _up # registered via decorator
141
-
142
341
  @kb.add(Keys.Enter, eager=True)
143
- def _enter(event: KeyPressEvent) -> None:
144
- indices, _ = visible_indices()
342
+ def _(event: KeyPressEvent) -> None:
343
+ indices, _ = _filter_items(items, get_filter_text())
145
344
  if not indices:
146
345
  event.app.exit(result=None)
147
346
  return
148
347
  idx = indices[pointed_at % len(indices)]
149
348
  event.app.exit(result=items[idx].value)
150
349
 
151
- _ = _enter # registered via decorator
152
-
153
350
  @kb.add(Keys.Escape, eager=True)
154
- def _esc(event: KeyPressEvent) -> None:
351
+ def _(event: KeyPressEvent) -> None:
155
352
  nonlocal pointed_at
156
353
  if use_search_filter and search_buffer is not None and search_buffer.text:
157
354
  search_buffer.reset(append_to_history=False)
@@ -160,15 +357,13 @@ def select_one[T](
160
357
  return
161
358
  event.app.exit(result=None)
162
359
 
163
- _ = _esc # registered via decorator
164
-
165
360
  if use_search_filter and search_buffer is not None:
166
- search_buffer.on_text_changed += _on_search_changed
361
+ search_buffer.on_text_changed += on_search_changed
167
362
 
168
363
  if initial_value is not None:
169
364
  try:
170
365
  full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
171
- indices, _ = visible_indices()
366
+ indices, _ = _filter_items(items, get_filter_text()) # pyright: ignore[reportAssignmentType]
172
367
  pointed_at = indices.index(full_index) if full_index in indices else 0
173
368
  except StopIteration:
174
369
  pointed_at = 0
@@ -193,70 +388,10 @@ def select_one[T](
193
388
  always_hide_cursor=Always(),
194
389
  )
195
390
 
196
- search_container = None
391
+ search_container: Container | None = None
197
392
  search_input_window: Window | None = None
198
393
  if use_search_filter and search_buffer is not None:
199
- placeholder_text = f"{search_placeholder} · ↑↓ to select"
200
-
201
- search_prefix_window = Window(
202
- FormattedTextControl([("class:search_prefix", "/ ")]),
203
- width=2,
204
- height=1,
205
- dont_extend_height=Always(),
206
- always_hide_cursor=Always(),
207
- )
208
- input_window = Window(
209
- BufferControl(buffer=search_buffer),
210
- height=1,
211
- dont_extend_height=Always(),
212
- style="class:search_input",
213
- )
214
- placeholder_window = ConditionalContainer(
215
- content=Window(
216
- FormattedTextControl([("class:search_placeholder", placeholder_text)]),
217
- height=1,
218
- dont_extend_height=Always(),
219
- always_hide_cursor=Always(),
220
- ),
221
- filter=Condition(lambda: search_buffer.text == ""),
222
- )
223
- search_input_window = input_window
224
- search_input_container = FloatContainer(
225
- content=input_window,
226
- floats=[Float(content=placeholder_window, top=0, left=0)],
227
- )
228
-
229
- def _rounded_frame(body: Container) -> HSplit:
230
- border = partial(Window, style="class:frame.border", height=1)
231
- top = VSplit(
232
- [
233
- border(width=1, char="╭"),
234
- border(char="─"),
235
- border(width=1, char="╮"),
236
- ],
237
- height=1,
238
- padding=0,
239
- )
240
- middle = VSplit(
241
- [
242
- border(width=1, char="│"),
243
- body,
244
- border(width=1, char="│"),
245
- ],
246
- padding=0,
247
- )
248
- bottom = VSplit(
249
- [
250
- border(width=1, char="╰"),
251
- border(char="─"),
252
- border(width=1, char="╯"),
253
- ],
254
- height=1,
255
- padding=0,
256
- )
257
- return HSplit([top, middle, bottom], padding=0, style="class:frame")
258
-
259
- search_container = _rounded_frame(VSplit([search_prefix_window, search_input_container], padding=0))
394
+ search_input_window, search_container = _build_search_container(search_buffer, search_placeholder)
260
395
 
261
396
  base_style = Style(
262
397
  [
@@ -281,3 +416,220 @@ def select_one[T](
281
416
  erase_when_done=True,
282
417
  )
283
418
  return app.run()
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # SelectOverlay: embedded overlay for existing prompt_toolkit Application
423
+ # ---------------------------------------------------------------------------
424
+
425
+
426
+ class SelectOverlay[T]:
427
+ """Embedded single-choice selector overlay for an existing prompt_toolkit Application.
428
+
429
+ Unlike `select_one()`, this does not create or run a new Application.
430
+ It is designed for use inside an already-running PromptSession.app.
431
+ """
432
+
433
+ def __init__(
434
+ self,
435
+ *,
436
+ pointer: str = "→",
437
+ use_search_filter: bool = True,
438
+ search_placeholder: str = "type to search",
439
+ list_height: int = 8,
440
+ highlight_pointed_item: bool = True,
441
+ on_select: Callable[[T], Coroutine[Any, Any, None] | None] | None = None,
442
+ on_cancel: Callable[[], Coroutine[Any, Any, None] | None] | None = None,
443
+ ) -> None:
444
+ self._pointer = pointer
445
+ self._use_search_filter = use_search_filter
446
+ self._search_placeholder = search_placeholder
447
+ self._list_height = max(1, list_height)
448
+ self._highlight_pointed_item = highlight_pointed_item
449
+ self._on_select = on_select
450
+ self._on_cancel = on_cancel
451
+
452
+ self._is_open = False
453
+ self._message: str = ""
454
+ self._items: list[SelectItem[T]] = []
455
+ self._pointed_at = 0
456
+
457
+ self._prev_focus: Window | None = None
458
+ self._search_buffer: Buffer | None = Buffer() if use_search_filter else None
459
+
460
+ self._list_window: Window | None = None
461
+ self._search_input_window: Window | None = None
462
+
463
+ self.key_bindings = self._build_key_bindings()
464
+ self.container = self._build_layout()
465
+
466
+ if self._use_search_filter and self._search_buffer is not None:
467
+ self._search_buffer.on_text_changed += self._on_search_changed
468
+
469
+ def _get_filter_text(self) -> str:
470
+ if self._use_search_filter and self._search_buffer is not None:
471
+ return self._search_buffer.text
472
+ return ""
473
+
474
+ def _get_visible_indices(self) -> tuple[list[int], bool]:
475
+ return _filter_items(self._items, self._get_filter_text())
476
+
477
+ def _on_search_changed(self, _buf: Buffer) -> None:
478
+ self._pointed_at = 0
479
+ with contextlib.suppress(Exception):
480
+ get_app().invalidate()
481
+
482
+ def _build_key_bindings(self) -> KeyBindings:
483
+ kb = KeyBindings()
484
+ is_open_filter = Condition(lambda: self._is_open)
485
+
486
+ @kb.add(Keys.Down, filter=is_open_filter, eager=True)
487
+ def _(event: KeyPressEvent) -> None:
488
+ self._pointed_at += 1
489
+ event.app.invalidate()
490
+
491
+ @kb.add(Keys.Up, filter=is_open_filter, eager=True)
492
+ def _(event: KeyPressEvent) -> None:
493
+ self._pointed_at -= 1
494
+ event.app.invalidate()
495
+
496
+ @kb.add(Keys.Enter, filter=is_open_filter, eager=True)
497
+ def _(event: KeyPressEvent) -> None:
498
+ indices, _ = self._get_visible_indices()
499
+ if not indices:
500
+ self.close()
501
+ return
502
+ idx = indices[self._pointed_at % len(indices)]
503
+ value = self._items[idx].value
504
+ self.close()
505
+
506
+ if self._on_select is None:
507
+ return
508
+
509
+ result = self._on_select(value)
510
+ if hasattr(result, "__await__"):
511
+ event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
512
+
513
+ @kb.add(Keys.Escape, filter=is_open_filter, eager=True)
514
+ def _(event: KeyPressEvent) -> None:
515
+ if self._use_search_filter and self._search_buffer is not None and self._search_buffer.text:
516
+ self._search_buffer.reset(append_to_history=False)
517
+ self._pointed_at = 0
518
+ event.app.invalidate()
519
+ return
520
+ self._close_and_invoke_cancel(event)
521
+
522
+ @kb.add(Keys.ControlL, filter=is_open_filter, eager=True)
523
+ def _(event: KeyPressEvent) -> None:
524
+ self.close()
525
+ event.app.invalidate()
526
+
527
+ @kb.add(Keys.ControlC, filter=is_open_filter, eager=True)
528
+ def _(event: KeyPressEvent) -> None:
529
+ self._close_and_invoke_cancel(event)
530
+
531
+ return kb
532
+
533
+ def _close_and_invoke_cancel(self, event: KeyPressEvent) -> None:
534
+ self.close()
535
+ if self._on_cancel is not None:
536
+ result = self._on_cancel()
537
+ if hasattr(result, "__await__"):
538
+ event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
539
+
540
+ def _build_layout(self) -> ConditionalContainer:
541
+ def get_header_tokens() -> list[tuple[str, str]]:
542
+ return [("class:question", self._message + " ")]
543
+
544
+ def get_choices_tokens() -> list[tuple[str, str]]:
545
+ indices, _ = self._get_visible_indices()
546
+ if indices:
547
+ self._pointed_at %= len(indices)
548
+ return _build_choices_tokens(
549
+ self._items,
550
+ indices,
551
+ self._pointed_at,
552
+ self._pointer,
553
+ highlight_pointed_item=self._highlight_pointed_item,
554
+ )
555
+
556
+ header_window = Window(
557
+ FormattedTextControl(get_header_tokens),
558
+ height=1,
559
+ dont_extend_height=Always(),
560
+ always_hide_cursor=Always(),
561
+ )
562
+ spacer_window = Window(
563
+ FormattedTextControl([("", "")]),
564
+ height=1,
565
+ dont_extend_height=Always(),
566
+ always_hide_cursor=Always(),
567
+ )
568
+ list_window = Window(
569
+ FormattedTextControl(get_choices_tokens),
570
+ height=self._list_height,
571
+ scroll_offsets=ScrollOffsets(top=0, bottom=2),
572
+ allow_scroll_beyond_bottom=True,
573
+ dont_extend_height=Always(),
574
+ always_hide_cursor=Always(),
575
+ )
576
+ self._list_window = list_window
577
+
578
+ search_container: Container | None = None
579
+ if self._use_search_filter and self._search_buffer is not None:
580
+ self._search_input_window, search_container = _build_search_container(
581
+ self._search_buffer, self._search_placeholder
582
+ )
583
+
584
+ root_children: list[Container] = [header_window, spacer_window, list_window]
585
+ if search_container is not None:
586
+ root_children.append(search_container)
587
+
588
+ return ConditionalContainer(
589
+ content=HSplit(root_children, padding=0),
590
+ filter=Condition(lambda: self._is_open),
591
+ )
592
+
593
+ @property
594
+ def is_open(self) -> bool:
595
+ return self._is_open
596
+
597
+ def set_content(self, *, message: str, items: list[SelectItem[T]], initial_value: T | None = None) -> None:
598
+ self._message = message
599
+ self._items = items
600
+
601
+ self._pointed_at = 0
602
+ if initial_value is not None:
603
+ try:
604
+ full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
605
+ self._pointed_at = full_index
606
+ except StopIteration:
607
+ self._pointed_at = 0
608
+
609
+ if self._use_search_filter and self._search_buffer is not None:
610
+ self._search_buffer.reset(append_to_history=False)
611
+
612
+ def open(self) -> None:
613
+ if self._is_open:
614
+ return
615
+ self._is_open = True
616
+ app = get_app()
617
+ self._prev_focus = cast(Window | None, getattr(app.layout, "current_window", None))
618
+ with contextlib.suppress(Exception):
619
+ if self._search_input_window is not None:
620
+ app.layout.focus(self._search_input_window)
621
+ elif self._list_window is not None:
622
+ app.layout.focus(self._list_window)
623
+ app.invalidate()
624
+
625
+ def close(self) -> None:
626
+ if not self._is_open:
627
+ return
628
+ self._is_open = False
629
+ app = get_app()
630
+ prev = self._prev_focus
631
+ self._prev_focus = None
632
+ if prev is not None:
633
+ with contextlib.suppress(Exception):
634
+ app.layout.focus(prev)
635
+ app.invalidate()