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/banner.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Startup ASCII-art banner shown on the main screen.
|
|
2
|
+
|
|
3
|
+
Generated from the project's cartoon image (red shirt / yellow pants on a
|
|
4
|
+
blue background) by quantizing each pixel to the nearest of four palette
|
|
5
|
+
colors and mapping them to characters:
|
|
6
|
+
|
|
7
|
+
- ``@`` dark outline (line art preserved with a per-cell coverage threshold)
|
|
8
|
+
- ``o`` red shirt
|
|
9
|
+
- ``.`` yellow pants
|
|
10
|
+
- `` `` (space) blue background
|
|
11
|
+
|
|
12
|
+
``startup_art_text()`` renders the same characters as a Rich ``Text`` with
|
|
13
|
+
the original image colors: each cell's foreground is its palette color on
|
|
14
|
+
the blue background, so the banner looks like the source cartoon.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
# Palette sampled from the source image (RGB).
|
|
20
|
+
BLUE = (48, 105, 169) # background
|
|
21
|
+
YELLOW = (253, 231, 92) # pants
|
|
22
|
+
RED = (237, 88, 83) # shirt
|
|
23
|
+
DARK = (65, 64, 94) # outline
|
|
24
|
+
|
|
25
|
+
_FG = {"@": DARK, "o": RED, ".": YELLOW, " ": BLUE}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _style(char: str) -> str:
|
|
29
|
+
r, g, b = _FG[char]
|
|
30
|
+
bg = BLUE
|
|
31
|
+
return f"rgb({r},{g},{b}) on rgb({bg[0]},{bg[1]},{bg[2]})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def startup_art_text() -> Text:
|
|
35
|
+
"""STARTUP_ART as a Rich Text colored with the original image palette.
|
|
36
|
+
|
|
37
|
+
Lines are padded to a uniform width so the blue background forms the
|
|
38
|
+
same solid rectangle as the source image.
|
|
39
|
+
"""
|
|
40
|
+
lines = STARTUP_ART.splitlines()
|
|
41
|
+
width = max(len(line) for line in lines)
|
|
42
|
+
text = Text()
|
|
43
|
+
for i, line in enumerate(lines):
|
|
44
|
+
if i:
|
|
45
|
+
text.append("\n")
|
|
46
|
+
for ch in line.ljust(width):
|
|
47
|
+
text.append(ch, style=_style(ch))
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
STARTUP_ART = """\
|
|
52
|
+
@@ooooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
53
|
+
@ooooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
54
|
+
@@oooo@@ooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
55
|
+
@@oooo@oooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
56
|
+
@@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
57
|
+
@@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
58
|
+
@@ooo@@oooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
59
|
+
@@ooo@@oooooooooooooooooooooooooooooo@@ooooooooooo@
|
|
60
|
+
@@ooo@@oooooooooooooooooooooooooooooo@@ooooooooooo@
|
|
61
|
+
@@ooo@@@ooooooooooooooooooooooooooooo@@oooooooooo@@
|
|
62
|
+
@@ooo@@@@@@@@ooooooooooooooooooooooo@@@ooooooooo@@
|
|
63
|
+
@@@@@@....@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ooooooo@@
|
|
64
|
+
@@@@@................................@@@@@@@@@@
|
|
65
|
+
@@@.......@@@@.@@@@................@@@@@@@@@@@
|
|
66
|
+
@@@.......@@@@..@...................@@@@@@...@
|
|
67
|
+
@@........@@@..@...................@@......@@@
|
|
68
|
+
@@.......@@@......................@@..@.@@@@@
|
|
69
|
+
@@@@ @@...@@..@@@@.....................@@@@@@@@@@@
|
|
70
|
+
@@@ @@..@@@....@@@@...................@@@@@@@@@
|
|
71
|
+
@@ @ @@@@...@...@@@@....................@@@@@@@@
|
|
72
|
+
@@@@@ @@.@@@....@@@....................@@
|
|
73
|
+
@@ @@.........@@@@.................@@
|
|
74
|
+
@@ @@...........@@.................@@
|
|
75
|
+
@@ @@...........@@.................@@
|
|
76
|
+
@@ @@...........@@.................@@
|
|
77
|
+
@@...........@@.................@@
|
|
78
|
+
@...........@@.................@@
|
|
79
|
+
@...........@@.................@@
|
|
80
|
+
@@..........@@.................@
|
|
81
|
+
@@...........@................@@
|
|
82
|
+
@@...........@................@@
|
|
83
|
+
@@...........@@...............@@"""
|
limbo/ui/commands.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Slash-command registry: one source for menu metadata and dispatch.
|
|
2
|
+
|
|
3
|
+
A slash command is defined once — name, description, whether it takes
|
|
4
|
+
arguments, and its handler. The autocomplete menu renders from the registry
|
|
5
|
+
and the input dispatcher consults it, so the two can never drift.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from limbo.skills import Skill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SlashCommand:
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
takes_args: bool = False
|
|
21
|
+
kind: str = "builtin" # "builtin" | "skill"
|
|
22
|
+
handler: Callable[[str], None] | None = field(
|
|
23
|
+
default=None, compare=False, repr=False
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SlashCommandRegistry:
|
|
28
|
+
"""Holds the built-in commands; merges skills for display and lookup."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._commands: dict[str, SlashCommand] = {}
|
|
32
|
+
|
|
33
|
+
def register(self, command: SlashCommand) -> None:
|
|
34
|
+
self._commands[command.name] = command
|
|
35
|
+
|
|
36
|
+
def get(self, name: str) -> SlashCommand | None:
|
|
37
|
+
return self._commands.get(name)
|
|
38
|
+
|
|
39
|
+
def all(self) -> list[SlashCommand]:
|
|
40
|
+
return list(self._commands.values())
|
|
41
|
+
|
|
42
|
+
def candidates(self, skills: list[Skill]) -> list[SlashCommand]:
|
|
43
|
+
"""Built-in commands plus skills (built-ins win on name collisions)."""
|
|
44
|
+
candidates = self.all()
|
|
45
|
+
for skill in skills:
|
|
46
|
+
if f"/{skill.name}" in self._commands:
|
|
47
|
+
continue
|
|
48
|
+
candidates.append(
|
|
49
|
+
SlashCommand(
|
|
50
|
+
f"/{skill.name}",
|
|
51
|
+
f"[skill] {skill.description}".rstrip(),
|
|
52
|
+
takes_args=True,
|
|
53
|
+
kind="skill",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return candidates
|
|
57
|
+
|
|
58
|
+
def help_text(self) -> str:
|
|
59
|
+
return "可用命令:" + " · ".join(
|
|
60
|
+
f"{command.name} {command.description}" for command in self.all()
|
|
61
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Limbo screens."""
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""2048 mini-game: pure game logic + a modal screen.
|
|
2
|
+
|
|
3
|
+
The screen is a modal pushed on top of the main chat screen. Textual keeps
|
|
4
|
+
workers on the underlying screen running while a modal is up, so the agent
|
|
5
|
+
turn continues in the background — the game never interrupts it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Grid, Vertical
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.widgets import Static
|
|
18
|
+
|
|
19
|
+
Direction = Literal["up", "down", "left", "right"]
|
|
20
|
+
|
|
21
|
+
TARGET = 2048
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Game2048:
|
|
25
|
+
"""Pure 2048 game logic, independent of the UI."""
|
|
26
|
+
|
|
27
|
+
SIZE = 4
|
|
28
|
+
|
|
29
|
+
def __init__(self, rng: random.Random | None = None) -> None:
|
|
30
|
+
self._rng = rng or random.Random()
|
|
31
|
+
self.grid: list[list[int]] = [
|
|
32
|
+
[0] * self.SIZE for _ in range(self.SIZE)
|
|
33
|
+
]
|
|
34
|
+
self.score = 0
|
|
35
|
+
self.won = False
|
|
36
|
+
self.spawn()
|
|
37
|
+
self.spawn()
|
|
38
|
+
|
|
39
|
+
# -- state ----------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def over(self) -> bool:
|
|
43
|
+
"""Game over when no empty cell and no adjacent equal tiles remain."""
|
|
44
|
+
size = self.SIZE
|
|
45
|
+
for r in range(size):
|
|
46
|
+
for c in range(size):
|
|
47
|
+
if self.grid[r][c] == 0:
|
|
48
|
+
return False
|
|
49
|
+
if c + 1 < size and self.grid[r][c] == self.grid[r][c + 1]:
|
|
50
|
+
return False
|
|
51
|
+
if r + 1 < size and self.grid[r][c] == self.grid[r + 1][c]:
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def spawn(self) -> bool:
|
|
56
|
+
"""Add a 2 (90%) or 4 (10%) to a random empty cell."""
|
|
57
|
+
empties = [
|
|
58
|
+
(r, c)
|
|
59
|
+
for r in range(self.SIZE)
|
|
60
|
+
for c in range(self.SIZE)
|
|
61
|
+
if self.grid[r][c] == 0
|
|
62
|
+
]
|
|
63
|
+
if not empties:
|
|
64
|
+
return False
|
|
65
|
+
r, c = self._rng.choice(empties)
|
|
66
|
+
self.grid[r][c] = 4 if self._rng.random() < 0.1 else 2
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# -- moves ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def move(self, direction: Direction) -> bool:
|
|
72
|
+
"""Slide and merge the grid. Returns True if anything changed."""
|
|
73
|
+
lines = self._lines(direction)
|
|
74
|
+
gained = 0
|
|
75
|
+
new_lines = []
|
|
76
|
+
for line in lines:
|
|
77
|
+
merged, line_gained = self._merge_left(line)
|
|
78
|
+
new_lines.append(merged)
|
|
79
|
+
gained += line_gained
|
|
80
|
+
if new_lines == lines:
|
|
81
|
+
return False
|
|
82
|
+
self.score += gained
|
|
83
|
+
self._write_lines(direction, new_lines)
|
|
84
|
+
if not self.won and any(
|
|
85
|
+
value >= TARGET for row in self.grid for value in row
|
|
86
|
+
):
|
|
87
|
+
self.won = True
|
|
88
|
+
self.spawn()
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def _lines(self, direction: Direction) -> list[list[int]]:
|
|
92
|
+
"""Extract rows/columns so every move reduces to 'merge left'."""
|
|
93
|
+
size = self.SIZE
|
|
94
|
+
if direction == "left":
|
|
95
|
+
return [row[:] for row in self.grid]
|
|
96
|
+
if direction == "right":
|
|
97
|
+
return [row[::-1] for row in self.grid]
|
|
98
|
+
if direction == "up":
|
|
99
|
+
return [[self.grid[r][c] for r in range(size)] for c in range(size)]
|
|
100
|
+
return [
|
|
101
|
+
[self.grid[r][c] for r in range(size - 1, -1, -1)]
|
|
102
|
+
for c in range(size)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
def _write_lines(self, direction: Direction, lines: list[list[int]]) -> None:
|
|
106
|
+
"""Inverse of `_lines`."""
|
|
107
|
+
size = self.SIZE
|
|
108
|
+
if direction == "left":
|
|
109
|
+
self.grid = [line[:] for line in lines]
|
|
110
|
+
elif direction == "right":
|
|
111
|
+
self.grid = [line[::-1] for line in lines]
|
|
112
|
+
elif direction == "up":
|
|
113
|
+
for c in range(size):
|
|
114
|
+
for r in range(size):
|
|
115
|
+
self.grid[r][c] = lines[c][r]
|
|
116
|
+
else:
|
|
117
|
+
for c in range(size):
|
|
118
|
+
for r in range(size):
|
|
119
|
+
self.grid[size - 1 - r][c] = lines[c][r]
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _merge_left(line: list[int]) -> tuple[list[int], int]:
|
|
123
|
+
"""Compress and merge one line towards index 0."""
|
|
124
|
+
tiles = [value for value in line if value]
|
|
125
|
+
merged: list[int] = []
|
|
126
|
+
gained = 0
|
|
127
|
+
i = 0
|
|
128
|
+
while i < len(tiles):
|
|
129
|
+
if i + 1 < len(tiles) and tiles[i] == tiles[i + 1]:
|
|
130
|
+
merged.append(tiles[i] * 2)
|
|
131
|
+
gained += tiles[i] * 2
|
|
132
|
+
i += 2
|
|
133
|
+
else:
|
|
134
|
+
merged.append(tiles[i])
|
|
135
|
+
i += 1
|
|
136
|
+
return merged + [0] * (len(line) - len(merged)), gained
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Game2048Screen(ModalScreen[None]):
|
|
140
|
+
"""Playable 2048 modal. The agent keeps running underneath."""
|
|
141
|
+
|
|
142
|
+
BINDINGS = [
|
|
143
|
+
Binding("up", "move('up')", "↑"),
|
|
144
|
+
Binding("down", "move('down')", "↓"),
|
|
145
|
+
Binding("left", "move('left')", "←"),
|
|
146
|
+
Binding("right", "move('right')", "→"),
|
|
147
|
+
Binding("w", "move('up')", show=False),
|
|
148
|
+
Binding("s", "move('down')", show=False),
|
|
149
|
+
Binding("a", "move('left')", show=False),
|
|
150
|
+
Binding("d", "move('right')", show=False),
|
|
151
|
+
Binding("r", "restart", "重开"),
|
|
152
|
+
Binding("q", "close", "关闭"),
|
|
153
|
+
Binding("escape", "close", "关闭"),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
157
|
+
super().__init__(*args, **kwargs)
|
|
158
|
+
self.game = Game2048()
|
|
159
|
+
|
|
160
|
+
def compose(self) -> ComposeResult:
|
|
161
|
+
with Vertical(id="game-container"):
|
|
162
|
+
yield Static("", id="game-header", markup=False)
|
|
163
|
+
with Grid(id="game-grid"):
|
|
164
|
+
for r in range(self.game.SIZE):
|
|
165
|
+
for c in range(self.game.SIZE):
|
|
166
|
+
yield Static("", classes="tile", id=f"cell-{r}-{c}")
|
|
167
|
+
yield Static(
|
|
168
|
+
"方向键 / WASD 移动 · R 重开 · Q/Esc 关闭",
|
|
169
|
+
id="game-footer",
|
|
170
|
+
markup=False,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def on_mount(self) -> None:
|
|
174
|
+
self._refresh()
|
|
175
|
+
|
|
176
|
+
# -- actions --------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def action_move(self, direction: Direction) -> None:
|
|
179
|
+
if self.game.over:
|
|
180
|
+
return
|
|
181
|
+
if self.game.move(direction):
|
|
182
|
+
self._refresh()
|
|
183
|
+
|
|
184
|
+
def action_restart(self) -> None:
|
|
185
|
+
self.game = Game2048()
|
|
186
|
+
self._refresh()
|
|
187
|
+
|
|
188
|
+
def action_close(self) -> None:
|
|
189
|
+
self.dismiss()
|
|
190
|
+
|
|
191
|
+
# -- rendering --------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _refresh(self) -> None:
|
|
194
|
+
header = self.query_one("#game-header", Static)
|
|
195
|
+
status = ""
|
|
196
|
+
if self.game.over:
|
|
197
|
+
status = " · 游戏结束,按 R 重开"
|
|
198
|
+
elif self.game.won:
|
|
199
|
+
status = " · 🎉 达成 2048!可继续挑战"
|
|
200
|
+
header.update(f"2048 · 分数 {self.game.score}{status}")
|
|
201
|
+
|
|
202
|
+
for r in range(self.game.SIZE):
|
|
203
|
+
for c in range(self.game.SIZE):
|
|
204
|
+
value = self.game.grid[r][c]
|
|
205
|
+
cell = self.query_one(f"#cell-{r}-{c}", Static)
|
|
206
|
+
cell.update(str(value) if value else "")
|
|
207
|
+
if value:
|
|
208
|
+
tile_class = (
|
|
209
|
+
f"tile-{value}" if value <= TARGET else "tile-super"
|
|
210
|
+
)
|
|
211
|
+
cell.set_classes(f"tile {tile_class}")
|
|
212
|
+
else:
|
|
213
|
+
cell.set_classes("tile")
|