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.
Files changed (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. 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()