llmcode-cli 1.0.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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""InputBar — fixed bottom input with prompt, multiline, slash autocomplete."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import events
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.widget import Widget
|
|
11
|
+
from textual.app import RenderResult
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from llm_code.tui.keybindings import KeybindingManager, load_keybindings
|
|
15
|
+
|
|
16
|
+
SLASH_COMMANDS = sorted([
|
|
17
|
+
"/help", "/clear", "/exit", "/quit", "/model", "/cost", "/budget",
|
|
18
|
+
"/undo", "/cd", "/config", "/thinking", "/vim", "/image", "/search",
|
|
19
|
+
"/index", "/session", "/skill", "/plugin", "/mcp", "/memory",
|
|
20
|
+
"/lsp", "/cancel", "/cron", "/task", "/swarm", "/voice", "/ide",
|
|
21
|
+
"/vcr", "/hida", "/checkpoint", "/keybind", "/audit",
|
|
22
|
+
"/plan", "/analyze", "/diff_check", "/dump", "/map",
|
|
23
|
+
"/harness", "/knowledge",
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
# Commands that execute immediately (no arguments needed)
|
|
27
|
+
_NO_ARG_COMMANDS = frozenset({
|
|
28
|
+
"/help", "/clear", "/cost", "/config", "/vim", "/skill", "/plugin",
|
|
29
|
+
"/mcp", "/lsp", "/cancel", "/exit", "/quit", "/hida",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
SLASH_COMMAND_DESCS: list[tuple[str, str]] = [
|
|
33
|
+
("/help", "Show help"),
|
|
34
|
+
("/clear", "Clear conversation"),
|
|
35
|
+
("/model", "Switch model"),
|
|
36
|
+
("/cost", "Token usage"),
|
|
37
|
+
("/budget", "Set token budget"),
|
|
38
|
+
("/undo", "Undo last change"),
|
|
39
|
+
("/cd", "Change directory"),
|
|
40
|
+
("/config", "Runtime config"),
|
|
41
|
+
("/thinking", "Toggle thinking"),
|
|
42
|
+
("/vim", "Toggle vim mode"),
|
|
43
|
+
("/image", "Attach image"),
|
|
44
|
+
("/search", "Search history"),
|
|
45
|
+
("/index", "Project index"),
|
|
46
|
+
("/session", "Sessions"),
|
|
47
|
+
("/skill", "Browse skills"),
|
|
48
|
+
("/plugin", "Browse plugins"),
|
|
49
|
+
("/mcp", "MCP servers"),
|
|
50
|
+
("/memory", "Project memory"),
|
|
51
|
+
("/cron", "Scheduled tasks"),
|
|
52
|
+
("/task", "Task lifecycle"),
|
|
53
|
+
("/swarm", "Swarm coordination"),
|
|
54
|
+
("/voice", "Voice input"),
|
|
55
|
+
("/ide", "IDE bridge"),
|
|
56
|
+
("/vcr", "VCR recording"),
|
|
57
|
+
("/checkpoint", "Checkpoints"),
|
|
58
|
+
("/hida", "HIDA classification"),
|
|
59
|
+
("/lsp", "LSP status"),
|
|
60
|
+
("/cancel", "Cancel generation"),
|
|
61
|
+
("/exit", "Quit"),
|
|
62
|
+
("/quit", "Quit"),
|
|
63
|
+
("/keybind", "Rebind keys"),
|
|
64
|
+
("/audit", "Audit log"),
|
|
65
|
+
("/plan", "Plan/Act mode"),
|
|
66
|
+
("/analyze", "Code analysis"),
|
|
67
|
+
("/diff_check", "Diff analysis"),
|
|
68
|
+
("/dump", "Dump context"),
|
|
69
|
+
("/map", "Repo map"),
|
|
70
|
+
("/harness", "Harness controls"),
|
|
71
|
+
("/knowledge", "Knowledge base"),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InputBar(Widget):
|
|
76
|
+
"""Bottom input bar: ❯ {text}"""
|
|
77
|
+
|
|
78
|
+
can_focus = True
|
|
79
|
+
|
|
80
|
+
PROMPT = "❯ "
|
|
81
|
+
|
|
82
|
+
DEFAULT_CSS = """
|
|
83
|
+
InputBar {
|
|
84
|
+
dock: bottom;
|
|
85
|
+
height: auto;
|
|
86
|
+
min-height: 3;
|
|
87
|
+
max-height: 8;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
background: $surface;
|
|
90
|
+
}
|
|
91
|
+
InputBar:focus {
|
|
92
|
+
border-top: solid $accent;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
value: reactive[str] = reactive("")
|
|
97
|
+
disabled: reactive[bool] = reactive(False)
|
|
98
|
+
vim_mode: reactive[str] = reactive("") # "" | "NORMAL" | "INSERT"
|
|
99
|
+
pending_image_count: reactive[int] = reactive(0)
|
|
100
|
+
|
|
101
|
+
_show_dropdown: bool = False
|
|
102
|
+
_dropdown_items: list[tuple[str, str]] = []
|
|
103
|
+
_dropdown_cursor: int = 0
|
|
104
|
+
|
|
105
|
+
def __init__(self) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self._vim_engine = None
|
|
108
|
+
self._cursor = 0 # cursor position within self.value
|
|
109
|
+
self._show_dropdown = False
|
|
110
|
+
self._dropdown_items = []
|
|
111
|
+
self._dropdown_cursor = 0
|
|
112
|
+
self._keybindings = load_keybindings(Path.home() / ".llm-code" / "keybindings.json")
|
|
113
|
+
|
|
114
|
+
class Submitted(Message):
|
|
115
|
+
"""Fired when user presses Enter."""
|
|
116
|
+
def __init__(self, value: str) -> None:
|
|
117
|
+
super().__init__()
|
|
118
|
+
self.value = value
|
|
119
|
+
|
|
120
|
+
class Cancelled(Message):
|
|
121
|
+
"""Fired when user presses Escape during generation."""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def watch_vim_mode(self) -> None:
|
|
125
|
+
if self.vim_mode:
|
|
126
|
+
from llm_code.vim.engine import VimEngine
|
|
127
|
+
if self._vim_engine is None:
|
|
128
|
+
self._vim_engine = VimEngine(self.value)
|
|
129
|
+
else:
|
|
130
|
+
self._vim_engine = None
|
|
131
|
+
self.refresh()
|
|
132
|
+
|
|
133
|
+
# Pink color matching Claude Code's image indicator
|
|
134
|
+
_IMAGE_STYLE = "bold #e05880"
|
|
135
|
+
_IMAGE_MARKER = "\x00IMG\x00" # sentinel in value text
|
|
136
|
+
|
|
137
|
+
def insert_image_marker(self) -> None:
|
|
138
|
+
"""Insert an [image] marker at current cursor position."""
|
|
139
|
+
self.value = self.value[:self._cursor] + self._IMAGE_MARKER + self.value[self._cursor:]
|
|
140
|
+
self._cursor += len(self._IMAGE_MARKER)
|
|
141
|
+
self.pending_image_count += 1
|
|
142
|
+
|
|
143
|
+
def _update_dropdown(self) -> None:
|
|
144
|
+
"""Recompute dropdown items based on current value."""
|
|
145
|
+
was_showing = self._show_dropdown
|
|
146
|
+
if self.value.startswith("/") and " " not in self.value:
|
|
147
|
+
query = self.value
|
|
148
|
+
self._dropdown_items = [
|
|
149
|
+
(cmd, desc) for cmd, desc in SLASH_COMMAND_DESCS if cmd.startswith(query)
|
|
150
|
+
]
|
|
151
|
+
self._dropdown_cursor = min(self._dropdown_cursor, max(0, len(self._dropdown_items) - 1))
|
|
152
|
+
self._show_dropdown = len(self._dropdown_items) > 0
|
|
153
|
+
else:
|
|
154
|
+
self._dropdown_items = []
|
|
155
|
+
self._dropdown_cursor = 0
|
|
156
|
+
self._show_dropdown = False
|
|
157
|
+
# Trigger relayout when dropdown visibility or item count changes
|
|
158
|
+
if self._show_dropdown != was_showing:
|
|
159
|
+
self.refresh(layout=True)
|
|
160
|
+
|
|
161
|
+
def render(self) -> RenderResult:
|
|
162
|
+
text = Text()
|
|
163
|
+
# Render dropdown above prompt when active
|
|
164
|
+
if self._show_dropdown and self._dropdown_items:
|
|
165
|
+
visible = self._dropdown_items[:8]
|
|
166
|
+
for i, (cmd, desc) in enumerate(visible):
|
|
167
|
+
if i == self._dropdown_cursor:
|
|
168
|
+
text.append(f" > {cmd:<20s} {desc}\n", style="bold white on #3a3a5a")
|
|
169
|
+
else:
|
|
170
|
+
text.append(f" {cmd:<20s} {desc}\n", style="dim")
|
|
171
|
+
if self.vim_mode == "NORMAL":
|
|
172
|
+
text.append("[N] ", style="yellow bold")
|
|
173
|
+
elif self.vim_mode == "INSERT":
|
|
174
|
+
text.append("[I] ", style="green bold")
|
|
175
|
+
# Leading image count (for images added before any text)
|
|
176
|
+
if self.pending_image_count > 0 and self._IMAGE_MARKER not in self.value:
|
|
177
|
+
n = self.pending_image_count
|
|
178
|
+
label = f"{n} image{'s' if n > 1 else ''}"
|
|
179
|
+
text.append(f"[{label}] ", style=self._IMAGE_STYLE)
|
|
180
|
+
text.append(self.PROMPT, style="bold cyan")
|
|
181
|
+
if self.disabled:
|
|
182
|
+
text.append("generating…", style="dim italic")
|
|
183
|
+
else:
|
|
184
|
+
# Render value with cursor at _cursor position
|
|
185
|
+
val = self.value
|
|
186
|
+
cur = min(self._cursor, len(val))
|
|
187
|
+
before = val[:cur]
|
|
188
|
+
after = val[cur:]
|
|
189
|
+
# Render before cursor
|
|
190
|
+
self._render_with_markers(text, before)
|
|
191
|
+
# Cursor block
|
|
192
|
+
if after:
|
|
193
|
+
# Show character at cursor with highlight
|
|
194
|
+
if after.startswith(self._IMAGE_MARKER):
|
|
195
|
+
text.append("[image]", style=f"{self._IMAGE_STYLE} reverse")
|
|
196
|
+
after = after[len(self._IMAGE_MARKER):]
|
|
197
|
+
else:
|
|
198
|
+
text.append(after[0], style="reverse")
|
|
199
|
+
after = after[1:]
|
|
200
|
+
self._render_with_markers(text, after)
|
|
201
|
+
else:
|
|
202
|
+
text.append("█", style="dim")
|
|
203
|
+
return text
|
|
204
|
+
|
|
205
|
+
def _render_with_markers(self, text: Text, s: str) -> None:
|
|
206
|
+
"""Render string with [image] markers styled in pink."""
|
|
207
|
+
parts = s.split(self._IMAGE_MARKER)
|
|
208
|
+
for i, part in enumerate(parts):
|
|
209
|
+
if i > 0:
|
|
210
|
+
text.append("[image] ", style=self._IMAGE_STYLE)
|
|
211
|
+
if part:
|
|
212
|
+
text.append(part)
|
|
213
|
+
|
|
214
|
+
def get_clean_value(self) -> str:
|
|
215
|
+
"""Return value with image markers stripped (for display in chat)."""
|
|
216
|
+
return self.value.replace(self._IMAGE_MARKER, "").strip()
|
|
217
|
+
|
|
218
|
+
def on_key(self, event: events.Key) -> None:
|
|
219
|
+
if self.disabled:
|
|
220
|
+
if event.key == "escape":
|
|
221
|
+
self.post_message(self.Cancelled())
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Dropdown navigation (when dropdown is visible)
|
|
225
|
+
if self._show_dropdown and self._dropdown_items:
|
|
226
|
+
if event.key == "up":
|
|
227
|
+
self._dropdown_cursor = (self._dropdown_cursor - 1) % min(len(self._dropdown_items), 8)
|
|
228
|
+
self.refresh()
|
|
229
|
+
event.prevent_default()
|
|
230
|
+
event.stop()
|
|
231
|
+
return
|
|
232
|
+
elif event.key == "down":
|
|
233
|
+
self._dropdown_cursor = (self._dropdown_cursor + 1) % min(len(self._dropdown_items), 8)
|
|
234
|
+
self.refresh()
|
|
235
|
+
event.prevent_default()
|
|
236
|
+
event.stop()
|
|
237
|
+
return
|
|
238
|
+
elif event.key in ("enter", "tab"):
|
|
239
|
+
selected_cmd = self._dropdown_items[self._dropdown_cursor][0]
|
|
240
|
+
self._show_dropdown = False
|
|
241
|
+
self._dropdown_items = []
|
|
242
|
+
self._dropdown_cursor = 0
|
|
243
|
+
if selected_cmd in _NO_ARG_COMMANDS:
|
|
244
|
+
# Execute immediately
|
|
245
|
+
self.value = selected_cmd
|
|
246
|
+
self._cursor = 0
|
|
247
|
+
self.post_message(self.Submitted(selected_cmd))
|
|
248
|
+
self.value = ""
|
|
249
|
+
else:
|
|
250
|
+
# Fill and wait for argument
|
|
251
|
+
self.value = selected_cmd + " "
|
|
252
|
+
self._cursor = len(self.value)
|
|
253
|
+
self.refresh()
|
|
254
|
+
return
|
|
255
|
+
elif event.key == "escape":
|
|
256
|
+
self._show_dropdown = False
|
|
257
|
+
self._dropdown_items = []
|
|
258
|
+
self._dropdown_cursor = 0
|
|
259
|
+
self.refresh()
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
# Tab autocomplete (before vim routing) — fallback when dropdown not shown
|
|
263
|
+
if event.key == "tab" and self.value.startswith("/"):
|
|
264
|
+
matches = [c for c in SLASH_COMMANDS if c.startswith(self.value)]
|
|
265
|
+
if len(matches) == 1:
|
|
266
|
+
self.value = matches[0] + " "
|
|
267
|
+
self._cursor = len(self.value)
|
|
268
|
+
elif matches:
|
|
269
|
+
prefix = os.path.commonprefix(matches)
|
|
270
|
+
if len(prefix) > len(self.value):
|
|
271
|
+
self.value = prefix
|
|
272
|
+
self._cursor = len(self.value)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
# Vim mode routing
|
|
276
|
+
if self._vim_engine is not None:
|
|
277
|
+
from llm_code.vim.types import VimMode
|
|
278
|
+
key_str = event.key if len(event.key) > 1 else (event.character or event.key)
|
|
279
|
+
self._vim_engine.feed_key(key_str)
|
|
280
|
+
self.value = self._vim_engine.buffer
|
|
281
|
+
# Update mode display
|
|
282
|
+
self.vim_mode = "NORMAL" if self._vim_engine.mode == VimMode.NORMAL else "INSERT"
|
|
283
|
+
# Handle enter in insert mode for submission
|
|
284
|
+
if event.key == "enter" and self._vim_engine.mode == VimMode.INSERT:
|
|
285
|
+
if self.value.strip():
|
|
286
|
+
self.post_message(self.Submitted(self.value))
|
|
287
|
+
self.value = ""
|
|
288
|
+
self._vim_engine.set_buffer("")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Normal (non-vim) key handling — table lookup
|
|
292
|
+
chord_action = self._keybindings.chord_state.feed(event.key)
|
|
293
|
+
if chord_action is not None:
|
|
294
|
+
self._handle_action(chord_action)
|
|
295
|
+
return
|
|
296
|
+
if self._keybindings.chord_state.pending is not None:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
action = self._keybindings.get_action(event.key)
|
|
300
|
+
if action:
|
|
301
|
+
self._handle_action(action)
|
|
302
|
+
elif event.character and len(event.character) == 1:
|
|
303
|
+
self.value = self.value[:self._cursor] + event.character + self.value[self._cursor:]
|
|
304
|
+
self._cursor += 1
|
|
305
|
+
event.prevent_default()
|
|
306
|
+
event.stop()
|
|
307
|
+
|
|
308
|
+
def _handle_action(self, action: str) -> None:
|
|
309
|
+
"""Execute a named keybinding action."""
|
|
310
|
+
if action == "submit":
|
|
311
|
+
if self.value.strip():
|
|
312
|
+
self.post_message(self.Submitted(self.value))
|
|
313
|
+
self.value = ""
|
|
314
|
+
self._cursor = 0
|
|
315
|
+
elif action == "newline":
|
|
316
|
+
self.value = self.value[:self._cursor] + "\n" + self.value[self._cursor:]
|
|
317
|
+
self._cursor += 1
|
|
318
|
+
elif action == "delete_back":
|
|
319
|
+
if self._cursor > 0:
|
|
320
|
+
self.value = self.value[:self._cursor - 1] + self.value[self._cursor:]
|
|
321
|
+
self._cursor -= 1
|
|
322
|
+
elif action == "delete_forward":
|
|
323
|
+
if self._cursor < len(self.value):
|
|
324
|
+
self.value = self.value[:self._cursor] + self.value[self._cursor + 1:]
|
|
325
|
+
elif action == "cursor_left":
|
|
326
|
+
if self._cursor > 0:
|
|
327
|
+
self._cursor -= 1
|
|
328
|
+
self.refresh()
|
|
329
|
+
elif action == "cursor_right":
|
|
330
|
+
if self._cursor < len(self.value):
|
|
331
|
+
self._cursor += 1
|
|
332
|
+
self.refresh()
|
|
333
|
+
elif action == "cursor_home":
|
|
334
|
+
self._cursor = 0
|
|
335
|
+
self.refresh()
|
|
336
|
+
elif action == "cursor_end":
|
|
337
|
+
self._cursor = len(self.value)
|
|
338
|
+
self.refresh()
|
|
339
|
+
elif action == "cancel":
|
|
340
|
+
self.value = ""
|
|
341
|
+
self._cursor = 0
|
|
342
|
+
self.post_message(self.Cancelled())
|
|
343
|
+
|
|
344
|
+
def watch_value(self) -> None:
|
|
345
|
+
# Keep cursor in bounds
|
|
346
|
+
if self._cursor > len(self.value):
|
|
347
|
+
self._cursor = len(self.value)
|
|
348
|
+
self._update_dropdown()
|
|
349
|
+
self.refresh()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Keybinding configuration — action registry, chord support, config loader."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class KeyAction:
|
|
14
|
+
"""A bindable action with a default key."""
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
default_key: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ACTION_REGISTRY: dict[str, KeyAction] = {
|
|
21
|
+
"submit": KeyAction("submit", "Submit input", "enter"),
|
|
22
|
+
"newline": KeyAction("newline", "Insert newline", "shift+enter"),
|
|
23
|
+
"cancel": KeyAction("cancel", "Cancel / clear input", "escape"),
|
|
24
|
+
"clear_input": KeyAction("clear_input", "Clear input line", "ctrl+u"),
|
|
25
|
+
"autocomplete": KeyAction("autocomplete", "Autocomplete slash command", "tab"),
|
|
26
|
+
"history_prev": KeyAction("history_prev", "Previous history", "ctrl+p"),
|
|
27
|
+
"history_next": KeyAction("history_next", "Next history", "ctrl+n"),
|
|
28
|
+
"toggle_thinking": KeyAction("toggle_thinking", "Toggle thinking display", "alt+t"),
|
|
29
|
+
"toggle_vim": KeyAction("toggle_vim", "Toggle vim mode", "ctrl+shift+v"),
|
|
30
|
+
"voice_input": KeyAction("voice_input", "Activate voice input", "ctrl+space"),
|
|
31
|
+
"cursor_left": KeyAction("cursor_left", "Move cursor left", "left"),
|
|
32
|
+
"cursor_right": KeyAction("cursor_right", "Move cursor right", "right"),
|
|
33
|
+
"cursor_home": KeyAction("cursor_home", "Move to line start", "home"),
|
|
34
|
+
"cursor_end": KeyAction("cursor_end", "Move to line end", "end"),
|
|
35
|
+
"delete_back": KeyAction("delete_back", "Delete char before cursor", "backspace"),
|
|
36
|
+
"delete_forward": KeyAction("delete_forward", "Delete char at cursor", "delete"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ChordBinding:
|
|
42
|
+
"""A two-key chord mapping."""
|
|
43
|
+
keys: tuple[str, ...]
|
|
44
|
+
action: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChordState:
|
|
49
|
+
"""Tracks chord key sequences."""
|
|
50
|
+
chords: dict[tuple[str, ...], str] = field(default_factory=dict)
|
|
51
|
+
pending: str | None = None
|
|
52
|
+
|
|
53
|
+
def feed(self, key: str) -> str | None:
|
|
54
|
+
if self.pending is not None:
|
|
55
|
+
combo = (self.pending, key)
|
|
56
|
+
self.pending = None
|
|
57
|
+
return self.chords.get(combo)
|
|
58
|
+
for chord_keys in self.chords:
|
|
59
|
+
if chord_keys[0] == key:
|
|
60
|
+
self.pending = key
|
|
61
|
+
return None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def reset(self) -> None:
|
|
65
|
+
self.pending = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class KeybindingManager:
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._bindings: dict[str, str] = {}
|
|
71
|
+
self._reverse: dict[str, str] = {}
|
|
72
|
+
self.chord_state = ChordState()
|
|
73
|
+
self.reset_all()
|
|
74
|
+
|
|
75
|
+
def get_action(self, key: str) -> str | None:
|
|
76
|
+
return self._bindings.get(key)
|
|
77
|
+
|
|
78
|
+
def get_key(self, action: str) -> str | None:
|
|
79
|
+
return self._reverse.get(action)
|
|
80
|
+
|
|
81
|
+
def rebind(self, action: str, new_key: str) -> None:
|
|
82
|
+
old_key = self._reverse.get(action)
|
|
83
|
+
if old_key and old_key in self._bindings:
|
|
84
|
+
del self._bindings[old_key]
|
|
85
|
+
self._bindings[new_key] = action
|
|
86
|
+
self._reverse[action] = new_key
|
|
87
|
+
|
|
88
|
+
def check_conflict(self, key: str) -> list[str]:
|
|
89
|
+
action = self._bindings.get(key)
|
|
90
|
+
return [action] if action else []
|
|
91
|
+
|
|
92
|
+
def reset_action(self, action: str) -> None:
|
|
93
|
+
if action not in ACTION_REGISTRY:
|
|
94
|
+
return
|
|
95
|
+
old_key = self._reverse.get(action)
|
|
96
|
+
if old_key and old_key in self._bindings:
|
|
97
|
+
del self._bindings[old_key]
|
|
98
|
+
default_key = ACTION_REGISTRY[action].default_key
|
|
99
|
+
self._bindings[default_key] = action
|
|
100
|
+
self._reverse[action] = default_key
|
|
101
|
+
|
|
102
|
+
def reset_all(self) -> None:
|
|
103
|
+
self._bindings.clear()
|
|
104
|
+
self._reverse.clear()
|
|
105
|
+
for name, action in ACTION_REGISTRY.items():
|
|
106
|
+
self._bindings[action.default_key] = name
|
|
107
|
+
self._reverse[name] = action.default_key
|
|
108
|
+
|
|
109
|
+
def get_all_bindings(self) -> dict[str, str]:
|
|
110
|
+
return dict(self._reverse)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_keybindings(path: Path) -> KeybindingManager:
|
|
114
|
+
mgr = KeybindingManager()
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
118
|
+
return mgr
|
|
119
|
+
|
|
120
|
+
bindings = data.get("bindings", {})
|
|
121
|
+
if isinstance(bindings, dict):
|
|
122
|
+
key_to_actions: dict[str, list[str]] = {}
|
|
123
|
+
for action, key in bindings.items():
|
|
124
|
+
key_to_actions.setdefault(key, []).append(action)
|
|
125
|
+
has_conflict = any(len(actions) > 1 for actions in key_to_actions.values())
|
|
126
|
+
if has_conflict:
|
|
127
|
+
_log.warning("Keybinding config has conflicts; using defaults")
|
|
128
|
+
return KeybindingManager()
|
|
129
|
+
for action, key in bindings.items():
|
|
130
|
+
if action in ACTION_REGISTRY:
|
|
131
|
+
mgr.rebind(action, key)
|
|
132
|
+
|
|
133
|
+
chords_raw = data.get("chords", {})
|
|
134
|
+
if isinstance(chords_raw, dict):
|
|
135
|
+
chords: dict[tuple[str, ...], str] = {}
|
|
136
|
+
for key_str, action in chords_raw.items():
|
|
137
|
+
keys = tuple(key_str.split())
|
|
138
|
+
if len(keys) == 2:
|
|
139
|
+
chords[keys] = action
|
|
140
|
+
mgr.chord_state = ChordState(chords=chords)
|
|
141
|
+
|
|
142
|
+
return mgr
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Marketplace browser — scrollable list for /skill, /plugin, /mcp browsing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.message import Message
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MarketplaceItem:
|
|
18
|
+
"""A single item in the marketplace list."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
source: str # "installed", "official", "community", "npm", "clawhub"
|
|
23
|
+
installed: bool = False
|
|
24
|
+
enabled: bool = True
|
|
25
|
+
repo: str = ""
|
|
26
|
+
extra: str = "" # e.g. "14 skills", "v1.2.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ItemRow(Widget):
|
|
30
|
+
"""A single selectable row in the marketplace."""
|
|
31
|
+
|
|
32
|
+
DEFAULT_CSS = """
|
|
33
|
+
ItemRow {
|
|
34
|
+
height: 2;
|
|
35
|
+
padding: 0 1;
|
|
36
|
+
}
|
|
37
|
+
ItemRow.selected {
|
|
38
|
+
background: $accent 30%;
|
|
39
|
+
}
|
|
40
|
+
ItemRow.installed {
|
|
41
|
+
color: $text;
|
|
42
|
+
}
|
|
43
|
+
ItemRow.available {
|
|
44
|
+
color: $text-muted;
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, item: MarketplaceItem, index: int) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._item = item
|
|
51
|
+
self._index = index
|
|
52
|
+
classes = "installed" if item.installed else "available"
|
|
53
|
+
self.add_class(classes)
|
|
54
|
+
|
|
55
|
+
def render(self) -> Text:
|
|
56
|
+
item = self._item
|
|
57
|
+
text = Text()
|
|
58
|
+
# Status indicator
|
|
59
|
+
if item.installed:
|
|
60
|
+
status = "enabled" if item.enabled else "disabled"
|
|
61
|
+
text.append(f"[{status}] ", style="green" if item.enabled else "yellow")
|
|
62
|
+
else:
|
|
63
|
+
text.append("[+] ", style="dim")
|
|
64
|
+
# Name
|
|
65
|
+
text.append(item.name, style="bold")
|
|
66
|
+
# Source tag
|
|
67
|
+
text.append(f" [{item.source}]", style="dim")
|
|
68
|
+
# Extra info
|
|
69
|
+
if item.extra:
|
|
70
|
+
text.append(f" {item.extra}", style="dim")
|
|
71
|
+
text.append("\n")
|
|
72
|
+
# Description
|
|
73
|
+
text.append(f" {item.description[:80]}", style="dim")
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MarketplaceBrowser(ModalScreen):
|
|
78
|
+
"""Modal screen for browsing marketplace items."""
|
|
79
|
+
|
|
80
|
+
BINDINGS = [
|
|
81
|
+
Binding("up", "cursor_up", "Up"),
|
|
82
|
+
Binding("down", "cursor_down", "Down"),
|
|
83
|
+
Binding("enter", "select", "Select"),
|
|
84
|
+
Binding("escape", "dismiss", "Close"),
|
|
85
|
+
Binding("i", "install", "Install"),
|
|
86
|
+
Binding("e", "enable_toggle", "Enable/Disable"),
|
|
87
|
+
Binding("r", "remove", "Remove"),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
DEFAULT_CSS = """
|
|
91
|
+
MarketplaceBrowser {
|
|
92
|
+
align: center middle;
|
|
93
|
+
}
|
|
94
|
+
#marketplace-container {
|
|
95
|
+
width: 90%;
|
|
96
|
+
height: 80%;
|
|
97
|
+
background: $surface;
|
|
98
|
+
border: round $accent;
|
|
99
|
+
padding: 1;
|
|
100
|
+
}
|
|
101
|
+
#marketplace-title {
|
|
102
|
+
text-align: center;
|
|
103
|
+
text-style: bold;
|
|
104
|
+
margin-bottom: 1;
|
|
105
|
+
}
|
|
106
|
+
#marketplace-hint {
|
|
107
|
+
dock: bottom;
|
|
108
|
+
height: 1;
|
|
109
|
+
color: $text-muted;
|
|
110
|
+
text-align: center;
|
|
111
|
+
}
|
|
112
|
+
#marketplace-list {
|
|
113
|
+
height: 1fr;
|
|
114
|
+
}
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
class ItemAction(Message):
|
|
118
|
+
"""Fired when user takes action on an item."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, action: str, item: MarketplaceItem) -> None:
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.action = action
|
|
123
|
+
self.item = item
|
|
124
|
+
|
|
125
|
+
def __init__(self, title: str, items: list[MarketplaceItem]) -> None:
|
|
126
|
+
super().__init__()
|
|
127
|
+
self._title = title
|
|
128
|
+
self._items = items
|
|
129
|
+
self._cursor = 0
|
|
130
|
+
|
|
131
|
+
def compose(self) -> ComposeResult:
|
|
132
|
+
with VerticalScroll(id="marketplace-container"):
|
|
133
|
+
yield Static(self._title, id="marketplace-title")
|
|
134
|
+
for i, item in enumerate(self._items):
|
|
135
|
+
yield ItemRow(item, i)
|
|
136
|
+
yield Static(
|
|
137
|
+
"↑↓ Navigate · Enter/i Install · e Enable/Disable · r Remove · Esc Close",
|
|
138
|
+
id="marketplace-hint",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def on_mount(self) -> None:
|
|
142
|
+
self._update_selection()
|
|
143
|
+
|
|
144
|
+
def on_key(self, event) -> None:
|
|
145
|
+
"""Intercept arrow keys before VerticalScroll consumes them."""
|
|
146
|
+
if event.key == "up":
|
|
147
|
+
self.action_cursor_up()
|
|
148
|
+
event.prevent_default()
|
|
149
|
+
event.stop()
|
|
150
|
+
elif event.key == "down":
|
|
151
|
+
self.action_cursor_down()
|
|
152
|
+
event.prevent_default()
|
|
153
|
+
event.stop()
|
|
154
|
+
|
|
155
|
+
def _update_selection(self) -> None:
|
|
156
|
+
rows = list(self.query(ItemRow))
|
|
157
|
+
for i, row in enumerate(rows):
|
|
158
|
+
if i == self._cursor:
|
|
159
|
+
row.add_class("selected")
|
|
160
|
+
row.scroll_visible()
|
|
161
|
+
else:
|
|
162
|
+
row.remove_class("selected")
|
|
163
|
+
|
|
164
|
+
def _selected_item(self) -> MarketplaceItem | None:
|
|
165
|
+
if 0 <= self._cursor < len(self._items):
|
|
166
|
+
return self._items[self._cursor]
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def action_cursor_up(self) -> None:
|
|
170
|
+
if self._cursor > 0:
|
|
171
|
+
self._cursor -= 1
|
|
172
|
+
self._update_selection()
|
|
173
|
+
|
|
174
|
+
def action_cursor_down(self) -> None:
|
|
175
|
+
if self._cursor < len(self._items) - 1:
|
|
176
|
+
self._cursor += 1
|
|
177
|
+
self._update_selection()
|
|
178
|
+
|
|
179
|
+
def action_select(self) -> None:
|
|
180
|
+
item = self._selected_item()
|
|
181
|
+
if item is None:
|
|
182
|
+
return
|
|
183
|
+
if item.installed:
|
|
184
|
+
action = "disable" if item.enabled else "enable"
|
|
185
|
+
else:
|
|
186
|
+
action = "install"
|
|
187
|
+
self.post_message(self.ItemAction(action, item))
|
|
188
|
+
self.dismiss()
|
|
189
|
+
|
|
190
|
+
def action_install(self) -> None:
|
|
191
|
+
item = self._selected_item()
|
|
192
|
+
if item and not item.installed:
|
|
193
|
+
self.post_message(self.ItemAction("install", item))
|
|
194
|
+
self.dismiss()
|
|
195
|
+
|
|
196
|
+
def action_enable_toggle(self) -> None:
|
|
197
|
+
item = self._selected_item()
|
|
198
|
+
if item and item.installed:
|
|
199
|
+
action = "disable" if item.enabled else "enable"
|
|
200
|
+
self.post_message(self.ItemAction(action, item))
|
|
201
|
+
self.dismiss()
|
|
202
|
+
|
|
203
|
+
def action_remove(self) -> None:
|
|
204
|
+
item = self._selected_item()
|
|
205
|
+
if item and item.installed:
|
|
206
|
+
self.post_message(self.ItemAction("remove", item))
|
|
207
|
+
self.dismiss()
|
|
208
|
+
|
|
209
|
+
def action_dismiss(self) -> None:
|
|
210
|
+
self.dismiss()
|