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.
- klaude_code/cli/main.py +22 -11
- klaude_code/cli/runtime.py +171 -34
- klaude_code/command/__init__.py +4 -0
- klaude_code/command/fork_session_cmd.py +220 -2
- 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/refresh_cmd.py +4 -4
- klaude_code/command/registry.py +23 -0
- klaude_code/command/resume_cmd.py +62 -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/llm/usage.py +1 -1
- 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 +65 -65
- klaude_code/session/session.py +18 -12
- klaude_code/ui/modes/repl/completers.py +27 -15
- klaude_code/ui/modes/repl/event_handler.py +24 -33
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +393 -57
- klaude_code/ui/modes/repl/key_bindings.py +30 -10
- klaude_code/ui/modes/repl/renderer.py +1 -1
- klaude_code/ui/renderers/developer.py +2 -2
- klaude_code/ui/renderers/metadata.py +11 -6
- klaude_code/ui/renderers/user_input.py +18 -1
- klaude_code/ui/rich/markdown.py +41 -9
- klaude_code/ui/rich/status.py +83 -22
- klaude_code/ui/rich/theme.py +2 -2
- klaude_code/ui/terminal/notifier.py +42 -0
- klaude_code/ui/terminal/selector.py +488 -136
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/METADATA +1 -1
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/RECORD +37 -35
- {klaude_code-1.4.3.dist-info → klaude_code-1.6.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
|
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,
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
144
|
-
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
|
|
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 +=
|
|
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, _ =
|
|
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
|
-
|
|
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()
|