vimlm 0.0.4__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
@@ -17,7 +17,6 @@ import subprocess
17
17
  import json
18
18
  import os
19
19
  from watchfiles import awatch
20
- from nanollama32 import Chat
21
20
  import shutil
22
21
  import time
23
22
  from itertools import accumulate
@@ -25,13 +24,18 @@ import argparse
25
24
  import tempfile
26
25
  from pathlib import Path
27
26
  from string import Template
27
+ import re
28
28
 
29
29
  DEBUG = True
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,40 +45,85 @@ 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
- if os.path.exists(WATCH_DIR):
45
- shutil.rmtree(WATCH_DIR)
46
- os.makedirs(WATCH_DIR)
47
-
48
- try:
49
- with open(CFG_PATH, "r") as f:
50
- cfg = cfg.load(f)
51
- DEBUG = config.get("DEBUG", DEBUG)
52
- NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
53
- SEP_CMD = config.get("SEP_CMD", SEP_CMD)
54
- except:
55
- with open(CFG_PATH, 'w') as f:
56
- json.dump(dict(DEBUG=DEBUG, NUM_TOKEN=NUM_TOKEN, SEP_CMD=SEP_CMD), f, indent=2)
57
-
58
- def toout(s, key='tovim'):
59
- 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:
60
52
  f.write(s)
61
- tolog(s, key)
53
+ tolog(s, key='tovim'+key+':'+mode)
62
54
 
63
55
  def tolog(log, key='debug'):
64
56
  if not DEBUG and key == 'debug':
65
57
  return
66
- if os.path.exists(LOG_PATH):
58
+ try:
67
59
  with open(LOG_PATH, "r", encoding="utf-8") as log_f:
68
60
  logs = json.load(log_f)
69
- else:
61
+ except:
70
62
  logs = []
71
63
  logs.append(dict(key=key, log=log, timestamp=time.ctime()))
72
64
  with open(LOG_PATH, "w", encoding="utf-8") as log_f:
73
65
  json.dump(logs, log_f, indent=2)
74
66
 
67
+ if os.path.exists(WATCH_DIR):
68
+ shutil.rmtree(WATCH_DIR)
69
+ os.makedirs(WATCH_DIR)
70
+
71
+ try:
72
+ with open(CFG_PATH, "r") as f:
73
+ config = json.load(f)
74
+ DEBUG = config.get("DEBUG", DEBUG)
75
+ LLM_MODEL = config.get("LLM_MODEL", LLM_MODEL)
76
+ NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
77
+ SEP_CMD = config.get("SEP_CMD", SEP_CMD)
78
+ USE_LEADER = config.get("USE_LEADER", USE_LEADER)
79
+ except Exception as e:
80
+ tolog(str(e))
81
+ with open(CFG_PATH, 'w') as f:
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)
83
+
75
84
  toout('Loading LLM...')
76
- chat = Chat(variant='uncn_llama_32_3b_it')
77
- toout('LLM is ready')
85
+ if LLM_MODEL is None:
86
+ from nanollama import Chat
87
+ chat = Chat(model_path='uncn_llama_32_3b_it')
88
+ toout(f'LLM is ready')
89
+ else:
90
+ from mlx_lm_utils import Chat
91
+ chat = Chat(model_path=LLM_MODEL)
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))
78
127
 
79
128
  def is_binary(file_path):
80
129
  try:
@@ -115,7 +164,7 @@ def split_str(doc, max_len=2000, get_len=len):
115
164
  return chunks
116
165
 
117
166
  def retrieve(src_path, max_len=2000, get_len=len):
118
- src_path = os.path.expanduser(src_path)
167
+ src_path = get_path(src_path)
119
168
  result = {}
120
169
  if not os.path.exists(src_path):
121
170
  tolog(f"The path {src_path} does not exist.", 'retrieve')
@@ -126,26 +175,31 @@ def retrieve(src_path, max_len=2000, get_len=len):
126
175
  content = f.read()
127
176
  result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
128
177
  except Exception as e:
129
- tolog(f'Skipped {filename} due to {e}', 'retrieve')
130
- return result
131
- for filename in os.listdir(src_path):
132
- try:
133
- file_path = os.path.join(src_path, filename)
134
- 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}')
135
191
  continue
136
- if os.path.isfile(file_path):
137
- with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
138
- content = f.read()
139
- result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
140
- except Exception as e:
141
- tolog(f'Skipped {filename} due to {e}', 'retrieve')
142
- continue
143
192
  return result
144
193
 
145
- def get_ntok(s):
146
- return len(chat.tokenizer.encode(s)[0])
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
147
201
 
148
- def ingest(src):
202
+ def ingest(src, max_len=NUM_TOKEN):
149
203
  def load_cache(cache_path=LTM_PATH):
150
204
  if os.path.exists(cache_path):
151
205
  with open(cache_path, 'r', encoding='utf-8') as f:
@@ -158,53 +212,135 @@ def ingest(src):
158
212
  current_data[k] = v
159
213
  with open(cache_path, 'w', encoding='utf-8') as f:
160
214
  json.dump(current_data, f, indent=2)
161
- toout('Ingesting...')
162
- format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
163
- 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'
164
- dict_doc = retrieve(src, get_len=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'
165
231
  dict_sum = {}
166
232
  cache = load_cache()
233
+ max_new_accum = int(max_len/len(dict_doc)) if len(dict_doc) > 0 else max_len
167
234
  for k, v in dict_doc.items():
168
- v_stamp = v['timestamp']
169
- if v_stamp == cache.get(k, {}).get('timestamp'):
170
- dict_sum[k] = cache[k]
171
- continue
172
235
  list_str = v['list_str']
236
+ v_stamp = v['timestamp']
173
237
  if len(list_str) == 0:
174
238
  continue
175
- if len(list_str) > 1:
176
- 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))
177
247
  volat = f'**{k}**:\n'
178
248
  accum = ''
179
- for s in list_str:
249
+ for i, s in enumerate(list_str):
180
250
  chat.reset()
181
- newsum = chat(format_ingest.format(volat=volat, incoming=s.strip()), max_new=max_new_sum, verbose=False, stream=None)[0][:-10].strip()
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
182
255
  accum += newsum + ' ...\n'
183
256
  volat = format_volat.format(k=k, newsum=newsum)
184
- else:
185
- accum = list_str[0]
186
- chat.reset()
187
- toout('')
188
- chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=int(NUM_TOKEN/4), verbose=False, stream=OUT_PATH)[0][:-10].strip()
189
- 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))
190
265
  dump_cache(dict_sum)
191
- result = ''
192
- for (k,v) in dict_sum.items():
193
- 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'
194
268
  result += '---\n\n'
269
+ toout(result, 'ingest')
195
270
  return result
196
271
 
197
- def process_command(data, str_template):
198
- try:
199
- if len(data['user_command'].strip()) > 0:
200
- src = data['user_command']
201
- else:
202
- src = data['dir']
203
- return ingest(src) + str_template
204
- except Exception as e:
205
- tolog(f'Failed to ingest due to {e}', 'process_command')
206
- return str_template
207
-
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
+
208
344
  async def monitor_directory():
209
345
  async for changes in awatch(WATCH_DIR):
210
346
  found_files = {os.path.basename(f) for _, f in changes}
@@ -220,48 +356,61 @@ async def monitor_directory():
220
356
  if 'followup' in os.listdir(WATCH_DIR):
221
357
  os.remove(os.path.join(WATCH_DIR, 'followup'))
222
358
  data['followup'] = True
223
- str_template = '{user}'
359
+ if 'quit' in os.listdir(WATCH_DIR):
360
+ os.remove(os.path.join(WATCH_DIR, 'quit'))
361
+ data['quit'] = True
224
362
  await process_files(data)
225
363
 
226
364
  async def process_files(data):
227
- tolog(f'{data=}')
228
- if 'followup' in data:
229
- str_template = "{user}"
230
- else:
231
- full_path = data['tree']
232
- data['dir'] = os.path.dirname(full_path)
233
- data['file'] = os.path.basename(full_path)
234
- data['ext'] = os.path.splitext(full_path)[1][1:]
235
- str_template = ''
236
- if len(data['file']) > 0 and data['file'] != '.tmp':
237
- str_template += '**{file}**\n'
238
- if len(data['context']) > 0:
239
- str_template += '```{ext}\n{context}\n```\n\n'
240
- if len(data['yank']) > 0:
241
- if '\n' in data['yank']:
242
- 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}"
243
380
  else:
244
381
  str_template += "`{yank}` "
245
- str_template += '{user_prompt}'
246
- if SEP_CMD in data['user']:
247
- data['user_prompt'], data['user_command'] = (x.strip() for x in data['user'].split(SEP_CMD))
248
- str_template = process_command(data, str_template)
249
- else:
250
- data['user_prompt'] = data['user']
251
- chat.reset()
252
- 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
253
385
  prompt = str_template.format(**data)
254
386
  tolog(prompt, 'tollm')
255
387
  toout('')
256
- response = chat(prompt, max_new=NUM_TOKEN - get_ntok(prompt), verbose=False, stream=OUT_PATH)
257
- toout(response[0][:-10].strip())
258
- tolog(response[-1], 'tps')
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'])
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>')
259
399
 
260
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']
261
402
  let s:watched_dir = expand('$WATCH_DIR')
262
403
 
263
404
  function! Monitor()
264
- 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
265
414
  let response_path = s:watched_dir . '/response.md'
266
415
  rightbelow vsplit | execute 'view ' . response_path
267
416
  setlocal autoread
@@ -283,41 +432,17 @@ function! CheckForUpdates(timer)
283
432
  endfunction
284
433
 
285
434
  function! s:CustomInput(prompt) abort
286
- let input = ''
287
- let clean_prompt = a:prompt
288
- let aborted = v:false
289
- echohl Question
290
- echon clean_prompt
291
- echohl None
292
- while 1
293
- let c = getchar()
294
- if type(c) == v:t_number
295
- if c == 13 " Enter
296
- break
297
- elseif c == 27 " Esc
298
- let aborted = v:true
299
- break
300
- elseif c == 8 || c == 127 " Backspace
301
- if len(input) > 0
302
- let input = input[:-2]
303
- echon "\r" . clean_prompt . input . ' '
304
- execute "normal! \<BS>"
305
- endif
306
- else
307
- let char = nr2char(c)
308
- let input .= char
309
- echon char
310
- endif
311
- endif
312
- endwhile
313
- let input_length = aborted ? 0 : len(input)
314
- let clear_length = len(clean_prompt) + input_length
315
- echon "\r" . repeat(' ', clear_length) . "\r"
316
- 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
317
442
  endfunction
318
443
 
319
- function! SaveUserInput()
320
- let user_input = s:CustomInput('Ask LLM: ')
444
+ function! SaveUserInput(prompt)
445
+ let user_input = s:CustomInput(a:prompt)
321
446
  if user_input is v:null
322
447
  echo "Input aborted"
323
448
  return
@@ -329,37 +454,105 @@ function! SaveUserInput()
329
454
  call writefile([current_file], tree_file, 'w')
330
455
  endfunction
331
456
 
332
- function! SaveUserInput()
333
- let user_input = s:CustomInput('Ask LLM: ')
334
- if user_input is v:null
335
- echo "Input aborted"
336
- return
337
- endif
338
- let user_file = s:watched_dir . '/user'
339
- call writefile([user_input], user_file, 'w')
340
- let current_file = expand('%:p')
341
- let tree_file = s:watched_dir . '/tree'
342
- 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: ')
464
+ endfunction
465
+
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: ')
343
472
  endfunction
344
473
 
345
- function! FollowUpInput()
474
+ function! FollowUpPrompt()
346
475
  call writefile([], s:watched_dir . '/yank', 'w')
347
476
  call writefile([], s:watched_dir . '/context', 'w')
348
477
  call writefile([], s:watched_dir . '/followup', 'w')
349
- call SaveUserInput()
478
+ call SaveUserInput('... ')
479
+ endfunction
480
+
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)
350
516
  endfunction
351
517
 
352
- vnoremap <C-l> :w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
353
- nnoremap <C-l> V:w! $WATCH_DIR/yank<CR>:w! $WATCH_DIR/context<CR>:call SaveUserInput()<CR>
354
- nnoremap <C-r> :call FollowUpInput()<CR>
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
355
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>
356
545
  call Monitor()
357
- """).safe_substitute(dict(WATCH_DIR=WATCH_DIR))
546
+ """).safe_substitute(dict(WATCH_DIR=WATCH_DIR, mapl=mapl, mapj=mapj, mapp=mapp))
358
547
 
359
548
  async def main():
360
549
  parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
550
+ parser.add_argument('--test', action='store_true')
361
551
  parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
362
552
  args = parser.parse_args()
553
+ if args.test:
554
+ test()
555
+ return
363
556
  with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
364
557
  f.write(VIMLMSCRIPT)
365
558
  vim_script = f.name
@@ -368,14 +561,17 @@ async def main():
368
561
  vim_command.extend(args.vim_args)
369
562
  else:
370
563
  vim_command.append('.tmp')
371
- monitor_task = asyncio.create_task(monitor_directory())
372
- vim_process = await asyncio.create_subprocess_exec(*vim_command)
373
- await vim_process.wait()
374
- monitor_task.cancel()
375
564
  try:
376
- await monitor_task
377
- except asyncio.CancelledError:
378
- 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)
379
575
 
380
576
  def run():
381
577
  asyncio.run(main())
@@ -1,82 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: vimlm
3
- Version: 0.0.4
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.3b0
12
- Requires-Dist: watchfiles==1.0.4
13
- Dynamic: author
14
- Dynamic: author-email
15
- Dynamic: description
16
- Dynamic: description-content-type
17
- Dynamic: home-page
18
- Dynamic: requires-dist
19
- Dynamic: requires-python
20
- Dynamic: summary
21
-
22
- # VimLM - Vim Language Model Assistant for privacy-conscious developers
23
-
24
- ![vimlm](https://github.com/user-attachments/assets/67a97048-48df-40c1-9109-53c759e85d96)
25
-
26
- 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.
27
-
28
- ## Features
29
-
30
- - **Real-Time Interaction with local LLMs**: Runs **fully offline** with local models (default: uncensored Llama-3.2-3B).
31
- - **Integration with Vim's Native Workflow**: Simple Vim keybindings for quick access and split-window interface for non-intrusive responses.
32
- - **Context-Awareness Beyond Single Files**: Inline support for external documents and project files for richer, more informed responses.
33
- - **Conversational AI Assistance**: Goes beyond simple code suggestions-explains reasoning, provides alternative solutions, and allows interactive follow-ups.
34
- - **Versatile Use Cases**: Not just for coding-use it for documentation lookup, general Q&A, or even casual (uncensored) conversations.
35
-
36
- ## Installation
37
-
38
- ```zsh
39
- pip install vimlm
40
- ```
41
-
42
- ## Usage
43
-
44
- 1. Start Vim with VimLM:
45
-
46
- ```zsh
47
- vimlm
48
- ```
49
-
50
- or
51
-
52
- ```zsh
53
- vimlm your_file.js
54
- ```
55
-
56
- 2. **From Normal Mode**:
57
- - `Ctrl-l`: Send current line + file context
58
- - Example prompt: "Regex for removing html tags in item.content"
59
-
60
- 3. **From Visual Mode**:
61
- - Select text → `Ctrl-l`: Send selection + file context
62
- - Example prompt: "Convert this to async/await syntax"
63
-
64
- 4. **Add Context**: Use `!@#$` to include additional files/folders:
65
- - `!@#$` (no path): Current folder
66
- - `!@#$ ~/scrap/jph00/hypermedia-applications.summ.md`: Specific folder
67
- - `!@#$ ~/wtm/utils.py`: Specific file
68
- - Example prompt: "AJAX-ify this app !@#$ ~/scrap/jph00/hypermedia-applications.summ.md"
69
-
70
- 5. **Follow-Up**: After initial response:
71
- - `Ctrl-r`: Continue thread
72
- - Example follow-up: "In Manifest V3"
73
-
74
- ## Key Bindings
75
-
76
- | Binding | Mode | Action |
77
- |------------|---------------|----------------------------------------|
78
- | `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
79
- | `Ctrl-r` | Normal | Continue conversation |
80
- | `Esc` | Prompt | Cancel input |
81
-
82
-
@@ -1,7 +0,0 @@
1
- vimlm.py,sha256=kCJphqs6B-ZUGzeikiW3u629Nw5Ih3DlDJV1rsKGXAY,13410
2
- vimlm-0.0.4.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
3
- vimlm-0.0.4.dist-info/METADATA,sha256=EdZyb9SyLpy0qyK_jILHa7nrd5G8lPxOm6Xfy7NMfiQ,2832
4
- vimlm-0.0.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
5
- vimlm-0.0.4.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
6
- vimlm-0.0.4.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
7
- vimlm-0.0.4.dist-info/RECORD,,
File without changes
File without changes