klaude-code 1.4.3__py3-none-any.whl → 1.5.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/cli/main.py +75 -11
- klaude_code/cli/runtime.py +171 -34
- klaude_code/command/__init__.py +4 -0
- klaude_code/command/help_cmd.py +2 -1
- klaude_code/command/model_cmd.py +3 -5
- klaude_code/command/model_select.py +84 -0
- klaude_code/command/registry.py +23 -0
- klaude_code/command/resume_cmd.py +52 -2
- klaude_code/command/thinking_cmd.py +30 -199
- klaude_code/config/select_model.py +47 -97
- klaude_code/config/thinking.py +255 -0
- klaude_code/core/executor.py +53 -63
- klaude_code/protocol/commands.py +11 -0
- klaude_code/protocol/op.py +15 -0
- klaude_code/session/__init__.py +2 -2
- klaude_code/session/selector.py +33 -61
- klaude_code/ui/modes/repl/completers.py +27 -15
- klaude_code/ui/modes/repl/event_handler.py +2 -1
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
- klaude_code/ui/modes/repl/key_bindings.py +30 -10
- klaude_code/ui/renderers/metadata.py +3 -6
- klaude_code/ui/renderers/user_input.py +18 -1
- klaude_code/ui/rich/theme.py +2 -2
- klaude_code/ui/terminal/notifier.py +42 -0
- klaude_code/ui/terminal/selector.py +419 -136
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/METADATA +1 -1
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/RECORD +29 -27
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.4.3.dist-info → klaude_code-1.5.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,194 @@ 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 _filter_items[T](
|
|
102
|
+
items: list[SelectItem[T]],
|
|
103
|
+
filter_text: str,
|
|
104
|
+
) -> tuple[list[int], bool]:
|
|
105
|
+
"""Return visible item indices and whether any matched the filter."""
|
|
106
|
+
if not items:
|
|
107
|
+
return [], True
|
|
108
|
+
if not filter_text:
|
|
109
|
+
return list(range(len(items))), True
|
|
110
|
+
|
|
111
|
+
needle = filter_text.lower()
|
|
112
|
+
matched = [i for i, it in enumerate(items) if needle in it.search_text.lower()]
|
|
113
|
+
if matched:
|
|
114
|
+
return matched, True
|
|
115
|
+
return list(range(len(items))), False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_choices_tokens[T](
|
|
119
|
+
items: list[SelectItem[T]],
|
|
120
|
+
visible_indices: list[int],
|
|
121
|
+
pointed_at: int,
|
|
122
|
+
pointer: str,
|
|
123
|
+
) -> list[tuple[str, str]]:
|
|
124
|
+
"""Build formatted tokens for the choice list."""
|
|
125
|
+
if not visible_indices:
|
|
126
|
+
return [("class:text", "(no items)\n")]
|
|
127
|
+
|
|
128
|
+
tokens: list[tuple[str, str]] = []
|
|
129
|
+
pointer_pad = " " * (2 + len(pointer))
|
|
130
|
+
pointed_prefix = f" {pointer} "
|
|
131
|
+
|
|
132
|
+
for pos, idx in enumerate(visible_indices):
|
|
133
|
+
is_pointed = pos == pointed_at
|
|
134
|
+
if is_pointed:
|
|
135
|
+
tokens.append(("class:pointer", pointed_prefix))
|
|
136
|
+
tokens.append(("[SetCursorPosition]", ""))
|
|
137
|
+
else:
|
|
138
|
+
tokens.append(("class:text", pointer_pad))
|
|
139
|
+
|
|
140
|
+
title_tokens = _restyle_title(items[idx].title, "class:highlighted") if is_pointed else items[idx].title
|
|
141
|
+
tokens.extend(title_tokens)
|
|
142
|
+
|
|
143
|
+
return tokens
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_rounded_frame(body: Container) -> HSplit:
|
|
147
|
+
"""Build a rounded border frame around the given container."""
|
|
148
|
+
border = partial(Window, style="class:frame.border", height=1)
|
|
149
|
+
top = VSplit(
|
|
150
|
+
[
|
|
151
|
+
border(width=1, char="╭"),
|
|
152
|
+
border(char="─"),
|
|
153
|
+
border(width=1, char="╮"),
|
|
154
|
+
],
|
|
155
|
+
height=1,
|
|
156
|
+
padding=0,
|
|
157
|
+
)
|
|
158
|
+
middle = VSplit(
|
|
159
|
+
[
|
|
160
|
+
border(width=1, char="│"),
|
|
161
|
+
body,
|
|
162
|
+
border(width=1, char="│"),
|
|
163
|
+
],
|
|
164
|
+
padding=0,
|
|
165
|
+
)
|
|
166
|
+
bottom = VSplit(
|
|
167
|
+
[
|
|
168
|
+
border(width=1, char="╰"),
|
|
169
|
+
border(char="─"),
|
|
170
|
+
border(width=1, char="╯"),
|
|
171
|
+
],
|
|
172
|
+
height=1,
|
|
173
|
+
padding=0,
|
|
174
|
+
)
|
|
175
|
+
return HSplit([top, middle, bottom], padding=0, style="class:frame")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _build_search_container(
|
|
179
|
+
search_buffer: Buffer,
|
|
180
|
+
search_placeholder: str,
|
|
181
|
+
) -> tuple[Window, Container]:
|
|
182
|
+
"""Build the search input container with placeholder."""
|
|
183
|
+
placeholder_text = f"{search_placeholder} · ↑↓ to select · esc to quit"
|
|
184
|
+
|
|
185
|
+
search_prefix_window = Window(
|
|
186
|
+
FormattedTextControl([("class:search_prefix", "/ ")]),
|
|
187
|
+
width=2,
|
|
188
|
+
height=1,
|
|
189
|
+
dont_extend_height=Always(),
|
|
190
|
+
always_hide_cursor=Always(),
|
|
191
|
+
)
|
|
192
|
+
input_window = Window(
|
|
193
|
+
BufferControl(buffer=search_buffer),
|
|
194
|
+
height=1,
|
|
195
|
+
dont_extend_height=Always(),
|
|
196
|
+
style="class:search_input",
|
|
197
|
+
)
|
|
198
|
+
placeholder_window = ConditionalContainer(
|
|
199
|
+
content=Window(
|
|
200
|
+
FormattedTextControl([("class:search_placeholder", placeholder_text)]),
|
|
201
|
+
height=1,
|
|
202
|
+
dont_extend_height=Always(),
|
|
203
|
+
always_hide_cursor=Always(),
|
|
204
|
+
),
|
|
205
|
+
filter=Condition(lambda: search_buffer.text == ""),
|
|
206
|
+
)
|
|
207
|
+
search_input_container = FloatContainer(
|
|
208
|
+
content=input_window,
|
|
209
|
+
floats=[Float(content=placeholder_window, top=0, left=0)],
|
|
210
|
+
)
|
|
211
|
+
framed = _build_rounded_frame(VSplit([search_prefix_window, search_input_container], padding=0))
|
|
212
|
+
return input_window, framed
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# select_one: standalone single-choice selector
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
30
220
|
def select_one[T](
|
|
31
221
|
*,
|
|
32
222
|
message: str,
|
|
@@ -37,14 +227,7 @@ def select_one[T](
|
|
|
37
227
|
initial_value: T | None = None,
|
|
38
228
|
search_placeholder: str = "type to search",
|
|
39
229
|
) -> 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
|
-
|
|
230
|
+
"""Terminal single-choice selector based on prompt_toolkit."""
|
|
48
231
|
if not items:
|
|
49
232
|
return None
|
|
50
233
|
|
|
@@ -54,61 +237,22 @@ def select_one[T](
|
|
|
54
237
|
|
|
55
238
|
pointed_at = 0
|
|
56
239
|
|
|
57
|
-
search_buffer: Buffer | None = None
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
240
|
+
search_buffer: Buffer | None = Buffer() if use_search_filter else None
|
|
241
|
+
|
|
242
|
+
def get_filter_text() -> str:
|
|
243
|
+
return search_buffer.text if (use_search_filter and search_buffer is not None) else ""
|
|
81
244
|
|
|
82
245
|
def get_header_tokens() -> list[tuple[str, str]]:
|
|
83
246
|
return [("class:question", message + " ")]
|
|
84
247
|
|
|
85
248
|
def get_choices_tokens() -> list[tuple[str, str]]:
|
|
86
249
|
nonlocal pointed_at
|
|
87
|
-
indices,
|
|
88
|
-
if
|
|
89
|
-
|
|
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))
|
|
250
|
+
indices, _ = _filter_items(items, get_filter_text())
|
|
251
|
+
if indices:
|
|
252
|
+
pointed_at %= len(indices)
|
|
253
|
+
return _build_choices_tokens(items, indices, pointed_at, pointer)
|
|
105
254
|
|
|
106
|
-
|
|
107
|
-
tokens.extend(title_tokens)
|
|
108
|
-
|
|
109
|
-
return tokens
|
|
110
|
-
|
|
111
|
-
def _on_search_changed(_buf: Buffer) -> None:
|
|
255
|
+
def on_search_changed(_buf: Buffer) -> None:
|
|
112
256
|
nonlocal pointed_at
|
|
113
257
|
pointed_at = 0
|
|
114
258
|
with contextlib.suppress(Exception):
|
|
@@ -118,40 +262,32 @@ def select_one[T](
|
|
|
118
262
|
|
|
119
263
|
@kb.add(Keys.ControlC, eager=True)
|
|
120
264
|
@kb.add(Keys.ControlQ, eager=True)
|
|
121
|
-
def
|
|
265
|
+
def _(event: KeyPressEvent) -> None:
|
|
122
266
|
event.app.exit(result=None)
|
|
123
267
|
|
|
124
|
-
_ = _cancel # registered via decorator
|
|
125
|
-
|
|
126
268
|
@kb.add(Keys.Down, eager=True)
|
|
127
|
-
def
|
|
269
|
+
def _(event: KeyPressEvent) -> None:
|
|
128
270
|
nonlocal pointed_at
|
|
129
271
|
pointed_at += 1
|
|
130
272
|
event.app.invalidate()
|
|
131
273
|
|
|
132
|
-
_ = _down # registered via decorator
|
|
133
|
-
|
|
134
274
|
@kb.add(Keys.Up, eager=True)
|
|
135
|
-
def
|
|
275
|
+
def _(event: KeyPressEvent) -> None:
|
|
136
276
|
nonlocal pointed_at
|
|
137
277
|
pointed_at -= 1
|
|
138
278
|
event.app.invalidate()
|
|
139
279
|
|
|
140
|
-
_ = _up # registered via decorator
|
|
141
|
-
|
|
142
280
|
@kb.add(Keys.Enter, eager=True)
|
|
143
|
-
def
|
|
144
|
-
indices, _ =
|
|
281
|
+
def _(event: KeyPressEvent) -> None:
|
|
282
|
+
indices, _ = _filter_items(items, get_filter_text())
|
|
145
283
|
if not indices:
|
|
146
284
|
event.app.exit(result=None)
|
|
147
285
|
return
|
|
148
286
|
idx = indices[pointed_at % len(indices)]
|
|
149
287
|
event.app.exit(result=items[idx].value)
|
|
150
288
|
|
|
151
|
-
_ = _enter # registered via decorator
|
|
152
|
-
|
|
153
289
|
@kb.add(Keys.Escape, eager=True)
|
|
154
|
-
def
|
|
290
|
+
def _(event: KeyPressEvent) -> None:
|
|
155
291
|
nonlocal pointed_at
|
|
156
292
|
if use_search_filter and search_buffer is not None and search_buffer.text:
|
|
157
293
|
search_buffer.reset(append_to_history=False)
|
|
@@ -160,15 +296,13 @@ def select_one[T](
|
|
|
160
296
|
return
|
|
161
297
|
event.app.exit(result=None)
|
|
162
298
|
|
|
163
|
-
_ = _esc # registered via decorator
|
|
164
|
-
|
|
165
299
|
if use_search_filter and search_buffer is not None:
|
|
166
|
-
search_buffer.on_text_changed +=
|
|
300
|
+
search_buffer.on_text_changed += on_search_changed
|
|
167
301
|
|
|
168
302
|
if initial_value is not None:
|
|
169
303
|
try:
|
|
170
304
|
full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
|
|
171
|
-
indices, _ =
|
|
305
|
+
indices, _ = _filter_items(items, get_filter_text()) # pyright: ignore[reportAssignmentType]
|
|
172
306
|
pointed_at = indices.index(full_index) if full_index in indices else 0
|
|
173
307
|
except StopIteration:
|
|
174
308
|
pointed_at = 0
|
|
@@ -193,70 +327,10 @@ def select_one[T](
|
|
|
193
327
|
always_hide_cursor=Always(),
|
|
194
328
|
)
|
|
195
329
|
|
|
196
|
-
search_container = None
|
|
330
|
+
search_container: Container | None = None
|
|
197
331
|
search_input_window: Window | None = None
|
|
198
332
|
if use_search_filter and search_buffer is not None:
|
|
199
|
-
|
|
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))
|
|
333
|
+
search_input_window, search_container = _build_search_container(search_buffer, search_placeholder)
|
|
260
334
|
|
|
261
335
|
base_style = Style(
|
|
262
336
|
[
|
|
@@ -281,3 +355,212 @@ def select_one[T](
|
|
|
281
355
|
erase_when_done=True,
|
|
282
356
|
)
|
|
283
357
|
return app.run()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# SelectOverlay: embedded overlay for existing prompt_toolkit Application
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class SelectOverlay[T]:
|
|
366
|
+
"""Embedded single-choice selector overlay for an existing prompt_toolkit Application.
|
|
367
|
+
|
|
368
|
+
Unlike `select_one()`, this does not create or run a new Application.
|
|
369
|
+
It is designed for use inside an already-running PromptSession.app.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
def __init__(
|
|
373
|
+
self,
|
|
374
|
+
*,
|
|
375
|
+
pointer: str = "→",
|
|
376
|
+
use_search_filter: bool = True,
|
|
377
|
+
search_placeholder: str = "type to search",
|
|
378
|
+
list_height: int = 8,
|
|
379
|
+
on_select: Callable[[T], Coroutine[Any, Any, None] | None] | None = None,
|
|
380
|
+
on_cancel: Callable[[], Coroutine[Any, Any, None] | None] | None = None,
|
|
381
|
+
) -> None:
|
|
382
|
+
self._pointer = pointer
|
|
383
|
+
self._use_search_filter = use_search_filter
|
|
384
|
+
self._search_placeholder = search_placeholder
|
|
385
|
+
self._list_height = max(1, list_height)
|
|
386
|
+
self._on_select = on_select
|
|
387
|
+
self._on_cancel = on_cancel
|
|
388
|
+
|
|
389
|
+
self._is_open = False
|
|
390
|
+
self._message: str = ""
|
|
391
|
+
self._items: list[SelectItem[T]] = []
|
|
392
|
+
self._pointed_at = 0
|
|
393
|
+
|
|
394
|
+
self._prev_focus: Window | None = None
|
|
395
|
+
self._search_buffer: Buffer | None = Buffer() if use_search_filter else None
|
|
396
|
+
|
|
397
|
+
self._list_window: Window | None = None
|
|
398
|
+
self._search_input_window: Window | None = None
|
|
399
|
+
|
|
400
|
+
self.key_bindings = self._build_key_bindings()
|
|
401
|
+
self.container = self._build_layout()
|
|
402
|
+
|
|
403
|
+
if self._use_search_filter and self._search_buffer is not None:
|
|
404
|
+
self._search_buffer.on_text_changed += self._on_search_changed
|
|
405
|
+
|
|
406
|
+
def _get_filter_text(self) -> str:
|
|
407
|
+
if self._use_search_filter and self._search_buffer is not None:
|
|
408
|
+
return self._search_buffer.text
|
|
409
|
+
return ""
|
|
410
|
+
|
|
411
|
+
def _get_visible_indices(self) -> tuple[list[int], bool]:
|
|
412
|
+
return _filter_items(self._items, self._get_filter_text())
|
|
413
|
+
|
|
414
|
+
def _on_search_changed(self, _buf: Buffer) -> None:
|
|
415
|
+
self._pointed_at = 0
|
|
416
|
+
with contextlib.suppress(Exception):
|
|
417
|
+
get_app().invalidate()
|
|
418
|
+
|
|
419
|
+
def _build_key_bindings(self) -> KeyBindings:
|
|
420
|
+
kb = KeyBindings()
|
|
421
|
+
is_open_filter = Condition(lambda: self._is_open)
|
|
422
|
+
|
|
423
|
+
@kb.add(Keys.Down, filter=is_open_filter, eager=True)
|
|
424
|
+
def _(event: KeyPressEvent) -> None:
|
|
425
|
+
self._pointed_at += 1
|
|
426
|
+
event.app.invalidate()
|
|
427
|
+
|
|
428
|
+
@kb.add(Keys.Up, filter=is_open_filter, eager=True)
|
|
429
|
+
def _(event: KeyPressEvent) -> None:
|
|
430
|
+
self._pointed_at -= 1
|
|
431
|
+
event.app.invalidate()
|
|
432
|
+
|
|
433
|
+
@kb.add(Keys.Enter, filter=is_open_filter, eager=True)
|
|
434
|
+
def _(event: KeyPressEvent) -> None:
|
|
435
|
+
indices, _ = self._get_visible_indices()
|
|
436
|
+
if not indices:
|
|
437
|
+
self.close()
|
|
438
|
+
return
|
|
439
|
+
idx = indices[self._pointed_at % len(indices)]
|
|
440
|
+
value = self._items[idx].value
|
|
441
|
+
self.close()
|
|
442
|
+
|
|
443
|
+
if self._on_select is None:
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
result = self._on_select(value)
|
|
447
|
+
if hasattr(result, "__await__"):
|
|
448
|
+
event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
|
|
449
|
+
|
|
450
|
+
@kb.add(Keys.Escape, filter=is_open_filter, eager=True)
|
|
451
|
+
def _(event: KeyPressEvent) -> None:
|
|
452
|
+
if self._use_search_filter and self._search_buffer is not None and self._search_buffer.text:
|
|
453
|
+
self._search_buffer.reset(append_to_history=False)
|
|
454
|
+
self._pointed_at = 0
|
|
455
|
+
event.app.invalidate()
|
|
456
|
+
return
|
|
457
|
+
self._close_and_invoke_cancel(event)
|
|
458
|
+
|
|
459
|
+
@kb.add(Keys.ControlL, filter=is_open_filter, eager=True)
|
|
460
|
+
def _(event: KeyPressEvent) -> None:
|
|
461
|
+
self.close()
|
|
462
|
+
event.app.invalidate()
|
|
463
|
+
|
|
464
|
+
@kb.add(Keys.ControlC, filter=is_open_filter, eager=True)
|
|
465
|
+
def _(event: KeyPressEvent) -> None:
|
|
466
|
+
self._close_and_invoke_cancel(event)
|
|
467
|
+
|
|
468
|
+
return kb
|
|
469
|
+
|
|
470
|
+
def _close_and_invoke_cancel(self, event: KeyPressEvent) -> None:
|
|
471
|
+
self.close()
|
|
472
|
+
if self._on_cancel is not None:
|
|
473
|
+
result = self._on_cancel()
|
|
474
|
+
if hasattr(result, "__await__"):
|
|
475
|
+
event.app.create_background_task(cast(Coroutine[Any, Any, None], result))
|
|
476
|
+
|
|
477
|
+
def _build_layout(self) -> ConditionalContainer:
|
|
478
|
+
def get_header_tokens() -> list[tuple[str, str]]:
|
|
479
|
+
return [("class:question", self._message + " ")]
|
|
480
|
+
|
|
481
|
+
def get_choices_tokens() -> list[tuple[str, str]]:
|
|
482
|
+
indices, _ = self._get_visible_indices()
|
|
483
|
+
if indices:
|
|
484
|
+
self._pointed_at %= len(indices)
|
|
485
|
+
return _build_choices_tokens(self._items, indices, self._pointed_at, self._pointer)
|
|
486
|
+
|
|
487
|
+
header_window = Window(
|
|
488
|
+
FormattedTextControl(get_header_tokens),
|
|
489
|
+
height=1,
|
|
490
|
+
dont_extend_height=Always(),
|
|
491
|
+
always_hide_cursor=Always(),
|
|
492
|
+
)
|
|
493
|
+
spacer_window = Window(
|
|
494
|
+
FormattedTextControl([("", "")]),
|
|
495
|
+
height=1,
|
|
496
|
+
dont_extend_height=Always(),
|
|
497
|
+
always_hide_cursor=Always(),
|
|
498
|
+
)
|
|
499
|
+
list_window = Window(
|
|
500
|
+
FormattedTextControl(get_choices_tokens),
|
|
501
|
+
height=self._list_height,
|
|
502
|
+
scroll_offsets=ScrollOffsets(top=0, bottom=2),
|
|
503
|
+
allow_scroll_beyond_bottom=True,
|
|
504
|
+
dont_extend_height=Always(),
|
|
505
|
+
always_hide_cursor=Always(),
|
|
506
|
+
)
|
|
507
|
+
self._list_window = list_window
|
|
508
|
+
|
|
509
|
+
search_container: Container | None = None
|
|
510
|
+
if self._use_search_filter and self._search_buffer is not None:
|
|
511
|
+
self._search_input_window, search_container = _build_search_container(
|
|
512
|
+
self._search_buffer, self._search_placeholder
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
root_children: list[Container] = [header_window, spacer_window, list_window]
|
|
516
|
+
if search_container is not None:
|
|
517
|
+
root_children.append(search_container)
|
|
518
|
+
|
|
519
|
+
return ConditionalContainer(
|
|
520
|
+
content=HSplit(root_children, padding=0),
|
|
521
|
+
filter=Condition(lambda: self._is_open),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
@property
|
|
525
|
+
def is_open(self) -> bool:
|
|
526
|
+
return self._is_open
|
|
527
|
+
|
|
528
|
+
def set_content(self, *, message: str, items: list[SelectItem[T]], initial_value: T | None = None) -> None:
|
|
529
|
+
self._message = message
|
|
530
|
+
self._items = items
|
|
531
|
+
|
|
532
|
+
self._pointed_at = 0
|
|
533
|
+
if initial_value is not None:
|
|
534
|
+
try:
|
|
535
|
+
full_index = next(i for i, it in enumerate(items) if it.value == initial_value)
|
|
536
|
+
self._pointed_at = full_index
|
|
537
|
+
except StopIteration:
|
|
538
|
+
self._pointed_at = 0
|
|
539
|
+
|
|
540
|
+
if self._use_search_filter and self._search_buffer is not None:
|
|
541
|
+
self._search_buffer.reset(append_to_history=False)
|
|
542
|
+
|
|
543
|
+
def open(self) -> None:
|
|
544
|
+
if self._is_open:
|
|
545
|
+
return
|
|
546
|
+
self._is_open = True
|
|
547
|
+
app = get_app()
|
|
548
|
+
self._prev_focus = cast(Window | None, getattr(app.layout, "current_window", None))
|
|
549
|
+
with contextlib.suppress(Exception):
|
|
550
|
+
if self._search_input_window is not None:
|
|
551
|
+
app.layout.focus(self._search_input_window)
|
|
552
|
+
elif self._list_window is not None:
|
|
553
|
+
app.layout.focus(self._list_window)
|
|
554
|
+
app.invalidate()
|
|
555
|
+
|
|
556
|
+
def close(self) -> None:
|
|
557
|
+
if not self._is_open:
|
|
558
|
+
return
|
|
559
|
+
self._is_open = False
|
|
560
|
+
app = get_app()
|
|
561
|
+
prev = self._prev_focus
|
|
562
|
+
self._prev_focus = None
|
|
563
|
+
if prev is not None:
|
|
564
|
+
with contextlib.suppress(Exception):
|
|
565
|
+
app.layout.focus(prev)
|
|
566
|
+
app.invalidate()
|