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.
@@ -1,26 +1,41 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import contextlib
4
5
  import shutil
5
- from collections.abc import AsyncIterator, Callable
6
+ import time
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
8
  from pathlib import Path
7
9
  from typing import NamedTuple, override
8
10
 
9
11
  import prompt_toolkit.layout.menus as pt_menus
10
12
  from prompt_toolkit import PromptSession
11
13
  from prompt_toolkit.application.current import get_app
14
+ from prompt_toolkit.buffer import Buffer
12
15
  from prompt_toolkit.completion import Completion, ThreadedCompleter
13
16
  from prompt_toolkit.cursor_shapes import CursorShape
14
17
  from prompt_toolkit.data_structures import Point
18
+ from prompt_toolkit.filters import Condition
15
19
  from prompt_toolkit.formatted_text import FormattedText, StyleAndTextTuples, to_formatted_text
16
20
  from prompt_toolkit.history import FileHistory
21
+ from prompt_toolkit.key_binding import merge_key_bindings
22
+ from prompt_toolkit.layout import Float
17
23
  from prompt_toolkit.layout.containers import Container, FloatContainer, Window
18
- from prompt_toolkit.layout.controls import UIContent
24
+ from prompt_toolkit.layout.controls import BufferControl, UIContent
19
25
  from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
20
26
  from prompt_toolkit.patch_stdout import patch_stdout
21
27
  from prompt_toolkit.styles import Style
22
28
  from prompt_toolkit.utils import get_cwidth
23
29
 
30
+ from klaude_code.config import load_config
31
+ from klaude_code.config.config import ModelEntry
32
+ from klaude_code.config.thinking import (
33
+ format_current_thinking,
34
+ get_thinking_picker_data,
35
+ parse_thinking_value,
36
+ )
37
+ from klaude_code.protocol import llm_param
38
+ from klaude_code.protocol.commands import CommandInfo
24
39
  from klaude_code.protocol.model import UserInputPayload
25
40
  from klaude_code.ui.core.input import InputProviderABC
26
41
  from klaude_code.ui.modes.repl.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
@@ -28,6 +43,7 @@ from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_c
28
43
  from klaude_code.ui.modes.repl.key_bindings import create_key_bindings
29
44
  from klaude_code.ui.renderers.user_input import USER_MESSAGE_MARK
30
45
  from klaude_code.ui.terminal.color import is_light_terminal_background
46
+ from klaude_code.ui.terminal.selector import SelectItem, SelectOverlay, build_model_select_items
31
47
 
32
48
 
33
49
  class REPLStatusSnapshot(NamedTuple):
@@ -49,6 +65,11 @@ PLACEHOLDER_SYMBOL_STYLE_LIGHT_BG = "bg:#e6e6e6 fg:#7a7a7a"
49
65
  PLACEHOLDER_SYMBOL_STYLE_UNKNOWN_BG = "bg:#2a2a2a fg:#8a8a8a"
50
66
 
51
67
 
68
+ # ---------------------------------------------------------------------------
69
+ # Layout helpers
70
+ # ---------------------------------------------------------------------------
71
+
72
+
52
73
  def _left_align_completion_menus(container: Container) -> None:
53
74
  """Force completion menus to render at column 0.
54
75
 
@@ -57,7 +78,6 @@ def _left_align_completion_menus(container: Container) -> None:
57
78
  We walk the layout tree and rewrite the Float positioning for completion menus
58
79
  to keep them fixed at the left edge.
59
80
  """
60
-
61
81
  if isinstance(container, FloatContainer):
62
82
  for flt in container.floats:
63
83
  if isinstance(flt.content, (CompletionsMenu, MultiColumnCompletionsMenu)):
@@ -68,12 +88,53 @@ def _left_align_completion_menus(container: Container) -> None:
68
88
  _left_align_completion_menus(child)
69
89
 
70
90
 
91
+ def _find_first_float_container(container: Container) -> FloatContainer | None:
92
+ if isinstance(container, FloatContainer):
93
+ return container
94
+ for child in container.get_children():
95
+ found = _find_first_float_container(child)
96
+ if found is not None:
97
+ return found
98
+ return None
99
+
100
+
101
+ def _find_window_for_buffer(container: Container, target_buffer: Buffer) -> Window | None:
102
+ if isinstance(container, Window):
103
+ content = container.content
104
+ if isinstance(content, BufferControl) and content.buffer is target_buffer:
105
+ return container
106
+
107
+ for child in container.get_children():
108
+ found = _find_window_for_buffer(child, target_buffer)
109
+ if found is not None:
110
+ return found
111
+ return None
112
+
113
+
114
+ def _patch_completion_menu_controls(container: Container) -> None:
115
+ """Replace prompt_toolkit completion menu controls with customized versions."""
116
+ if isinstance(container, Window):
117
+ content = container.content
118
+ if isinstance(content, pt_menus.CompletionsMenuControl) and not isinstance(
119
+ content, _KlaudeCompletionsMenuControl
120
+ ):
121
+ container.content = _KlaudeCompletionsMenuControl()
122
+
123
+ for child in container.get_children():
124
+ _patch_completion_menu_controls(child)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Custom completion menu control
129
+ # ---------------------------------------------------------------------------
130
+
131
+
71
132
  class _KlaudeCompletionsMenuControl(pt_menus.CompletionsMenuControl):
72
133
  """CompletionsMenuControl with stable 2-char left prefix.
73
134
 
74
135
  Requirements:
75
136
  - Add a 2-character prefix for every row.
76
- - Render " " for the selected row, and " " for non-selected rows.
137
+ - Render "-> " for the selected row, and " " for non-selected rows.
77
138
 
78
139
  Keep completion text unstyled so that the menu's current-row style can
79
140
  override it entirely.
@@ -85,9 +146,8 @@ class _KlaudeCompletionsMenuControl(pt_menus.CompletionsMenuControl):
85
146
  """Return the width of the main column.
86
147
 
87
148
  This is prompt_toolkit's default implementation, except we reserve one
88
- extra character for the 2-char prefix (" "/" ").
149
+ extra character for the 2-char prefix ("-> "/" ").
89
150
  """
90
-
91
151
  return min(
92
152
  max_width,
93
153
  max(
@@ -157,18 +217,9 @@ class _KlaudeCompletionsMenuControl(pt_menus.CompletionsMenuControl):
157
217
  )
158
218
 
159
219
 
160
- def _patch_completion_menu_controls(container: Container) -> None:
161
- """Replace prompt_toolkit completion menu controls with customized versions."""
162
-
163
- if isinstance(container, Window):
164
- content = container.content
165
- if isinstance(content, pt_menus.CompletionsMenuControl) and not isinstance(
166
- content, _KlaudeCompletionsMenuControl
167
- ):
168
- container.content = _KlaudeCompletionsMenuControl()
169
-
170
- for child in container.get_children():
171
- _patch_completion_menu_controls(child)
220
+ # ---------------------------------------------------------------------------
221
+ # PromptToolkitInput
222
+ # ---------------------------------------------------------------------------
172
223
 
173
224
 
174
225
  class PromptToolkitInput(InputProviderABC):
@@ -179,27 +230,57 @@ class PromptToolkitInput(InputProviderABC):
179
230
  pre_prompt: Callable[[], None] | None = None,
180
231
  post_prompt: Callable[[], None] | None = None,
181
232
  is_light_background: bool | None = None,
182
- ): #
233
+ on_change_model: Callable[[str], Awaitable[None]] | None = None,
234
+ get_current_model_config_name: Callable[[], str | None] | None = None,
235
+ on_change_thinking: Callable[[llm_param.Thinking], Awaitable[None]] | None = None,
236
+ get_current_llm_config: Callable[[], llm_param.LLMConfigParameter | None] | None = None,
237
+ command_info_provider: Callable[[], list[CommandInfo]] | None = None,
238
+ ):
183
239
  self._status_provider = status_provider
184
240
  self._pre_prompt = pre_prompt
185
241
  self._post_prompt = post_prompt
242
+ self._on_change_model = on_change_model
243
+ self._get_current_model_config_name = get_current_model_config_name
244
+ self._on_change_thinking = on_change_thinking
245
+ self._get_current_llm_config = get_current_llm_config
246
+ self._command_info_provider = command_info_provider
247
+
248
+ self._toast_message: str | None = None
249
+ self._toast_until: float = 0.0
250
+
186
251
  # Use provided value if available to avoid redundant TTY queries that may interfere
187
252
  # with prompt_toolkit's terminal state after interactive UIs have been used.
188
253
  self._is_light_terminal_background = (
189
254
  is_light_background if is_light_background is not None else is_light_terminal_background(timeout=0.2)
190
255
  )
191
256
 
257
+ self._session = self._build_prompt_session(prompt)
258
+ self._setup_model_picker()
259
+ self._setup_thinking_picker()
260
+ self._apply_layout_customizations()
261
+
262
+ def _build_prompt_session(self, prompt: str) -> PromptSession[str]:
263
+ """Build the prompt_toolkit PromptSession with key bindings and styles."""
192
264
  project = str(Path.cwd()).strip("/").replace("/", "-")
193
265
  history_path = Path.home() / ".klaude" / "projects" / project / "input" / "input_history.txt"
194
-
195
266
  history_path.parent.mkdir(parents=True, exist_ok=True)
196
267
  history_path.touch(exist_ok=True)
197
268
 
198
- # Create key bindings with injected dependencies
269
+ # Model and thinking pickers will be set up later; create placeholder condition
270
+ self._model_picker: SelectOverlay[str] | None = None
271
+ self._thinking_picker: SelectOverlay[str] | None = None
272
+ input_enabled = Condition(
273
+ lambda: (self._model_picker is None or not self._model_picker.is_open)
274
+ and (self._thinking_picker is None or not self._thinking_picker.is_open)
275
+ )
276
+
199
277
  kb = create_key_bindings(
200
278
  capture_clipboard_tag=capture_clipboard_tag,
201
279
  copy_to_clipboard=copy_to_clipboard,
202
280
  at_token_pattern=AT_TOKEN_PATTERN,
281
+ input_enabled=input_enabled,
282
+ open_model_picker=self._open_model_picker,
283
+ open_thinking_picker=self._open_thinking_picker,
203
284
  )
204
285
 
205
286
  # Select completion selected color based on terminal background
@@ -210,14 +291,14 @@ class PromptToolkitInput(InputProviderABC):
210
291
  else:
211
292
  completion_selected = COMPLETION_SELECTED_UNKNOWN_BG
212
293
 
213
- self._session: PromptSession[str] = PromptSession(
294
+ return PromptSession(
214
295
  [(INPUT_PROMPT_STYLE, prompt)],
215
296
  history=FileHistory(str(history_path)),
216
297
  multiline=True,
217
298
  cursor=CursorShape.BLINKING_BEAM,
218
299
  prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
219
300
  key_bindings=kb,
220
- completer=ThreadedCompleter(create_repl_completer()),
301
+ completer=ThreadedCompleter(create_repl_completer(command_info_provider=self._command_info_provider)),
221
302
  complete_while_typing=True,
222
303
  erase_when_done=True,
223
304
  mouse_support=False,
@@ -231,53 +312,299 @@ class PromptToolkitInput(InputProviderABC):
231
312
  "completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
232
313
  "completion-menu.completion.current": f"noreverse bg:default fg:{completion_selected}",
233
314
  "completion-menu.meta.completion.current": f"bg:default fg:{completion_selected}",
315
+ # Embedded selector overlay styles
316
+ "pointer": "ansigreen",
317
+ "highlighted": "ansigreen",
318
+ "text": "ansibrightblack",
319
+ "question": "bold",
320
+ "msg": "",
321
+ "meta": "fg:ansibrightblack",
322
+ "frame.border": "fg:ansibrightblack",
323
+ "search_prefix": "fg:ansibrightblack",
324
+ "search_placeholder": "fg:ansibrightblack italic",
325
+ "search_input": "",
326
+ # Empty bottom-toolbar style
327
+ "bottom-toolbar": "bg:default fg:default noreverse",
328
+ "bottom-toolbar.text": "bg:default fg:default noreverse",
234
329
  }
235
330
  ),
236
331
  )
237
332
 
238
- # Keep completion popups left-aligned instead of shifting with the caret.
333
+ def _setup_model_picker(self) -> None:
334
+ """Initialize the model picker overlay and attach it to the layout."""
335
+ model_picker = SelectOverlay[str](
336
+ pointer="→",
337
+ use_search_filter=True,
338
+ search_placeholder="type to search",
339
+ list_height=10,
340
+ on_select=self._handle_model_selected,
341
+ )
342
+ self._model_picker = model_picker
343
+
344
+ # Merge overlay key bindings with existing session key bindings
345
+ existing_kb = self._session.key_bindings
346
+ if existing_kb is not None:
347
+ merged_kb = merge_key_bindings([existing_kb, model_picker.key_bindings])
348
+ self._session.key_bindings = merged_kb
349
+
350
+ # Attach overlay as a float above the prompt
351
+ with contextlib.suppress(Exception):
352
+ root = self._session.app.layout.container
353
+ overlay_float = Float(content=model_picker.container, bottom=1, left=0)
354
+
355
+ # Always attach this overlay at the top level so it is not clipped by
356
+ # small nested FloatContainers (e.g. the completion-menu container).
357
+ if isinstance(root, FloatContainer):
358
+ root.floats.append(overlay_float)
359
+ else:
360
+ self._session.app.layout.container = FloatContainer(content=root, floats=[overlay_float])
361
+
362
+ def _setup_thinking_picker(self) -> None:
363
+ """Initialize the thinking picker overlay and attach it to the layout."""
364
+ thinking_picker = SelectOverlay[str](
365
+ pointer="→",
366
+ use_search_filter=False,
367
+ list_height=6,
368
+ on_select=self._handle_thinking_selected,
369
+ )
370
+ self._thinking_picker = thinking_picker
371
+
372
+ # Merge overlay key bindings with existing session key bindings
373
+ existing_kb = self._session.key_bindings
374
+ if existing_kb is not None:
375
+ merged_kb = merge_key_bindings([existing_kb, thinking_picker.key_bindings])
376
+ self._session.key_bindings = merged_kb
377
+
378
+ # Attach overlay as a float above the prompt
379
+ with contextlib.suppress(Exception):
380
+ root = self._session.app.layout.container
381
+ overlay_float = Float(content=thinking_picker.container, bottom=1, left=0)
382
+
383
+ if isinstance(root, FloatContainer):
384
+ root.floats.append(overlay_float)
385
+ else:
386
+ self._session.app.layout.container = FloatContainer(content=root, floats=[overlay_float])
387
+
388
+ def _apply_layout_customizations(self) -> None:
389
+ """Apply layout customizations after session is created."""
390
+ # Make the Escape key feel responsive
391
+ with contextlib.suppress(Exception):
392
+ self._session.app.ttimeoutlen = 0.05
393
+
394
+ # Keep completion popups left-aligned
239
395
  with contextlib.suppress(Exception):
240
396
  _left_align_completion_menus(self._session.app.layout.container)
241
397
 
242
- # Customize completion rendering (2-space indent + selected arrow prefix).
398
+ # Customize completion rendering
243
399
  with contextlib.suppress(Exception):
244
400
  _patch_completion_menu_controls(self._session.app.layout.container)
245
401
 
246
- def _select_first_completion_on_open(buf) -> None: # type: ignore[no-untyped-def]
247
- """Default to selecting the first completion without inserting it."""
402
+ # Reserve more vertical space while the model picker overlay is open.
403
+ # prompt_toolkit's default multiline prompt caps out at ~9 lines.
404
+ self._patch_prompt_height_for_model_picker()
248
405
 
249
- try:
250
- state = buf.complete_state # type: ignore[reportUnknownMemberType]
251
- if state is None:
252
- return
253
- if not state.completions: # type: ignore[reportUnknownMemberType]
254
- return
255
- if state.complete_index is None: # type: ignore[reportUnknownMemberType]
256
- state.complete_index = 0 # type: ignore[reportUnknownMemberType]
257
- with contextlib.suppress(Exception):
258
- self._session.app.invalidate()
259
- except Exception:
260
- return
406
+ # Ensure completion menu has default selection
407
+ self._session.default_buffer.on_completions_changed += self._select_first_completion_on_open # pyright: ignore[reportUnknownMemberType]
261
408
 
262
- # Ensure the completion menu always has a default selection (first item),
263
- # so Enter/Tab can accept immediately.
264
- self._session.default_buffer.on_completions_changed += _select_first_completion_on_open
409
+ def _patch_prompt_height_for_model_picker(self) -> None:
410
+ if self._model_picker is None and self._thinking_picker is None:
411
+ return
265
412
 
266
- def _get_bottom_toolbar(self) -> FormattedText | None:
267
- """Return bottom toolbar content only when there's an update message available."""
268
- if not self._status_provider:
269
- return None
413
+ with contextlib.suppress(Exception):
414
+ root = self._session.app.layout.container
415
+ input_window = _find_window_for_buffer(root, self._session.default_buffer)
416
+ if input_window is None:
417
+ return
418
+
419
+ original_height = input_window.height
270
420
 
421
+ def _height(): # type: ignore[no-untyped-def]
422
+ picker_open = (self._model_picker is not None and self._model_picker.is_open) or (
423
+ self._thinking_picker is not None and self._thinking_picker.is_open
424
+ )
425
+ if picker_open:
426
+ # Target 20 rows, but cap to the current terminal size.
427
+ # Leave a small buffer to avoid triggering "Window too small".
428
+ try:
429
+ rows = get_app().output.get_size().rows
430
+ except Exception:
431
+ rows = 0
432
+ return max(3, min(20, rows - 2))
433
+
434
+ if callable(original_height):
435
+ return original_height()
436
+ return original_height
437
+
438
+ input_window.height = _height
439
+
440
+ def _select_first_completion_on_open(self, buf) -> None: # type: ignore[no-untyped-def]
441
+ """Default to selecting the first completion without inserting it."""
271
442
  try:
272
- status = self._status_provider()
273
- update_message = status.update_message
274
- except (AttributeError, RuntimeError):
275
- return None
443
+ state = buf.complete_state # type: ignore[reportUnknownMemberType]
444
+ if state is None:
445
+ return
446
+ if not state.completions: # type: ignore[reportUnknownMemberType]
447
+ return
448
+ if state.complete_index is None: # type: ignore[reportUnknownMemberType]
449
+ state.complete_index = 0 # type: ignore[reportUnknownMemberType]
450
+ with contextlib.suppress(Exception):
451
+ self._session.app.invalidate()
452
+ except Exception:
453
+ return
454
+
455
+ # -------------------------------------------------------------------------
456
+ # Model picker
457
+ # -------------------------------------------------------------------------
458
+
459
+ def _build_model_picker_items(self) -> tuple[list[SelectItem[str]], str | None]:
460
+ config = load_config()
461
+ models: list[ModelEntry] = sorted(
462
+ config.iter_model_entries(only_available=True),
463
+ key=lambda m: m.model_name.lower(),
464
+ )
465
+ if not models:
466
+ return [], None
467
+
468
+ items = build_model_select_items(models)
469
+
470
+ initial = None
471
+ if self._get_current_model_config_name is not None:
472
+ with contextlib.suppress(Exception):
473
+ initial = self._get_current_model_config_name()
474
+ if initial is None:
475
+ initial = config.main_model
476
+ return items, initial
477
+
478
+ def _open_model_picker(self) -> None:
479
+ if self._model_picker is None:
480
+ return
481
+ items, initial = self._build_model_picker_items()
482
+ if not items:
483
+ return
484
+ self._model_picker.set_content(message="Select a model:", items=items, initial_value=initial)
485
+ self._model_picker.open()
486
+
487
+ async def _handle_model_selected(self, model_name: str) -> None:
488
+ current = None
489
+ if self._get_current_model_config_name is not None:
490
+ with contextlib.suppress(Exception):
491
+ current = self._get_current_model_config_name()
492
+ if current is not None and model_name == current:
493
+ return
494
+ if self._on_change_model is None:
495
+ return
496
+ await self._on_change_model(model_name)
497
+ self._set_toast(f"model: {model_name}")
498
+
499
+ # -------------------------------------------------------------------------
500
+ # Thinking picker
501
+ # -------------------------------------------------------------------------
502
+
503
+ def _build_thinking_picker_items(
504
+ self, config: llm_param.LLMConfigParameter
505
+ ) -> tuple[list[SelectItem[str]], str | None]:
506
+ data = get_thinking_picker_data(config)
507
+ if data is None:
508
+ return [], None
509
+
510
+ items: list[SelectItem[str]] = [
511
+ SelectItem(title=[("class:text", opt.label + "\n")], value=opt.value, search_text=opt.label)
512
+ for opt in data.options
513
+ ]
514
+ return items, data.current_value
515
+
516
+ def _open_thinking_picker(self) -> None:
517
+ if self._thinking_picker is None:
518
+ return
519
+ if self._get_current_llm_config is None:
520
+ return
521
+ config = self._get_current_llm_config()
522
+ if config is None:
523
+ return
524
+ items, initial = self._build_thinking_picker_items(config)
525
+ if not items:
526
+ return
527
+ current = format_current_thinking(config)
528
+ self._thinking_picker.set_content(
529
+ message=f"Select thinking level (current: {current}):", items=items, initial_value=initial
530
+ )
531
+ self._thinking_picker.open()
532
+
533
+ async def _handle_thinking_selected(self, value: str) -> None:
534
+ if self._on_change_thinking is None:
535
+ return
536
+
537
+ new_thinking = parse_thinking_value(value)
538
+ if new_thinking is None:
539
+ return
540
+
541
+ # Build toast label
542
+ if value.startswith("effort:"):
543
+ toast_label = value[7:]
544
+ elif value.startswith("budget:"):
545
+ budget = int(value[7:])
546
+ toast_label = "off" if budget == 0 else f"{budget} tokens"
547
+ else:
548
+ toast_label = "updated"
549
+
550
+ await self._on_change_thinking(new_thinking)
551
+ self._set_toast(f"thinking: {toast_label}")
552
+
553
+ # -------------------------------------------------------------------------
554
+ # Toast notifications
555
+ # -------------------------------------------------------------------------
556
+
557
+ def _set_toast(self, message: str, *, duration_sec: float = 2.0) -> None:
558
+ self._toast_message = message
559
+ self._toast_until = time.monotonic() + duration_sec
560
+ with contextlib.suppress(Exception):
561
+ self._session.app.invalidate()
562
+
563
+ async def _clear_later() -> None:
564
+ await asyncio.sleep(duration_sec)
565
+ self._toast_message = None
566
+ self._toast_until = 0.0
567
+ with contextlib.suppress(Exception):
568
+ self._session.app.invalidate()
569
+
570
+ with contextlib.suppress(Exception):
571
+ self._session.app.create_background_task(_clear_later())
276
572
 
277
- if not update_message:
278
- return None
573
+ # -------------------------------------------------------------------------
574
+ # Bottom toolbar
575
+ # -------------------------------------------------------------------------
279
576
 
280
- left_text = " " + update_message
577
+ def _get_bottom_toolbar(self) -> FormattedText | None:
578
+ """Return bottom toolbar content.
579
+
580
+ This is used inside the prompt_toolkit Application, so avoid printing or
581
+ doing any blocking IO here.
582
+ """
583
+ update_message: str | None = None
584
+ if self._status_provider is not None:
585
+ try:
586
+ status = self._status_provider()
587
+ update_message = status.update_message
588
+ except (AttributeError, RuntimeError):
589
+ update_message = None
590
+
591
+ toast: str | None = None
592
+ now = time.monotonic()
593
+ if self._toast_message is not None and now < self._toast_until:
594
+ toast = self._toast_message
595
+
596
+ # If nothing to show, return a blank line to actively clear any previously
597
+ # rendered content. (When `bottom_toolbar` is a callable, prompt_toolkit
598
+ # will still reserve the toolbar line.)
599
+ if not toast and not update_message:
600
+ try:
601
+ terminal_width = shutil.get_terminal_size().columns
602
+ except (OSError, ValueError):
603
+ terminal_width = 0
604
+ return FormattedText([("", " " * max(0, terminal_width))])
605
+
606
+ parts = [p for p in [toast, update_message] if p]
607
+ left_text = " " + " · ".join(parts)
281
608
  try:
282
609
  terminal_width = shutil.get_terminal_size().columns
283
610
  padding = " " * max(0, terminal_width - len(left_text))
@@ -287,6 +614,10 @@ class PromptToolkitInput(InputProviderABC):
287
614
  toolbar_text = left_text + padding
288
615
  return FormattedText([("#ansiyellow", toolbar_text)])
289
616
 
617
+ # -------------------------------------------------------------------------
618
+ # Placeholder
619
+ # -------------------------------------------------------------------------
620
+
290
621
  def _render_input_placeholder(self) -> FormattedText:
291
622
  if self._is_light_terminal_background is True:
292
623
  text_style = PLACEHOLDER_TEXT_STYLE_LIGHT_BG
@@ -312,9 +643,17 @@ class PromptToolkitInput(InputProviderABC):
312
643
  (symbol_style, " / "),
313
644
  (text_style, " "),
314
645
  (text_style, "commands"),
646
+ (text_style, " "),
647
+ (symbol_style, " ctrl-l "),
648
+ (text_style, " "),
649
+ (text_style, "models"),
315
650
  ]
316
651
  )
317
652
 
653
+ # -------------------------------------------------------------------------
654
+ # InputProviderABC implementation
655
+ # -------------------------------------------------------------------------
656
+
318
657
  async def start(self) -> None:
319
658
  pass
320
659
 
@@ -328,13 +667,10 @@ class PromptToolkitInput(InputProviderABC):
328
667
  with contextlib.suppress(Exception):
329
668
  self._pre_prompt()
330
669
 
331
- # Only show bottom toolbar if there's an update message
332
- bottom_toolbar = self._get_bottom_toolbar()
333
-
334
670
  with patch_stdout():
335
671
  line: str = await self._session.prompt_async(
336
672
  placeholder=self._render_input_placeholder(),
337
- bottom_toolbar=bottom_toolbar,
673
+ bottom_toolbar=self._get_bottom_toolbar,
338
674
  )
339
675
  if self._post_prompt is not None:
340
676
  with contextlib.suppress(Exception):