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,62 @@
|
|
|
1
|
+
"""Non-blocking version check against GitHub releases API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
_RELEASES_URL = "https://api.github.com/repos/djfeu-adam/llm-code/releases/latest"
|
|
11
|
+
_TIMEOUT = 5.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True)
|
|
15
|
+
class VersionInfo:
|
|
16
|
+
current: str
|
|
17
|
+
latest: str
|
|
18
|
+
is_outdated: bool
|
|
19
|
+
release_url: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_version(tag: str) -> tuple[int, ...]:
|
|
23
|
+
"""Parse a version tag like 'v1.2.3' or '1.2.3' into a comparable tuple."""
|
|
24
|
+
cleaned = tag.lstrip("v")
|
|
25
|
+
try:
|
|
26
|
+
return tuple(int(x) for x in cleaned.split("."))
|
|
27
|
+
except ValueError:
|
|
28
|
+
return (0,)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def check_latest_version(current: str) -> VersionInfo | None:
|
|
32
|
+
"""Fetch latest release from GitHub and compare with *current*.
|
|
33
|
+
|
|
34
|
+
Returns a :class:`VersionInfo` or ``None`` on any network/parse failure.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
40
|
+
response = await client.get(
|
|
41
|
+
_RELEASES_URL,
|
|
42
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
43
|
+
follow_redirects=True,
|
|
44
|
+
)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
data = response.json()
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
tag_name: str = data["tag_name"]
|
|
52
|
+
release_url: str = data.get("html_url", "")
|
|
53
|
+
except (KeyError, TypeError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
is_outdated = _parse_version(tag_name) > _parse_version(current)
|
|
57
|
+
return VersionInfo(
|
|
58
|
+
current=current,
|
|
59
|
+
latest=tag_name.lstrip("v"),
|
|
60
|
+
is_outdated=is_outdated,
|
|
61
|
+
release_url=release_url,
|
|
62
|
+
)
|
llm_code/vim/__init__.py
ADDED
llm_code/vim/engine.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""VimEngine — top-level API for the vim editing engine.
|
|
2
|
+
|
|
3
|
+
Wraps the pure-functional state machine in a mutable shell for
|
|
4
|
+
convenient imperative use.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from llm_code.vim.types import VimMode, VimState, initial_state
|
|
9
|
+
from llm_code.vim.transitions import handle_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VimEngine:
|
|
13
|
+
"""Mutable wrapper around the immutable VimState."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, buffer: str = "") -> None:
|
|
16
|
+
self._state = initial_state(buffer)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def buffer(self) -> str:
|
|
20
|
+
return self._state.buffer
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def cursor(self) -> int:
|
|
24
|
+
return self._state.cursor
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def mode(self) -> VimMode:
|
|
28
|
+
return self._state.mode
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def mode_display(self) -> str:
|
|
32
|
+
if self._state.mode == VimMode.NORMAL:
|
|
33
|
+
return "-- NORMAL --"
|
|
34
|
+
return "-- INSERT --"
|
|
35
|
+
|
|
36
|
+
def feed_key(self, key: str) -> None:
|
|
37
|
+
"""Process a single key press."""
|
|
38
|
+
self._state = handle_key(self._state, key)
|
|
39
|
+
|
|
40
|
+
def feed_keys(self, keys: str) -> None:
|
|
41
|
+
"""Process a sequence of key presses."""
|
|
42
|
+
for k in keys:
|
|
43
|
+
self.feed_key(k)
|
|
44
|
+
|
|
45
|
+
def set_buffer(self, buffer: str) -> None:
|
|
46
|
+
"""Replace the buffer (used when syncing with external input)."""
|
|
47
|
+
self._state = self._state.with_buffer(buffer, cursor=len(buffer))
|
|
48
|
+
|
|
49
|
+
def snapshot(self) -> VimState:
|
|
50
|
+
"""Return an immutable snapshot of the current state."""
|
|
51
|
+
return self._state
|
llm_code/vim/motions.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Vim motion functions.
|
|
2
|
+
|
|
3
|
+
Each motion takes a VimState and count (or char for f/F/t/T) and returns
|
|
4
|
+
the new cursor position (int). Motions are pure functions — they never
|
|
5
|
+
mutate state.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from llm_code.vim.types import VimState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Character motions ─────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def move_h(state: VimState, count: int) -> int:
|
|
16
|
+
return max(0, state.cursor - count)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def move_l(state: VimState, count: int) -> int:
|
|
20
|
+
max_pos = max(0, len(state.buffer) - 1)
|
|
21
|
+
return min(max_pos, state.cursor + count)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Word motions (small word = split on non-alnum boundary) ───────
|
|
25
|
+
|
|
26
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def move_w(state: VimState, count: int) -> int:
|
|
30
|
+
pos = state.cursor
|
|
31
|
+
for _ in range(count):
|
|
32
|
+
matches = list(_WORD_RE.finditer(state.buffer))
|
|
33
|
+
found = False
|
|
34
|
+
for m in matches:
|
|
35
|
+
if m.start() > pos:
|
|
36
|
+
pos = m.start()
|
|
37
|
+
found = True
|
|
38
|
+
break
|
|
39
|
+
if not found:
|
|
40
|
+
break
|
|
41
|
+
return pos
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def move_b(state: VimState, count: int) -> int:
|
|
45
|
+
pos = state.cursor
|
|
46
|
+
for _ in range(count):
|
|
47
|
+
matches = list(_WORD_RE.finditer(state.buffer))
|
|
48
|
+
found = False
|
|
49
|
+
for m in reversed(matches):
|
|
50
|
+
if m.start() < pos:
|
|
51
|
+
pos = m.start()
|
|
52
|
+
found = True
|
|
53
|
+
break
|
|
54
|
+
if not found:
|
|
55
|
+
pos = 0
|
|
56
|
+
break
|
|
57
|
+
return pos
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def move_e(state: VimState, count: int) -> int:
|
|
61
|
+
pos = state.cursor
|
|
62
|
+
for _ in range(count):
|
|
63
|
+
matches = list(_WORD_RE.finditer(state.buffer))
|
|
64
|
+
found = False
|
|
65
|
+
for m in matches:
|
|
66
|
+
end = m.end() - 1
|
|
67
|
+
if end > pos:
|
|
68
|
+
pos = end
|
|
69
|
+
found = True
|
|
70
|
+
break
|
|
71
|
+
if not found:
|
|
72
|
+
break
|
|
73
|
+
return pos
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── WORD motions (big word = split on whitespace only) ────────────
|
|
77
|
+
|
|
78
|
+
_BIGWORD_RE = re.compile(r"\S+")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def move_W(state: VimState, count: int) -> int:
|
|
82
|
+
pos = state.cursor
|
|
83
|
+
for _ in range(count):
|
|
84
|
+
matches = list(_BIGWORD_RE.finditer(state.buffer))
|
|
85
|
+
found = False
|
|
86
|
+
for m in matches:
|
|
87
|
+
if m.start() > pos:
|
|
88
|
+
pos = m.start()
|
|
89
|
+
found = True
|
|
90
|
+
break
|
|
91
|
+
if not found:
|
|
92
|
+
break
|
|
93
|
+
return pos
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def move_B(state: VimState, count: int) -> int:
|
|
97
|
+
pos = state.cursor
|
|
98
|
+
for _ in range(count):
|
|
99
|
+
matches = list(_BIGWORD_RE.finditer(state.buffer))
|
|
100
|
+
found = False
|
|
101
|
+
for m in reversed(matches):
|
|
102
|
+
if m.start() < pos:
|
|
103
|
+
pos = m.start()
|
|
104
|
+
found = True
|
|
105
|
+
break
|
|
106
|
+
if not found:
|
|
107
|
+
pos = 0
|
|
108
|
+
break
|
|
109
|
+
return pos
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def move_E(state: VimState, count: int) -> int:
|
|
113
|
+
pos = state.cursor
|
|
114
|
+
for _ in range(count):
|
|
115
|
+
matches = list(_BIGWORD_RE.finditer(state.buffer))
|
|
116
|
+
found = False
|
|
117
|
+
for m in matches:
|
|
118
|
+
end = m.end() - 1
|
|
119
|
+
if end > pos:
|
|
120
|
+
pos = end
|
|
121
|
+
found = True
|
|
122
|
+
break
|
|
123
|
+
if not found:
|
|
124
|
+
break
|
|
125
|
+
return pos
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Line motions ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def move_0(state: VimState) -> int:
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def move_caret(state: VimState) -> int:
|
|
135
|
+
stripped = state.buffer.lstrip()
|
|
136
|
+
return len(state.buffer) - len(stripped)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def move_dollar(state: VimState) -> int:
|
|
140
|
+
return max(0, len(state.buffer) - 1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Document motions ─────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def move_gg(state: VimState) -> int:
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def move_G(state: VimState) -> int:
|
|
150
|
+
return max(0, len(state.buffer) - 1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Char search motions ─────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def move_f(state: VimState, char: str) -> int:
|
|
156
|
+
idx = state.buffer.find(char, state.cursor + 1)
|
|
157
|
+
return idx if idx != -1 else state.cursor
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def move_F(state: VimState, char: str) -> int:
|
|
161
|
+
idx = state.buffer.rfind(char, 0, state.cursor)
|
|
162
|
+
return idx if idx != -1 else state.cursor
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def move_t(state: VimState, char: str) -> int:
|
|
166
|
+
idx = state.buffer.find(char, state.cursor + 1)
|
|
167
|
+
return idx - 1 if idx > 0 else state.cursor
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def move_T(state: VimState, char: str) -> int:
|
|
171
|
+
idx = state.buffer.rfind(char, 0, state.cursor)
|
|
172
|
+
return idx + 1 if idx != -1 else state.cursor
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Vim operator functions.
|
|
2
|
+
|
|
3
|
+
Operators take a VimState (and a region) and return a new VimState.
|
|
4
|
+
All functions are pure — they never mutate the input state.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
|
|
10
|
+
from llm_code.vim.types import VimMode, VimState, Register
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def op_delete(state: VimState, start: int, end: int) -> VimState:
|
|
14
|
+
"""Delete text in [start, end) and yank it into register."""
|
|
15
|
+
deleted = state.buffer[start:end]
|
|
16
|
+
new_buf = state.buffer[:start] + state.buffer[end:]
|
|
17
|
+
cursor = min(start, max(0, len(new_buf) - 1))
|
|
18
|
+
return replace(
|
|
19
|
+
state,
|
|
20
|
+
buffer=new_buf,
|
|
21
|
+
cursor=max(0, cursor),
|
|
22
|
+
register=Register(content=deleted),
|
|
23
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def op_change(state: VimState, start: int, end: int) -> VimState:
|
|
28
|
+
"""Delete text in [start, end), yank it, and enter INSERT mode."""
|
|
29
|
+
deleted = state.buffer[start:end]
|
|
30
|
+
new_buf = state.buffer[:start] + state.buffer[end:]
|
|
31
|
+
return replace(
|
|
32
|
+
state,
|
|
33
|
+
buffer=new_buf,
|
|
34
|
+
cursor=start,
|
|
35
|
+
mode=VimMode.INSERT,
|
|
36
|
+
register=Register(content=deleted),
|
|
37
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def op_yank(state: VimState, start: int, end: int) -> VimState:
|
|
42
|
+
"""Yank text in [start, end) into register without modifying buffer."""
|
|
43
|
+
yanked = state.buffer[start:end]
|
|
44
|
+
return replace(
|
|
45
|
+
state,
|
|
46
|
+
cursor=start,
|
|
47
|
+
register=Register(content=yanked),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def op_delete_line(state: VimState) -> VimState:
|
|
52
|
+
"""Delete entire buffer (dd)."""
|
|
53
|
+
return op_delete(state, 0, len(state.buffer))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def op_change_line(state: VimState) -> VimState:
|
|
57
|
+
"""Clear entire buffer and enter INSERT (cc)."""
|
|
58
|
+
return op_change(state, 0, len(state.buffer))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def op_yank_line(state: VimState) -> VimState:
|
|
62
|
+
"""Yank entire buffer (yy)."""
|
|
63
|
+
return op_yank(state, 0, len(state.buffer))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def op_x(state: VimState) -> VimState:
|
|
67
|
+
"""Delete character under cursor."""
|
|
68
|
+
if not state.buffer:
|
|
69
|
+
return state
|
|
70
|
+
end = min(state.cursor + 1, len(state.buffer))
|
|
71
|
+
return op_delete(state, state.cursor, end)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def op_replace(state: VimState, char: str) -> VimState:
|
|
75
|
+
"""Replace character under cursor with char."""
|
|
76
|
+
if not state.buffer or state.cursor >= len(state.buffer):
|
|
77
|
+
return state
|
|
78
|
+
new_buf = state.buffer[:state.cursor] + char + state.buffer[state.cursor + 1:]
|
|
79
|
+
return replace(
|
|
80
|
+
state,
|
|
81
|
+
buffer=new_buf,
|
|
82
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def op_tilde(state: VimState) -> VimState:
|
|
87
|
+
"""Toggle case of character under cursor and advance."""
|
|
88
|
+
if not state.buffer or state.cursor >= len(state.buffer):
|
|
89
|
+
return state
|
|
90
|
+
ch = state.buffer[state.cursor]
|
|
91
|
+
toggled = ch.lower() if ch.isupper() else ch.upper()
|
|
92
|
+
new_buf = state.buffer[:state.cursor] + toggled + state.buffer[state.cursor + 1:]
|
|
93
|
+
new_cursor = min(state.cursor + 1, max(0, len(new_buf) - 1))
|
|
94
|
+
return replace(
|
|
95
|
+
state,
|
|
96
|
+
buffer=new_buf,
|
|
97
|
+
cursor=new_cursor,
|
|
98
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def op_join(state: VimState) -> VimState:
|
|
103
|
+
"""Join lines (J). For single-line input buffer, this is a no-op."""
|
|
104
|
+
return state
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def op_put_after(state: VimState) -> VimState:
|
|
108
|
+
"""Paste register content after cursor (p)."""
|
|
109
|
+
content = state.register.content
|
|
110
|
+
if not content:
|
|
111
|
+
return state
|
|
112
|
+
insert_pos = state.cursor + 1
|
|
113
|
+
new_buf = state.buffer[:insert_pos] + content + state.buffer[insert_pos:]
|
|
114
|
+
return replace(
|
|
115
|
+
state,
|
|
116
|
+
buffer=new_buf,
|
|
117
|
+
cursor=insert_pos + len(content) - 1,
|
|
118
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def op_put_before(state: VimState) -> VimState:
|
|
123
|
+
"""Paste register content before cursor (P)."""
|
|
124
|
+
content = state.register.content
|
|
125
|
+
if not content:
|
|
126
|
+
return state
|
|
127
|
+
insert_pos = state.cursor
|
|
128
|
+
new_buf = state.buffer[:insert_pos] + content + state.buffer[insert_pos:]
|
|
129
|
+
return replace(
|
|
130
|
+
state,
|
|
131
|
+
buffer=new_buf,
|
|
132
|
+
cursor=insert_pos + len(content) - 1,
|
|
133
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def op_open_below(state: VimState) -> VimState:
|
|
138
|
+
"""Open line below (o) — append newline and enter INSERT."""
|
|
139
|
+
return replace(
|
|
140
|
+
state,
|
|
141
|
+
cursor=len(state.buffer),
|
|
142
|
+
mode=VimMode.INSERT,
|
|
143
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def op_open_above(state: VimState) -> VimState:
|
|
148
|
+
"""Open line above (O) — prepend and enter INSERT."""
|
|
149
|
+
return replace(
|
|
150
|
+
state,
|
|
151
|
+
cursor=0,
|
|
152
|
+
mode=VimMode.INSERT,
|
|
153
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def op_indent_right(state: VimState) -> VimState:
|
|
158
|
+
"""Indent right (>>). Add 2 spaces."""
|
|
159
|
+
new_buf = " " + state.buffer
|
|
160
|
+
return replace(
|
|
161
|
+
state,
|
|
162
|
+
buffer=new_buf,
|
|
163
|
+
cursor=state.cursor + 2,
|
|
164
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def op_indent_left(state: VimState) -> VimState:
|
|
169
|
+
"""Indent left (<<). Remove up to 2 leading spaces."""
|
|
170
|
+
if state.buffer.startswith(" "):
|
|
171
|
+
new_buf = state.buffer[2:]
|
|
172
|
+
new_cursor = max(0, state.cursor - 2)
|
|
173
|
+
elif state.buffer.startswith(" "):
|
|
174
|
+
new_buf = state.buffer[1:]
|
|
175
|
+
new_cursor = max(0, state.cursor - 1)
|
|
176
|
+
else:
|
|
177
|
+
return state
|
|
178
|
+
return replace(
|
|
179
|
+
state,
|
|
180
|
+
buffer=new_buf,
|
|
181
|
+
cursor=new_cursor,
|
|
182
|
+
undo_stack=state.undo_stack + ((state.buffer, state.cursor),),
|
|
183
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Vim text object selectors.
|
|
2
|
+
|
|
3
|
+
Each function returns (start, end) as an exclusive range [start, end),
|
|
4
|
+
or None if the text object cannot be found at the cursor position.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from llm_code.vim.types import VimState
|
|
10
|
+
|
|
11
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+")
|
|
12
|
+
_BIGWORD_RE = re.compile(r"\S+")
|
|
13
|
+
|
|
14
|
+
_BRACKET_PAIRS = {
|
|
15
|
+
"(": ("(", ")"),
|
|
16
|
+
")": ("(", ")"),
|
|
17
|
+
"[": ("[", "]"),
|
|
18
|
+
"]": ("[", "]"),
|
|
19
|
+
"{": ("{", "}"),
|
|
20
|
+
"}": ("{", "}"),
|
|
21
|
+
"<": ("<", ">"),
|
|
22
|
+
">": ("<", ">"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select_text_object(
|
|
27
|
+
state: VimState, obj: str
|
|
28
|
+
) -> tuple[int, int] | None:
|
|
29
|
+
"""Select a text object region.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
state: Current vim state.
|
|
33
|
+
obj: Text object string, e.g. "iw", "a(", "i\"".
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
(start, end) exclusive range, or None if not found.
|
|
37
|
+
"""
|
|
38
|
+
if len(obj) < 2:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
kind = obj[0] # 'i' (inner) or 'a' (around)
|
|
42
|
+
target = obj[1]
|
|
43
|
+
|
|
44
|
+
if target in ("w",):
|
|
45
|
+
return _select_word(state, kind, small=True)
|
|
46
|
+
if target in ("W",):
|
|
47
|
+
return _select_word(state, kind, small=False)
|
|
48
|
+
if target in ('"', "'"):
|
|
49
|
+
return _select_quoted(state, kind, target)
|
|
50
|
+
if target in _BRACKET_PAIRS:
|
|
51
|
+
return _select_bracket(state, kind, target)
|
|
52
|
+
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _select_word(
|
|
57
|
+
state: VimState, kind: str, *, small: bool
|
|
58
|
+
) -> tuple[int, int] | None:
|
|
59
|
+
pattern = _WORD_RE if small else _BIGWORD_RE
|
|
60
|
+
for m in pattern.finditer(state.buffer):
|
|
61
|
+
if m.start() <= state.cursor < m.end():
|
|
62
|
+
start, end = m.start(), m.end()
|
|
63
|
+
if kind == "a":
|
|
64
|
+
# Include trailing whitespace
|
|
65
|
+
while end < len(state.buffer) and state.buffer[end] == " ":
|
|
66
|
+
end += 1
|
|
67
|
+
return (start, end)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _select_quoted(
|
|
72
|
+
state: VimState, kind: str, quote: str
|
|
73
|
+
) -> tuple[int, int] | None:
|
|
74
|
+
buf = state.buffer
|
|
75
|
+
# Find opening quote before or at cursor
|
|
76
|
+
open_idx = buf.rfind(quote, 0, state.cursor + 1)
|
|
77
|
+
if open_idx == -1:
|
|
78
|
+
return None
|
|
79
|
+
# Find closing quote after opening
|
|
80
|
+
close_idx = buf.find(quote, open_idx + 1)
|
|
81
|
+
if close_idx == -1 or close_idx < state.cursor:
|
|
82
|
+
return None
|
|
83
|
+
if kind == "i":
|
|
84
|
+
return (open_idx + 1, close_idx)
|
|
85
|
+
return (open_idx, close_idx + 1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _select_bracket(
|
|
89
|
+
state: VimState, kind: str, target: str
|
|
90
|
+
) -> tuple[int, int] | None:
|
|
91
|
+
open_ch, close_ch = _BRACKET_PAIRS[target]
|
|
92
|
+
buf = state.buffer
|
|
93
|
+
|
|
94
|
+
# For angle brackets, use simple nearest-neighbor search
|
|
95
|
+
# (find nearest > to the left and < to the right of cursor)
|
|
96
|
+
if open_ch == "<":
|
|
97
|
+
open_idx = buf.rfind(close_ch, 0, state.cursor + 1)
|
|
98
|
+
if open_idx == -1:
|
|
99
|
+
return None
|
|
100
|
+
close_idx = buf.find(open_ch, state.cursor)
|
|
101
|
+
if close_idx == -1:
|
|
102
|
+
return None
|
|
103
|
+
if kind == "i":
|
|
104
|
+
return (open_idx + 1, close_idx)
|
|
105
|
+
return (open_idx, close_idx + 1)
|
|
106
|
+
|
|
107
|
+
# Search backward for opening bracket (with nesting support)
|
|
108
|
+
depth = 0
|
|
109
|
+
open_idx = -1
|
|
110
|
+
for i in range(state.cursor, -1, -1):
|
|
111
|
+
if buf[i] == close_ch:
|
|
112
|
+
depth += 1
|
|
113
|
+
elif buf[i] == open_ch:
|
|
114
|
+
if depth == 0:
|
|
115
|
+
open_idx = i
|
|
116
|
+
break
|
|
117
|
+
depth -= 1
|
|
118
|
+
|
|
119
|
+
if open_idx == -1:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Search forward for matching closing bracket
|
|
123
|
+
depth = 0
|
|
124
|
+
close_idx = -1
|
|
125
|
+
for i in range(open_idx, len(buf)):
|
|
126
|
+
if buf[i] == open_ch:
|
|
127
|
+
depth += 1
|
|
128
|
+
elif buf[i] == close_ch:
|
|
129
|
+
depth -= 1
|
|
130
|
+
if depth == 0:
|
|
131
|
+
close_idx = i
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if close_idx == -1:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
if kind == "i":
|
|
138
|
+
return (open_idx + 1, close_idx)
|
|
139
|
+
return (open_idx, close_idx + 1)
|