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.
@@ -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
+ ![vimlm](https://raw.githubusercontent.com/JosefAlbers/VimLM/main/assets/captioned_vimlm.gif)
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 = "cfg.json"
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='tovim'):
45
- with open(OUT_PATH, 'w', encoding='utf-8') as f:
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 nanollama32 import Chat
80
- chat = Chat(variant='uncn_llama_32_3b_it')
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'{LLM_MODEL.split('/')[-1]} is ready')
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 = os.path.expanduser(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'Skipped {filename} due to {e}', 'retrieve')
138
- return result
139
- for filename in os.listdir(src_path):
140
- try:
141
- file_path = os.path.join(src_path, filename)
142
- if filename.startswith('.') or is_binary(file_path):
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 ingest(src):
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
- toout('Ingesting...')
167
- format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
168
- 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'
169
- dict_doc = retrieve(src, get_len=chat.get_ntok)
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) > 1:
181
- max_new_sum = int(NUM_TOKEN / len(list_str) / 2)
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
- newsum = chat(format_ingest.format(volat=volat, incoming=s.strip()), max_new=max_new_sum, verbose=False, stream=None)['text']
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
- else:
190
- accum = list_str[0]
191
- chat.reset()
192
- toout('')
193
- chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=int(NUM_TOKEN/4), verbose=False, stream=OUT_PATH)['text']
194
- dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary)
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
- result = ''
197
- for (k,v) in dict_sum.items():
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, str_template):
203
- try:
204
- if len(data['user_command'].strip()) > 0:
205
- src = data['user_command']
206
- else:
207
- src = data['dir']
208
- return ingest(src) + str_template
209
- except Exception as e:
210
- tolog(f'Failed to ingest due to {e}', 'process_command')
211
- return str_template
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
- str_template = '{user}'
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
- if 'followup' in data:
234
- str_template = "{user}"
235
- else:
236
- full_path = data['tree']
237
- data['dir'] = os.path.dirname(full_path)
238
- data['file'] = os.path.basename(full_path)
239
- data['ext'] = os.path.splitext(full_path)[1][1:]
240
- str_template = ''
241
- if len(data['file']) > 0 and data['file'] != '.tmp':
242
- str_template += '**{file}**\n'
243
- if len(data['context']) > 0:
244
- str_template += '```{ext}\n{context}\n```\n\n'
245
- if len(data['yank']) > 0:
246
- if '\n' in data['yank']:
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
- str_template += '{user_prompt}'
251
- if SEP_CMD in data['user']:
252
- data['user_prompt'], data['user_command'] = (x.strip() for x in data['user'].split(SEP_CMD))
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
- response = chat(prompt, max_new=NUM_TOKEN - chat.get_ntok(prompt), verbose=False, stream=OUT_PATH)
262
- toout(response['text'])
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
- write
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
- let input = ''
292
- let clean_prompt = a:prompt
293
- let aborted = v:false
294
- echohl Question
295
- echon clean_prompt
296
- echohl None
297
- while 1
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('Ask LLM: ')
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! SaveUserInput()
338
- let user_input = s:CustomInput('Ask LLM: ')
339
- if user_input is v:null
340
- echo "Input aborted"
341
- return
342
- endif
343
- let user_file = s:watched_dir . '/user'
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! FollowUpInput()
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
- vnoremap <C-l> :w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
358
- nnoremap <C-l> V:w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
359
- nnoremap <C-r> :call FollowUpInput()<CR>
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
- await monitor_task
382
- except asyncio.CancelledError:
383
- pass
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())
@@ -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
- ![vimlm](https://github.com/user-attachments/assets/67a97048-48df-40c1-9109-53c759e85d96)
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
-
@@ -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