limbo-code 0.1.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.
@@ -0,0 +1,369 @@
1
+ """Main screen: pi-style single-column chat layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.screen import Screen
10
+ from textual.widgets import Static
11
+
12
+ from limbo.agent import (
13
+ Agent,
14
+ AgentEvent,
15
+ ErrorEvent,
16
+ TextDelta,
17
+ ThinkingDelta,
18
+ ToolCallRequest,
19
+ ToolResultEvent,
20
+ )
21
+ from limbo.config import Config
22
+ from limbo.llm.client import LLMClient
23
+ from limbo.llm.factory import create_llm_client
24
+ from limbo.sessions import derive_title, export_jsonl, export_markdown, list_sessions
25
+ from limbo.skills import Skill, discover_skills
26
+ from limbo.ui.banner import startup_art_text
27
+ from limbo.ui.commands import SlashCommand, SlashCommandRegistry
28
+ from limbo.ui.screens.game2048 import Game2048Screen
29
+ from limbo.ui.screens.session_picker import SessionPicker
30
+ from limbo.ui.widgets.chat import ChatWidget
31
+ from limbo.ui.widgets.command_menu import SlashCommandMenu
32
+ from limbo.ui.widgets.input import InputWidget, UserSubmitted
33
+ from limbo.ui.widgets.status_bar import StatusBar
34
+
35
+
36
+ class MainScreen(Screen[None]):
37
+ """Single-column chat screen: status bar / chat flow / input / hint."""
38
+
39
+ BINDINGS = [
40
+ Binding("ctrl+o", "toggle_tools", "展开/收起工具输出"),
41
+ Binding("ctrl+g", "game2048", "2048 小游戏"),
42
+ ]
43
+
44
+ def __init__(
45
+ self,
46
+ workdir: Path,
47
+ config: Config | None = None,
48
+ llm_client: LLMClient | None = None,
49
+ session_dir: Path | None = None,
50
+ resume: Path | None = None,
51
+ *args,
52
+ **kwargs,
53
+ ):
54
+ super().__init__(*args, **kwargs)
55
+ self.workdir = workdir
56
+ self.config = config or Config()
57
+ self.llm_client = llm_client or create_llm_client(self.config)
58
+ self.session_dir = session_dir or Path.home() / ".limbo" / "sessions"
59
+ self.agent = self._new_agent(resume=resume)
60
+ self._slash_menu_open = False
61
+ self._commands = SlashCommandRegistry()
62
+ self._register_builtin_commands()
63
+
64
+ def _new_agent(self, resume: Path | None = None) -> Agent:
65
+ return Agent(
66
+ config=self.config,
67
+ llm_client=self.llm_client,
68
+ workdir=self.workdir,
69
+ session_dir=self.session_dir,
70
+ resume=resume,
71
+ )
72
+
73
+ def compose(self) -> ComposeResult:
74
+ yield StatusBar(
75
+ model=self.config.llm.model,
76
+ workdir=str(self.workdir),
77
+ id="statusbar",
78
+ )
79
+ yield ChatWidget(id="chat")
80
+ yield SlashCommandMenu(id="slash-menu")
81
+ yield InputWidget(id="input")
82
+ yield Static(
83
+ "Enter 发送 · Shift+Enter 换行 · ↑↓ 历史输入 · / 命令 · ctrl+o 工具输出 · ctrl+g 2048",
84
+ id="hint",
85
+ markup=False,
86
+ )
87
+
88
+ def on_mount(self) -> None:
89
+ chat = self.query_one("#chat", ChatWidget)
90
+ resumed = len(self.agent.messages) > 1
91
+ if not resumed:
92
+ chat.add_art(startup_art_text())
93
+ chat.add_info(f"Limbo ready · {self.config.llm.model} · {self.workdir}")
94
+ if resumed:
95
+ self._render_history()
96
+ meta = self.agent.session_meta
97
+ chat.add_info(
98
+ f"已恢复会话 {meta.id} · {meta.title or '(无标题)'}"
99
+ )
100
+ self.query_one("#input", InputWidget).focus()
101
+
102
+ # -- slash command menu ---------------------------------------------------
103
+
104
+ @property
105
+ def slash_menu_open(self) -> bool:
106
+ return self._slash_menu_open
107
+
108
+ def on_text_area_changed(self, event) -> None:
109
+ """Show/filter the command menu while the input starts with '/'."""
110
+ if getattr(event.text_area, "id", None) != "input":
111
+ return
112
+ text = event.text_area.text
113
+ if text.startswith("/") and not any(ch.isspace() for ch in text):
114
+ matches = [
115
+ c for c in self._slash_candidates() if c.name.startswith(text)
116
+ ]
117
+ if matches:
118
+ menu = self.query_one("#slash-menu", SlashCommandMenu)
119
+ menu.show_commands(matches)
120
+ self._slash_menu_open = True
121
+ return
122
+ self.slash_menu_close()
123
+
124
+ def _slash_candidates(self) -> list:
125
+ """Built-in commands plus discovered skills. Re-scanned on each menu
126
+ update so skills added while Limbo is running appear immediately."""
127
+ return self._commands.candidates(discover_skills(self.workdir))
128
+
129
+ def slash_menu_move(self, delta: int) -> None:
130
+ menu = self.query_one("#slash-menu", SlashCommandMenu)
131
+ if delta > 0:
132
+ menu.action_cursor_down()
133
+ else:
134
+ menu.action_cursor_up()
135
+
136
+ def slash_menu_close(self) -> None:
137
+ self._slash_menu_open = False
138
+ self.query_one("#slash-menu", SlashCommandMenu).close()
139
+
140
+ def slash_menu_complete(self, execute: bool) -> bool:
141
+ """Complete the highlighted command. Returns False if nothing done.
142
+
143
+ With ``execute=True``, commands that take no arguments run
144
+ immediately; arg-taking commands are completed into the input so the
145
+ user can type the argument.
146
+ """
147
+ if not self._slash_menu_open:
148
+ return False
149
+ menu = self.query_one("#slash-menu", SlashCommandMenu)
150
+ command = menu.highlighted_command()
151
+ if command is None:
152
+ return False
153
+ input_widget = self.query_one("#input", InputWidget)
154
+ if execute and input_widget.text.strip() == command.name:
155
+ # Exact match: Enter submits the command as typed (e.g. invoking
156
+ # a skill without args) instead of completing it.
157
+ self.slash_menu_close()
158
+ return False
159
+ self.slash_menu_close()
160
+ if execute and not command.takes_args:
161
+ input_widget.clear()
162
+ self._handle_command(command.name)
163
+ else:
164
+ input_widget.text = command.name + " "
165
+ input_widget.move_cursor(input_widget.document.end)
166
+ input_widget.focus()
167
+ return True
168
+
169
+ def on_option_list_option_selected(self, event) -> None:
170
+ """Mouse click on a menu item completes it like Enter."""
171
+ if isinstance(getattr(event, "option_list", None), SlashCommandMenu):
172
+ event.stop()
173
+ self.slash_menu_complete(execute=True)
174
+
175
+ # -- slash commands ---------------------------------------------------------
176
+
177
+ def _register_builtin_commands(self) -> None:
178
+ self._commands.register(
179
+ SlashCommand(
180
+ "/sessions", "切换历史会话", handler=lambda arg: self._open_session_picker()
181
+ )
182
+ )
183
+ self._commands.register(
184
+ SlashCommand("/new", "开始新会话", handler=lambda arg: self._start_new_session())
185
+ )
186
+ self._commands.register(
187
+ SlashCommand(
188
+ "/export",
189
+ "导出会话日志(默认 JSONL,路径以 .md 结尾则导出 Markdown)[path]",
190
+ takes_args=True,
191
+ handler=lambda arg: self._export_session(arg),
192
+ )
193
+ )
194
+ self._commands.register(
195
+ SlashCommand("/help", "显示帮助", handler=lambda arg: self._show_help())
196
+ )
197
+ self._commands.register(
198
+ SlashCommand(
199
+ "/2048",
200
+ "玩一局 2048(不打断当前任务)",
201
+ handler=lambda arg: self._open_game2048(),
202
+ )
203
+ )
204
+
205
+ def _handle_command(self, text: str) -> None:
206
+ chat = self.query_one("#chat", ChatWidget)
207
+ name, _, arg = text.partition(" ")
208
+ arg = arg.strip()
209
+
210
+ command = self._commands.get(name.lower())
211
+ if command is not None and command.handler is not None:
212
+ command.handler(arg)
213
+ return
214
+ skill = self._find_skill(name.removeprefix("/"))
215
+ if skill is not None:
216
+ self._invoke_skill(skill, arg)
217
+ else:
218
+ chat.add_info(f"未知命令 {name},{self._commands.help_text()}")
219
+
220
+ def _show_help(self) -> None:
221
+ self.query_one("#chat", ChatWidget).add_info(self._commands.help_text())
222
+
223
+ def _start_new_session(self) -> None:
224
+ chat = self.query_one("#chat", ChatWidget)
225
+ self.agent = self._new_agent()
226
+ chat.clear()
227
+ chat.add_info(f"已开始新会话 {self.agent.session_id}")
228
+
229
+ def _find_skill(self, name: str) -> Skill | None:
230
+ if self._commands.get(f"/{name}") is not None:
231
+ return None
232
+ for skill in discover_skills(self.workdir):
233
+ if skill.name == name:
234
+ return skill
235
+ return None
236
+
237
+ def _invoke_skill(self, skill: Skill, arg: str) -> None:
238
+ """Invoke a skill: its body becomes the turn's instruction."""
239
+ chat = self.query_one("#chat", ChatWidget)
240
+ chat.add_user_message(f"/{skill.name}" + (f" {arg}" if arg else ""))
241
+ prompt = (
242
+ f"# Skill: {skill.name}\n\n{skill.body.strip()}\n\n"
243
+ f"(Skill 文件位于 {skill.path},其中引用的相对路径基于其所在目录解析。)"
244
+ )
245
+ if arg:
246
+ prompt += f"\n\n## 用户输入\n\n{arg}"
247
+ self.run_worker(self._handle_turn(prompt))
248
+
249
+ def _open_session_picker(self) -> None:
250
+ chat = self.query_one("#chat", ChatWidget)
251
+ sessions = list_sessions(self.session_dir, workdir=self.workdir)
252
+ if not sessions:
253
+ chat.add_info("没有可切换的历史会话")
254
+ return
255
+ self.app.push_screen(SessionPicker(sessions), self._on_session_picked)
256
+
257
+ def _on_session_picked(self, path: Path | None) -> None:
258
+ if path is None:
259
+ return
260
+ chat = self.query_one("#chat", ChatWidget)
261
+ self.agent = self._new_agent(resume=path)
262
+ chat.clear()
263
+ self._render_history()
264
+ meta = self.agent.session_meta
265
+ chat.add_info(f"已切换到会话 {meta.id} · {meta.title or '(无标题)'}")
266
+
267
+ def _render_history(self) -> None:
268
+ """Render the agent's restored history into the chat flow.
269
+
270
+ User/assistant text is rendered as-is; raw tool outputs are summarized
271
+ (tool cards are not rebuilt for history).
272
+ """
273
+ chat = self.query_one("#chat", ChatWidget)
274
+ skipped_tools = 0
275
+ for msg in self.agent.messages[1:]: # skip the system message
276
+ if msg.role == "user" and msg.content:
277
+ chat.add_user_message(msg.content)
278
+ elif msg.role == "assistant" and msg.content:
279
+ chat.add_assistant_message(msg.content)
280
+ elif msg.role == "tool":
281
+ skipped_tools += 1
282
+ if skipped_tools:
283
+ chat.add_info(f"(已省略 {skipped_tools} 条历史工具输出)")
284
+
285
+ def _export_session(self, arg: str) -> None:
286
+ chat = self.query_one("#chat", ChatWidget)
287
+ meta = self.agent.session_meta
288
+ if not meta.title:
289
+ meta.title = derive_title(self.agent.messages)
290
+ if arg:
291
+ out = Path(arg).expanduser()
292
+ else:
293
+ out = (
294
+ Path.home() / ".limbo" / "exports" / f"{self.agent.session_id}.jsonl"
295
+ )
296
+ try:
297
+ if out.suffix == ".md":
298
+ export_markdown(meta, self.agent.messages, out)
299
+ else:
300
+ export_jsonl(
301
+ meta, self.agent.messages, out, trace_path=self.agent.trace.path
302
+ )
303
+ except OSError as e:
304
+ chat.add_error(f"导出失败:{e}")
305
+ return
306
+ chat.add_info(f"已导出到 {out}")
307
+
308
+ def action_toggle_tools(self) -> None:
309
+ self.query_one("#chat", ChatWidget).toggle_tool_bodies()
310
+
311
+ def _open_game2048(self) -> None:
312
+ """Open the 2048 modal. The agent turn (if any) keeps running."""
313
+ self.app.push_screen(Game2048Screen())
314
+
315
+ def action_game2048(self) -> None:
316
+ self._open_game2048()
317
+
318
+ async def on_unmount(self) -> None:
319
+ """Close the LLM client on shutdown to release its HTTP resources."""
320
+ close = getattr(self.llm_client, "close", None)
321
+ if close is not None:
322
+ await close()
323
+
324
+ def on_user_submitted(self, event: UserSubmitted) -> None:
325
+ text = event.message
326
+ if text.startswith("/"):
327
+ self._handle_command(text)
328
+ return
329
+ chat = self.query_one("#chat", ChatWidget)
330
+ chat.add_user_message(text)
331
+ self.run_worker(self._handle_turn(text))
332
+
333
+ async def _handle_turn(self, user_input: str) -> None:
334
+ input_widget = self.query_one("#input", InputWidget)
335
+ statusbar = self.query_one("#statusbar", StatusBar)
336
+ input_widget.disabled = True
337
+ statusbar.set_state("thinking…", "thinking")
338
+ try:
339
+ async for event in self.agent.run(user_input):
340
+ await self._process_agent_event(event)
341
+ finally:
342
+ input_widget.disabled = False
343
+ # Disabling the input mid-turn moves focus away; give it back so
344
+ # the user can keep typing without clicking.
345
+ input_widget.focus()
346
+ statusbar.set_state("idle")
347
+
348
+ async def _process_agent_event(self, event: AgentEvent) -> None:
349
+ chat = self.query_one("#chat", ChatWidget)
350
+ statusbar = self.query_one("#statusbar", StatusBar)
351
+
352
+ if isinstance(event, TextDelta):
353
+ await chat.append_assistant_text(event.text)
354
+ elif isinstance(event, ThinkingDelta):
355
+ await chat.append_thinking_text(event.text)
356
+ elif isinstance(event, ErrorEvent):
357
+ chat.add_error(event.message)
358
+ elif isinstance(event, ToolCallRequest):
359
+ chat.add_tool_card(event.id, event.name, event.arguments)
360
+ statusbar.set_state(f"running {event.name}…", "tool")
361
+ elif isinstance(event, ToolResultEvent):
362
+ result = event.result
363
+ card = chat.add_tool_card(event.id, event.name, event.arguments)
364
+ if result.success:
365
+ card.set_success(result.output or "")
366
+ statusbar.set_state("thinking…", "thinking")
367
+ else:
368
+ card.set_error(result.error or "Tool failed.")
369
+ statusbar.set_state("idle")
@@ -0,0 +1,59 @@
1
+ """Session picker modal: list sessions and let the user switch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Vertical
10
+ from textual.screen import Screen
11
+ from textual.widgets import Label, ListItem, ListView, Static
12
+
13
+ from limbo.sessions import SessionMeta
14
+
15
+
16
+ class SessionPicker(Screen[Path | None]):
17
+ """Modal list of sessions; Enter switches, Esc cancels.
18
+
19
+ Dismisses with the selected session path, or None when cancelled.
20
+ """
21
+
22
+ BINDINGS = [
23
+ Binding("escape", "cancel", "取消"),
24
+ ]
25
+
26
+ def __init__(self, sessions: list[SessionMeta], *args, **kwargs):
27
+ super().__init__(*args, **kwargs)
28
+ self._sessions = sessions
29
+
30
+ def compose(self) -> ComposeResult:
31
+ with Vertical(id="session-picker"):
32
+ yield Static(
33
+ "选择会话 · Enter 切换 · Esc 取消",
34
+ id="picker-title",
35
+ markup=False,
36
+ )
37
+ yield ListView(
38
+ *[
39
+ ListItem(Label(self._format(meta), markup=False))
40
+ for meta in self._sessions
41
+ ]
42
+ )
43
+
44
+ def on_mount(self) -> None:
45
+ self.query_one(ListView).focus()
46
+
47
+ @staticmethod
48
+ def _format(meta: SessionMeta) -> str:
49
+ title = meta.title or "(无标题)"
50
+ updated = meta.updated_at[:16].replace("T", " ")
51
+ return f"{title} · {updated} · {meta.id}"
52
+
53
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
54
+ index = event.list_view.index
55
+ if index is not None and 0 <= index < len(self._sessions):
56
+ self.dismiss(self._sessions[index].path)
57
+
58
+ def action_cancel(self) -> None:
59
+ self.dismiss(None)
@@ -0,0 +1 @@
1
+ """Limbo UI widgets."""
@@ -0,0 +1,149 @@
1
+ """Chat message flow widget (pi-style single column).
2
+
3
+ Renders the conversation as a top-to-bottom stream: user messages, assistant
4
+ Markdown (streamed), inline tool-call cards, and error/info lines.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from rich.text import Text
12
+ from textual.containers import VerticalScroll
13
+ from textual.widgets import Markdown, Static
14
+
15
+ from limbo.ui.widgets.tool_card import ToolCard
16
+
17
+
18
+ class ChatWidget(VerticalScroll):
19
+ """Displays the conversation as a single scrolling flow."""
20
+
21
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
22
+ super().__init__(*args, **kwargs)
23
+ # Text-like messages (user/assistant/error/info) in display order.
24
+ self.messages: list[Static | Markdown] = []
25
+ # Tool cards keyed by tool-call id; ToolCallRequest events may arrive
26
+ # twice for the same call (once streamed, once before execution).
27
+ self.tool_cards: dict[str, ToolCard] = {}
28
+ self._current_assistant: Markdown | None = None
29
+ self._current_thinking: Static | None = None
30
+
31
+ # -- messages -----------------------------------------------------------
32
+
33
+ def add_user_message(self, text: str) -> None:
34
+ self._current_assistant = None
35
+ self._current_thinking = None
36
+ msg = Static(f"❯ {text}", classes="user-message", markup=False)
37
+ self.messages.append(msg)
38
+ self._mount_and_scroll(msg)
39
+
40
+ def add_info(self, text: str) -> None:
41
+ msg = Static(text, classes="info-message", markup=False)
42
+ self.messages.append(msg)
43
+ self._mount_and_scroll(msg)
44
+
45
+ def add_art(self, text: str | Text) -> None:
46
+ """Add preformatted ASCII art (e.g. the startup banner) verbatim.
47
+
48
+ Accepts a Rich ``Text`` for per-character colors; plain strings are
49
+ rendered without markup.
50
+ """
51
+ msg = Static(text, classes="ascii-art", markup=False)
52
+ self.messages.append(msg)
53
+ self._mount_and_scroll(msg)
54
+
55
+ def add_error(self, text: str) -> None:
56
+ self._current_assistant = None
57
+ self._current_thinking = None
58
+ msg = Static(text, classes="error-message", markup=False)
59
+ self.messages.append(msg)
60
+ self._mount_and_scroll(msg)
61
+
62
+ async def append_thinking_text(self, text: str) -> None:
63
+ """Append a streamed reasoning chunk to the current thinking block.
64
+
65
+ Rendered as muted plain text (not Markdown) to keep thinking visually
66
+ secondary to the assistant's reply.
67
+ """
68
+ if self._current_thinking is None:
69
+ # Thinking after assistant text starts a new block.
70
+ self._current_assistant = None
71
+ block = Static("", classes="thinking-message", markup=False)
72
+ self.messages.append(block)
73
+ self._current_thinking = block
74
+ await self.mount(block)
75
+ self._current_thinking.update(str(self._current_thinking.content) + text)
76
+ self.scroll_end(animate=False)
77
+
78
+ async def append_assistant_text(self, text: str) -> None:
79
+ """Append a streamed chunk to the current assistant Markdown block."""
80
+ # Assistant text after thinking starts a new block.
81
+ self._current_thinking = None
82
+ if self._current_assistant is None:
83
+ # markdown=None: on_mount applies the initial markdown and would
84
+ # wipe any chunks appended before mounting finished, so the mount
85
+ # must be awaited before the first append.
86
+ md = Markdown(classes="assistant-message")
87
+ self.messages.append(md)
88
+ self._current_assistant = md
89
+ await self.mount(md)
90
+ # Markdown.append() mutates its source synchronously but defers the
91
+ # re-render via AwaitComplete. Not awaiting it lets fast chunk bursts
92
+ # queue multiple stale renders that re-mount existing blocks
93
+ # (visually duplicated content), so each append is awaited.
94
+ await self._current_assistant.append(text)
95
+ self.scroll_end(animate=False)
96
+
97
+ def add_assistant_message(self, text: str) -> None:
98
+ """Add a complete (non-streamed) assistant Markdown block."""
99
+ self._current_assistant = None
100
+ md = Markdown(text, classes="assistant-message")
101
+ self.messages.append(md)
102
+ self._mount_and_scroll(md)
103
+
104
+ def clear(self) -> None:
105
+ """Remove all rendered messages and tool cards."""
106
+ for child in list(self.children):
107
+ child.remove()
108
+ self.messages.clear()
109
+ self.tool_cards.clear()
110
+ self._current_assistant = None
111
+ self._current_thinking = None
112
+
113
+ # -- tool cards -----------------------------------------------------------
114
+
115
+ def add_tool_card(
116
+ self, tool_id: str, name: str, arguments: dict[str, Any]
117
+ ) -> ToolCard:
118
+ """Get or create the card for a tool call (idempotent by tool-call id)."""
119
+ existing = self.tool_cards.get(tool_id)
120
+ if existing is not None:
121
+ return existing
122
+ # Text after a tool card must start a new assistant block.
123
+ self._current_assistant = None
124
+ self._current_thinking = None
125
+ card = ToolCard(tool_id, name, arguments)
126
+ self.tool_cards[tool_id] = card
127
+ self._mount_and_scroll(card)
128
+ return card
129
+
130
+ def toggle_tool_bodies(self) -> None:
131
+ """Expand/collapse all tool cards that have output."""
132
+ for card in self.tool_cards.values():
133
+ card.toggle()
134
+
135
+ # -- helpers --------------------------------------------------------------
136
+
137
+ def transcript_text(self) -> str:
138
+ """Combined plain text of all text-like messages (for tests/debug)."""
139
+ parts: list[str] = []
140
+ for msg in self.messages:
141
+ if isinstance(msg, Markdown):
142
+ parts.append(msg.source)
143
+ else:
144
+ parts.append(str(msg.content))
145
+ return "\n".join(parts)
146
+
147
+ def _mount_and_scroll(self, widget: Any) -> None:
148
+ self.mount(widget)
149
+ self.scroll_end(animate=False)
@@ -0,0 +1,45 @@
1
+ """Slash-command autocomplete menu.
2
+
3
+ Shown above the input box whenever the input starts with ``/`` (and contains
4
+ no whitespace yet). Enter executes the highlighted command (arg-taking
5
+ commands are completed into the input instead), Tab completes, Esc closes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from textual.widgets import OptionList
11
+ from textual.widgets.option_list import Option
12
+
13
+ from limbo.ui.commands import SlashCommand
14
+
15
+
16
+ class SlashCommandMenu(OptionList):
17
+ """Autocomplete popup listing slash commands; hidden by default."""
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self.display = False
22
+ self._commands: list[SlashCommand] = []
23
+
24
+ @property
25
+ def is_open(self) -> bool:
26
+ return bool(self.display)
27
+
28
+ def show_commands(self, commands: list[SlashCommand]) -> None:
29
+ self._commands = list(commands)
30
+ self.clear_options()
31
+ self.add_options(
32
+ [Option(f"{c.name} {c.description}") for c in self._commands]
33
+ )
34
+ if self._commands:
35
+ self.highlighted = 0
36
+ self.display = True
37
+
38
+ def close(self) -> None:
39
+ self.display = False
40
+
41
+ def highlighted_command(self) -> SlashCommand | None:
42
+ index = self.highlighted
43
+ if index is None or not (0 <= index < len(self._commands)):
44
+ return None
45
+ return self._commands[index]