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.
- limbo/__init__.py +3 -0
- limbo/__main__.py +6 -0
- limbo/agent.py +456 -0
- limbo/app.py +107 -0
- limbo/config.py +99 -0
- limbo/history.py +90 -0
- limbo/llm/__init__.py +1 -0
- limbo/llm/anthropic_client.py +320 -0
- limbo/llm/catalog.py +234 -0
- limbo/llm/client.py +21 -0
- limbo/llm/factory.py +46 -0
- limbo/llm/openai_client.py +261 -0
- limbo/models.py +81 -0
- limbo/sessions.py +258 -0
- limbo/skills.py +89 -0
- limbo/tools/__init__.py +1 -0
- limbo/tools/base.py +102 -0
- limbo/tools/bash.py +172 -0
- limbo/tools/edit.py +70 -0
- limbo/tools/find.py +71 -0
- limbo/tools/grep.py +182 -0
- limbo/tools/ignore.py +111 -0
- limbo/tools/ls.py +41 -0
- limbo/tools/read.py +105 -0
- limbo/tools/registry.py +66 -0
- limbo/tools/write.py +34 -0
- limbo/trace.py +100 -0
- limbo/ui/__init__.py +1 -0
- limbo/ui/app.py +52 -0
- limbo/ui/app.tcss +174 -0
- limbo/ui/banner.py +83 -0
- limbo/ui/commands.py +61 -0
- limbo/ui/screens/__init__.py +1 -0
- limbo/ui/screens/game2048.py +213 -0
- limbo/ui/screens/main.py +369 -0
- limbo/ui/screens/session_picker.py +59 -0
- limbo/ui/widgets/__init__.py +1 -0
- limbo/ui/widgets/chat.py +149 -0
- limbo/ui/widgets/command_menu.py +45 -0
- limbo/ui/widgets/input.py +199 -0
- limbo/ui/widgets/status_bar.py +32 -0
- limbo/ui/widgets/tool_card.py +179 -0
- limbo_code-0.1.0.dist-info/METADATA +16 -0
- limbo_code-0.1.0.dist-info/RECORD +46 -0
- limbo_code-0.1.0.dist-info/WHEEL +4 -0
- limbo_code-0.1.0.dist-info/entry_points.txt +2 -0
limbo/ui/screens/main.py
ADDED
|
@@ -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."""
|
limbo/ui/widgets/chat.py
ADDED
|
@@ -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]
|