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,279 @@
|
|
|
1
|
+
"""Vim state machine — key handling, mode transitions, repeat, undo.
|
|
2
|
+
|
|
3
|
+
The central function is handle_key(state, key) -> VimState.
|
|
4
|
+
It dispatches based on current mode and accumulated pending_keys.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
from llm_code.vim.types import VimMode, VimState, ParsedCommand
|
|
11
|
+
from llm_code.vim.motions import (
|
|
12
|
+
move_h, move_l, move_w, move_b, move_e,
|
|
13
|
+
move_W, move_B, move_E,
|
|
14
|
+
move_0, move_caret, move_dollar,
|
|
15
|
+
move_gg, move_G,
|
|
16
|
+
move_f, move_F, move_t, move_T,
|
|
17
|
+
)
|
|
18
|
+
from llm_code.vim.operators import (
|
|
19
|
+
op_delete, op_change, op_yank,
|
|
20
|
+
op_delete_line, op_change_line, op_yank_line,
|
|
21
|
+
op_x, op_replace, op_tilde, op_join,
|
|
22
|
+
op_put_after, op_put_before,
|
|
23
|
+
op_open_below, op_open_above,
|
|
24
|
+
op_indent_right, op_indent_left,
|
|
25
|
+
)
|
|
26
|
+
from llm_code.vim.text_objects import select_text_object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_MOTION_KEYS = frozenset("hlwbeWBE0^$")
|
|
30
|
+
_OPERATOR_KEYS = frozenset("dcy")
|
|
31
|
+
_SINGLE_CHAR_OPS = frozenset("xrR~JpPoO")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_key(state: VimState, key: str) -> VimState:
|
|
35
|
+
"""Process a single key press and return the new VimState."""
|
|
36
|
+
if state.mode == VimMode.INSERT:
|
|
37
|
+
return _handle_insert(state, key)
|
|
38
|
+
return _handle_normal(state, key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _handle_insert(state: VimState, key: str) -> VimState:
|
|
42
|
+
"""INSERT mode: type characters, Esc to exit."""
|
|
43
|
+
if key == "\x1b": # Escape
|
|
44
|
+
cursor = max(0, state.cursor - 1)
|
|
45
|
+
return replace(state, mode=VimMode.NORMAL, cursor=cursor)
|
|
46
|
+
if key in ("\x7f", "\x08"): # Backspace
|
|
47
|
+
if state.cursor == 0:
|
|
48
|
+
return state
|
|
49
|
+
new_buf = state.buffer[:state.cursor - 1] + state.buffer[state.cursor:]
|
|
50
|
+
return replace(state, buffer=new_buf, cursor=state.cursor - 1)
|
|
51
|
+
# Regular character insertion
|
|
52
|
+
new_buf = state.buffer[:state.cursor] + key + state.buffer[state.cursor:]
|
|
53
|
+
return replace(state, buffer=new_buf, cursor=state.cursor + 1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _handle_normal(state: VimState, key: str) -> VimState:
|
|
57
|
+
"""NORMAL mode: motions, operators, mode switches."""
|
|
58
|
+
pending = state.pending_keys + key
|
|
59
|
+
|
|
60
|
+
# ── Count prefix ──────────────────────────────────────────────
|
|
61
|
+
if pending.isdigit() and pending != "0":
|
|
62
|
+
return replace(state, pending_keys=pending)
|
|
63
|
+
|
|
64
|
+
# ── Parse accumulated pending keys ────────────────────────────
|
|
65
|
+
count = 1
|
|
66
|
+
rest = pending
|
|
67
|
+
# Extract leading digits as count
|
|
68
|
+
i = 0
|
|
69
|
+
while i < len(rest) and rest[i].isdigit() and (i > 0 or rest[i] != "0"):
|
|
70
|
+
i += 1
|
|
71
|
+
if i > 0:
|
|
72
|
+
count = int(rest[:i])
|
|
73
|
+
rest = rest[i:]
|
|
74
|
+
|
|
75
|
+
if not rest:
|
|
76
|
+
return replace(state, pending_keys=pending)
|
|
77
|
+
|
|
78
|
+
# ── Dot repeat ────────────────────────────────────────────────
|
|
79
|
+
if rest == ".":
|
|
80
|
+
if state.last_command is not None:
|
|
81
|
+
return _replay_command(replace(state, pending_keys=""), state.last_command)
|
|
82
|
+
return replace(state, pending_keys="")
|
|
83
|
+
|
|
84
|
+
# ── Undo ──────────────────────────────────────────────────────
|
|
85
|
+
if rest == "u":
|
|
86
|
+
return replace(state.pop_undo(), pending_keys="")
|
|
87
|
+
|
|
88
|
+
# ── Mode switches ─────────────────────────────────────────────
|
|
89
|
+
if rest == "i":
|
|
90
|
+
return replace(state, mode=VimMode.INSERT, pending_keys="")
|
|
91
|
+
if rest == "a":
|
|
92
|
+
cursor = min(state.cursor + 1, len(state.buffer))
|
|
93
|
+
return replace(state, mode=VimMode.INSERT, cursor=cursor, pending_keys="")
|
|
94
|
+
if rest == "A":
|
|
95
|
+
return replace(state, mode=VimMode.INSERT, cursor=len(state.buffer), pending_keys="")
|
|
96
|
+
if rest == "I":
|
|
97
|
+
pos = move_caret(state)
|
|
98
|
+
return replace(state, mode=VimMode.INSERT, cursor=pos, pending_keys="")
|
|
99
|
+
|
|
100
|
+
# ── Single-key operators ──────────────────────────────────────
|
|
101
|
+
if rest == "x":
|
|
102
|
+
result = state
|
|
103
|
+
for _ in range(count):
|
|
104
|
+
result = op_x(result)
|
|
105
|
+
cmd = ParsedCommand(count=count, operator="x")
|
|
106
|
+
return replace(result, pending_keys="", last_command=cmd)
|
|
107
|
+
|
|
108
|
+
if rest == "~":
|
|
109
|
+
result = state
|
|
110
|
+
for _ in range(count):
|
|
111
|
+
result = op_tilde(result)
|
|
112
|
+
return replace(result, pending_keys="")
|
|
113
|
+
|
|
114
|
+
if rest == "J":
|
|
115
|
+
return replace(op_join(state), pending_keys="")
|
|
116
|
+
|
|
117
|
+
if rest == "p":
|
|
118
|
+
return replace(op_put_after(state), pending_keys="")
|
|
119
|
+
|
|
120
|
+
if rest == "P":
|
|
121
|
+
return replace(op_put_before(state), pending_keys="")
|
|
122
|
+
|
|
123
|
+
if rest == "o":
|
|
124
|
+
return replace(op_open_below(state), pending_keys="")
|
|
125
|
+
|
|
126
|
+
if rest == "O":
|
|
127
|
+
return replace(op_open_above(state), pending_keys="")
|
|
128
|
+
|
|
129
|
+
# ── Operator waiting for motion/text-object ───────────────────
|
|
130
|
+
if len(rest) == 1 and rest in _OPERATOR_KEYS:
|
|
131
|
+
return replace(state, pending_keys=pending)
|
|
132
|
+
|
|
133
|
+
# ── Line-wise doubled operators: dd, cc, yy ──────────────────
|
|
134
|
+
if rest == "dd":
|
|
135
|
+
result = op_delete_line(state)
|
|
136
|
+
cmd = ParsedCommand(count=count, operator="dd")
|
|
137
|
+
return replace(result, pending_keys="", last_command=cmd)
|
|
138
|
+
if rest == "cc":
|
|
139
|
+
result = op_change_line(state)
|
|
140
|
+
return replace(result, pending_keys="")
|
|
141
|
+
if rest == "yy":
|
|
142
|
+
result = op_yank_line(state)
|
|
143
|
+
return replace(result, pending_keys="")
|
|
144
|
+
|
|
145
|
+
# ── Indent ────────────────────────────────────────────────────
|
|
146
|
+
if rest == ">>":
|
|
147
|
+
result = state
|
|
148
|
+
for _ in range(count):
|
|
149
|
+
result = op_indent_right(result)
|
|
150
|
+
return replace(result, pending_keys="")
|
|
151
|
+
if rest == "<<":
|
|
152
|
+
result = state
|
|
153
|
+
for _ in range(count):
|
|
154
|
+
result = op_indent_left(result)
|
|
155
|
+
return replace(result, pending_keys="")
|
|
156
|
+
if rest in (">", "<"):
|
|
157
|
+
return replace(state, pending_keys=pending)
|
|
158
|
+
|
|
159
|
+
# ── Replace: r{char} ─────────────────────────────────────────
|
|
160
|
+
if len(rest) == 1 and rest == "r":
|
|
161
|
+
return replace(state, pending_keys=pending)
|
|
162
|
+
if len(rest) == 2 and rest[0] == "r":
|
|
163
|
+
result = op_replace(state, rest[1])
|
|
164
|
+
return replace(result, pending_keys="")
|
|
165
|
+
|
|
166
|
+
# ── Char-search: f/F/t/T{char} ──────────────────────────────
|
|
167
|
+
if len(rest) == 1 and rest in "fFtT":
|
|
168
|
+
return replace(state, pending_keys=pending)
|
|
169
|
+
if len(rest) == 2 and rest[0] in "fFtT":
|
|
170
|
+
motion_fn = {"f": move_f, "F": move_F, "t": move_t, "T": move_T}[rest[0]]
|
|
171
|
+
new_cursor = motion_fn(state, rest[1])
|
|
172
|
+
return replace(state, cursor=new_cursor, pending_keys="")
|
|
173
|
+
|
|
174
|
+
# ── g-prefixed: gg, gj, gk ──────────────────────────────────
|
|
175
|
+
if rest == "g":
|
|
176
|
+
return replace(state, pending_keys=pending)
|
|
177
|
+
if rest == "gg":
|
|
178
|
+
return replace(state, cursor=move_gg(state), pending_keys="")
|
|
179
|
+
if rest == "G":
|
|
180
|
+
return replace(state, cursor=move_G(state), pending_keys="")
|
|
181
|
+
|
|
182
|
+
# ── Operator + text object (e.g., "diw", "ca(") ─────────────
|
|
183
|
+
if len(rest) >= 3 and rest[0] in _OPERATOR_KEYS and rest[1] in "ia":
|
|
184
|
+
text_obj = rest[1:]
|
|
185
|
+
region = select_text_object(state, text_obj)
|
|
186
|
+
if region is None:
|
|
187
|
+
return replace(state, pending_keys="")
|
|
188
|
+
start, end = region
|
|
189
|
+
op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[rest[0]]
|
|
190
|
+
result = op_fn(state, start, end)
|
|
191
|
+
cmd = ParsedCommand(count=count, operator=rest[0], text_object=text_obj)
|
|
192
|
+
return replace(result, pending_keys="", last_command=cmd)
|
|
193
|
+
|
|
194
|
+
# ── Operator + motion (e.g., "dw", "cw", "yw") ──────────────
|
|
195
|
+
if len(rest) >= 2 and rest[0] in _OPERATOR_KEYS:
|
|
196
|
+
motion_key = rest[1:]
|
|
197
|
+
# cw/cW acts like ce/cE in vim (stops before trailing whitespace)
|
|
198
|
+
effective_motion = motion_key
|
|
199
|
+
if rest[0] == "c" and motion_key == "w":
|
|
200
|
+
effective_motion = "e"
|
|
201
|
+
elif rest[0] == "c" and motion_key == "W":
|
|
202
|
+
effective_motion = "E"
|
|
203
|
+
new_cursor = _resolve_motion(state, effective_motion, count)
|
|
204
|
+
if new_cursor is not None:
|
|
205
|
+
start = min(state.cursor, new_cursor)
|
|
206
|
+
# For e/E motions, end is inclusive so add 1
|
|
207
|
+
end = max(state.cursor, new_cursor)
|
|
208
|
+
if effective_motion in ("e", "E"):
|
|
209
|
+
end = end + 1
|
|
210
|
+
op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[rest[0]]
|
|
211
|
+
result = op_fn(state, start, end)
|
|
212
|
+
cmd = ParsedCommand(count=count, operator=rest[0], motion=motion_key)
|
|
213
|
+
return replace(result, pending_keys="", last_command=cmd)
|
|
214
|
+
# Unknown motion after operator — wait for more input or reset
|
|
215
|
+
if len(rest) < 4:
|
|
216
|
+
return replace(state, pending_keys=pending)
|
|
217
|
+
return replace(state, pending_keys="")
|
|
218
|
+
|
|
219
|
+
# ── Simple motions ────────────────────────────────────────────
|
|
220
|
+
new_cursor = _resolve_motion(state, rest, count)
|
|
221
|
+
if new_cursor is not None:
|
|
222
|
+
return replace(state, cursor=new_cursor, pending_keys="")
|
|
223
|
+
|
|
224
|
+
# ── Unknown key sequence — reset pending ──────────────────────
|
|
225
|
+
return replace(state, pending_keys="")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_motion(state: VimState, key: str, count: int) -> int | None:
|
|
229
|
+
"""Resolve a motion key to a cursor position, or None if not a motion."""
|
|
230
|
+
if key == "h":
|
|
231
|
+
return move_h(state, count)
|
|
232
|
+
if key == "l":
|
|
233
|
+
return move_l(state, count)
|
|
234
|
+
if key == "w":
|
|
235
|
+
return move_w(state, count)
|
|
236
|
+
if key == "b":
|
|
237
|
+
return move_b(state, count)
|
|
238
|
+
if key == "e":
|
|
239
|
+
return move_e(state, count)
|
|
240
|
+
if key == "W":
|
|
241
|
+
return move_W(state, count)
|
|
242
|
+
if key == "B":
|
|
243
|
+
return move_B(state, count)
|
|
244
|
+
if key == "E":
|
|
245
|
+
return move_E(state, count)
|
|
246
|
+
if key == "0":
|
|
247
|
+
return move_0(state)
|
|
248
|
+
if key == "^":
|
|
249
|
+
return move_caret(state)
|
|
250
|
+
if key == "$":
|
|
251
|
+
return move_dollar(state)
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _replay_command(state: VimState, cmd: ParsedCommand) -> VimState:
|
|
256
|
+
"""Replay a recorded command (dot repeat)."""
|
|
257
|
+
if cmd.operator == "x":
|
|
258
|
+
result = state
|
|
259
|
+
for _ in range(cmd.count):
|
|
260
|
+
result = op_x(result)
|
|
261
|
+
return replace(result, last_command=cmd)
|
|
262
|
+
if cmd.operator == "dd":
|
|
263
|
+
return replace(op_delete_line(state), last_command=cmd)
|
|
264
|
+
if cmd.operator and cmd.text_object:
|
|
265
|
+
region = select_text_object(state, cmd.text_object)
|
|
266
|
+
if region is None:
|
|
267
|
+
return state
|
|
268
|
+
start, end = region
|
|
269
|
+
op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[cmd.operator]
|
|
270
|
+
return replace(op_fn(state, start, end), last_command=cmd)
|
|
271
|
+
if cmd.operator and cmd.motion:
|
|
272
|
+
new_cursor = _resolve_motion(state, cmd.motion, cmd.count)
|
|
273
|
+
if new_cursor is None:
|
|
274
|
+
return state
|
|
275
|
+
start = min(state.cursor, new_cursor)
|
|
276
|
+
end = max(state.cursor, new_cursor)
|
|
277
|
+
op_fn = {"d": op_delete, "c": op_change, "y": op_yank}[cmd.operator]
|
|
278
|
+
return replace(op_fn(state, start, end), last_command=cmd)
|
|
279
|
+
return state
|
llm_code/vim/types.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Core vim types — mode, state, register, parsed command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VimMode(enum.Enum):
|
|
9
|
+
NORMAL = "normal"
|
|
10
|
+
INSERT = "insert"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Register:
|
|
15
|
+
content: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ParsedCommand:
|
|
20
|
+
count: int = 1
|
|
21
|
+
operator: str | None = None
|
|
22
|
+
motion: str | None = None
|
|
23
|
+
text_object: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class VimState:
|
|
28
|
+
buffer: str
|
|
29
|
+
cursor: int
|
|
30
|
+
mode: VimMode
|
|
31
|
+
register: Register
|
|
32
|
+
undo_stack: tuple[tuple[str, int], ...] = ()
|
|
33
|
+
last_command: ParsedCommand | None = None
|
|
34
|
+
pending_keys: str = ""
|
|
35
|
+
|
|
36
|
+
def with_cursor(self, pos: int) -> VimState:
|
|
37
|
+
clamped = max(0, min(pos, len(self.buffer)))
|
|
38
|
+
return replace(self, cursor=clamped)
|
|
39
|
+
|
|
40
|
+
def with_buffer(self, buf: str, cursor: int | None = None) -> VimState:
|
|
41
|
+
c = cursor if cursor is not None else self.cursor
|
|
42
|
+
clamped = max(0, min(c, len(buf)))
|
|
43
|
+
return replace(self, buffer=buf, cursor=clamped)
|
|
44
|
+
|
|
45
|
+
def with_mode(self, mode: VimMode) -> VimState:
|
|
46
|
+
return replace(self, mode=mode)
|
|
47
|
+
|
|
48
|
+
def with_register(self, content: str) -> VimState:
|
|
49
|
+
return replace(self, register=Register(content=content))
|
|
50
|
+
|
|
51
|
+
def push_undo(self) -> VimState:
|
|
52
|
+
entry = (self.buffer, self.cursor)
|
|
53
|
+
return replace(self, undo_stack=self.undo_stack + (entry,))
|
|
54
|
+
|
|
55
|
+
def pop_undo(self) -> VimState:
|
|
56
|
+
if not self.undo_stack:
|
|
57
|
+
return self
|
|
58
|
+
*rest, (buf, cur) = self.undo_stack
|
|
59
|
+
return replace(self, buffer=buf, cursor=cur, undo_stack=tuple(rest))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def initial_state(buffer: str) -> VimState:
|
|
63
|
+
return VimState(
|
|
64
|
+
buffer=buffer,
|
|
65
|
+
cursor=len(buffer),
|
|
66
|
+
mode=VimMode.INSERT,
|
|
67
|
+
register=Register(),
|
|
68
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Voice input (STT) module for llm-code."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Supported STT language codes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
LANGUAGE_MAP: dict[str, str] = {
|
|
5
|
+
"en": "English",
|
|
6
|
+
"zh": "Chinese",
|
|
7
|
+
"ja": "Japanese",
|
|
8
|
+
"ko": "Korean",
|
|
9
|
+
"es": "Spanish",
|
|
10
|
+
"fr": "French",
|
|
11
|
+
"de": "German",
|
|
12
|
+
"pt": "Portuguese",
|
|
13
|
+
"ru": "Russian",
|
|
14
|
+
"ar": "Arabic",
|
|
15
|
+
"it": "Italian",
|
|
16
|
+
"nl": "Dutch",
|
|
17
|
+
"pl": "Polish",
|
|
18
|
+
"sv": "Swedish",
|
|
19
|
+
"da": "Danish",
|
|
20
|
+
"no": "Norwegian",
|
|
21
|
+
"fi": "Finnish",
|
|
22
|
+
"tr": "Turkish",
|
|
23
|
+
"th": "Thai",
|
|
24
|
+
"vi": "Vietnamese",
|
|
25
|
+
"id": "Indonesian",
|
|
26
|
+
"ms": "Malay",
|
|
27
|
+
"hi": "Hindi",
|
|
28
|
+
"uk": "Ukrainian",
|
|
29
|
+
"cs": "Czech",
|
|
30
|
+
"el": "Greek",
|
|
31
|
+
"he": "Hebrew",
|
|
32
|
+
"hu": "Hungarian",
|
|
33
|
+
"ro": "Romanian",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_language(code: str) -> str:
|
|
38
|
+
"""Return the code if valid, raise ValueError otherwise."""
|
|
39
|
+
if code not in LANGUAGE_MAP:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Unsupported language code: {code!r}. Valid: {sorted(LANGUAGE_MAP)}"
|
|
42
|
+
)
|
|
43
|
+
return code
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Audio recording with sounddevice (primary) and sox/arecord fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RecorderBackend(enum.Enum):
|
|
12
|
+
SOUNDDEVICE = "sounddevice"
|
|
13
|
+
SOX = "sox"
|
|
14
|
+
ARECORD = "arecord"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_backend() -> RecorderBackend:
|
|
18
|
+
"""Detect the best available recording backend."""
|
|
19
|
+
try:
|
|
20
|
+
import sounddevice as _sd # noqa: F401
|
|
21
|
+
return RecorderBackend.SOUNDDEVICE
|
|
22
|
+
except (ImportError, TypeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
if shutil.which("sox"):
|
|
26
|
+
return RecorderBackend.SOX
|
|
27
|
+
if shutil.which("arecord"):
|
|
28
|
+
return RecorderBackend.ARECORD
|
|
29
|
+
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"No audio recording backend available. "
|
|
32
|
+
"Install sounddevice (`pip install llm-code[voice]`) or ensure sox/arecord is on PATH."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AudioRecorder:
|
|
37
|
+
"""Records 16kHz mono 16-bit PCM audio from the microphone."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
backend: RecorderBackend | None = None,
|
|
42
|
+
sample_rate: int = 16000,
|
|
43
|
+
channels: int = 1,
|
|
44
|
+
):
|
|
45
|
+
self._backend = backend or RecorderBackend.SOUNDDEVICE
|
|
46
|
+
self.sample_rate = sample_rate
|
|
47
|
+
self.channels = channels
|
|
48
|
+
self._buffer = bytearray()
|
|
49
|
+
self._recording = False
|
|
50
|
+
self._start_time: float | None = None
|
|
51
|
+
self._stream = None
|
|
52
|
+
self._process: subprocess.Popen | None = None
|
|
53
|
+
self._thread: threading.Thread | None = None
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
"""Begin recording audio."""
|
|
57
|
+
self._buffer = bytearray()
|
|
58
|
+
self._recording = True
|
|
59
|
+
self._start_time = time.monotonic()
|
|
60
|
+
|
|
61
|
+
if self._backend == RecorderBackend.SOUNDDEVICE:
|
|
62
|
+
self._start_sounddevice()
|
|
63
|
+
elif self._backend == RecorderBackend.SOX:
|
|
64
|
+
self._start_external([
|
|
65
|
+
"sox", "-d", "-t", "raw", "-r", str(self.sample_rate),
|
|
66
|
+
"-e", "signed", "-b", "16", "-c", str(self.channels), "-",
|
|
67
|
+
])
|
|
68
|
+
elif self._backend == RecorderBackend.ARECORD:
|
|
69
|
+
self._start_external([
|
|
70
|
+
"arecord", "-f", "S16_LE", "-r", str(self.sample_rate),
|
|
71
|
+
"-c", str(self.channels), "-t", "raw", "-",
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
def stop(self) -> bytes:
|
|
75
|
+
"""Stop recording and return the captured PCM bytes."""
|
|
76
|
+
if not self._recording:
|
|
77
|
+
return b""
|
|
78
|
+
|
|
79
|
+
self._recording = False
|
|
80
|
+
|
|
81
|
+
if self._backend == RecorderBackend.SOUNDDEVICE and self._stream is not None:
|
|
82
|
+
self._stream.stop()
|
|
83
|
+
self._stream.close()
|
|
84
|
+
self._stream = None
|
|
85
|
+
elif self._process is not None:
|
|
86
|
+
self._process.terminate()
|
|
87
|
+
self._process.wait(timeout=3)
|
|
88
|
+
self._process = None
|
|
89
|
+
if self._thread is not None:
|
|
90
|
+
self._thread.join(timeout=3)
|
|
91
|
+
self._thread = None
|
|
92
|
+
|
|
93
|
+
result = bytes(self._buffer)
|
|
94
|
+
self._buffer = bytearray()
|
|
95
|
+
self._start_time = None
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
def elapsed_seconds(self) -> float:
|
|
99
|
+
"""Return seconds since recording started, or 0.0 if not recording."""
|
|
100
|
+
if self._start_time is None or not self._recording:
|
|
101
|
+
return 0.0
|
|
102
|
+
return time.monotonic() - self._start_time
|
|
103
|
+
|
|
104
|
+
def _start_sounddevice(self) -> None:
|
|
105
|
+
import sounddevice as sd # type: ignore[import]
|
|
106
|
+
|
|
107
|
+
def callback(indata, frames, time_info, status):
|
|
108
|
+
if self._recording:
|
|
109
|
+
self._buffer.extend(indata.tobytes())
|
|
110
|
+
|
|
111
|
+
self._stream = sd.RawInputStream(
|
|
112
|
+
samplerate=self.sample_rate,
|
|
113
|
+
channels=self.channels,
|
|
114
|
+
dtype="int16",
|
|
115
|
+
callback=callback,
|
|
116
|
+
)
|
|
117
|
+
self._stream.start()
|
|
118
|
+
|
|
119
|
+
def _start_external(self, cmd: list[str]) -> None:
|
|
120
|
+
self._process = subprocess.Popen(
|
|
121
|
+
cmd,
|
|
122
|
+
stdout=subprocess.PIPE,
|
|
123
|
+
stderr=subprocess.DEVNULL,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _read_loop():
|
|
127
|
+
assert self._process is not None
|
|
128
|
+
assert self._process.stdout is not None
|
|
129
|
+
while self._recording:
|
|
130
|
+
chunk = self._process.stdout.read(4096)
|
|
131
|
+
if not chunk:
|
|
132
|
+
break
|
|
133
|
+
self._buffer.extend(chunk)
|
|
134
|
+
|
|
135
|
+
self._thread = threading.Thread(target=_read_loop, daemon=True)
|
|
136
|
+
self._thread.start()
|
llm_code/voice/stt.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""STT engine protocol and factory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from llm_code.runtime.config import VoiceConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class STTEngine(Protocol):
|
|
11
|
+
"""Protocol for speech-to-text backends."""
|
|
12
|
+
|
|
13
|
+
def transcribe(self, audio_bytes: bytes, language: str) -> str:
|
|
14
|
+
"""Transcribe raw PCM audio bytes to text."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_stt_engine(config: VoiceConfig) -> STTEngine:
|
|
19
|
+
"""Factory: create an STT engine from config."""
|
|
20
|
+
backend = config.backend
|
|
21
|
+
|
|
22
|
+
if backend == "whisper":
|
|
23
|
+
from llm_code.voice.stt_whisper import WhisperSTT
|
|
24
|
+
return WhisperSTT(url=config.whisper_url)
|
|
25
|
+
|
|
26
|
+
if backend == "google":
|
|
27
|
+
from llm_code.voice.stt_google import GoogleSTT
|
|
28
|
+
return GoogleSTT(language_code=config.google_language_code or config.language)
|
|
29
|
+
|
|
30
|
+
if backend == "anthropic":
|
|
31
|
+
from llm_code.voice.stt_anthropic import AnthropicSTT
|
|
32
|
+
return AnthropicSTT(ws_url=config.anthropic_ws_url)
|
|
33
|
+
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Unknown STT backend: {backend!r}. Valid: whisper, google, anthropic"
|
|
36
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Anthropic WebSocket STT backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ws_transcribe(ws_url: str, audio_bytes: bytes, language: str) -> str:
|
|
11
|
+
"""Connect to Anthropic voice_stream WebSocket and transcribe."""
|
|
12
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
13
|
+
_async_ws_transcribe(ws_url, audio_bytes, language)
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _async_ws_transcribe(ws_url: str, audio_bytes: bytes, language: str) -> str:
|
|
18
|
+
"""Async WebSocket transcription."""
|
|
19
|
+
import websockets # type: ignore[import]
|
|
20
|
+
|
|
21
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
22
|
+
url = f"{ws_url}/v1/voice_stream"
|
|
23
|
+
|
|
24
|
+
async with websockets.connect(
|
|
25
|
+
url,
|
|
26
|
+
additional_headers={"x-api-key": api_key, "anthropic-version": "2024-01-01"},
|
|
27
|
+
) as ws:
|
|
28
|
+
await ws.send(json.dumps({
|
|
29
|
+
"type": "audio_start",
|
|
30
|
+
"language": language,
|
|
31
|
+
"encoding": "pcm_s16le",
|
|
32
|
+
"sample_rate": 16000,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
# Send audio in chunks
|
|
36
|
+
chunk_size = 32000 # 1 second of 16kHz 16-bit
|
|
37
|
+
for i in range(0, len(audio_bytes), chunk_size):
|
|
38
|
+
chunk = audio_bytes[i: i + chunk_size]
|
|
39
|
+
await ws.send(json.dumps({
|
|
40
|
+
"type": "audio_data",
|
|
41
|
+
"data": base64.b64encode(chunk).decode(),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
await ws.send(json.dumps({"type": "audio_end"}))
|
|
45
|
+
|
|
46
|
+
# Collect transcription
|
|
47
|
+
transcript_parts: list[str] = []
|
|
48
|
+
async for msg in ws:
|
|
49
|
+
data = json.loads(msg)
|
|
50
|
+
if data.get("type") == "transcription":
|
|
51
|
+
transcript_parts.append(data.get("text", ""))
|
|
52
|
+
elif data.get("type") == "transcription_complete":
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
return " ".join(transcript_parts).strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AnthropicSTT:
|
|
59
|
+
"""Transcribe audio via Anthropic WebSocket voice_stream."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, ws_url: str = "wss://api.anthropic.com"):
|
|
62
|
+
self._ws_url = ws_url
|
|
63
|
+
|
|
64
|
+
def transcribe(self, audio_bytes: bytes, language: str) -> str:
|
|
65
|
+
"""Send audio to Anthropic WebSocket STT."""
|
|
66
|
+
return _ws_transcribe(self._ws_url, audio_bytes, language)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Google Cloud Speech STT backend."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _get_client():
|
|
6
|
+
"""Lazy import and create Google Speech client."""
|
|
7
|
+
from google.cloud import speech # type: ignore[import]
|
|
8
|
+
return speech.SpeechClient()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoogleSTT:
|
|
12
|
+
"""Transcribe audio via Google Cloud Speech-to-Text API."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, language_code: str = "en-US"):
|
|
15
|
+
self._language_code = language_code
|
|
16
|
+
|
|
17
|
+
def transcribe(self, audio_bytes: bytes, language: str) -> str:
|
|
18
|
+
"""Send PCM audio to Google Cloud Speech."""
|
|
19
|
+
from google.cloud import speech # type: ignore[import]
|
|
20
|
+
|
|
21
|
+
client = _get_client()
|
|
22
|
+
audio = speech.RecognitionAudio(content=audio_bytes)
|
|
23
|
+
config = speech.RecognitionConfig(
|
|
24
|
+
encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16,
|
|
25
|
+
sample_rate_hertz=16000,
|
|
26
|
+
language_code=self._language_code if self._language_code else language,
|
|
27
|
+
)
|
|
28
|
+
response = client.recognize(config=config, audio=audio)
|
|
29
|
+
|
|
30
|
+
if not response.results:
|
|
31
|
+
return ""
|
|
32
|
+
return response.results[0].alternatives[0].transcript
|