vimlm 0.0.5__py3-none-any.whl → 0.0.6__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.
- vimlm-0.0.6.dist-info/METADATA +158 -0
- vimlm-0.0.6.dist-info/RECORD +7 -0
- vimlm.py +334 -143
- vimlm-0.0.5.dist-info/METADATA +0 -108
- vimlm-0.0.5.dist-info/RECORD +0 -7
- {vimlm-0.0.5.dist-info → vimlm-0.0.6.dist-info}/LICENSE +0 -0
- {vimlm-0.0.5.dist-info → vimlm-0.0.6.dist-info}/WHEEL +0 -0
- {vimlm-0.0.5.dist-info → vimlm-0.0.6.dist-info}/entry_points.txt +0 -0
- {vimlm-0.0.5.dist-info → vimlm-0.0.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,158 @@
|
|
1
|
+
Metadata-Version: 2.2
|
2
|
+
Name: vimlm
|
3
|
+
Version: 0.0.6
|
4
|
+
Summary: VimLM - LLM-powered Vim assistant
|
5
|
+
Home-page: https://github.com/JosefAlbers/vimlm
|
6
|
+
Author: Josef Albers
|
7
|
+
Author-email: albersj66@gmail.com
|
8
|
+
Requires-Python: >=3.12.8
|
9
|
+
Description-Content-Type: text/markdown
|
10
|
+
License-File: LICENSE
|
11
|
+
Requires-Dist: nanollama==0.0.5
|
12
|
+
Requires-Dist: mlx_lm_utils==0.0.2
|
13
|
+
Requires-Dist: watchfiles==1.0.4
|
14
|
+
Dynamic: author
|
15
|
+
Dynamic: author-email
|
16
|
+
Dynamic: description
|
17
|
+
Dynamic: description-content-type
|
18
|
+
Dynamic: home-page
|
19
|
+
Dynamic: requires-dist
|
20
|
+
Dynamic: requires-python
|
21
|
+
Dynamic: summary
|
22
|
+
|
23
|
+
|
24
|
+
# VimLM - Local LLM-Powered Coding Assistant for Vim
|
25
|
+
|
26
|
+

|
27
|
+
|
28
|
+
LLM-powered coding companion for Vim, inspired by GitHub Copilot/Cursor. Integrates contextual code understanding, summarization, and AI assistance directly into your Vim workflow.
|
29
|
+
|
30
|
+
## Features
|
31
|
+
|
32
|
+
- **Model Agnostic** - Use any MLX-compatible model via a configuration file
|
33
|
+
- **Vim-Native UX** - Intuitive keybindings and split-window responses
|
34
|
+
- **Deep Context** - Understands code context from:
|
35
|
+
- Current file
|
36
|
+
- Visual selections
|
37
|
+
- Referenced files
|
38
|
+
- Project directory structure
|
39
|
+
- **Conversational Coding** - Iterative refinement with follow-up queries
|
40
|
+
- **Air-Gapped Security** - 100% offline - no APIs, no tracking, no data leaks
|
41
|
+
|
42
|
+
## Requirements
|
43
|
+
|
44
|
+
- Apple M-series chip
|
45
|
+
- Python 3.12.8
|
46
|
+
|
47
|
+
## Quick Start
|
48
|
+
|
49
|
+
Install:
|
50
|
+
|
51
|
+
```zsh
|
52
|
+
pip install vimlm
|
53
|
+
```
|
54
|
+
|
55
|
+
Launch:
|
56
|
+
|
57
|
+
```zsh
|
58
|
+
vimlm
|
59
|
+
```
|
60
|
+
|
61
|
+
or
|
62
|
+
|
63
|
+
```zsh
|
64
|
+
vimlm path/to/your_file
|
65
|
+
```
|
66
|
+
|
67
|
+
This launches Vim with the LLM in a split window, ready to assist you.
|
68
|
+
|
69
|
+
## Basic Usage
|
70
|
+
|
71
|
+
1. **From Normal Mode**:
|
72
|
+
- `Ctrl-l`: Adds current line + file to context
|
73
|
+
- Example prompt: "Regex for removing html tags from item.content"
|
74
|
+
|
75
|
+
2. **From Visual Mode**:
|
76
|
+
- Select code → `Ctrl-l`: Adds selected block + current file to context
|
77
|
+
- Example prompt: "Convert this to async/await syntax"
|
78
|
+
|
79
|
+
3. **Inline Commands**:
|
80
|
+
|
81
|
+
!include: Adds specified outside files/folders to context:
|
82
|
+
- `!include` (no path): Current folder
|
83
|
+
- `!include ~/scrap/jph00/hypermedia-applications.summ.md`: Specific folder
|
84
|
+
- `!include ~/wtm/utils.py`: Specific file
|
85
|
+
- Example prompt: "AJAX-ify this app @ ~/scrap/jph00/hypermedia-applications.summ.md"
|
86
|
+
|
87
|
+
!deploy: Extract code blocks to files in user specified dir (current dir if none specified).
|
88
|
+
|
89
|
+
!continue: Lets the LLM resume the generation from where it had halted due to length limits.
|
90
|
+
|
91
|
+
!followup: Continue the thread (equivalent to `Ctrl-j`
|
92
|
+
|
93
|
+
4. **Follow-Up**: After initial response:
|
94
|
+
- `Ctrl-j`: Continue thread
|
95
|
+
- Example follow-up: "In Manifest V3"
|
96
|
+
|
97
|
+
4. **Code Extraction: Press `Ctrl-p` to choose a code block from the response and insert them into:
|
98
|
+
- The last selected visual block (in Normal mode)
|
99
|
+
- The current selection (in Visual mode)
|
100
|
+
- Example workflow:
|
101
|
+
1. Select a block of code in Visual mode.
|
102
|
+
2. Prompt the LLM with `Ctrl-l` (e.g., "Convert this to async/await syntax").
|
103
|
+
3. Once the response is generated, press `Ctrl-p` to replace the selected block with the extracted code.
|
104
|
+
|
105
|
+
### Key Bindings
|
106
|
+
|
107
|
+
| Binding | Mode | Action |
|
108
|
+
|------------|---------------|----------------------------------------|
|
109
|
+
| `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
|
110
|
+
| `Ctrl-j` | Normal | Continue conversation |
|
111
|
+
| `Ctrl-p` | Normal/Visual | Replace the selection with generated code |
|
112
|
+
| `Esc` | Prompt | Cancel input |
|
113
|
+
|
114
|
+
## Advanced Configuration
|
115
|
+
|
116
|
+
VimLM uses a JSON config file with the following configurable parameters:
|
117
|
+
|
118
|
+
```json
|
119
|
+
{
|
120
|
+
"DEBUG": true,
|
121
|
+
"LLM_MODEL": null,
|
122
|
+
"NUM_TOKEN": 2000,
|
123
|
+
"SEP_CMD": "!",
|
124
|
+
"USE_LEADER": false
|
125
|
+
}
|
126
|
+
```
|
127
|
+
|
128
|
+
### Custom Model Setup
|
129
|
+
|
130
|
+
1. **Browse models**: [MLX Community Models on Hugging Face](https://huggingface.co/mlx-community)
|
131
|
+
|
132
|
+
2. **Edit config file**:
|
133
|
+
|
134
|
+
```json
|
135
|
+
{
|
136
|
+
"LLM_MODEL": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
|
137
|
+
}
|
138
|
+
```
|
139
|
+
3. **Save to**:
|
140
|
+
|
141
|
+
```
|
142
|
+
~/vimlm/config.json
|
143
|
+
```
|
144
|
+
|
145
|
+
4. **Restart VimLM**
|
146
|
+
|
147
|
+
|
148
|
+
### Custom Keybinding
|
149
|
+
|
150
|
+
If you prefer using `<Leader>` in place of `<Ctrl>` for the ViMLM key bindings:
|
151
|
+
|
152
|
+
```json
|
153
|
+
{
|
154
|
+
"USER_LEADER": true
|
155
|
+
}
|
156
|
+
```
|
157
|
+
|
158
|
+
|
@@ -0,0 +1,7 @@
|
|
1
|
+
vimlm.py,sha256=_2EKVmfvM-Ikf48XzaxvC0rcLCxiHO4f6_fCslAqyCo,21181
|
2
|
+
vimlm-0.0.6.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
|
3
|
+
vimlm-0.0.6.dist-info/METADATA,sha256=INHaQDN1Id04li0LNWmlkVCIEmnKi9ABpbhAYFGokYE,4233
|
4
|
+
vimlm-0.0.6.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
5
|
+
vimlm-0.0.6.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
|
6
|
+
vimlm-0.0.6.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
|
7
|
+
vimlm-0.0.6.dist-info/RECORD,,
|
vimlm.py
CHANGED
@@ -24,14 +24,18 @@ import argparse
|
|
24
24
|
import tempfile
|
25
25
|
from pathlib import Path
|
26
26
|
from string import Template
|
27
|
+
import re
|
27
28
|
|
28
29
|
DEBUG = True
|
29
|
-
LLM_MODEL = "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
|
30
|
+
LLM_MODEL = None # "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
|
30
31
|
NUM_TOKEN = 2000
|
31
|
-
SEP_CMD = '
|
32
|
+
SEP_CMD = '!'
|
33
|
+
USE_LEADER = False
|
34
|
+
DO_RESET = True
|
35
|
+
SHOW_USER = False
|
32
36
|
VIMLM_DIR = os.path.expanduser("~/vimlm")
|
33
37
|
WATCH_DIR = os.path.expanduser("~/vimlm/watch_dir")
|
34
|
-
CFG_FILE =
|
38
|
+
CFG_FILE = 'cfg.json'
|
35
39
|
LOG_FILE = "log.json"
|
36
40
|
LTM_FILE = "cache.json"
|
37
41
|
OUT_FILE = "response.md"
|
@@ -41,10 +45,12 @@ LOG_PATH = os.path.join(VIMLM_DIR, LOG_FILE)
|
|
41
45
|
LTM_PATH = os.path.join(VIMLM_DIR, LTM_FILE)
|
42
46
|
OUT_PATH = os.path.join(WATCH_DIR, OUT_FILE)
|
43
47
|
|
44
|
-
def toout(s, key=
|
45
|
-
|
48
|
+
def toout(s, key=None, mode=None):
|
49
|
+
key = '' if key is None else ':'+key
|
50
|
+
mode = 'w' if mode is None else mode
|
51
|
+
with open(OUT_PATH, mode, encoding='utf-8') as f:
|
46
52
|
f.write(s)
|
47
|
-
tolog(s, key)
|
53
|
+
tolog(s, key='tovim'+key+':'+mode)
|
48
54
|
|
49
55
|
def tolog(log, key='debug'):
|
50
56
|
if not DEBUG and key == 'debug':
|
@@ -69,20 +75,55 @@ try:
|
|
69
75
|
LLM_MODEL = config.get("LLM_MODEL", LLM_MODEL)
|
70
76
|
NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
|
71
77
|
SEP_CMD = config.get("SEP_CMD", SEP_CMD)
|
78
|
+
USE_LEADER = config.get("USE_LEADER", USE_LEADER)
|
72
79
|
except Exception as e:
|
73
80
|
tolog(str(e))
|
74
81
|
with open(CFG_PATH, 'w') as f:
|
75
|
-
json.dump(dict(DEBUG=DEBUG, LLM_MODEL=LLM_MODEL, NUM_TOKEN=NUM_TOKEN, SEP_CMD=SEP_CMD), f, indent=2)
|
82
|
+
json.dump(dict(DEBUG=DEBUG, LLM_MODEL=LLM_MODEL, NUM_TOKEN=NUM_TOKEN, SEP_CMD=SEP_CMD, USE_LEADER=USE_LEADER), f, indent=2)
|
76
83
|
|
77
84
|
toout('Loading LLM...')
|
78
85
|
if LLM_MODEL is None:
|
79
|
-
from
|
80
|
-
chat = Chat(
|
81
|
-
toout('LLM is ready')
|
86
|
+
from nanollama import Chat
|
87
|
+
chat = Chat(model_path='uncn_llama_32_3b_it')
|
88
|
+
toout(f'LLM is ready')
|
82
89
|
else:
|
83
90
|
from mlx_lm_utils import Chat
|
84
91
|
chat = Chat(model_path=LLM_MODEL)
|
85
|
-
toout(f'{
|
92
|
+
toout(f'{model_path.split('/')[-1]} is ready')
|
93
|
+
|
94
|
+
def deploy(dest=None, src=None, reformat=True):
|
95
|
+
prompt_deploy = 'Reformat the text to ensure each code block is preceded by a filename in **filename.ext** format, with only alphanumeric characters, dots, underscores, or hyphens in the filename. Remove any extraneous characters from filenames.'
|
96
|
+
tolog(f'deploy {dest=} {src=} {reformat=}')
|
97
|
+
if src:
|
98
|
+
chat.reset()
|
99
|
+
with open(src, 'r') as f:
|
100
|
+
prompt_deploy = f.read().strip() + '\n\n---\n\n' + prompt_deploy
|
101
|
+
if reformat:
|
102
|
+
response = chat(prompt_deploy, max_new=NUM_TOKEN, verbose=False, stream=False)['text']
|
103
|
+
toout(response, 'deploy')
|
104
|
+
lines = response.splitlines()
|
105
|
+
else:
|
106
|
+
with open(OUT_PATH, 'r') as f:
|
107
|
+
lines = f.readlines()
|
108
|
+
dest = get_path(dest)
|
109
|
+
os.makedirs(dest, exist_ok=True)
|
110
|
+
current_filename = None
|
111
|
+
code_block = []
|
112
|
+
in_code_block = False
|
113
|
+
for line in lines:
|
114
|
+
line = line.rstrip()
|
115
|
+
if line.startswith("```"):
|
116
|
+
if in_code_block and current_filename:
|
117
|
+
with open(os.path.join(dest, os.path.basename(current_filename)), "w", encoding="utf-8") as code_file:
|
118
|
+
code_file.write("\n".join(code_block) + "\n")
|
119
|
+
code_block = []
|
120
|
+
in_code_block = not in_code_block
|
121
|
+
elif in_code_block:
|
122
|
+
code_block.append(line)
|
123
|
+
else:
|
124
|
+
match = re.match(r"^\*\*(.+?)\*\*$", line)
|
125
|
+
if match:
|
126
|
+
current_filename = re.sub(r"[^a-zA-Z0-9_.-]", "", match.group(1))
|
86
127
|
|
87
128
|
def is_binary(file_path):
|
88
129
|
try:
|
@@ -123,7 +164,7 @@ def split_str(doc, max_len=2000, get_len=len):
|
|
123
164
|
return chunks
|
124
165
|
|
125
166
|
def retrieve(src_path, max_len=2000, get_len=len):
|
126
|
-
src_path =
|
167
|
+
src_path = get_path(src_path)
|
127
168
|
result = {}
|
128
169
|
if not os.path.exists(src_path):
|
129
170
|
tolog(f"The path {src_path} does not exist.", 'retrieve')
|
@@ -134,23 +175,31 @@ def retrieve(src_path, max_len=2000, get_len=len):
|
|
134
175
|
content = f.read()
|
135
176
|
result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
|
136
177
|
except Exception as e:
|
137
|
-
tolog(f'
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
178
|
+
tolog(f'Failed to retrieve({filename}) due to {e}')
|
179
|
+
else:
|
180
|
+
for filename in os.listdir(src_path):
|
181
|
+
try:
|
182
|
+
file_path = os.path.join(src_path, filename)
|
183
|
+
if filename.startswith('.') or is_binary(file_path):
|
184
|
+
continue
|
185
|
+
if os.path.isfile(file_path):
|
186
|
+
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
187
|
+
content = f.read()
|
188
|
+
result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
|
189
|
+
except Exception as e:
|
190
|
+
tolog(f'Failed to retrieve({filename}) due to {e}')
|
143
191
|
continue
|
144
|
-
if os.path.isfile(file_path):
|
145
|
-
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
146
|
-
content = f.read()
|
147
|
-
result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
|
148
|
-
except Exception as e:
|
149
|
-
tolog(f'Skipped {filename} due to {e}', 'retrieve')
|
150
|
-
continue
|
151
192
|
return result
|
152
193
|
|
153
|
-
def
|
194
|
+
def get_path(s):
|
195
|
+
if not s:
|
196
|
+
s = '.'
|
197
|
+
s = s.strip()
|
198
|
+
s = os.path.expanduser(s)
|
199
|
+
s = os.path.abspath(s)
|
200
|
+
return s
|
201
|
+
|
202
|
+
def ingest(src, max_len=NUM_TOKEN):
|
154
203
|
def load_cache(cache_path=LTM_PATH):
|
155
204
|
if os.path.exists(cache_path):
|
156
205
|
with open(cache_path, 'r', encoding='utf-8') as f:
|
@@ -163,53 +212,135 @@ def ingest(src):
|
|
163
212
|
current_data[k] = v
|
164
213
|
with open(cache_path, 'w', encoding='utf-8') as f:
|
165
214
|
json.dump(current_data, f, indent=2)
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
215
|
+
src = get_path(src)
|
216
|
+
tolog(f'ingest {src=}')
|
217
|
+
result = ''
|
218
|
+
src_base = os.path.basename(src)
|
219
|
+
if os.path.isdir(src):
|
220
|
+
listdir = [i for i in os.listdir(src) if not i.startswith('.') and '.' in i]
|
221
|
+
result = '\n- '.join([f'--- {src_base} ---', *listdir]) + '\n\n'
|
222
|
+
elif os.path.isfile(src):
|
223
|
+
result = ''
|
224
|
+
else:
|
225
|
+
tolog(f'Failed to ingest({src})')
|
226
|
+
return ''
|
227
|
+
dict_doc = retrieve(src, max_len=max_len, get_len=chat.get_ntok)
|
228
|
+
toout(f'Ingesting {src}...')
|
229
|
+
format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
|
230
|
+
format_volat = 'Here is a summary of part 1 of **{k}**:\n\n---\n\n{newsum}\n\n---\n\nHere is the next part:\n\n---\n\n'
|
170
231
|
dict_sum = {}
|
171
232
|
cache = load_cache()
|
233
|
+
max_new_accum = int(max_len/len(dict_doc)) if len(dict_doc) > 0 else max_len
|
172
234
|
for k, v in dict_doc.items():
|
173
|
-
v_stamp = v['timestamp']
|
174
|
-
if v_stamp == cache.get(k, {}).get('timestamp'):
|
175
|
-
dict_sum[k] = cache[k]
|
176
|
-
continue
|
177
235
|
list_str = v['list_str']
|
236
|
+
v_stamp = v['timestamp']
|
178
237
|
if len(list_str) == 0:
|
179
238
|
continue
|
180
|
-
if len(list_str)
|
181
|
-
|
239
|
+
if len(list_str) == 1 and chat.get_ntok(list_str[0]) <= max_new_accum:
|
240
|
+
chat_summary = list_str[0]
|
241
|
+
else:
|
242
|
+
k_base = os.path.basename(k)
|
243
|
+
if v_stamp == cache.get(k, {}).get('timestamp'):
|
244
|
+
dict_sum[k] = cache[k]
|
245
|
+
continue
|
246
|
+
max_new_sum = int(max_len/len(list_str))
|
182
247
|
volat = f'**{k}**:\n'
|
183
248
|
accum = ''
|
184
|
-
for s in list_str:
|
249
|
+
for i, s in enumerate(list_str):
|
185
250
|
chat.reset()
|
186
|
-
|
251
|
+
toout(f'\n\nIngesting {k_base} {i+1}/{len(list_str)}...\n\n', mode='a')
|
252
|
+
tolog(f'{s=}') # DEBUG
|
253
|
+
newsum = chat(format_ingest.format(volat=volat, incoming=s.rstrip()), max_new=max_new_sum, verbose=False, stream=OUT_PATH)['text'].rstrip()
|
254
|
+
tolog(f'{k} {i+1}/{len(list_str)}: {newsum=}') # DEBUG
|
187
255
|
accum += newsum + ' ...\n'
|
188
256
|
volat = format_volat.format(k=k, newsum=newsum)
|
189
|
-
|
190
|
-
accum
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
257
|
+
toout(f'\n\nIngesting {k_base}...\n\n', mode='a')
|
258
|
+
tolog(f'{accum=}') # DEBUG
|
259
|
+
if chat.get_ntok(accum) <= max_new_accum:
|
260
|
+
chat_summary = accum.strip()
|
261
|
+
else:
|
262
|
+
chat.reset()
|
263
|
+
chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=max_new_accum, verbose=False, stream=OUT_PATH)['text'].strip()
|
264
|
+
dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary, ntok=chat.get_ntok(chat_summary))
|
195
265
|
dump_cache(dict_sum)
|
196
|
-
|
197
|
-
|
198
|
-
result += f'--- Summary of **{k}** ---\n{v["summary"]}\n\n'
|
266
|
+
for k, v in dict_sum.items():
|
267
|
+
result += f'--- **{os.path.basename(k)}** ---\n{v["summary"].strip()}\n\n'
|
199
268
|
result += '---\n\n'
|
269
|
+
toout(result, 'ingest')
|
200
270
|
return result
|
201
271
|
|
202
|
-
def process_command(data
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
return
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
272
|
+
def process_command(data):
|
273
|
+
if len(data['user']) == 0:
|
274
|
+
response = chat.resume(max_new=NUM_TOKEN, verbose=False, stream=OUT_PATH)
|
275
|
+
toout(response['text'], mode='a')
|
276
|
+
tolog(response)
|
277
|
+
data['user_prompt'] = ''
|
278
|
+
return data
|
279
|
+
if SEP_CMD in data['user']:
|
280
|
+
data['user_prompt'], *cmds = (x.strip() for x in data['user'].split(SEP_CMD))
|
281
|
+
else:
|
282
|
+
data['user_prompt'] = data['user'].strip()
|
283
|
+
cmds = []
|
284
|
+
tolog(f'process_command i {cmds=} {data=}')
|
285
|
+
|
286
|
+
do_reset = False if 'followup' in data else DO_RESET
|
287
|
+
for cmd in cmds:
|
288
|
+
if cmd.startswith('continue'):
|
289
|
+
arg = cmd.removeprefix('continue').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
290
|
+
data['max_new'] = NUM_TOKEN if len(arg) == 0 else int(arg)
|
291
|
+
response = chat.resume(max_new=data['max_new'], verbose=False, stream=OUT_PATH)
|
292
|
+
toout(response['text'], mode='a')
|
293
|
+
tolog(response)
|
294
|
+
do_reset = False
|
295
|
+
break
|
296
|
+
|
297
|
+
if cmd.startswith('reset'):
|
298
|
+
do_reset = True
|
299
|
+
break
|
300
|
+
if cmd.startswith('followup'):
|
301
|
+
do_reset = False
|
302
|
+
break
|
303
|
+
if do_reset:
|
304
|
+
chat.reset()
|
305
|
+
|
306
|
+
full_path = data['tree']
|
307
|
+
data['dir'] = os.path.dirname(full_path)
|
308
|
+
data['file'] = os.path.basename(full_path)
|
309
|
+
data['ext'] = os.path.splitext(full_path)[1][1:]
|
310
|
+
if chat.stop:
|
311
|
+
data['file'] = ''
|
312
|
+
data['context'] = ''
|
313
|
+
if data['tree'] == OUT_PATH:
|
314
|
+
data['dir'] = os.getcwd()
|
315
|
+
data['file'] = ''
|
316
|
+
data['context'] = ''
|
317
|
+
data['ext'] = ''
|
318
|
+
if data['file'] == '.tmp':
|
319
|
+
data['file'] = ''
|
320
|
+
data['ext'] = ''
|
321
|
+
|
322
|
+
if len(cmds) == 1 and len(cmds[0]) == 0:
|
323
|
+
data['include'] = ingest(data['dir'])
|
324
|
+
return data
|
325
|
+
|
326
|
+
data['include'] = ''
|
327
|
+
for cmd in cmds:
|
328
|
+
if cmd.startswith('include'):
|
329
|
+
arg = cmd.removeprefix('include').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
330
|
+
src = data['dir'] if len(arg) == 0 else arg
|
331
|
+
data['include'] += ingest(src)
|
332
|
+
|
333
|
+
for cmd in cmds:
|
334
|
+
if cmd.startswith('deploy'):
|
335
|
+
arg = cmd.removeprefix('deploy').strip('(').strip(')').strip().strip('"').strip("'").strip()
|
336
|
+
if len(data['user_prompt']) == 0:
|
337
|
+
deploy(dest=arg)
|
338
|
+
data['user_prompt'] = ''
|
339
|
+
return data
|
340
|
+
data['user_prompt'] += "\n\nEnsure that each code block is preceded by a filename in **filename.ext** format. The filename should only contain alphanumeric characters, dots, underscores, or hyphens. Ensure that any extraneous characters are removed from the filenames."
|
341
|
+
data['deploy_dest'] = arg
|
342
|
+
return data
|
343
|
+
|
213
344
|
async def monitor_directory():
|
214
345
|
async for changes in awatch(WATCH_DIR):
|
215
346
|
found_files = {os.path.basename(f) for _, f in changes}
|
@@ -225,48 +356,61 @@ async def monitor_directory():
|
|
225
356
|
if 'followup' in os.listdir(WATCH_DIR):
|
226
357
|
os.remove(os.path.join(WATCH_DIR, 'followup'))
|
227
358
|
data['followup'] = True
|
228
|
-
|
359
|
+
if 'quit' in os.listdir(WATCH_DIR):
|
360
|
+
os.remove(os.path.join(WATCH_DIR, 'quit'))
|
361
|
+
data['quit'] = True
|
229
362
|
await process_files(data)
|
230
363
|
|
231
364
|
async def process_files(data):
|
232
|
-
tolog(f'{data=}')
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
str_template
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
str_template += "```{ext}\n{yank}\n```\n\n"
|
365
|
+
tolog(f'process_files i {data=}')
|
366
|
+
str_template = '{include}'
|
367
|
+
data = process_command(data)
|
368
|
+
if len(data['user_prompt']) == 0:
|
369
|
+
return
|
370
|
+
if len(data['file']) > 0:
|
371
|
+
str_template += '**{file}**\n'
|
372
|
+
if len(data['context']) > 0 and data['yank'] != data['context']:
|
373
|
+
str_template += '```{ext}\n{context}\n```\n\n'
|
374
|
+
if len(data['yank']) > 0:
|
375
|
+
if '\n' in data['yank']:
|
376
|
+
str_template += "```{ext}\n{yank}\n```\n\n"
|
377
|
+
else:
|
378
|
+
if data['user'] == 0:
|
379
|
+
str_template += "{yank}"
|
248
380
|
else:
|
249
381
|
str_template += "`{yank}` "
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
str_template = process_command(data, str_template)
|
254
|
-
else:
|
255
|
-
data['user_prompt'] = data['user']
|
256
|
-
chat.reset()
|
257
|
-
tolog(f'{str_template=}') # DEBUG
|
382
|
+
str_template += '{user_prompt}'
|
383
|
+
tolog(f'process_files o {data=}') # DEBUG
|
384
|
+
tolog(f'process_files {str_template=}') # DEBUG
|
258
385
|
prompt = str_template.format(**data)
|
259
386
|
tolog(prompt, 'tollm')
|
260
387
|
toout('')
|
261
|
-
|
262
|
-
|
388
|
+
max_new = data['max_new'] if 'max_new' in data else max(10, NUM_TOKEN - chat.get_ntok(prompt))
|
389
|
+
response = chat(prompt, max_new=max_new, verbose=False, stream=OUT_PATH)
|
390
|
+
if SHOW_USER:
|
391
|
+
toout(response['text'])
|
392
|
+
else:
|
393
|
+
toout(response['text'])
|
263
394
|
tolog(response['benchmark'])
|
395
|
+
if 'deploy_dest' in data:
|
396
|
+
deploy(dest=data['deploy_dest'], reformat=False)
|
397
|
+
|
398
|
+
mapl, mapj, mapp = ('<Leader>l', '<Leader>j', '<Leader>p') if USE_LEADER else ('<C-l>', '<C-j>', '<C-p>')
|
264
399
|
|
265
400
|
VIMLMSCRIPT = Template(r"""
|
401
|
+
let s:register_names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u']
|
266
402
|
let s:watched_dir = expand('$WATCH_DIR')
|
267
403
|
|
268
404
|
function! Monitor()
|
269
|
-
|
405
|
+
if exists('s:monitor_timer')
|
406
|
+
call timer_stop(s:monitor_timer)
|
407
|
+
unlet s:monitor_timer
|
408
|
+
endif
|
409
|
+
let response_path = s:watched_dir . '/response.md'
|
410
|
+
let bufnum = bufnr(response_path)
|
411
|
+
if bufnum != -1
|
412
|
+
execute 'bwipeout ' . bufnum
|
413
|
+
endif
|
270
414
|
let response_path = s:watched_dir . '/response.md'
|
271
415
|
rightbelow vsplit | execute 'view ' . response_path
|
272
416
|
setlocal autoread
|
@@ -288,41 +432,17 @@ function! CheckForUpdates(timer)
|
|
288
432
|
endfunction
|
289
433
|
|
290
434
|
function! s:CustomInput(prompt) abort
|
291
|
-
|
292
|
-
let
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
let c = getchar()
|
299
|
-
if type(c) == v:t_number
|
300
|
-
if c == 13 " Enter
|
301
|
-
break
|
302
|
-
elseif c == 27 " Esc
|
303
|
-
let aborted = v:true
|
304
|
-
break
|
305
|
-
elseif c == 8 || c == 127 " Backspace
|
306
|
-
if len(input) > 0
|
307
|
-
let input = input[:-2]
|
308
|
-
echon "\r" . clean_prompt . input . ' '
|
309
|
-
execute "normal! \<BS>"
|
310
|
-
endif
|
311
|
-
else
|
312
|
-
let char = nr2char(c)
|
313
|
-
let input .= char
|
314
|
-
echon char
|
315
|
-
endif
|
316
|
-
endif
|
317
|
-
endwhile
|
318
|
-
let input_length = aborted ? 0 : len(input)
|
319
|
-
let clear_length = len(clean_prompt) + input_length
|
320
|
-
echon "\r" . repeat(' ', clear_length) . "\r"
|
321
|
-
return aborted ? v:null : input
|
435
|
+
call inputsave()
|
436
|
+
let input = input(a:prompt)
|
437
|
+
call inputrestore()
|
438
|
+
if empty(input)
|
439
|
+
return v:null
|
440
|
+
endif
|
441
|
+
return input
|
322
442
|
endfunction
|
323
443
|
|
324
|
-
function! SaveUserInput()
|
325
|
-
let user_input = s:CustomInput(
|
444
|
+
function! SaveUserInput(prompt)
|
445
|
+
let user_input = s:CustomInput(a:prompt)
|
326
446
|
if user_input is v:null
|
327
447
|
echo "Input aborted"
|
328
448
|
return
|
@@ -334,37 +454,105 @@ function! SaveUserInput()
|
|
334
454
|
call writefile([current_file], tree_file, 'w')
|
335
455
|
endfunction
|
336
456
|
|
337
|
-
function!
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
call writefile([user_input], user_file, 'w')
|
345
|
-
let current_file = expand('%:p')
|
346
|
-
let tree_file = s:watched_dir . '/tree'
|
347
|
-
call writefile([current_file], tree_file, 'w')
|
457
|
+
function! VisualPrompt()
|
458
|
+
silent! execute "normal! \<ESC>"
|
459
|
+
silent execute "'<,'>w! " . s:watched_dir . "/yank"
|
460
|
+
silent execute "w! " . s:watched_dir . "/context"
|
461
|
+
" silent! execute "normal! `<V`>"
|
462
|
+
" silent! execute "normal! \<ESC>"
|
463
|
+
call SaveUserInput('VimLM: ')
|
348
464
|
endfunction
|
349
465
|
|
350
|
-
function!
|
466
|
+
function! NormalPrompt()
|
467
|
+
silent! execute "normal! V\<ESC>"
|
468
|
+
silent execute "'<,'>w! " . s:watched_dir . "/yank"
|
469
|
+
silent execute "w! " . s:watched_dir . "/context"
|
470
|
+
" silent! execute "normal! \<ESC>"
|
471
|
+
call SaveUserInput('VimLM: ')
|
472
|
+
endfunction
|
473
|
+
|
474
|
+
function! FollowUpPrompt()
|
351
475
|
call writefile([], s:watched_dir . '/yank', 'w')
|
352
476
|
call writefile([], s:watched_dir . '/context', 'w')
|
353
477
|
call writefile([], s:watched_dir . '/followup', 'w')
|
354
|
-
call SaveUserInput()
|
478
|
+
call SaveUserInput('... ')
|
355
479
|
endfunction
|
356
480
|
|
357
|
-
|
358
|
-
|
359
|
-
|
481
|
+
function! ExtractAllCodeBlocks()
|
482
|
+
let filepath = s:watched_dir . '/response.md'
|
483
|
+
if !filereadable(filepath)
|
484
|
+
echoerr "File not found: " . filepath
|
485
|
+
return
|
486
|
+
endif
|
487
|
+
let lines = readfile(filepath)
|
488
|
+
let in_code_block = 0
|
489
|
+
let code_blocks = []
|
490
|
+
let current_block = []
|
491
|
+
for line in lines
|
492
|
+
if line =~ '^```'
|
493
|
+
if in_code_block
|
494
|
+
call add(code_blocks, current_block)
|
495
|
+
let current_block = []
|
496
|
+
let in_code_block = 0
|
497
|
+
else
|
498
|
+
let in_code_block = 1
|
499
|
+
endif
|
500
|
+
elseif in_code_block
|
501
|
+
call add(current_block, line)
|
502
|
+
endif
|
503
|
+
endfor
|
504
|
+
if in_code_block
|
505
|
+
call add(code_blocks, current_block)
|
506
|
+
endif
|
507
|
+
for idx in range(len(code_blocks))
|
508
|
+
if idx >= len(s:register_names)
|
509
|
+
break
|
510
|
+
endif
|
511
|
+
let code_block_text = join(code_blocks[idx], "\n")
|
512
|
+
let register_name = s:register_names[idx]
|
513
|
+
call setreg(register_name, code_block_text, 'v')
|
514
|
+
endfor
|
515
|
+
return len(code_blocks)
|
516
|
+
endfunction
|
360
517
|
|
518
|
+
function! PasteIntoLastVisualSelection()
|
519
|
+
let num_blocks = ExtractAllCodeBlocks()
|
520
|
+
echo "Extracted " . num_blocks . " blocks into registers @a-@" . s:register_names[num_blocks - 1] . ". Enter register name: "
|
521
|
+
let register_name = nr2char(getchar())
|
522
|
+
if register_name !~ '^[a-z]$'
|
523
|
+
echoerr "Invalid register name. Please enter a single lowercase letter (e.g., a, b, c)."
|
524
|
+
return
|
525
|
+
endif
|
526
|
+
let register_content = getreg(register_name)
|
527
|
+
if register_content == ''
|
528
|
+
echoerr "Register @" . register_name . " is empty."
|
529
|
+
return
|
530
|
+
endif
|
531
|
+
let current_mode = mode()
|
532
|
+
if current_mode == 'v' || current_mode == 'V' || current_mode == ''
|
533
|
+
execute 'normal! "' . register_name . 'p'
|
534
|
+
else
|
535
|
+
normal! gv
|
536
|
+
execute 'normal! "' . register_name . 'p'
|
537
|
+
endif
|
538
|
+
endfunction
|
539
|
+
|
540
|
+
nnoremap $mapp :call PasteIntoLastVisualSelection()<CR>
|
541
|
+
vnoremap $mapp <Cmd>:call PasteIntoLastVisualSelection()<CR>
|
542
|
+
vnoremap $mapl <Cmd>:call VisualPrompt()<CR>
|
543
|
+
nnoremap $mapl :call NormalPrompt()<CR>
|
544
|
+
nnoremap $mapj :call FollowUpPrompt()<CR>
|
361
545
|
call Monitor()
|
362
|
-
""").safe_substitute(dict(WATCH_DIR=WATCH_DIR))
|
546
|
+
""").safe_substitute(dict(WATCH_DIR=WATCH_DIR, mapl=mapl, mapj=mapj, mapp=mapp))
|
363
547
|
|
364
548
|
async def main():
|
365
549
|
parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
|
550
|
+
parser.add_argument('--test', action='store_true')
|
366
551
|
parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
|
367
552
|
args = parser.parse_args()
|
553
|
+
if args.test:
|
554
|
+
test()
|
555
|
+
return
|
368
556
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
|
369
557
|
f.write(VIMLMSCRIPT)
|
370
558
|
vim_script = f.name
|
@@ -373,14 +561,17 @@ async def main():
|
|
373
561
|
vim_command.extend(args.vim_args)
|
374
562
|
else:
|
375
563
|
vim_command.append('.tmp')
|
376
|
-
monitor_task = asyncio.create_task(monitor_directory())
|
377
|
-
vim_process = await asyncio.create_subprocess_exec(*vim_command)
|
378
|
-
await vim_process.wait()
|
379
|
-
monitor_task.cancel()
|
380
564
|
try:
|
381
|
-
|
382
|
-
|
383
|
-
|
565
|
+
monitor_task = asyncio.create_task(monitor_directory())
|
566
|
+
vim_process = await asyncio.create_subprocess_exec(*vim_command)
|
567
|
+
await vim_process.wait()
|
568
|
+
finally:
|
569
|
+
monitor_task.cancel()
|
570
|
+
try:
|
571
|
+
await monitor_task
|
572
|
+
except asyncio.CancelledError:
|
573
|
+
pass
|
574
|
+
os.remove(vim_script)
|
384
575
|
|
385
576
|
def run():
|
386
577
|
asyncio.run(main())
|
vimlm-0.0.5.dist-info/METADATA
DELETED
@@ -1,108 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.2
|
2
|
-
Name: vimlm
|
3
|
-
Version: 0.0.5
|
4
|
-
Summary: VimLM - LLM-powered Vim assistant
|
5
|
-
Home-page: https://github.com/JosefAlbers/vimlm
|
6
|
-
Author: Josef Albers
|
7
|
-
Author-email: albersj66@gmail.com
|
8
|
-
Requires-Python: >=3.12.8
|
9
|
-
Description-Content-Type: text/markdown
|
10
|
-
License-File: LICENSE
|
11
|
-
Requires-Dist: nanollama==0.0.5b0
|
12
|
-
Requires-Dist: mlx_lm_utils==0.0.1a0
|
13
|
-
Requires-Dist: watchfiles==1.0.4
|
14
|
-
Dynamic: author
|
15
|
-
Dynamic: author-email
|
16
|
-
Dynamic: description
|
17
|
-
Dynamic: description-content-type
|
18
|
-
Dynamic: home-page
|
19
|
-
Dynamic: requires-dist
|
20
|
-
Dynamic: requires-python
|
21
|
-
Dynamic: summary
|
22
|
-
|
23
|
-
# VimLM - Local LLM-Powered Coding Assistant for Vim
|
24
|
-
|
25
|
-

|
26
|
-
|
27
|
-
An LLM-powered coding companion for Vim, inspired by GitHub Copilot/Cursor. Integrates contextual code understanding, summarization, and AI assistance directly into your Vim workflow.
|
28
|
-
|
29
|
-
## Features
|
30
|
-
|
31
|
-
- **Model Agnostic** - Use any MLX-compatible model via config file
|
32
|
-
- **Vim-Native UX** - Ctrl-l/Ctrl-r keybindings and split-window responses
|
33
|
-
- **Deep Context** - Understands code context from:
|
34
|
-
- Current file
|
35
|
-
- Visual selections
|
36
|
-
- Referenced files (`!@#$` syntax)
|
37
|
-
- Project directory structure
|
38
|
-
- **Conversational Coding** - Iterative refinement with follow-up queries
|
39
|
-
- **Air-Gapped Security** - 100% offline - no APIs, no tracking, no data leaks
|
40
|
-
|
41
|
-
## Requirements
|
42
|
-
|
43
|
-
- Apple M-series chip (M1/M2/M3/M4)
|
44
|
-
- Python 3.12.8
|
45
|
-
|
46
|
-
## Installation
|
47
|
-
|
48
|
-
```zsh
|
49
|
-
pip install vimlm
|
50
|
-
```
|
51
|
-
|
52
|
-
## Quick Start
|
53
|
-
|
54
|
-
1. Launch with default model (DeepSeek-R1-Distill-Qwen-7B-4bit):
|
55
|
-
|
56
|
-
```zsh
|
57
|
-
vimlm your_file.js
|
58
|
-
```
|
59
|
-
|
60
|
-
2. **From Normal Mode**:
|
61
|
-
- `Ctrl-l`: Send current line + file context
|
62
|
-
- Example prompt: "Regex for removing html tags in item.content"
|
63
|
-
|
64
|
-
3. **From Visual Mode**:
|
65
|
-
- Select code → `Ctrl-l`: Send selection + file context
|
66
|
-
- Example prompt: "Convert this to async/await syntax"
|
67
|
-
|
68
|
-
4. **Add Context**: Use `!@#$` to include additional files/folders:
|
69
|
-
- `!@#$` (no path): Current folder
|
70
|
-
- `!@#$ ~/scrap/jph00/hypermedia-applications.summ.md`: Specific folder
|
71
|
-
- `!@#$ ~/wtm/utils.py`: Specific file
|
72
|
-
- Example prompt: "AJAX-ify this app !@#$ ~/scrap/jph00/hypermedia-applications.summ.md"
|
73
|
-
|
74
|
-
5. **Follow-Up**: After initial response:
|
75
|
-
- `Ctrl-r`: Continue thread
|
76
|
-
- Example follow-up: "In Manifest V3"
|
77
|
-
|
78
|
-
## Advanced Configuration
|
79
|
-
|
80
|
-
### Custom Model Setup
|
81
|
-
|
82
|
-
1. **Browse models**: [MLX Community Models on Hugging Face](https://huggingface.co/mlx-community)
|
83
|
-
|
84
|
-
2. **Edit config file**:
|
85
|
-
|
86
|
-
```json
|
87
|
-
{
|
88
|
-
"LLM_MODEL": "/path/to/your/mlx_model"
|
89
|
-
}
|
90
|
-
```
|
91
|
-
|
92
|
-
3. **Save to**:
|
93
|
-
|
94
|
-
```
|
95
|
-
~/vimlm/cfg.json
|
96
|
-
```
|
97
|
-
|
98
|
-
4. **Restart VimLM**
|
99
|
-
|
100
|
-
## Key Bindings
|
101
|
-
|
102
|
-
| Binding | Mode | Action |
|
103
|
-
|------------|---------------|----------------------------------------|
|
104
|
-
| `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
|
105
|
-
| `Ctrl-r` | Normal | Continue conversation |
|
106
|
-
| `Esc` | Prompt | Cancel input |
|
107
|
-
|
108
|
-
|
vimlm-0.0.5.dist-info/RECORD
DELETED
@@ -1,7 +0,0 @@
|
|
1
|
-
vimlm.py,sha256=6bXIkDHF0fLTT_NRCbDLcP5CDTu5e3uacuMdzyGUrhI,13643
|
2
|
-
vimlm-0.0.5.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
|
3
|
-
vimlm-0.0.5.dist-info/METADATA,sha256=d7xZr9bBIJlYuPVNnFpa8w3-1XSFVMiWwikQVt5sLBw,3009
|
4
|
-
vimlm-0.0.5.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
5
|
-
vimlm-0.0.5.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
|
6
|
-
vimlm-0.0.5.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
|
7
|
-
vimlm-0.0.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|