kimi-cli 0.35__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.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

Files changed (76) hide show
  1. kimi_cli/CHANGELOG.md +304 -0
  2. kimi_cli/__init__.py +374 -0
  3. kimi_cli/agent.py +261 -0
  4. kimi_cli/agents/koder/README.md +3 -0
  5. kimi_cli/agents/koder/agent.yaml +24 -0
  6. kimi_cli/agents/koder/sub.yaml +11 -0
  7. kimi_cli/agents/koder/system.md +72 -0
  8. kimi_cli/config.py +138 -0
  9. kimi_cli/llm.py +8 -0
  10. kimi_cli/metadata.py +117 -0
  11. kimi_cli/prompts/metacmds/__init__.py +4 -0
  12. kimi_cli/prompts/metacmds/compact.md +74 -0
  13. kimi_cli/prompts/metacmds/init.md +21 -0
  14. kimi_cli/py.typed +0 -0
  15. kimi_cli/share.py +8 -0
  16. kimi_cli/soul/__init__.py +59 -0
  17. kimi_cli/soul/approval.py +69 -0
  18. kimi_cli/soul/context.py +142 -0
  19. kimi_cli/soul/denwarenji.py +37 -0
  20. kimi_cli/soul/kimisoul.py +248 -0
  21. kimi_cli/soul/message.py +76 -0
  22. kimi_cli/soul/toolset.py +25 -0
  23. kimi_cli/soul/wire.py +101 -0
  24. kimi_cli/tools/__init__.py +85 -0
  25. kimi_cli/tools/bash/__init__.py +97 -0
  26. kimi_cli/tools/bash/bash.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +38 -0
  28. kimi_cli/tools/dmail/dmail.md +15 -0
  29. kimi_cli/tools/file/__init__.py +21 -0
  30. kimi_cli/tools/file/glob.md +17 -0
  31. kimi_cli/tools/file/glob.py +149 -0
  32. kimi_cli/tools/file/grep.md +5 -0
  33. kimi_cli/tools/file/grep.py +285 -0
  34. kimi_cli/tools/file/patch.md +8 -0
  35. kimi_cli/tools/file/patch.py +131 -0
  36. kimi_cli/tools/file/read.md +14 -0
  37. kimi_cli/tools/file/read.py +139 -0
  38. kimi_cli/tools/file/replace.md +7 -0
  39. kimi_cli/tools/file/replace.py +132 -0
  40. kimi_cli/tools/file/write.md +5 -0
  41. kimi_cli/tools/file/write.py +107 -0
  42. kimi_cli/tools/mcp.py +85 -0
  43. kimi_cli/tools/task/__init__.py +156 -0
  44. kimi_cli/tools/task/task.md +26 -0
  45. kimi_cli/tools/test.py +55 -0
  46. kimi_cli/tools/think/__init__.py +21 -0
  47. kimi_cli/tools/think/think.md +1 -0
  48. kimi_cli/tools/todo/__init__.py +27 -0
  49. kimi_cli/tools/todo/set_todo_list.md +15 -0
  50. kimi_cli/tools/utils.py +150 -0
  51. kimi_cli/tools/web/__init__.py +4 -0
  52. kimi_cli/tools/web/fetch.md +1 -0
  53. kimi_cli/tools/web/fetch.py +94 -0
  54. kimi_cli/tools/web/search.md +1 -0
  55. kimi_cli/tools/web/search.py +126 -0
  56. kimi_cli/ui/__init__.py +68 -0
  57. kimi_cli/ui/acp/__init__.py +441 -0
  58. kimi_cli/ui/print/__init__.py +176 -0
  59. kimi_cli/ui/shell/__init__.py +326 -0
  60. kimi_cli/ui/shell/console.py +3 -0
  61. kimi_cli/ui/shell/liveview.py +158 -0
  62. kimi_cli/ui/shell/metacmd.py +309 -0
  63. kimi_cli/ui/shell/prompt.py +574 -0
  64. kimi_cli/ui/shell/setup.py +192 -0
  65. kimi_cli/ui/shell/update.py +204 -0
  66. kimi_cli/utils/changelog.py +101 -0
  67. kimi_cli/utils/logging.py +18 -0
  68. kimi_cli/utils/message.py +8 -0
  69. kimi_cli/utils/path.py +23 -0
  70. kimi_cli/utils/provider.py +64 -0
  71. kimi_cli/utils/pyinstaller.py +24 -0
  72. kimi_cli/utils/string.py +12 -0
  73. kimi_cli-0.35.dist-info/METADATA +24 -0
  74. kimi_cli-0.35.dist-info/RECORD +76 -0
  75. kimi_cli-0.35.dist-info/WHEEL +4 -0
  76. kimi_cli-0.35.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,574 @@
1
+ import asyncio
2
+ import contextlib
3
+ import getpass
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ from collections.abc import Callable
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from hashlib import md5
12
+ from pathlib import Path
13
+ from typing import override
14
+
15
+ from prompt_toolkit import PromptSession
16
+ from prompt_toolkit.application.current import get_app_or_none
17
+ from prompt_toolkit.completion import (
18
+ Completer,
19
+ Completion,
20
+ DummyCompleter,
21
+ FuzzyCompleter,
22
+ WordCompleter,
23
+ merge_completers,
24
+ )
25
+ from prompt_toolkit.document import Document
26
+ from prompt_toolkit.filters import Always, Never, has_completions
27
+ from prompt_toolkit.formatted_text import FormattedText
28
+ from prompt_toolkit.history import InMemoryHistory
29
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
30
+ from prompt_toolkit.patch_stdout import patch_stdout
31
+ from pydantic import BaseModel, ValidationError
32
+
33
+ from kimi_cli.share import get_share_dir
34
+ from kimi_cli.soul import StatusSnapshot
35
+ from kimi_cli.ui.shell.metacmd import get_meta_commands
36
+ from kimi_cli.utils.logging import logger
37
+
38
+
39
+ class MetaCommandCompleter(Completer):
40
+ """A completer that:
41
+ - Shows one line per meta command in the form: "/name (alias1, alias2)"
42
+ - Matches by primary name or any alias while inserting the canonical "/name"
43
+ - Only activates when the current token starts with '/'
44
+ """
45
+
46
+ @override
47
+ def get_completions(self, document, complete_event):
48
+ text = document.text_before_cursor
49
+
50
+ # Only autocomplete when the input buffer has no other content.
51
+ if document.text_after_cursor.strip():
52
+ return
53
+
54
+ # Only consider the last token (allowing future arguments after a space)
55
+ last_space = text.rfind(" ")
56
+ token = text[last_space + 1 :]
57
+ prefix = text[: last_space + 1] if last_space != -1 else ""
58
+
59
+ if prefix.strip():
60
+ return
61
+ if not token.startswith("/"):
62
+ return
63
+
64
+ typed = token[1:]
65
+ typed_lower = typed.lower()
66
+
67
+ for cmd in sorted(get_meta_commands(), key=lambda c: c.name):
68
+ names = [cmd.name] + list(cmd.aliases)
69
+ if typed == "" or any(n.lower().startswith(typed_lower) for n in names):
70
+ yield Completion(
71
+ text=f"/{cmd.name}",
72
+ start_position=-len(token),
73
+ display=cmd.slash_name(),
74
+ display_meta=cmd.description,
75
+ )
76
+
77
+
78
+ class FileMentionCompleter(Completer):
79
+ """Offer fuzzy `@` path completion by indexing workspace files."""
80
+
81
+ _FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
82
+ _TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
83
+ _IGNORED_NAME_GROUPS: dict[str, tuple[str, ...]] = {
84
+ "vcs_metadata": (".DS_Store", ".bzr", ".git", ".hg", ".svn"),
85
+ "tooling_caches": (
86
+ ".build",
87
+ ".cache",
88
+ ".coverage",
89
+ ".fleet",
90
+ ".gradle",
91
+ ".idea",
92
+ ".ipynb_checkpoints",
93
+ ".pnpm-store",
94
+ ".pytest_cache",
95
+ ".pub-cache",
96
+ ".ruff_cache",
97
+ ".swiftpm",
98
+ ".tox",
99
+ ".venv",
100
+ ".vs",
101
+ ".vscode",
102
+ ".yarn",
103
+ ".yarn-cache",
104
+ ),
105
+ "js_frontend": (
106
+ ".next",
107
+ ".nuxt",
108
+ ".parcel-cache",
109
+ ".svelte-kit",
110
+ ".turbo",
111
+ ".vercel",
112
+ "node_modules",
113
+ ),
114
+ "python_packaging": (
115
+ "__pycache__",
116
+ "build",
117
+ "coverage",
118
+ "dist",
119
+ "htmlcov",
120
+ "pip-wheel-metadata",
121
+ "venv",
122
+ ),
123
+ "java_jvm": (".mvn", "out", "target"),
124
+ "dotnet_native": ("bin", "cmake-build-debug", "cmake-build-release", "obj"),
125
+ "bazel_buck": ("bazel-bin", "bazel-out", "bazel-testlogs", "buck-out"),
126
+ "misc_artifacts": (
127
+ ".dart_tool",
128
+ ".serverless",
129
+ ".stack-work",
130
+ ".terraform",
131
+ ".terragrunt-cache",
132
+ "DerivedData",
133
+ "Pods",
134
+ "deps",
135
+ "tmp",
136
+ "vendor",
137
+ ),
138
+ }
139
+ _IGNORED_NAMES = frozenset(name for group in _IGNORED_NAME_GROUPS.values() for name in group)
140
+ _IGNORED_PATTERN_PARTS: tuple[str, ...] = (
141
+ r".*_cache$",
142
+ r".*-cache$",
143
+ r".*\.egg-info$",
144
+ r".*\.dist-info$",
145
+ r".*\.py[co]$",
146
+ r".*\.class$",
147
+ r".*\.sw[po]$",
148
+ r".*~$",
149
+ r".*\.(?:tmp|bak)$",
150
+ )
151
+ _IGNORED_PATTERNS = re.compile(
152
+ "|".join(f"(?:{part})" for part in _IGNORED_PATTERN_PARTS),
153
+ re.IGNORECASE,
154
+ )
155
+
156
+ def __init__(
157
+ self,
158
+ root: Path,
159
+ *,
160
+ refresh_interval: float = 2.0,
161
+ limit: int = 1000,
162
+ ) -> None:
163
+ self._root = root
164
+ self._refresh_interval = refresh_interval
165
+ self._limit = limit
166
+ self._cache_time: float = 0.0
167
+ self._cached_paths: list[str] = []
168
+ self._top_cache_time: float = 0.0
169
+ self._top_cached_paths: list[str] = []
170
+ self._fragment_hint: str | None = None
171
+
172
+ self._word_completer = WordCompleter(
173
+ self._get_paths,
174
+ WORD=False,
175
+ pattern=self._FRAGMENT_PATTERN,
176
+ )
177
+
178
+ self._fuzzy = FuzzyCompleter(
179
+ self._word_completer,
180
+ WORD=False,
181
+ pattern=r"^[^\s@]*",
182
+ )
183
+
184
+ @classmethod
185
+ def _is_ignored(cls, name: str) -> bool:
186
+ if not name:
187
+ return True
188
+ if name in cls._IGNORED_NAMES:
189
+ return True
190
+ return bool(cls._IGNORED_PATTERNS.fullmatch(name))
191
+
192
+ def _get_paths(self) -> list[str]:
193
+ fragment = self._fragment_hint or ""
194
+ if "/" not in fragment and len(fragment) < 3:
195
+ return self._get_top_level_paths()
196
+ return self._get_deep_paths()
197
+
198
+ def _get_top_level_paths(self) -> list[str]:
199
+ now = time.monotonic()
200
+ if now - self._top_cache_time <= self._refresh_interval:
201
+ return self._top_cached_paths
202
+
203
+ entries: list[str] = []
204
+ try:
205
+ for entry in sorted(self._root.iterdir(), key=lambda p: p.name):
206
+ name = entry.name
207
+ if self._is_ignored(name):
208
+ continue
209
+ entries.append(f"{name}/" if entry.is_dir() else name)
210
+ if len(entries) >= self._limit:
211
+ break
212
+ except OSError:
213
+ return self._top_cached_paths
214
+
215
+ self._top_cached_paths = entries
216
+ self._top_cache_time = now
217
+ return self._top_cached_paths
218
+
219
+ def _get_deep_paths(self) -> list[str]:
220
+ now = time.monotonic()
221
+ if now - self._cache_time <= self._refresh_interval:
222
+ return self._cached_paths
223
+
224
+ paths: list[str] = []
225
+ try:
226
+ for current_root, dirs, files in os.walk(self._root):
227
+ relative_root = Path(current_root).relative_to(self._root)
228
+
229
+ # Prevent descending into ignored directories.
230
+ dirs[:] = sorted(d for d in dirs if not self._is_ignored(d))
231
+
232
+ if relative_root.parts and any(
233
+ self._is_ignored(part) for part in relative_root.parts
234
+ ):
235
+ dirs[:] = []
236
+ continue
237
+
238
+ if relative_root.parts:
239
+ paths.append(relative_root.as_posix() + "/")
240
+ if len(paths) >= self._limit:
241
+ break
242
+
243
+ for file_name in sorted(files):
244
+ if self._is_ignored(file_name):
245
+ continue
246
+ relative = (relative_root / file_name).as_posix()
247
+ if not relative:
248
+ continue
249
+ paths.append(relative)
250
+ if len(paths) >= self._limit:
251
+ break
252
+
253
+ if len(paths) >= self._limit:
254
+ break
255
+ except OSError:
256
+ return self._cached_paths
257
+
258
+ self._cached_paths = paths
259
+ self._cache_time = now
260
+ return self._cached_paths
261
+
262
+ @staticmethod
263
+ def _extract_fragment(text: str) -> str | None:
264
+ index = text.rfind("@")
265
+ if index == -1:
266
+ return None
267
+
268
+ if index > 0:
269
+ prev = text[index - 1]
270
+ if prev.isalnum() or prev in FileMentionCompleter._TRIGGER_GUARDS:
271
+ return None
272
+
273
+ fragment = text[index + 1 :]
274
+ if not fragment:
275
+ return ""
276
+
277
+ if any(ch.isspace() for ch in fragment):
278
+ return None
279
+
280
+ return fragment
281
+
282
+ def _is_completed_file(self, fragment: str) -> bool:
283
+ candidate = fragment.rstrip("/")
284
+ if not candidate:
285
+ return False
286
+ try:
287
+ return (self._root / candidate).is_file()
288
+ except OSError:
289
+ return False
290
+
291
+ @override
292
+ def get_completions(self, document, complete_event):
293
+ fragment = self._extract_fragment(document.text_before_cursor)
294
+ if fragment is None:
295
+ return
296
+ if self._is_completed_file(fragment):
297
+ return
298
+
299
+ mention_doc = Document(text=fragment, cursor_position=len(fragment))
300
+ self._fragment_hint = fragment
301
+ try:
302
+ yield from self._fuzzy.get_completions(mention_doc, complete_event)
303
+ finally:
304
+ self._fragment_hint = None
305
+
306
+
307
+ class _HistoryEntry(BaseModel):
308
+ content: str
309
+
310
+
311
+ def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
312
+ entries: list[_HistoryEntry] = []
313
+ if not history_file.exists():
314
+ return entries
315
+
316
+ try:
317
+ with history_file.open(encoding="utf-8") as f:
318
+ for raw_line in f:
319
+ line = raw_line.strip()
320
+ if not line:
321
+ continue
322
+ try:
323
+ record = json.loads(line)
324
+ except json.JSONDecodeError:
325
+ logger.warning(
326
+ "Failed to parse user history line; skipping: {line}",
327
+ line=line,
328
+ )
329
+ continue
330
+ try:
331
+ entry = _HistoryEntry.model_validate(record)
332
+ entries.append(entry)
333
+ except ValidationError:
334
+ logger.warning(
335
+ "Failed to validate user history entry; skipping: {line}",
336
+ line=line,
337
+ )
338
+ continue
339
+ except OSError as exc:
340
+ logger.warning(
341
+ "Failed to load user history file: {file} ({error})",
342
+ file=history_file,
343
+ error=exc,
344
+ )
345
+
346
+ return entries
347
+
348
+
349
+ class PromptMode(Enum):
350
+ AGENT = "agent"
351
+ SHELL = "shell"
352
+
353
+ def toggle(self) -> "PromptMode":
354
+ return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
355
+
356
+ def __str__(self) -> str:
357
+ return self.value
358
+
359
+
360
+ class UserInput(BaseModel):
361
+ mode: PromptMode
362
+ command: str
363
+
364
+ def __str__(self) -> str:
365
+ return self.command
366
+
367
+ def __bool__(self) -> bool:
368
+ return bool(self.command)
369
+
370
+
371
+ _REFRESH_INTERVAL = 1.0
372
+ _toast_queue: asyncio.Queue[tuple[str, float]] = asyncio.Queue()
373
+
374
+
375
+ def toast(message: str, duration: float = 5.0) -> None:
376
+ duration = max(duration, _REFRESH_INTERVAL)
377
+ _toast_queue.put_nowait((message, duration))
378
+
379
+
380
+ class CustomPromptSession:
381
+ def __init__(self, status_provider: Callable[[], StatusSnapshot]):
382
+ history_dir = get_share_dir() / "user-history"
383
+ history_dir.mkdir(parents=True, exist_ok=True)
384
+ work_dir_id = md5(str(Path.cwd()).encode()).hexdigest()
385
+ self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
386
+ self._status_provider = status_provider
387
+ self._last_history_content: str | None = None
388
+ self._mode: PromptMode = PromptMode.AGENT
389
+
390
+ history_entries = _load_history_entries(self._history_file)
391
+ history = InMemoryHistory()
392
+ for entry in history_entries:
393
+ history.append_string(entry.content)
394
+
395
+ if history_entries:
396
+ # for consecutive deduplication
397
+ self._last_history_content = history_entries[-1].content
398
+
399
+ # Build completers
400
+ self._agent_mode_completer = merge_completers(
401
+ [
402
+ MetaCommandCompleter(),
403
+ FileMentionCompleter(Path.cwd()),
404
+ ],
405
+ deduplicate=True,
406
+ )
407
+
408
+ # Build key bindings
409
+ _kb = KeyBindings()
410
+
411
+ @_kb.add("enter", filter=has_completions)
412
+ def _accept_completion(event: KeyPressEvent) -> None:
413
+ """Accept the first completion when Enter is pressed and completions are shown."""
414
+ buff = event.current_buffer
415
+ if buff.complete_state and buff.complete_state.completions:
416
+ # Get the current completion, or use the first one if none is selected
417
+ completion = buff.complete_state.current_completion
418
+ if not completion:
419
+ completion = buff.complete_state.completions[0]
420
+ buff.apply_completion(completion)
421
+
422
+ @_kb.add("c-k", eager=True)
423
+ def _toggle_mode(event: KeyPressEvent) -> None:
424
+ self._mode = self._mode.toggle()
425
+ # Apply mode-specific settings
426
+ self._apply_mode(event)
427
+ # Redraw UI
428
+ event.app.invalidate()
429
+
430
+ self._session = PromptSession(
431
+ message=self._render_message,
432
+ prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
433
+ completer=self._agent_mode_completer,
434
+ complete_while_typing=True,
435
+ key_bindings=_kb,
436
+ history=history,
437
+ bottom_toolbar=self._render_bottom_toolbar,
438
+ )
439
+
440
+ self._status_refresh_task: asyncio.Task | None = None
441
+ self._current_toast: str | None = None
442
+ self._current_toast_duration: float = 0.0
443
+
444
+ def _render_message(self) -> FormattedText:
445
+ symbol = "✨" if self._mode == PromptMode.AGENT else "$"
446
+ return FormattedText([("bold", f"{getpass.getuser()}{symbol} ")])
447
+
448
+ def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
449
+ # Apply mode to the active buffer (not the PromptSession itself)
450
+ try:
451
+ buff = event.current_buffer if event is not None else self._session.default_buffer
452
+ except Exception:
453
+ buff = None
454
+
455
+ if self._mode == PromptMode.SHELL:
456
+ # Cancel any active completion menu
457
+ with contextlib.suppress(Exception):
458
+ if buff is not None:
459
+ buff.cancel_completion()
460
+ if buff is not None:
461
+ buff.completer = DummyCompleter()
462
+ buff.complete_while_typing = Never()
463
+ else:
464
+ if buff is not None:
465
+ buff.completer = self._agent_mode_completer
466
+ buff.complete_while_typing = Always()
467
+
468
+ def __enter__(self) -> "CustomPromptSession":
469
+ if self._status_refresh_task is not None and not self._status_refresh_task.done():
470
+ return self
471
+
472
+ async def _refresh(interval: float) -> None:
473
+ try:
474
+ while True:
475
+ app = get_app_or_none()
476
+ if app is not None:
477
+ app.invalidate()
478
+
479
+ try:
480
+ asyncio.get_running_loop()
481
+ except RuntimeError:
482
+ logger.warning("No running loop found, exiting status refresh task")
483
+ self._status_refresh_task = None
484
+ break
485
+
486
+ await asyncio.sleep(interval)
487
+ except asyncio.CancelledError:
488
+ # graceful exit
489
+ pass
490
+
491
+ self._status_refresh_task = asyncio.create_task(_refresh(_REFRESH_INTERVAL))
492
+ return self
493
+
494
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
495
+ if self._status_refresh_task is not None and not self._status_refresh_task.done():
496
+ self._status_refresh_task.cancel()
497
+ self._status_refresh_task = None
498
+
499
+ async def prompt(self) -> UserInput:
500
+ with patch_stdout():
501
+ command = str(await self._session.prompt_async()).strip()
502
+ self._append_history_entry(command)
503
+ return UserInput(mode=self._mode, command=command)
504
+
505
+ def _append_history_entry(self, text: str) -> None:
506
+ entry = _HistoryEntry(content=text.strip())
507
+ if not entry.content:
508
+ return
509
+
510
+ # skip if same as last entry
511
+ if entry.content == self._last_history_content:
512
+ return
513
+
514
+ try:
515
+ self._history_file.parent.mkdir(parents=True, exist_ok=True)
516
+ with self._history_file.open("a", encoding="utf-8") as f:
517
+ f.write(entry.model_dump_json(ensure_ascii=False) + "\n")
518
+ self._last_history_content = entry.content
519
+ except OSError as exc:
520
+ logger.warning(
521
+ "Failed to append user history entry: {file} ({error})",
522
+ file=self._history_file,
523
+ error=exc,
524
+ )
525
+
526
+ def _render_bottom_toolbar(self) -> FormattedText:
527
+ app = get_app_or_none()
528
+ assert app is not None
529
+ columns = app.output.get_size().columns
530
+
531
+ fragments: list[tuple[str, str]] = []
532
+
533
+ now_text = datetime.now().strftime("%H:%M")
534
+ fragments.extend([("", now_text), ("", " " * 2)])
535
+ columns -= len(now_text) + 2
536
+
537
+ mode = str(self._mode).lower()
538
+ fragments.extend([("", f"{mode}"), ("", " " * 2)])
539
+ columns -= len(mode) + 2
540
+
541
+ status = self._status_provider()
542
+ status_text = self._format_status(status)
543
+
544
+ if self._current_toast is not None:
545
+ fragments.extend([("", self._current_toast), ("", " " * 2)])
546
+ columns -= len(self._current_toast) + 2
547
+ self._current_toast_duration -= _REFRESH_INTERVAL
548
+ if self._current_toast_duration <= 0.0:
549
+ self._current_toast = None
550
+ else:
551
+ shortcuts = [
552
+ "ctrl-k: toggle mode",
553
+ "ctrl-d: exit",
554
+ ]
555
+ for shortcut in shortcuts:
556
+ if columns - len(status_text) > len(shortcut) + 2:
557
+ fragments.extend([("", shortcut), ("", " " * 2)])
558
+ columns -= len(shortcut) + 2
559
+ else:
560
+ break
561
+
562
+ if self._current_toast is None and not _toast_queue.empty():
563
+ self._current_toast, self._current_toast_duration = _toast_queue.get_nowait()
564
+
565
+ padding = max(1, columns - len(status_text))
566
+ fragments.append(("", " " * padding))
567
+ fragments.append(("", status_text))
568
+
569
+ return FormattedText(fragments)
570
+
571
+ @staticmethod
572
+ def _format_status(status: StatusSnapshot) -> str:
573
+ bounded = max(0.0, min(status.context_usage, 1.0))
574
+ return f"context: {bounded:.1%}"