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,199 @@
1
+ """User input widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.cells import cell_len
8
+ from textual.binding import Binding
9
+ from textual.message import Message
10
+ from textual.widgets import TextArea
11
+
12
+ # Widget height is text rows + 2 rows of round border. The input starts at
13
+ # one text row (height 3) and grows up to MAX_TEXT_ROWS before scrolling.
14
+ MIN_TEXT_ROWS = 1
15
+ MAX_TEXT_ROWS = 8
16
+
17
+
18
+ class UserSubmitted(Message):
19
+ """Event emitted when the user submits a message."""
20
+
21
+ def __init__(self, message: str):
22
+ self.message = message
23
+ super().__init__()
24
+
25
+
26
+ class InputWidget(TextArea):
27
+ """Multi-line input that submits on Enter (Shift+Enter for newline).
28
+
29
+ The widget grows vertically as lines are added (up to MAX_TEXT_ROWS),
30
+ and recalls previously submitted input with the up/down arrow keys
31
+ (shell-style history, in-memory per session).
32
+
33
+ When the screen's slash-command menu is open, Enter/Tab/up/down/Esc are
34
+ redirected to the menu instead of their default behavior.
35
+
36
+ Styles live in ``limbo/ui/app.tcss``.
37
+ """
38
+
39
+ # Use priority bindings so these actions run before TextArea's default
40
+ # key handling, which would otherwise insert a newline on Enter.
41
+ BINDINGS = [
42
+ Binding("enter", "submit", "Submit", priority=True),
43
+ Binding("shift+enter", "newline", "Newline", priority=True),
44
+ Binding("up", "menu_up", show=False, priority=True),
45
+ Binding("down", "menu_down", show=False, priority=True),
46
+ Binding("tab", "menu_complete", show=False, priority=True),
47
+ Binding("escape", "menu_cancel", show=False, priority=True),
48
+ ]
49
+
50
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
51
+ super().__init__(*args, **kwargs)
52
+ self.show_line_numbers = False
53
+ # Shell-style input history (in-memory, per session).
54
+ self._history: list[str] = []
55
+ self._history_index: int | None = None
56
+ self._draft: str = ""
57
+ # Last value applied via history recall; used to tell recall edits
58
+ # apart from manual edits in the (async) Changed handler.
59
+ self._history_value: str | None = None
60
+
61
+ def _menu_screen(self):
62
+ """The screen, but only when its slash-command menu is open."""
63
+ screen = self.screen
64
+ if getattr(screen, "slash_menu_open", False):
65
+ return screen
66
+ return None
67
+
68
+ # -- auto-growing height ---------------------------------------------------
69
+
70
+ def _visual_rows(self) -> int:
71
+ """Number of display rows the current text occupies (soft-wrapped)."""
72
+ width = self.wrap_width
73
+ rows = 0
74
+ for line in self.document.lines:
75
+ length = cell_len(line)
76
+ if width > 0:
77
+ rows += max(1, -(-length // width))
78
+ else:
79
+ rows += 1
80
+ return max(MIN_TEXT_ROWS, rows)
81
+
82
+ def _auto_resize(self) -> None:
83
+ height = min(self._visual_rows(), MAX_TEXT_ROWS) + 2
84
+ current = self.styles.height
85
+ if current is not None and getattr(current, "value", None) == height:
86
+ return
87
+ self.styles.height = height
88
+
89
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
90
+ if event.text_area is not self:
91
+ return
92
+ self._auto_resize()
93
+ # Editing by hand while browsing history cancels the recall position,
94
+ # so the next up/down starts fresh from the latest entry. Changes
95
+ # applied by history recall itself keep the navigation position.
96
+ if self._history_value is not None and self.text == self._history_value:
97
+ return
98
+ self._history_value = None
99
+ self._history_index = None
100
+
101
+ def on_resize(self) -> None:
102
+ # Terminal width changes affect soft-wrapping, hence the row count.
103
+ self._auto_resize()
104
+
105
+ # -- submission ------------------------------------------------------------
106
+
107
+ def action_submit(self) -> None:
108
+ screen = self._menu_screen()
109
+ if screen is not None and screen.slash_menu_complete(execute=True):
110
+ return
111
+ text = self.text.strip()
112
+ if text:
113
+ if not self._history or self._history[-1] != text:
114
+ self._history.append(text)
115
+ self._history_index = None
116
+ self._history_value = None
117
+ self._draft = ""
118
+ self.post_message(UserSubmitted(text))
119
+ self.clear()
120
+
121
+ def action_newline(self) -> None:
122
+ """Insert a newline at the current cursor position."""
123
+ self.insert("\n")
124
+
125
+ # -- history recall --------------------------------------------------------
126
+
127
+ def _set_text_from_history(self, value: str) -> None:
128
+ self._history_value = value
129
+ self.text = value
130
+ self.move_cursor(self.document.end)
131
+
132
+ def _cursor_on_first_visual_row(self) -> bool:
133
+ """Whether the cursor sits on the first *visual* (soft-wrapped) row.
134
+
135
+ With soft wrap a single logical line spans several display rows; the
136
+ logical row from ``cursor_location`` stays 0, so the column is used
137
+ to approximate the visual position (wide chars make this approximate).
138
+ """
139
+ row, col = self.cursor_location
140
+ if row > 0:
141
+ return False
142
+ width = self.wrap_width
143
+ return width <= 0 or col < width
144
+
145
+ def _history_prev(self) -> None:
146
+ if not self._history:
147
+ self.action_cursor_up()
148
+ return
149
+ if self._history_index is None:
150
+ # Multi-line/soft-wrapped input: up moves the cursor until it
151
+ # reaches the first visual row; only then does it start
152
+ # recalling history.
153
+ if not self._cursor_on_first_visual_row():
154
+ self.action_cursor_up()
155
+ return
156
+ self._draft = self.text
157
+ self._history_index = len(self._history) - 1
158
+ else:
159
+ self._history_index = max(0, self._history_index - 1)
160
+ self._set_text_from_history(self._history[self._history_index])
161
+
162
+ def _history_next(self) -> None:
163
+ if self._history_index is None:
164
+ self.action_cursor_down()
165
+ return
166
+ self._history_index += 1
167
+ if self._history_index >= len(self._history):
168
+ self._history_index = None
169
+ self._set_text_from_history(self._draft)
170
+ else:
171
+ self._set_text_from_history(self._history[self._history_index])
172
+
173
+ # -- slash-menu key redirection --------------------------------------------
174
+
175
+ def action_menu_up(self) -> None:
176
+ screen = self._menu_screen()
177
+ if screen is None:
178
+ self._history_prev()
179
+ else:
180
+ screen.slash_menu_move(-1)
181
+
182
+ def action_menu_down(self) -> None:
183
+ screen = self._menu_screen()
184
+ if screen is None:
185
+ self._history_next()
186
+ else:
187
+ screen.slash_menu_move(1)
188
+
189
+ def action_menu_complete(self) -> None:
190
+ screen = self._menu_screen()
191
+ if screen is None:
192
+ self.screen.focus_next()
193
+ else:
194
+ screen.slash_menu_complete(execute=False)
195
+
196
+ def action_menu_cancel(self) -> None:
197
+ screen = self._menu_screen()
198
+ if screen is not None:
199
+ screen.slash_menu_close()
@@ -0,0 +1,32 @@
1
+ """Top status bar: agent state on the left, model/workdir on the right."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal
9
+ from textual.widgets import Static
10
+
11
+
12
+ class StatusBar(Horizontal):
13
+ """One-line status bar docked at the top of the screen."""
14
+
15
+ def __init__(self, model: str, workdir: str, *args: Any, **kwargs: Any) -> None:
16
+ super().__init__(*args, **kwargs)
17
+ # NOTE: attribute names must not collide with MessagePump internals
18
+ # (e.g. `_context` is a method on MessagePump).
19
+ self._state_label = Static("● idle", id="statusbar-state", markup=False)
20
+ self._context_label = Static(
21
+ f"{model} · {workdir}", id="statusbar-context", markup=False
22
+ )
23
+
24
+ def compose(self) -> ComposeResult:
25
+ yield self._state_label
26
+ yield self._context_label
27
+
28
+ def set_state(self, text: str, style: str = "idle") -> None:
29
+ """Update the left side. ``style`` is one of idle/thinking/tool."""
30
+ self._state_label.update(f"● {text}")
31
+ self._state_label.set_class(style == "thinking", "thinking")
32
+ self._state_label.set_class(style == "tool", "tool")
@@ -0,0 +1,179 @@
1
+ """Inline tool-call card shown in the chat flow.
2
+
3
+ A tool card renders as a single summary line (state symbol + tool name +
4
+ argument summary + elapsed time) and can be expanded to show the full tool
5
+ output. State machine: running → success | error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any
12
+
13
+ from rich.text import Text
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Vertical
16
+ from textual.css.query import NoMatches
17
+ from textual.widgets import RichLog, Static
18
+
19
+ _STATE_SYMBOLS = {
20
+ "running": "…",
21
+ "success": "✓",
22
+ "error": "✗",
23
+ }
24
+ _STATE_LABELS = {
25
+ "running": "运行中",
26
+ "success": "",
27
+ "error": "失败",
28
+ }
29
+
30
+ # Argument keys worth showing in the one-line summary, in priority order.
31
+ _SUMMARY_KEYS = ("path", "command", "pattern", "old_text")
32
+
33
+
34
+ class ToolCard(Vertical):
35
+ """One-line tool summary that expands to show full output."""
36
+
37
+ def __init__(
38
+ self,
39
+ tool_id: str,
40
+ name: str,
41
+ arguments: dict[str, Any],
42
+ *args: Any,
43
+ **kwargs: Any,
44
+ ) -> None:
45
+ super().__init__(*args, **kwargs)
46
+ self.tool_id = tool_id
47
+ self.tool_name = name
48
+ self.arguments = arguments
49
+ self.state = "running"
50
+ self._started = time.monotonic()
51
+ self._elapsed: float | None = None
52
+ self._has_body = False
53
+ self._body_content: tuple[str, str | None] | None = None
54
+ self.add_class("running")
55
+
56
+ def compose(self) -> ComposeResult:
57
+ yield Static(self._header_text(), classes="tool-header", markup=False)
58
+ body = RichLog(classes="tool-body", wrap=True, max_lines=1000)
59
+ body.display = False
60
+ yield body
61
+
62
+ def on_mount(self) -> None:
63
+ # State transitions may have happened before composition finished
64
+ # (events can arrive in the same loop turn as the card creation).
65
+ self._refresh_header()
66
+ if self._body_content is not None:
67
+ self._write_body(*self._body_content)
68
+
69
+ @property
70
+ def header(self) -> Static:
71
+ return self.query_one(".tool-header", Static)
72
+
73
+ @property
74
+ def body(self) -> RichLog:
75
+ return self.query_one(".tool-body", RichLog)
76
+
77
+ # -- state transitions -------------------------------------------------
78
+
79
+ def set_success(self, output: str) -> None:
80
+ self._set_state("success")
81
+ self._set_body(output, lexer=self._lexer_for_body())
82
+
83
+ def set_error(self, error: str) -> None:
84
+ self._set_state("error")
85
+ self._set_body(error)
86
+
87
+ # -- expansion ----------------------------------------------------------
88
+
89
+ def toggle(self) -> None:
90
+ if self._has_body:
91
+ self.body.display = not self.body.display
92
+
93
+ def on_click(self) -> None:
94
+ self.toggle()
95
+
96
+ # -- internals ----------------------------------------------------------
97
+
98
+ def _set_state(self, state: str) -> None:
99
+ if self.state != state:
100
+ self.remove_class(self.state)
101
+ self.state = state
102
+ self.add_class(state)
103
+ if state != "running" and self._elapsed is None:
104
+ self._elapsed = time.monotonic() - self._started
105
+ self._refresh_header()
106
+
107
+ def _refresh_header(self) -> None:
108
+ try:
109
+ self.header.update(self._header_text())
110
+ except NoMatches:
111
+ pass # Not composed yet; on_mount refreshes.
112
+
113
+ def _set_body(self, content: str, lexer: str | None = None) -> None:
114
+ if not content:
115
+ return
116
+ self._has_body = True
117
+ self._body_content = (content, lexer)
118
+ try:
119
+ self._write_body(content, lexer)
120
+ except NoMatches:
121
+ pass # Not composed yet; on_mount writes the body.
122
+
123
+ def _write_body(self, content: str, lexer: str | None = None) -> None:
124
+ renderable: Any = Text(content)
125
+ if lexer:
126
+ try:
127
+ from rich.syntax import Syntax
128
+
129
+ renderable = Syntax(content, lexer, theme="ansi_dark")
130
+ except Exception: # noqa: BLE001 - fall back to plain text
131
+ renderable = Text(content)
132
+ self.body.write(renderable)
133
+
134
+ def _lexer_for_body(self) -> str | None:
135
+ """Pick a syntax-highlighting lexer based on the tool and its target."""
136
+ if self.tool_name == "edit":
137
+ return "diff"
138
+ if self.tool_name in ("read", "write"):
139
+ path = self.arguments.get("path")
140
+ if isinstance(path, str) and path:
141
+ try:
142
+ from pygments.lexers import ( # type: ignore[import-untyped]
143
+ get_lexer_for_filename,
144
+ )
145
+ from pygments.util import ( # type: ignore[import-untyped]
146
+ ClassNotFound,
147
+ )
148
+
149
+ try:
150
+ aliases: list[str] = get_lexer_for_filename(path).aliases
151
+ return aliases[0] if aliases else None
152
+ except ClassNotFound:
153
+ return None
154
+ except Exception: # noqa: BLE001
155
+ return None
156
+ return None
157
+
158
+ def _summary(self) -> str:
159
+ for key in _SUMMARY_KEYS:
160
+ value = self.arguments.get(key)
161
+ if isinstance(value, str) and value:
162
+ first_line = value.splitlines()[0] if value.strip() else value
163
+ return (
164
+ first_line if len(first_line) <= 60 else first_line[:57] + "..."
165
+ )
166
+ return ""
167
+
168
+ def _header_text(self) -> str:
169
+ symbol = _STATE_SYMBOLS.get(self.state, "?")
170
+ parts = [f"{symbol} {self.tool_name}"]
171
+ summary = self._summary()
172
+ if summary:
173
+ parts.append(summary)
174
+ label = _STATE_LABELS.get(self.state, "")
175
+ if label:
176
+ parts.append(f"({label})")
177
+ if self._elapsed is not None and self._elapsed >= 0.05:
178
+ parts.append(f"{self._elapsed:.1f}s")
179
+ return " ".join(parts)
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: limbo-code
3
+ Version: 0.1.0
4
+ Summary: A minimal terminal AI coding agent
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: openai>=1.30
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: textual>=0.58
10
+ Requires-Dist: toml>=0.10
11
+ Provides-Extra: dev
12
+ Requires-Dist: mypy>=1.10; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Requires-Dist: respx>=0.21; extra == 'dev'
16
+ Requires-Dist: ruff>=0.4; extra == 'dev'
@@ -0,0 +1,46 @@
1
+ limbo/__init__.py,sha256=nmQZ4JiUyT_OMv0zZlfqfN9ZhmPsR65wPgxl1a2LsWM,72
2
+ limbo/__main__.py,sha256=zAxWJyXU_Gmqu2jxQJM2qmjxX3FllekW2u4HaUdGAAY,126
3
+ limbo/agent.py,sha256=98SBFk4ev4v3Zl1_zZxNdsqAuDhi8n2NKNwAMy2d6qU,16496
4
+ limbo/app.py,sha256=E0rsLGAQAPZFLOD8Q-iIyx23F61mq4FawdqCGk2EVhA,3066
5
+ limbo/config.py,sha256=5CUuUYyiWyXO0mk3LbhmoRy8xon8nGvDK9g8F3gBmOc,3146
6
+ limbo/history.py,sha256=1E3JgkxXNmGJsvLpx261VqbIh7hc3VpUqzHPK_C1aSg,3066
7
+ limbo/models.py,sha256=ZvpA56OlfA3vWye3VJNqey3kQ8MfSRt80Yokps65e1w,2156
8
+ limbo/sessions.py,sha256=RHWRQD9nWojvxcZvnafZEZH2SKDvO_ueetkxNdp_zig,8428
9
+ limbo/skills.py,sha256=GyLtmdAo-cE3dqE6anLoE4nFW_hkKOyKeIWl_gTIaQ4,2959
10
+ limbo/trace.py,sha256=qqoWOXupUJHDja_3dFO6mtJ5ou891HD93xgJNt0lLqw,3299
11
+ limbo/llm/__init__.py,sha256=t2HYY6CphVNUIxWioSdhoQWqMBCq3IJRv366SAckMAs,26
12
+ limbo/llm/anthropic_client.py,sha256=2p6Jsqw0UUeMIRXBNLaJhBARbOkuAyo0GdMg3g_000Q,12568
13
+ limbo/llm/catalog.py,sha256=pLft6sn5yew4yVIaRRIWW4hcEq-CpDuBFJgUGLfCwpA,8294
14
+ limbo/llm/client.py,sha256=6DFajD-wY705HEQX48fiRxhVWZcCKiw9bCf4t7NAIOE,599
15
+ limbo/llm/factory.py,sha256=rKqYviEAKc7UFD0g779amfV8KAkjeSewN_GySw8RX4Q,1536
16
+ limbo/llm/openai_client.py,sha256=uZkUYt8a0FgCK8T6STHJ5PagvsTHyxtAHjkARU3zQeY,9942
17
+ limbo/tools/__init__.py,sha256=FFDBp7pUO15wxLeJGOxJ7TMV3GrNxzwUGKpZiCgP8Dg,27
18
+ limbo/tools/base.py,sha256=l6UQB6FWZRm-n-vY79rSIWAk3CtysfjuzvynyjAUraw,3423
19
+ limbo/tools/bash.py,sha256=UZV5XaXiiEtzo3a7mfRpjuyW5Sq3k2rdWnt8Oocr09s,6257
20
+ limbo/tools/edit.py,sha256=aQeX5Yrm8Jb2EH6TtsROLG6MZoe60_Qm7RuXbbu8oFU,2422
21
+ limbo/tools/find.py,sha256=shvmcxrF-A0NZup9Hz1Oix9WHJoHJuEcN3ysnqghLbQ,2205
22
+ limbo/tools/grep.py,sha256=Wjh3KpYnV1yifKNfLlGn3mMkzivxe4hym_SA-KMtSXI,6229
23
+ limbo/tools/ignore.py,sha256=NXurQ3HnpRvgTz3ICF1gGDOMDgt_91wdxA7bTpBVl1U,3449
24
+ limbo/tools/ls.py,sha256=Rf9Us8_Y1R5YEsTUaIoC7wyYSayHvcLw2aL5APx7BUE,1212
25
+ limbo/tools/read.py,sha256=sBog3GAMJlTyJfLTOhcqcf7xJ3jHf6CdPuEOYYtLHZM,3511
26
+ limbo/tools/registry.py,sha256=0xXcRQt8YswrjIgxFdObPkQelS0h3HG3A4V4NnJCQhI,2301
27
+ limbo/tools/write.py,sha256=tphwTuVcqngwRuzLVoKuv_S9IWStJgAMPI8zWPtOCHI,1103
28
+ limbo/ui/__init__.py,sha256=pcY6-j4Z4FcG0CxRYVssqowkAewo7l3kQvc1Y1IVvGU,28
29
+ limbo/ui/app.py,sha256=pqhP7uEc6dAjov2MAXl1he7v4i4EWr_E1ggRquPJkX4,1371
30
+ limbo/ui/app.tcss,sha256=dF4NqtaqtLP39pL40YO4U2Q57BSXTHa_BVojxvbnn9g,3712
31
+ limbo/ui/banner.py,sha256=FJJyYgp6cVbEbjzqFv_YXlFE1g38BcRdsW30ncKz4dM,3342
32
+ limbo/ui/commands.py,sha256=36bSpvJdsz7fFhHfd-hcn7in8o2wGVoJ_aLNghFEVUk,1922
33
+ limbo/ui/screens/__init__.py,sha256=HsHJQF2SQ9BCUxhchFdDECHh_vk2XJuc9DQFMv8l1jw,21
34
+ limbo/ui/screens/game2048.py,sha256=9nepzBqDjrgtktKV8i-NhxbxddPj5t4P9cYPYGKw_YM,7317
35
+ limbo/ui/screens/main.py,sha256=F9RbTvk8jkn-53gBdh7wKgEQy3xZejE3VQzUK5fG0PA,14266
36
+ limbo/ui/screens/session_picker.py,sha256=0bUCn9QMY-V3iLZlDlCxAKzYnoc7JSwQ-x8YUpyWb5g,1817
37
+ limbo/ui/widgets/__init__.py,sha256=eIeZxQhS1gKqW7kSRh0yRfozVyvDfiSYpK9w5sqnfbE,24
38
+ limbo/ui/widgets/chat.py,sha256=EFKZf3QqCYNEQ0xGWnh0urQKnMdzKQ0Sw604J9Z_FTs,6017
39
+ limbo/ui/widgets/command_menu.py,sha256=oifnskHQEEPCvlyixzaUgBezdH12u2467zVpk-LYn-k,1398
40
+ limbo/ui/widgets/input.py,sha256=ACVtU9CZy34Y4R608ne4nDiI-9xti6HC61T75yXwHy8,7256
41
+ limbo/ui/widgets/status_bar.py,sha256=Ht0Ov7U-22WIwLE2PKmQSmkr7FH8sJumUq7QaiEQzAo,1237
42
+ limbo/ui/widgets/tool_card.py,sha256=aI4ZQ-kF9UtTr-bZfWyC_kvHMm6v17pW8pjaq8Zj76E,6014
43
+ limbo_code-0.1.0.dist-info/METADATA,sha256=YDSA6cwi0nBEHvLK07934qqb4DywrWSk6rxHz73seQc,502
44
+ limbo_code-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
45
+ limbo_code-0.1.0.dist-info/entry_points.txt,sha256=YU3Qkp1sof5v9ROdiBCprNUJ1KuxeehhS95nxFoKPRI,41
46
+ limbo_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ limbo = limbo.app:main