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/tools/ignore.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Gitignore matching for file tools.
|
|
2
|
+
|
|
3
|
+
Shared by ``find`` and ``grep``: a minimal matcher that reads the workdir
|
|
4
|
+
root ``.gitignore``. Does not support nested .gitignore files or advanced
|
|
5
|
+
glob/negation rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitignoreMatcher:
|
|
15
|
+
"""Minimal gitignore matcher that reads the workdir root `.gitignore`."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_dir: Path) -> None:
|
|
18
|
+
self.rules: list[tuple[bool, re.Pattern[str], bool]] = []
|
|
19
|
+
gitignore = base_dir / ".gitignore"
|
|
20
|
+
if gitignore.is_file():
|
|
21
|
+
try:
|
|
22
|
+
text = gitignore.read_text(encoding="utf-8")
|
|
23
|
+
except OSError:
|
|
24
|
+
text = ""
|
|
25
|
+
for line in text.splitlines():
|
|
26
|
+
rule = self._parse_line(line)
|
|
27
|
+
if rule is not None:
|
|
28
|
+
self.rules.append(rule)
|
|
29
|
+
|
|
30
|
+
def _parse_line(self, line: str) -> tuple[bool, re.Pattern[str], bool] | None:
|
|
31
|
+
line = line.rstrip("\n")
|
|
32
|
+
if not line or line.startswith("#"):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
negation = False
|
|
36
|
+
if line.startswith("!"):
|
|
37
|
+
negation = True
|
|
38
|
+
line = line[1:]
|
|
39
|
+
if not line:
|
|
40
|
+
return None
|
|
41
|
+
elif line.startswith("\\!"):
|
|
42
|
+
line = line[1:]
|
|
43
|
+
|
|
44
|
+
if line.startswith("#"):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
dir_only = line.endswith("/")
|
|
48
|
+
if dir_only:
|
|
49
|
+
line = line[:-1]
|
|
50
|
+
|
|
51
|
+
anchored = "/" in line
|
|
52
|
+
if line.startswith("/"):
|
|
53
|
+
line = line[1:]
|
|
54
|
+
|
|
55
|
+
regex = self._glob_to_regex(line)
|
|
56
|
+
if not anchored:
|
|
57
|
+
regex = f"(?:.*/)?{regex}"
|
|
58
|
+
pattern = re.compile(f"^{regex}$")
|
|
59
|
+
return (negation, pattern, dir_only)
|
|
60
|
+
|
|
61
|
+
def _glob_to_regex(self, pattern: str) -> str:
|
|
62
|
+
i = 0
|
|
63
|
+
n = len(pattern)
|
|
64
|
+
result: list[str] = []
|
|
65
|
+
while i < n:
|
|
66
|
+
c = pattern[i]
|
|
67
|
+
if c == "*" and i + 1 < n and pattern[i + 1] == "*":
|
|
68
|
+
result.append(".*")
|
|
69
|
+
i += 2
|
|
70
|
+
elif c == "*":
|
|
71
|
+
result.append("[^/]*")
|
|
72
|
+
i += 1
|
|
73
|
+
elif c == "?":
|
|
74
|
+
result.append("[^/]")
|
|
75
|
+
i += 1
|
|
76
|
+
elif c == "[":
|
|
77
|
+
j = i + 1
|
|
78
|
+
if j < n and pattern[j] == "!":
|
|
79
|
+
j += 1
|
|
80
|
+
if j < n and pattern[j] == "]":
|
|
81
|
+
j += 1
|
|
82
|
+
while j < n and pattern[j] != "]":
|
|
83
|
+
j += 1
|
|
84
|
+
if j >= n:
|
|
85
|
+
result.append(re.escape(c))
|
|
86
|
+
i += 1
|
|
87
|
+
else:
|
|
88
|
+
chars = pattern[i + 1 : j]
|
|
89
|
+
if chars.startswith("!"):
|
|
90
|
+
chars = f"^{chars[1:]}"
|
|
91
|
+
result.append(f"[{chars}]")
|
|
92
|
+
i = j + 1
|
|
93
|
+
else:
|
|
94
|
+
result.append(re.escape(c))
|
|
95
|
+
i += 1
|
|
96
|
+
return "".join(result)
|
|
97
|
+
|
|
98
|
+
def is_ignored(self, rel_path: str) -> bool:
|
|
99
|
+
parts = rel_path.split("/")
|
|
100
|
+
paths = [rel_path]
|
|
101
|
+
paths.extend("/".join(parts[:k]) for k in range(1, len(parts)))
|
|
102
|
+
|
|
103
|
+
ignored = False
|
|
104
|
+
for negation, regex, dir_only in self.rules:
|
|
105
|
+
for path in paths:
|
|
106
|
+
if dir_only and path == rel_path:
|
|
107
|
+
continue
|
|
108
|
+
if regex.match(path):
|
|
109
|
+
ignored = not negation
|
|
110
|
+
break
|
|
111
|
+
return ignored
|
limbo/tools/ls.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""List directory contents tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from limbo.models import ToolResult
|
|
8
|
+
from limbo.tools.base import BaseTool
|
|
9
|
+
|
|
10
|
+
MAX_ENTRIES = 500
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LsTool(BaseTool):
|
|
14
|
+
name = "ls"
|
|
15
|
+
description = "List directory contents. Includes dotfiles."
|
|
16
|
+
parameters = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"path": {"type": "string", "description": "Directory to list"},
|
|
20
|
+
"limit": {"type": "integer", "description": "Max entries"},
|
|
21
|
+
},
|
|
22
|
+
"required": [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
26
|
+
path = arguments.get("path", ".")
|
|
27
|
+
limit = arguments.get("limit", MAX_ENTRIES)
|
|
28
|
+
|
|
29
|
+
target = self.resolve_existing(path, kind="dir")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
33
|
+
except OSError as e:
|
|
34
|
+
return ToolResult(success=False, error=f"Could not list directory: {e}")
|
|
35
|
+
|
|
36
|
+
lines = []
|
|
37
|
+
for entry in entries[:limit]:
|
|
38
|
+
suffix = "/" if entry.is_dir() else ""
|
|
39
|
+
lines.append(f"{entry.name}{suffix}")
|
|
40
|
+
|
|
41
|
+
return ToolResult(success=True, output="\n".join(lines))
|
limbo/tools/read.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Read file contents tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from limbo.config import DEFAULT_SENSITIVE_FILES
|
|
9
|
+
from limbo.models import ToolResult
|
|
10
|
+
from limbo.tools.base import MAX_OUTPUT_BYTES, BaseTool, ToolError
|
|
11
|
+
|
|
12
|
+
MAX_LINES = 2000
|
|
13
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadTool(BaseTool):
|
|
17
|
+
name = "read"
|
|
18
|
+
description = "Read the contents of a file. Use offset/limit for large files."
|
|
19
|
+
parameters = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"path": {"type": "string", "description": "Path to the file"},
|
|
23
|
+
"offset": {
|
|
24
|
+
"type": "integer",
|
|
25
|
+
"description": "Line number to start from (1-indexed, optional)",
|
|
26
|
+
},
|
|
27
|
+
"limit": {
|
|
28
|
+
"type": "integer",
|
|
29
|
+
"description": "Maximum lines to read (optional)",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
"required": ["path"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, workdir: Path, sensitive_files: list[str] | None = None
|
|
37
|
+
):
|
|
38
|
+
super().__init__(workdir)
|
|
39
|
+
self.sensitive_files = set(sensitive_files or DEFAULT_SENSITIVE_FILES)
|
|
40
|
+
|
|
41
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
42
|
+
raw_path = arguments.get("path", "")
|
|
43
|
+
target = self.resolve(raw_path)
|
|
44
|
+
|
|
45
|
+
if target.name in self.sensitive_files or any(
|
|
46
|
+
part in self.sensitive_files for part in target.parts
|
|
47
|
+
):
|
|
48
|
+
return ToolResult(success=False, error="Refusing to read sensitive file.")
|
|
49
|
+
|
|
50
|
+
if not target.exists():
|
|
51
|
+
raise ToolError(f"File not found: {raw_path}")
|
|
52
|
+
if not target.is_file():
|
|
53
|
+
raise ToolError(f"Not a file: {raw_path}")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
file_size = target.stat().st_size
|
|
57
|
+
except OSError as e:
|
|
58
|
+
return ToolResult(success=False, error=f"Could not read file: {e}")
|
|
59
|
+
if file_size > MAX_FILE_SIZE:
|
|
60
|
+
return ToolResult(
|
|
61
|
+
success=False,
|
|
62
|
+
error=(
|
|
63
|
+
f"File too large ({file_size} bytes). "
|
|
64
|
+
f"Maximum size is {MAX_FILE_SIZE} bytes."
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
text = target.read_text(encoding="utf-8", errors="replace")
|
|
70
|
+
except OSError as e:
|
|
71
|
+
return ToolResult(success=False, error=f"Could not read file: {e}")
|
|
72
|
+
|
|
73
|
+
lines = text.splitlines(keepends=True)
|
|
74
|
+
offset = arguments.get("offset")
|
|
75
|
+
limit = arguments.get("limit")
|
|
76
|
+
|
|
77
|
+
if offset is not None and offset < 1:
|
|
78
|
+
return ToolResult(
|
|
79
|
+
success=False, error="offset must be a positive integer."
|
|
80
|
+
)
|
|
81
|
+
if limit is not None and limit < 1:
|
|
82
|
+
return ToolResult(
|
|
83
|
+
success=False, error="limit must be a positive integer."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if offset is not None:
|
|
87
|
+
start = max(0, offset - 1)
|
|
88
|
+
lines = lines[start:]
|
|
89
|
+
if limit is not None:
|
|
90
|
+
lines = lines[:limit]
|
|
91
|
+
|
|
92
|
+
output = "".join(lines)
|
|
93
|
+
truncated = False
|
|
94
|
+
encoded = output.encode("utf-8", errors="replace")
|
|
95
|
+
if len(encoded) > MAX_OUTPUT_BYTES:
|
|
96
|
+
output = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace")
|
|
97
|
+
truncated = True
|
|
98
|
+
elif len(lines) > MAX_LINES:
|
|
99
|
+
output = "".join(lines[:MAX_LINES])
|
|
100
|
+
truncated = True
|
|
101
|
+
|
|
102
|
+
if truncated:
|
|
103
|
+
output += "\n[Output truncated. Use offset/limit to read more.]"
|
|
104
|
+
|
|
105
|
+
return ToolResult(success=True, output=output)
|
limbo/tools/registry.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tool registry that collects all available tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from limbo.config import Config
|
|
10
|
+
from limbo.models import ToolResult
|
|
11
|
+
from limbo.tools.base import BaseTool
|
|
12
|
+
from limbo.tools.bash import BashTool
|
|
13
|
+
from limbo.tools.edit import EditTool
|
|
14
|
+
from limbo.tools.find import FindTool
|
|
15
|
+
from limbo.tools.grep import GrepTool
|
|
16
|
+
from limbo.tools.ls import LsTool
|
|
17
|
+
from limbo.tools.read import ReadTool
|
|
18
|
+
from limbo.tools.write import WriteTool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolRegistry:
|
|
22
|
+
"""Registers and executes tools."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, workdir: Path, config: Config | None = None):
|
|
25
|
+
self.workdir = workdir
|
|
26
|
+
self.config = config or Config()
|
|
27
|
+
self._tools: dict[str, BaseTool] = {}
|
|
28
|
+
# Tools without configurable safety settings are registered generically.
|
|
29
|
+
for tool_class in [EditTool, WriteTool, GrepTool, FindTool, LsTool]:
|
|
30
|
+
self.register(tool_class) # type: ignore[type-abstract]
|
|
31
|
+
# Wire configurable safety options from Config.
|
|
32
|
+
self._tools["read"] = ReadTool(
|
|
33
|
+
workdir=workdir,
|
|
34
|
+
sensitive_files=self.config.safety.sensitive_files,
|
|
35
|
+
)
|
|
36
|
+
if self.config.tools.bash_enabled:
|
|
37
|
+
self._tools["bash"] = BashTool(
|
|
38
|
+
workdir=workdir,
|
|
39
|
+
dangerous_patterns=self.config.safety.dangerous_commands,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def register(self, tool_class: type[BaseTool]) -> None:
|
|
43
|
+
tool = tool_class(workdir=self.workdir)
|
|
44
|
+
self._tools[tool.name] = tool
|
|
45
|
+
|
|
46
|
+
def get(self, name: str) -> BaseTool | None:
|
|
47
|
+
return self._tools.get(name)
|
|
48
|
+
|
|
49
|
+
async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult:
|
|
50
|
+
tool = self.get(name)
|
|
51
|
+
if tool is None:
|
|
52
|
+
return ToolResult(success=False, error=f"Unknown tool: {name}")
|
|
53
|
+
return await asyncio.to_thread(tool.execute, arguments)
|
|
54
|
+
|
|
55
|
+
def definitions(self) -> list[dict[str, Any]]:
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
"type": "function",
|
|
59
|
+
"function": {
|
|
60
|
+
"name": tool.name,
|
|
61
|
+
"description": tool.description,
|
|
62
|
+
"parameters": tool.parameters,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
for tool in self._tools.values()
|
|
66
|
+
]
|
limbo/tools/write.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Write file contents tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from limbo.models import ToolResult
|
|
8
|
+
from limbo.tools.base import BaseTool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WriteTool(BaseTool):
|
|
12
|
+
name = "write"
|
|
13
|
+
description = "Create or overwrite a file. Use only for new files or complete rewrites."
|
|
14
|
+
parameters = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"path": {"type": "string", "description": "Path to the file"},
|
|
18
|
+
"content": {"type": "string", "description": "Content to write"},
|
|
19
|
+
},
|
|
20
|
+
"required": ["path", "content"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
24
|
+
raw_path = arguments.get("path", "")
|
|
25
|
+
content = arguments.get("content", "")
|
|
26
|
+
target = self.resolve_creatable(raw_path)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
target.write_text(content, encoding="utf-8")
|
|
31
|
+
except OSError as e:
|
|
32
|
+
return ToolResult(success=False, error=f"Could not write file: {e}")
|
|
33
|
+
|
|
34
|
+
return ToolResult(success=True, output=f"Wrote {raw_path}.")
|
limbo/trace.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Append-only JSONL trace log for analyzing complete agent runs.
|
|
2
|
+
|
|
3
|
+
The trace lives in a ``traces/`` subdirectory of the session directory
|
|
4
|
+
(``traces/<session>.trace.jsonl``) and records everything the message
|
|
5
|
+
history cannot reconstruct after the fact: full LLM request bodies,
|
|
6
|
+
token usage, tool execution timing, and errors/interruptions.
|
|
7
|
+
|
|
8
|
+
Writes are appended and flushed immediately so a crash or interrupt loses
|
|
9
|
+
at most the in-flight event. Tracing must never break the agent: all write
|
|
10
|
+
failures degrade to a warning.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import warnings
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
TRACE_SUFFIX = ".trace.jsonl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def trace_path_for(session_file: Path) -> Path:
|
|
26
|
+
"""Return the trace file path belonging to a session file.
|
|
27
|
+
|
|
28
|
+
Traces live in a ``traces/`` subdirectory so session listing/globbing
|
|
29
|
+
(``*.jsonl``) never picks them up.
|
|
30
|
+
"""
|
|
31
|
+
return session_file.parent / "traces" / f"{session_file.stem}{TRACE_SUFFIX}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _utc_now_iso() -> str:
|
|
35
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TraceLogger:
|
|
39
|
+
"""Thread-safe, append-only JSONL event log (one record per line)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: Path):
|
|
42
|
+
self.path = path
|
|
43
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
44
|
+
file_existed = self.path.exists()
|
|
45
|
+
self._file = self.path.open("a", encoding="utf-8")
|
|
46
|
+
if not file_existed:
|
|
47
|
+
self.path.chmod(0o600)
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._failed = False
|
|
50
|
+
|
|
51
|
+
def log(self, event_type: str, **fields: Any) -> None:
|
|
52
|
+
"""Append one event record. Never raises."""
|
|
53
|
+
record = {"ts": _utc_now_iso(), "type": event_type, **fields}
|
|
54
|
+
try:
|
|
55
|
+
line = json.dumps(record, ensure_ascii=False, default=str)
|
|
56
|
+
except (TypeError, ValueError) as e:
|
|
57
|
+
# Extremely defensive: `default=str` should catch everything.
|
|
58
|
+
line = json.dumps(
|
|
59
|
+
{
|
|
60
|
+
"ts": record["ts"],
|
|
61
|
+
"type": event_type,
|
|
62
|
+
"serialization_error": str(e),
|
|
63
|
+
},
|
|
64
|
+
ensure_ascii=False,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
with self._lock:
|
|
68
|
+
self._file.write(line + "\n")
|
|
69
|
+
self._file.flush()
|
|
70
|
+
except OSError as e:
|
|
71
|
+
if not self._failed:
|
|
72
|
+
self._failed = True
|
|
73
|
+
warnings.warn(f"Trace log write failed: {e}", stacklevel=2)
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
try:
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._file.close()
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_trace(path: Path) -> list[dict[str, Any]]:
|
|
84
|
+
"""Read all trace records, skipping malformed lines."""
|
|
85
|
+
records: list[dict[str, Any]] = []
|
|
86
|
+
try:
|
|
87
|
+
with path.open("r", encoding="utf-8") as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if not line:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
record = json.loads(line)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
if isinstance(record, dict):
|
|
97
|
+
records.append(record)
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
return records
|
limbo/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Limbo TUI components."""
|
limbo/ui/app.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Textual App for Limbo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import App
|
|
8
|
+
|
|
9
|
+
from limbo.config import Config
|
|
10
|
+
from limbo.llm.client import LLMClient
|
|
11
|
+
from limbo.ui.screens.main import MainScreen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LimboApp(App[None]):
|
|
15
|
+
"""Main TUI application."""
|
|
16
|
+
|
|
17
|
+
TITLE = "Limbo"
|
|
18
|
+
CSS_PATH = "app.tcss"
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
workdir: str | Path,
|
|
23
|
+
config: Config | None = None,
|
|
24
|
+
llm_client: LLMClient | None = None,
|
|
25
|
+
session_dir: Path | None = None,
|
|
26
|
+
resume: Path | None = None,
|
|
27
|
+
*args,
|
|
28
|
+
**kwargs,
|
|
29
|
+
):
|
|
30
|
+
super().__init__(*args, **kwargs)
|
|
31
|
+
self.workdir = Path(workdir).resolve()
|
|
32
|
+
self.config = config
|
|
33
|
+
self.llm_client = llm_client
|
|
34
|
+
self.session_dir = session_dir
|
|
35
|
+
self.resume = resume
|
|
36
|
+
theme = (config.ui.theme if config else None) or None
|
|
37
|
+
if theme:
|
|
38
|
+
try:
|
|
39
|
+
self.theme = theme
|
|
40
|
+
except Exception: # noqa: BLE001 - unknown theme name, keep default
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
self.push_screen(
|
|
45
|
+
MainScreen(
|
|
46
|
+
workdir=self.workdir,
|
|
47
|
+
config=self.config,
|
|
48
|
+
llm_client=self.llm_client,
|
|
49
|
+
session_dir=self.session_dir,
|
|
50
|
+
resume=self.resume,
|
|
51
|
+
)
|
|
52
|
+
)
|
limbo/ui/app.tcss
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/* Limbo TUI — pi 风格单栏布局,集中管理所有样式 */
|
|
2
|
+
|
|
3
|
+
/* ---- 顶部状态栏 ---- */
|
|
4
|
+
#statusbar {
|
|
5
|
+
height: 1;
|
|
6
|
+
padding: 0 1;
|
|
7
|
+
background: $surface;
|
|
8
|
+
}
|
|
9
|
+
#statusbar-state {
|
|
10
|
+
width: 1fr;
|
|
11
|
+
color: $text-muted;
|
|
12
|
+
}
|
|
13
|
+
#statusbar-state.thinking {
|
|
14
|
+
color: $warning;
|
|
15
|
+
}
|
|
16
|
+
#statusbar-state.tool {
|
|
17
|
+
color: $accent;
|
|
18
|
+
}
|
|
19
|
+
#statusbar-context {
|
|
20
|
+
width: auto;
|
|
21
|
+
color: $text-muted;
|
|
22
|
+
text-align: right;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ---- 聊天流 ---- */
|
|
26
|
+
ChatWidget {
|
|
27
|
+
width: 1fr;
|
|
28
|
+
height: 1fr;
|
|
29
|
+
padding: 0 1;
|
|
30
|
+
}
|
|
31
|
+
.user-message {
|
|
32
|
+
color: $text-accent;
|
|
33
|
+
margin-top: 1;
|
|
34
|
+
}
|
|
35
|
+
.assistant-message {
|
|
36
|
+
margin-top: 1;
|
|
37
|
+
padding: 0;
|
|
38
|
+
}
|
|
39
|
+
.error-message {
|
|
40
|
+
color: $error;
|
|
41
|
+
margin-top: 1;
|
|
42
|
+
}
|
|
43
|
+
.info-message {
|
|
44
|
+
color: $text-muted;
|
|
45
|
+
}
|
|
46
|
+
.ascii-art {
|
|
47
|
+
color: $text-muted;
|
|
48
|
+
margin-top: 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ---- 工具调用卡片 ---- */
|
|
52
|
+
ToolCard {
|
|
53
|
+
height: auto;
|
|
54
|
+
padding-left: 1;
|
|
55
|
+
}
|
|
56
|
+
ToolCard .tool-header {
|
|
57
|
+
height: 1;
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
}
|
|
60
|
+
ToolCard.running .tool-header {
|
|
61
|
+
color: $warning;
|
|
62
|
+
}
|
|
63
|
+
ToolCard.success .tool-header {
|
|
64
|
+
color: $success;
|
|
65
|
+
}
|
|
66
|
+
ToolCard.error .tool-header {
|
|
67
|
+
color: $error;
|
|
68
|
+
}
|
|
69
|
+
ToolCard .tool-body {
|
|
70
|
+
height: auto;
|
|
71
|
+
max-height: 24;
|
|
72
|
+
padding-left: 1;
|
|
73
|
+
border-left: solid $primary-lighten-2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ---- 斜杠命令自动补全菜单 ---- */
|
|
77
|
+
#slash-menu {
|
|
78
|
+
height: auto;
|
|
79
|
+
max-height: 8;
|
|
80
|
+
margin: 0 1;
|
|
81
|
+
border: round $primary;
|
|
82
|
+
background: $surface;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ---- 输入框(全局唯一的圆角边框,视觉锚点) ---- */
|
|
86
|
+
#input {
|
|
87
|
+
height: 3;
|
|
88
|
+
border: round $accent;
|
|
89
|
+
margin: 0 1;
|
|
90
|
+
}
|
|
91
|
+
#input:focus {
|
|
92
|
+
border: round $accent-lighten-1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- 底部快捷键提示 ---- */
|
|
96
|
+
#hint {
|
|
97
|
+
height: 1;
|
|
98
|
+
padding: 0 2;
|
|
99
|
+
color: $text-muted;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---- 会话选择弹窗 ---- */
|
|
103
|
+
SessionPicker {
|
|
104
|
+
align: center middle;
|
|
105
|
+
}
|
|
106
|
+
SessionPicker #session-picker {
|
|
107
|
+
width: 80%;
|
|
108
|
+
max-width: 110;
|
|
109
|
+
height: auto;
|
|
110
|
+
max-height: 80%;
|
|
111
|
+
border: round $accent;
|
|
112
|
+
background: $surface;
|
|
113
|
+
padding: 1 2;
|
|
114
|
+
}
|
|
115
|
+
SessionPicker #picker-title {
|
|
116
|
+
text-style: bold;
|
|
117
|
+
margin-bottom: 1;
|
|
118
|
+
}
|
|
119
|
+
SessionPicker ListView {
|
|
120
|
+
height: auto;
|
|
121
|
+
max-height: 20;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---- 2048 游戏弹窗 ---- */
|
|
125
|
+
Game2048Screen {
|
|
126
|
+
align: center middle;
|
|
127
|
+
}
|
|
128
|
+
Game2048Screen #game-container {
|
|
129
|
+
width: auto;
|
|
130
|
+
height: auto;
|
|
131
|
+
border: round $accent;
|
|
132
|
+
background: $surface;
|
|
133
|
+
padding: 1 2;
|
|
134
|
+
}
|
|
135
|
+
Game2048Screen #game-header {
|
|
136
|
+
text-style: bold;
|
|
137
|
+
margin-bottom: 1;
|
|
138
|
+
content-align: center middle;
|
|
139
|
+
width: 100%;
|
|
140
|
+
}
|
|
141
|
+
Game2048Screen #game-grid {
|
|
142
|
+
grid-size: 4 4;
|
|
143
|
+
grid-columns: 6;
|
|
144
|
+
grid-rows: 3;
|
|
145
|
+
grid-gutter: 1;
|
|
146
|
+
width: 27;
|
|
147
|
+
height: 15;
|
|
148
|
+
}
|
|
149
|
+
Game2048Screen #game-footer {
|
|
150
|
+
margin-top: 1;
|
|
151
|
+
color: $text-muted;
|
|
152
|
+
content-align: center middle;
|
|
153
|
+
width: 100%;
|
|
154
|
+
}
|
|
155
|
+
Game2048Screen .tile {
|
|
156
|
+
content-align: center middle;
|
|
157
|
+
text-style: bold;
|
|
158
|
+
width: 100%;
|
|
159
|
+
height: 3;
|
|
160
|
+
background: $surface-lighten-1;
|
|
161
|
+
color: $text-muted;
|
|
162
|
+
}
|
|
163
|
+
Game2048Screen .tile-2 { background: #eee4da; color: #776e65; }
|
|
164
|
+
Game2048Screen .tile-4 { background: #ede0c8; color: #776e65; }
|
|
165
|
+
Game2048Screen .tile-8 { background: #f2b179; color: #f9f6f2; }
|
|
166
|
+
Game2048Screen .tile-16 { background: #f59563; color: #f9f6f2; }
|
|
167
|
+
Game2048Screen .tile-32 { background: #f67c5f; color: #f9f6f2; }
|
|
168
|
+
Game2048Screen .tile-64 { background: #f65e3b; color: #f9f6f2; }
|
|
169
|
+
Game2048Screen .tile-128 { background: #edcf72; color: #f9f6f2; }
|
|
170
|
+
Game2048Screen .tile-256 { background: #edcc61; color: #f9f6f2; }
|
|
171
|
+
Game2048Screen .tile-512 { background: #edc850; color: #f9f6f2; }
|
|
172
|
+
Game2048Screen .tile-1024 { background: #edc53f; color: #f9f6f2; }
|
|
173
|
+
Game2048Screen .tile-2048 { background: #edc22e; color: #f9f6f2; }
|
|
174
|
+
Game2048Screen .tile-super { background: #3c3a32; color: #f9f6f2; }
|