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,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
+ )
@@ -0,0 +1,4 @@
1
+ """Vim mode engine for llm-code."""
2
+ from llm_code.vim.types import VimMode, VimState, Register, ParsedCommand, initial_state
3
+
4
+ __all__ = ["VimMode", "VimState", "Register", "ParsedCommand", "initial_state"]
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
@@ -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)