vimlm 0.0.5__py3-none-any.whl → 0.0.7__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,204 @@
1
+ Metadata-Version: 2.2
2
+ Name: vimlm
3
+ Version: 0.0.7
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
+ ```zsh
50
+ pip install vimlm
51
+ vimlm
52
+ ```
53
+
54
+ ## Basic Usage
55
+
56
+ ### 1. **From Normal Mode**
57
+ **`Ctrl-l`**: Add current line + file to context
58
+
59
+ *Example prompt:* `"Regex for removing HTML tags from item.content"`
60
+
61
+ ### 2. **From Visual Mode**
62
+ Select code → **`Ctrl-l`**: Add selected block + current file to context
63
+
64
+ *Example prompt:* `"Convert this to async/await syntax"`
65
+
66
+ ### 3. **Follow-Up Conversations**
67
+ **`Ctrl-j`**: Continue current thread
68
+
69
+ *Example follow-up:* `"Use Manifest V3 instead"`
70
+
71
+ ### 4. **Code Extraction & Replacement**
72
+ **`Ctrl-p`**: Insert code blocks from response into:
73
+ - Last visual selection (Normal mode)
74
+ - Active selection (Visual mode)
75
+
76
+ **Workflow Example**:
77
+ 1. Select a block of code in Visual mode
78
+ 2. Prompt with `Ctrl-l`: `"Convert this to async/await syntax"`
79
+ 3. Press `Ctrl-p` to replace selection with generated code
80
+
81
+ ### 5. **Inline Commands**
82
+
83
+ #### `!include` - Add External Context
84
+ ```text
85
+ !include [PATH] # Add files/folders to context
86
+ ```
87
+ - **`!include`** (no path): Current folder
88
+ - **`!include ~/projects/utils.py`**: Specific file
89
+ - **`!include ~/docs/api-specs/`**: Entire folder
90
+
91
+ *Example:* `"AJAX-ify this app !include ~/scrap/hypermedia-applications.summ.md"`
92
+
93
+ #### `!deploy` - Generate Project Files
94
+ ```text
95
+ !deploy [DEST_DIR] # Extract code blocks to directory
96
+ ```
97
+ - **`!deploy`** (no path): Current directory
98
+ - **`!deploy ./src`**: Specific directory
99
+
100
+ *Example:* `"Create REST API endpoint !deploy ./api"`
101
+
102
+ #### `!continue` - Resume Generation
103
+ ```text
104
+ !continue [MAX_TOKENS] # Continue stopped response
105
+ ```
106
+ - **`!continue`**: Default 2000 tokens
107
+ - **`!continue 3000`**: Custom token limit
108
+
109
+ *Example:* `"tl;dr !include large-file.txt !continue 5000"`
110
+
111
+ #### `!followup` - Thread Continuation
112
+ ```text
113
+ !followup # Equivalent to Ctrl-j
114
+ ```
115
+ *Example:*
116
+
117
+ Initial: `"Create Chrome extension"`
118
+
119
+ Follow-up: `"Add dark mode support !followup"`
120
+
121
+ #### **Command Combinations**
122
+ Chain multiple commands in one prompt:
123
+ ```text
124
+ "Create HTMX component !include ~/lib/styles.css !deploy ./components !continue 4000"
125
+ ```
126
+
127
+ ### 6. **Command-Line Mode `:VimLM`**
128
+ ```vim
129
+ :VimLM "prompt" [!command1] [!command2]...
130
+ ```
131
+ Use predefined command chains for repetitive tasks:
132
+
133
+ **Example 1 – CI/CD Fixer Macro**:
134
+ ```vim
135
+ " Debug CI failures using error logs
136
+ :VimLM Fix Dockerfile !include .gitlab-ci.yml !include $(tail -n 20 ci.log)
137
+ ```
138
+
139
+ **Example 2 – Test Generation Workflow**:
140
+ ```vim
141
+ " Generate unit tests for selected functions and save to test/
142
+ :VimLM Write pytest tests for this !include ./src !deploy ./test
143
+ ```
144
+
145
+ **Example 3 – Documentation Helper**:
146
+ ```vim
147
+ " Add docstrings to all Python functions in file
148
+ :VimLM Add Google-style docstrings !include % !continue 4000
149
+ ```
150
+
151
+ ### Key Bindings
152
+
153
+ | Binding | Mode | Action |
154
+ |------------|---------------|----------------------------------------|
155
+ | `Ctrl-l` | Normal/Visual | Send current file + selection to LLM |
156
+ | `Ctrl-j` | Normal | Continue conversation |
157
+ | `Ctrl-p` | Normal/Visual | Replace the selection with generated code |
158
+ | `Esc` | Prompt | Cancel input |
159
+
160
+ ## Advanced Configuration
161
+ VimLM uses a JSON config file with the following configurable parameters:
162
+ ```json
163
+ {
164
+ "LLM_MODEL": null,
165
+ "NUM_TOKEN": 2000,
166
+ "USE_LEADER": false,
167
+ "KEY_MAP": {},
168
+ "DO_RESET": true,
169
+ "SHOW_USER": false,
170
+ "SEP_CMD": "!",
171
+ "VERSION": "0.0.7",
172
+ "DEBUG": true
173
+ }
174
+ ```
175
+ ### Custom Model Setup
176
+ 1. **Browse models**: [MLX Community Models on Hugging Face](https://huggingface.co/mlx-community)
177
+ 2. **Edit config file**:
178
+ ```json
179
+ {
180
+ "LLM_MODEL": "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit",
181
+ "NUM_TOKEN": 9999
182
+ }
183
+ ```
184
+ 3. **Save to**: `~/vimlm/cfg.json`
185
+ 4. **Restart VimLM**
186
+
187
+ ### Custom Key Bindings
188
+ You can also configure shortcuts:
189
+ ```json
190
+ {
191
+ "USE_LEADER": true, // Swap Ctrl for Leader key
192
+ "KEY_MAP": { // Remap default keys (l/j/p)
193
+ "l": "a", // <Leader>a instead of <Leader>l
194
+ "j": "s", // <Leader>s instead of <Leader>j
195
+ "p": "d" // <Leader>d instead of <Leader>p
196
+ }
197
+ }
198
+ ```
199
+
200
+ ## License
201
+
202
+ VimLM is licensed under the [Apache-2.0 license](LICENSE).
203
+
204
+
@@ -0,0 +1,7 @@
1
+ vimlm.py,sha256=QrboU-loWuQjTPO5HTJnt_CCxZcV6cclX6UOz438rEM,23126
2
+ vimlm-0.0.7.dist-info/LICENSE,sha256=f1xgK8fAXg_intwnbc9nLkHf7ODPLtgpHs7DetQHOro,11343
3
+ vimlm-0.0.7.dist-info/METADATA,sha256=0V2b8X4pq2uGlyuxyqCUlCYZ7tmlJhPOLZFaDjG0V6s,5668
4
+ vimlm-0.0.7.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
5
+ vimlm-0.0.7.dist-info/entry_points.txt,sha256=mU5V4MYsuIzCc6YB-Ro-6USSHWN5vHw8UDnTEoq0isw,36
6
+ vimlm-0.0.7.dist-info/top_level.txt,sha256=I8GjqoiP--scYsO3AfLhha-6Ax9ci3IvbWvVbPv8g94,6
7
+ vimlm-0.0.7.dist-info/RECORD,,
vimlm.py CHANGED
@@ -24,14 +24,23 @@ import argparse
24
24
  import tempfile
25
25
  from pathlib import Path
26
26
  from string import Template
27
+ import re
28
+
29
+ DEFAULTS = dict(
30
+ LLM_MODEL = None, # "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
31
+ NUM_TOKEN = 2000,
32
+ USE_LEADER = False,
33
+ KEY_MAP = {},
34
+ DO_RESET = True,
35
+ SHOW_USER = False,
36
+ SEP_CMD = '!',
37
+ VERSION = '0.0.7',
38
+ DEBUG = True,
39
+ )
27
40
 
28
- DEBUG = True
29
- LLM_MODEL = "mlx-community/DeepSeek-R1-Distill-Qwen-7B-4bit"
30
- NUM_TOKEN = 2000
31
- SEP_CMD = '!@#$'
32
41
  VIMLM_DIR = os.path.expanduser("~/vimlm")
33
42
  WATCH_DIR = os.path.expanduser("~/vimlm/watch_dir")
34
- CFG_FILE = "cfg.json"
43
+ CFG_FILE = 'cfg.json'
35
44
  LOG_FILE = "log.json"
36
45
  LTM_FILE = "cache.json"
37
46
  OUT_FILE = "response.md"
@@ -41,10 +50,42 @@ LOG_PATH = os.path.join(VIMLM_DIR, LOG_FILE)
41
50
  LTM_PATH = os.path.join(VIMLM_DIR, LTM_FILE)
42
51
  OUT_PATH = os.path.join(WATCH_DIR, OUT_FILE)
43
52
 
44
- def toout(s, key='tovim'):
45
- with open(OUT_PATH, 'w', encoding='utf-8') as f:
53
+ def is_old(config):
54
+ v_str = config.get('VERSION', 0)
55
+ for min_v, usr_v in zip(DEFAULTS['VERSION'].split('.'), v_str.split('.')):
56
+ if int(min_v) < int(usr_v):
57
+ return False
58
+ elif int(min_v) > int(usr_v):
59
+ return True
60
+ return False
61
+
62
+ if os.path.exists(WATCH_DIR):
63
+ shutil.rmtree(WATCH_DIR)
64
+ os.makedirs(WATCH_DIR)
65
+
66
+ try:
67
+ with open(CFG_PATH, "r") as f:
68
+ config = json.load(f)
69
+ if is_old(config):
70
+ for p in [CFG_PATH, LOG_PATH, LTM_PATH]:
71
+ if os.path.isfile(p):
72
+ os.remove(p)
73
+ raise ValueError(f'Version mismatch')
74
+ except Exception as e:
75
+ print(e)
76
+ config = DEFAULTS
77
+ with open(CFG_PATH, 'w') as f:
78
+ json.dump(DEFAULTS, f, indent=2)
79
+
80
+ for k, v in DEFAULTS.items():
81
+ globals()[k] = config.get(k, v)
82
+
83
+ def toout(s, key=None, mode=None):
84
+ key = '' if key is None else ':'+key
85
+ mode = 'w' if mode is None else mode
86
+ with open(OUT_PATH, mode, encoding='utf-8') as f:
46
87
  f.write(s)
47
- tolog(s, key)
88
+ tolog(s, key='tovim'+key+':'+mode)
48
89
 
49
90
  def tolog(log, key='debug'):
50
91
  if not DEBUG and key == 'debug':
@@ -58,31 +99,49 @@ def tolog(log, key='debug'):
58
99
  with open(LOG_PATH, "w", encoding="utf-8") as log_f:
59
100
  json.dump(logs, log_f, indent=2)
60
101
 
61
- if os.path.exists(WATCH_DIR):
62
- shutil.rmtree(WATCH_DIR)
63
- os.makedirs(WATCH_DIR)
64
-
65
- try:
66
- with open(CFG_PATH, "r") as f:
67
- config = json.load(f)
68
- DEBUG = config.get("DEBUG", DEBUG)
69
- LLM_MODEL = config.get("LLM_MODEL", LLM_MODEL)
70
- NUM_TOKEN = config.get("NUM_TOKEN", NUM_TOKEN)
71
- SEP_CMD = config.get("SEP_CMD", SEP_CMD)
72
- except Exception as e:
73
- tolog(str(e))
74
- 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)
76
-
77
102
  toout('Loading LLM...')
78
103
  if LLM_MODEL is None:
79
- from nanollama32 import Chat
80
- chat = Chat(variant='uncn_llama_32_3b_it')
81
- toout('LLM is ready')
104
+ from nanollama import Chat
105
+ chat = Chat(model_path='uncn_llama_32_3b_it')
106
+ toout(f'LLM is ready')
82
107
  else:
83
108
  from mlx_lm_utils import Chat
84
109
  chat = Chat(model_path=LLM_MODEL)
85
- toout(f'{LLM_MODEL.split('/')[-1]} is ready')
110
+ toout(f'{model_path.split('/')[-1]} is ready')
111
+
112
+ def deploy(dest=None, src=None, reformat=True):
113
+ 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.'
114
+ tolog(f'deploy {dest=} {src=} {reformat=}')
115
+ if src:
116
+ chat.reset()
117
+ with open(src, 'r') as f:
118
+ prompt_deploy = f.read().strip() + '\n\n---\n\n' + prompt_deploy
119
+ if reformat:
120
+ response = chat(prompt_deploy, max_new=NUM_TOKEN, verbose=False, stream=False)['text']
121
+ toout(response, 'deploy')
122
+ lines = response.splitlines()
123
+ else:
124
+ with open(OUT_PATH, 'r') as f:
125
+ lines = f.readlines()
126
+ dest = get_path(dest)
127
+ os.makedirs(dest, exist_ok=True)
128
+ current_filename = None
129
+ code_block = []
130
+ in_code_block = False
131
+ for line in lines:
132
+ line = line.rstrip()
133
+ if line.startswith("```"):
134
+ if in_code_block and current_filename:
135
+ with open(os.path.join(dest, os.path.basename(current_filename)), "w", encoding="utf-8") as code_file:
136
+ code_file.write("\n".join(code_block) + "\n")
137
+ code_block = []
138
+ in_code_block = not in_code_block
139
+ elif in_code_block:
140
+ code_block.append(line)
141
+ else:
142
+ match = re.match(r"^\*\*(.+?)\*\*$", line)
143
+ if match:
144
+ current_filename = re.sub(r"[^a-zA-Z0-9_.-]", "", match.group(1))
86
145
 
87
146
  def is_binary(file_path):
88
147
  try:
@@ -123,7 +182,7 @@ def split_str(doc, max_len=2000, get_len=len):
123
182
  return chunks
124
183
 
125
184
  def retrieve(src_path, max_len=2000, get_len=len):
126
- src_path = os.path.expanduser(src_path)
185
+ src_path = get_path(src_path)
127
186
  result = {}
128
187
  if not os.path.exists(src_path):
129
188
  tolog(f"The path {src_path} does not exist.", 'retrieve')
@@ -134,23 +193,31 @@ def retrieve(src_path, max_len=2000, get_len=len):
134
193
  content = f.read()
135
194
  result = {src_path:dict(timestamp=os.path.getmtime(src_path), list_str=split_str(content, max_len=max_len, get_len=get_len))}
136
195
  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):
196
+ tolog(f'Failed to retrieve({filename}) due to {e}')
197
+ else:
198
+ for filename in os.listdir(src_path):
199
+ try:
200
+ file_path = os.path.join(src_path, filename)
201
+ if filename.startswith('.') or is_binary(file_path):
202
+ continue
203
+ if os.path.isfile(file_path):
204
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
205
+ content = f.read()
206
+ result[file_path] = dict(timestamp=os.path.getmtime(file_path), list_str=split_str(content, max_len=max_len, get_len=get_len))
207
+ except Exception as e:
208
+ tolog(f'Failed to retrieve({filename}) due to {e}')
143
209
  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
210
  return result
152
211
 
153
- def ingest(src):
212
+ def get_path(s):
213
+ if not s:
214
+ s = '.'
215
+ s = s.strip()
216
+ s = os.path.expanduser(s)
217
+ s = os.path.abspath(s)
218
+ return s
219
+
220
+ def ingest(src, max_len=NUM_TOKEN):
154
221
  def load_cache(cache_path=LTM_PATH):
155
222
  if os.path.exists(cache_path):
156
223
  with open(cache_path, 'r', encoding='utf-8') as f:
@@ -163,53 +230,149 @@ def ingest(src):
163
230
  current_data[k] = v
164
231
  with open(cache_path, 'w', encoding='utf-8') as f:
165
232
  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)
233
+ src = get_path(src)
234
+ tolog(f'ingest {src=}')
235
+ result = ''
236
+ src_base = os.path.basename(src)
237
+ if os.path.isdir(src):
238
+ listdir = [i for i in os.listdir(src) if not i.startswith('.') and '.' in i]
239
+ result = '\n- '.join([f'--- {src_base} ---', *listdir]) + '\n\n'
240
+ elif os.path.isfile(src):
241
+ result = ''
242
+ else:
243
+ tolog(f'Failed to ingest({src})')
244
+ return ''
245
+ dict_doc = retrieve(src, max_len=max_len, get_len=chat.get_ntok)
246
+ toout(f'Ingesting {src}...')
247
+ format_ingest = '{volat}{incoming}\n\n---\n\nPlease provide a succint bullet point summary for above:'
248
+ 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
249
  dict_sum = {}
171
250
  cache = load_cache()
251
+ max_new_accum = int(max_len/len(dict_doc)) if len(dict_doc) > 0 else max_len
172
252
  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
253
  list_str = v['list_str']
254
+ v_stamp = v['timestamp']
178
255
  if len(list_str) == 0:
179
256
  continue
180
- if len(list_str) > 1:
181
- max_new_sum = int(NUM_TOKEN / len(list_str) / 2)
257
+ if len(list_str) == 1 and chat.get_ntok(list_str[0]) <= max_new_accum:
258
+ chat_summary = list_str[0]
259
+ else:
260
+ k_base = os.path.basename(k)
261
+ if v_stamp == cache.get(k, {}).get('timestamp'):
262
+ dict_sum[k] = cache[k]
263
+ continue
264
+ max_new_sum = int(max_len/len(list_str))
182
265
  volat = f'**{k}**:\n'
183
266
  accum = ''
184
- for s in list_str:
267
+ for i, s in enumerate(list_str):
185
268
  chat.reset()
186
- newsum = chat(format_ingest.format(volat=volat, incoming=s.strip()), max_new=max_new_sum, verbose=False, stream=None)['text']
269
+ toout(f'\n\nIngesting {k_base} {i+1}/{len(list_str)}...\n\n', mode='a')
270
+ tolog(f'{s=}') # DEBUG
271
+ newsum = chat(format_ingest.format(volat=volat, incoming=s.rstrip()), max_new=max_new_sum, verbose=False, stream=OUT_PATH)['text'].rstrip()
272
+ tolog(f'{k} {i+1}/{len(list_str)}: {newsum=}') # DEBUG
187
273
  accum += newsum + ' ...\n'
188
274
  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)
275
+ toout(f'\n\nIngesting {k_base}...\n\n', mode='a')
276
+ tolog(f'{accum=}') # DEBUG
277
+ if chat.get_ntok(accum) <= max_new_accum:
278
+ chat_summary = accum.strip()
279
+ else:
280
+ chat.reset()
281
+ chat_summary = chat(format_ingest.format(volat=f'**{k}**:\n', incoming=accum), max_new=max_new_accum, verbose=False, stream=OUT_PATH)['text'].strip()
282
+ dict_sum[k] = dict(timestamp=v_stamp, summary=chat_summary, ntok=chat.get_ntok(chat_summary))
195
283
  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'
284
+ for k, v in dict_sum.items():
285
+ result += f'--- **{os.path.basename(k)}** ---\n{v["summary"].strip()}\n\n'
199
286
  result += '---\n\n'
287
+ toout(result, 'ingest')
200
288
  return result
201
289
 
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
-
290
+ def process_command(data):
291
+ if len(data['user']) == 0:
292
+ response = chat.resume(max_new=NUM_TOKEN, verbose=False, stream=OUT_PATH)
293
+ toout(response['text'], mode='a')
294
+ tolog(response)
295
+ data['user_prompt'] = ''
296
+ return data
297
+ if SEP_CMD in data['user']:
298
+ data['user_prompt'], *cmds = (x.strip() for x in data['user'].split(SEP_CMD))
299
+ else:
300
+ data['user_prompt'] = data['user'].strip()
301
+ cmds = []
302
+ tolog(f'process_command i {cmds=} {data=}')
303
+
304
+ do_reset = False if 'followup' in data else DO_RESET
305
+ for cmd in cmds:
306
+ if cmd.startswith('continue'):
307
+ arg = cmd.removeprefix('continue').strip('(').strip(')').strip().strip('"').strip("'").strip()
308
+ data['max_new'] = NUM_TOKEN if len(arg) == 0 else int(arg)
309
+ response = chat.resume(max_new=data['max_new'], verbose=False, stream=OUT_PATH)
310
+ toout(response['text'], mode='a')
311
+ tolog(response)
312
+ do_reset = False
313
+ break
314
+
315
+ if cmd.startswith('reset'):
316
+ do_reset = True
317
+ break
318
+ if cmd.startswith('followup'):
319
+ do_reset = False
320
+ break
321
+ if do_reset:
322
+ chat.reset()
323
+
324
+ full_path = data['tree']
325
+ data['dir'] = os.path.dirname(full_path)
326
+ data['file'] = os.path.basename(full_path)
327
+ data['ext'] = os.path.splitext(full_path)[1][1:]
328
+ if chat.stop:
329
+ data['file'] = ''
330
+ data['context'] = ''
331
+ if data['tree'] == OUT_PATH:
332
+ data['dir'] = os.getcwd()
333
+ data['file'] = ''
334
+ data['context'] = ''
335
+ data['ext'] = ''
336
+ if data['file'] == '.tmp':
337
+ data['file'] = ''
338
+ data['ext'] = ''
339
+
340
+ if len(cmds) == 1 and len(cmds[0]) == 0:
341
+ data['include'] = ingest(data['dir'])
342
+ return data
343
+
344
+ data['include'] = ''
345
+ for cmd in cmds:
346
+ if cmd.startswith('include'):
347
+ arg = cmd.removeprefix('include').strip().strip('(').strip(')').strip().strip('"').strip("'").strip()
348
+ src = data['dir'] if len(arg) == 0 else arg
349
+ if arg == '%':
350
+ continue
351
+ if src.startswith('`') or src.startswith('$('):
352
+ shell_cmd = src.strip('`') if src.startswith('`') else src.strip('$()')
353
+ shell_cmd = shell_cmd.strip()
354
+ try:
355
+ result = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True)
356
+ if result.returncode == 0:
357
+ data['include'] += f'--- **{shell_cmd}** ---\n```\n{result.stdout.strip()}\n```\n---\n\n'
358
+ else:
359
+ tolog(f'{shell_cmd} failed {result.stderr.strip()}')
360
+ except Exception as e:
361
+ tolog(f'Error executing {shell_cmd}: {e}')
362
+ else:
363
+ data['include'] += ingest(src)
364
+
365
+ for cmd in cmds:
366
+ if cmd.startswith('deploy'):
367
+ arg = cmd.removeprefix('deploy').strip().strip('(').strip(')').strip().strip('"').strip("'").strip()
368
+ if len(data['user_prompt']) == 0:
369
+ deploy(dest=arg)
370
+ data['user_prompt'] = ''
371
+ return data
372
+ 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."
373
+ data['deploy_dest'] = arg
374
+ return data
375
+
213
376
  async def monitor_directory():
214
377
  async for changes in awatch(WATCH_DIR):
215
378
  found_files = {os.path.basename(f) for _, f in changes}
@@ -225,48 +388,63 @@ async def monitor_directory():
225
388
  if 'followup' in os.listdir(WATCH_DIR):
226
389
  os.remove(os.path.join(WATCH_DIR, 'followup'))
227
390
  data['followup'] = True
228
- str_template = '{user}'
391
+ if 'quit' in os.listdir(WATCH_DIR):
392
+ os.remove(os.path.join(WATCH_DIR, 'quit'))
393
+ data['quit'] = True
229
394
  await process_files(data)
230
395
 
231
396
  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"
397
+ tolog(f'process_files i {data=}')
398
+ str_template = '{include}'
399
+ data = process_command(data)
400
+ if len(data['user_prompt']) == 0:
401
+ return
402
+ if len(data['file']) > 0:
403
+ str_template += '**{file}**\n'
404
+ if len(data['context']) > 0 and data['yank'] != data['context']:
405
+ str_template += '```{ext}\n{context}\n```\n\n'
406
+ if len(data['yank']) > 0:
407
+ if '\n' in data['yank']:
408
+ str_template += "```{ext}\n{yank}\n```\n\n"
409
+ else:
410
+ if data['user'] == 0:
411
+ str_template += "{yank}"
248
412
  else:
249
413
  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
414
+ str_template += '{user_prompt}'
415
+ tolog(f'process_files o {data=}') # DEBUG
416
+ tolog(f'process_files {str_template=}') # DEBUG
258
417
  prompt = str_template.format(**data)
259
418
  tolog(prompt, 'tollm')
260
419
  toout('')
261
- response = chat(prompt, max_new=NUM_TOKEN - chat.get_ntok(prompt), verbose=False, stream=OUT_PATH)
262
- toout(response['text'])
420
+ max_new = data['max_new'] if 'max_new' in data else max(10, NUM_TOKEN - chat.get_ntok(prompt))
421
+ response = chat(prompt, max_new=max_new, verbose=False, stream=OUT_PATH)
422
+ if SHOW_USER:
423
+ toout(response['text'])
424
+ else:
425
+ toout(response['text'])
263
426
  tolog(response['benchmark'])
427
+ if 'deploy_dest' in data:
428
+ deploy(dest=data['deploy_dest'], reformat=False)
264
429
 
430
+ KEYL = KEY_MAP.get('l', 'l')
431
+ KEYJ = KEY_MAP.get('j', 'j')
432
+ KEYP = KEY_MAP.get('p', 'p')
433
+ mapl, mapj, mapp = (f'<Leader>{KEYL}', f'<Leader>{KEYJ}', f'<Leader>{KEYP}') if USE_LEADER else (f'<C-{KEYL}>', f'<C-{KEYJ}>', f'<C-{KEYP}>')
265
434
  VIMLMSCRIPT = Template(r"""
435
+ 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
436
  let s:watched_dir = expand('$WATCH_DIR')
267
437
 
268
438
  function! Monitor()
269
- write
439
+ if exists('s:monitor_timer')
440
+ call timer_stop(s:monitor_timer)
441
+ unlet s:monitor_timer
442
+ endif
443
+ let response_path = s:watched_dir . '/response.md'
444
+ let bufnum = bufnr(response_path)
445
+ if bufnum != -1
446
+ execute 'bwipeout ' . bufnum
447
+ endif
270
448
  let response_path = s:watched_dir . '/response.md'
271
449
  rightbelow vsplit | execute 'view ' . response_path
272
450
  setlocal autoread
@@ -288,41 +466,17 @@ function! CheckForUpdates(timer)
288
466
  endfunction
289
467
 
290
468
  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
469
+ call inputsave()
470
+ let input = input(a:prompt)
471
+ call inputrestore()
472
+ if empty(input)
473
+ return v:null
474
+ endif
475
+ return input
322
476
  endfunction
323
477
 
324
- function! SaveUserInput()
325
- let user_input = s:CustomInput('Ask LLM: ')
478
+ function! SaveUserInput(prompt)
479
+ let user_input = s:CustomInput(a:prompt)
326
480
  if user_input is v:null
327
481
  echo "Input aborted"
328
482
  return
@@ -334,12 +488,107 @@ function! SaveUserInput()
334
488
  call writefile([current_file], tree_file, 'w')
335
489
  endfunction
336
490
 
337
- function! SaveUserInput()
338
- let user_input = s:CustomInput('Ask LLM: ')
339
- if user_input is v:null
340
- echo "Input aborted"
491
+ function! VisualPrompt()
492
+ silent! execute "normal! \<ESC>"
493
+ silent execute "'<,'>w! " . s:watched_dir . "/yank"
494
+ silent execute "w! " . s:watched_dir . "/context"
495
+ " silent! execute "normal! `<V`>"
496
+ " silent! execute "normal! \<ESC>"
497
+ call SaveUserInput('VimLM: ')
498
+ endfunction
499
+
500
+ function! NormalPrompt()
501
+ silent! execute "normal! V\<ESC>"
502
+ silent execute "'<,'>w! " . s:watched_dir . "/yank"
503
+ silent execute "w! " . s:watched_dir . "/context"
504
+ " silent! execute "normal! \<ESC>"
505
+ call SaveUserInput('VimLM: ')
506
+ endfunction
507
+
508
+ function! FollowUpPrompt()
509
+ call writefile([], s:watched_dir . '/yank', 'w')
510
+ call writefile([], s:watched_dir . '/context', 'w')
511
+ call writefile([], s:watched_dir . '/followup', 'w')
512
+ call SaveUserInput('... ')
513
+ endfunction
514
+
515
+ function! ExtractAllCodeBlocks()
516
+ let filepath = s:watched_dir . '/response.md'
517
+ if !filereadable(filepath)
518
+ echoerr "File not found: " . filepath
519
+ return
520
+ endif
521
+ let lines = readfile(filepath)
522
+ let in_code_block = 0
523
+ let code_blocks = []
524
+ let current_block = []
525
+ for line in lines
526
+ if line =~ '^```'
527
+ if in_code_block
528
+ call add(code_blocks, current_block)
529
+ let current_block = []
530
+ let in_code_block = 0
531
+ else
532
+ let in_code_block = 1
533
+ endif
534
+ elseif in_code_block
535
+ call add(current_block, line)
536
+ endif
537
+ endfor
538
+ if in_code_block
539
+ call add(code_blocks, current_block)
540
+ endif
541
+ for idx in range(len(code_blocks))
542
+ if idx >= len(s:register_names)
543
+ break
544
+ endif
545
+ let code_block_text = join(code_blocks[idx], "\n")
546
+ let register_name = s:register_names[idx]
547
+ call setreg(register_name, code_block_text, 'v')
548
+ endfor
549
+ return len(code_blocks)
550
+ endfunction
551
+
552
+ function! PasteIntoLastVisualSelection(...)
553
+ let num_blocks = ExtractAllCodeBlocks()
554
+ if a:0 > 0
555
+ let register_name = a:1
556
+ else
557
+ echo "Extracted " . num_blocks . " blocks into registers @a-@" . s:register_names[num_blocks - 1] . ". Enter register name: "
558
+ let register_name = nr2char(getchar())
559
+ endif
560
+
561
+ if register_name !~ '^[a-z]$'
562
+ echoerr "Invalid register name. Please enter a single lowercase letter (e.g., a, b, c)."
563
+ return
564
+ endif
565
+
566
+ let register_content = getreg(register_name)
567
+ if register_content == ''
568
+ echoerr "Register @" . register_name . " is empty."
569
+ return
570
+ endif
571
+
572
+ let current_mode = mode()
573
+ if current_mode == 'v' || current_mode == 'V' || current_mode == ''
574
+ execute 'normal! "' . register_name . 'p'
575
+ else
576
+ normal! gv
577
+ execute 'normal! "' . register_name . 'p'
578
+ endif
579
+ endfunction
580
+
581
+ function! VimLM(...) range
582
+ let user_input = join(a:000, ' ')
583
+ if empty(user_input)
584
+ echo "Usage: :VimLM <prompt> [!command1] [!command2] ..."
341
585
  return
342
586
  endif
587
+ if line("'<") == line("'>")
588
+ silent! execute "normal! V\<ESC>"
589
+ endif
590
+ silent execute "'<,'>w! " . s:watched_dir . "/yank"
591
+ silent execute "w! " . s:watched_dir . "/context"
343
592
  let user_file = s:watched_dir . '/user'
344
593
  call writefile([user_input], user_file, 'w')
345
594
  let current_file = expand('%:p')
@@ -347,24 +596,23 @@ function! SaveUserInput()
347
596
  call writefile([current_file], tree_file, 'w')
348
597
  endfunction
349
598
 
350
- function! FollowUpInput()
351
- call writefile([], s:watched_dir . '/yank', 'w')
352
- call writefile([], s:watched_dir . '/context', 'w')
353
- call writefile([], s:watched_dir . '/followup', 'w')
354
- call SaveUserInput()
355
- endfunction
356
-
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>
360
-
599
+ command! -range -nargs=+ VimLM call VimLM(<f-args>)
600
+ nnoremap $mapp :call PasteIntoLastVisualSelection()<CR>
601
+ vnoremap $mapp <Cmd>:call PasteIntoLastVisualSelection()<CR>
602
+ vnoremap $mapl <Cmd>:call VisualPrompt()<CR>
603
+ nnoremap $mapl :call NormalPrompt()<CR>
604
+ nnoremap $mapj :call FollowUpPrompt()<CR>
361
605
  call Monitor()
362
- """).safe_substitute(dict(WATCH_DIR=WATCH_DIR))
606
+ """).safe_substitute(dict(WATCH_DIR=WATCH_DIR, mapl=mapl, mapj=mapj, mapp=mapp))
363
607
 
364
608
  async def main():
365
609
  parser = argparse.ArgumentParser(description="VimLM - LLM-powered Vim assistant")
610
+ parser.add_argument('--test', action='store_true')
366
611
  parser.add_argument("vim_args", nargs=argparse.REMAINDER, help="Vim arguments")
367
612
  args = parser.parse_args()
613
+ if args.test:
614
+ test()
615
+ return
368
616
  with tempfile.NamedTemporaryFile(mode='w', suffix='.vim', delete=False) as f:
369
617
  f.write(VIMLMSCRIPT)
370
618
  vim_script = f.name
@@ -373,14 +621,17 @@ async def main():
373
621
  vim_command.extend(args.vim_args)
374
622
  else:
375
623
  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
624
  try:
381
- await monitor_task
382
- except asyncio.CancelledError:
383
- pass
625
+ monitor_task = asyncio.create_task(monitor_directory())
626
+ vim_process = await asyncio.create_subprocess_exec(*vim_command)
627
+ await vim_process.wait()
628
+ finally:
629
+ monitor_task.cancel()
630
+ try:
631
+ await monitor_task
632
+ except asyncio.CancelledError:
633
+ pass
634
+ os.remove(vim_script)
384
635
 
385
636
  def run():
386
637
  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