mlx-code 0.0.27__tar.gz → 0.0.28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {mlx_code-0.0.27 → mlx_code-0.0.28}/PKG-INFO +26 -13
  2. {mlx_code-0.0.27 → mlx_code-0.0.28}/README.md +25 -12
  3. mlx_code-0.0.28/mlx_code/bare.py +276 -0
  4. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/bats.py +2 -1
  5. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/main.py +9 -2
  6. mlx_code-0.0.28/mlx_code/repl.py +808 -0
  7. mlx_code-0.0.28/mlx_code/tui.py +576 -0
  8. mlx_code-0.0.28/mlx_code/web.py +485 -0
  9. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/PKG-INFO +26 -13
  10. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/SOURCES.txt +2 -0
  11. {mlx_code-0.0.27 → mlx_code-0.0.28}/setup.py +1 -2
  12. mlx_code-0.0.27/mlx_code/bare.py +0 -484
  13. mlx_code-0.0.27/mlx_code/repl.py +0 -1149
  14. {mlx_code-0.0.27 → mlx_code-0.0.28}/LICENSE +0 -0
  15. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/__init__.py +0 -0
  16. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/apis.py +0 -0
  17. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/gits.py +0 -0
  18. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/lsp_tool.py +0 -0
  19. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/mcb.py +0 -0
  20. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/mcb_tool.py +0 -0
  21. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/stream_log.py +0 -0
  22. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/tools.py +0 -0
  23. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/util.py +0 -0
  24. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/view_git.py +0 -0
  25. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code/view_log.py +0 -0
  26. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/dependency_links.txt +0 -0
  27. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/entry_points.txt +0 -0
  28. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/requires.txt +0 -0
  29. {mlx_code-0.0.27 → mlx_code-0.0.28}/mlx_code.egg-info/top_level.txt +0 -0
  30. {mlx_code-0.0.27 → mlx_code-0.0.28}/setup.cfg +0 -0
  31. {mlx_code-0.0.27 → mlx_code-0.0.28}/tests/__init__.py +0 -0
  32. {mlx_code-0.0.27 → mlx_code-0.0.28}/tests/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlx-code
3
- Version: 0.0.27
3
+ Version: 0.0.28
4
4
  Summary: Coding Agent for Mac
5
5
  Home-page: https://josefalbers.github.io/mlx-code/
6
6
  Author: J Joe
@@ -40,7 +40,7 @@ Dynamic: summary
40
40
 
41
41
  A Git-native coding agent that can run entirely on your Mac. No API keys, no cloud, and no data leaving your machine. Powered by Apple MLX, it turns commits, branches, and worktrees into the agent’s state, history, and execution model
42
42
 
43
- [![Link](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code-v0.0.20.gif)](https://youtu.be/0lkY7YQCyCo)
43
+ [![v0.0.27](https://github.com/user-attachments/assets/8a1c131a-dda1-4b52-9fa6-9c0fbccb5ea6)](https://youtube.com/shorts/1LuifKFKixc)
44
44
 
45
45
  ---
46
46
 
@@ -49,7 +49,7 @@ A Git-native coding agent that can run entirely on your Mac. No API keys, no clo
49
49
  ```
50
50
  Worktrees:
51
51
 
52
- main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat history
52
+ main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat hx
53
53
  │ │
54
54
  │ └── branch-1 ──●──●──●
55
55
  │ │ ┌────────────┐
@@ -57,16 +57,14 @@ Worktrees:
57
57
  │ └─────┬──────┘
58
58
  └── branch-0 ──●──●──● │
59
59
 
60
-
61
60
  Tabs: ├────────────► Tab = git branch + Agent
62
61
 
63
-
64
- ┌──────────────────────────────────────────────┼─────────┐
62
+ ┌──────────────────────────────────────────────│─────────┐
65
63
  │ TUI tabs │ │
66
64
  │ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌─────┴──────┐ │
67
65
  │ │ main │ │ branch-0 │ │ branch-1 │ │ branch-1-0 │ │
68
66
  │ └──────┘ └────┬─────┘ └──────────┘ └────────────┘ │
69
- └─────────────────┼──────────────────────────────────────┘
67
+ └─────────────────│──────────────────────────────────────┘
70
68
 
71
69
  Agents: ├─────────────────────────────────────────► Each tab runs its own Agent
72
70
 
@@ -522,10 +520,10 @@ All file tools enforce path sandboxing. The agent cannot read or write outside t
522
520
 
523
521
  | Backend | Flag | Notes |
524
522
  |---------|------|-------|
525
- | MLX (local) | `--api noapi` | Default. Runs on-device, no API key needed |
523
+ | MLX-LM (local) | `--api noapi` | Default. Runs on-device, no API key needed |
526
524
  | Claude | `--api claude` | Requires `ANTHROPIC_API_KEY` |
527
525
  | Gemini | `--api gemini` | Requires `GOOGLE_API_KEY` |
528
- | DeepSeek | `--api deepseek` | DeepSeek API or compatible endpoint |
526
+ | DeepSeek | `--api deepseek` | Requires `DEEPSEEK_API_KEY` |
529
527
  | Codex | `--api codex` | OpenAI Codex CLI integration |
530
528
  | OpenAI | `--api openai` | Any OpenAI-compatible endpoint |
531
529
 
@@ -534,10 +532,25 @@ All file tools enforce path sandboxing. The agent cannot read or write outside t
534
532
  The local MLX server speaks OpenAI, Anthropic, and Gemini wire formats simultaneously, so you can use any compatible CLI as the frontend:
535
533
 
536
534
  ```bash
537
- mlc --leash claude # claude CLI routes through local model
538
- mlc --leash codex # codex CLI routes through local model
539
- mlc --leash gemini # gemini CLI routes through local model
540
- mlc --leash none # server only
535
+ mlc # default
536
+ mlc --web # web UI (api.mlx-code.com)
537
+ mlc --bare # no TUI
538
+ mlc --leash none # no harness
539
+ mlc --leash codex # codex CLI
540
+ mlc --leash gemini # gemini CLI
541
+ mlc --leash claude # claude code
542
+ ```
543
+
544
+ #### WebUI
545
+
546
+ ```bash
547
+ [protect & connect]-[networking]-[tunnels]-[add route]-[add published application]:
548
+ - subdomain: jjoe
549
+ - domain: mlx-code.com
550
+ - service url: http://host.containers.internal:8080
551
+ mlc --host 0.0.0.0 --engine batch --web &
552
+ podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
553
+ phone http://jjoe.mlx-code.com
541
554
  ```
542
555
 
543
556
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Git-native coding agent that can run entirely on your Mac. No API keys, no cloud, and no data leaving your machine. Powered by Apple MLX, it turns commits, branches, and worktrees into the agent’s state, history, and execution model
4
4
 
5
- [![Link](https://raw.githubusercontent.com/JosefAlbers/mlx-code/main/assets/mlx-code-v0.0.20.gif)](https://youtu.be/0lkY7YQCyCo)
5
+ [![v0.0.27](https://github.com/user-attachments/assets/8a1c131a-dda1-4b52-9fa6-9c0fbccb5ea6)](https://youtube.com/shorts/1LuifKFKixc)
6
6
 
7
7
  ---
8
8
 
@@ -11,7 +11,7 @@ A Git-native coding agent that can run entirely on your Mac. No API keys, no clo
11
11
  ```
12
12
  Worktrees:
13
13
 
14
- main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat history
14
+ main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat hx
15
15
  │ │
16
16
  │ └── branch-1 ──●──●──●
17
17
  │ │ ┌────────────┐
@@ -19,16 +19,14 @@ Worktrees:
19
19
  │ └─────┬──────┘
20
20
  └── branch-0 ──●──●──● │
21
21
 
22
-
23
22
  Tabs: ├────────────► Tab = git branch + Agent
24
23
 
25
-
26
- ┌──────────────────────────────────────────────┼─────────┐
24
+ ┌──────────────────────────────────────────────│─────────┐
27
25
  │ TUI tabs │ │
28
26
  │ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌─────┴──────┐ │
29
27
  │ │ main │ │ branch-0 │ │ branch-1 │ │ branch-1-0 │ │
30
28
  │ └──────┘ └────┬─────┘ └──────────┘ └────────────┘ │
31
- └─────────────────┼──────────────────────────────────────┘
29
+ └─────────────────│──────────────────────────────────────┘
32
30
 
33
31
  Agents: ├─────────────────────────────────────────► Each tab runs its own Agent
34
32
 
@@ -484,10 +482,10 @@ All file tools enforce path sandboxing. The agent cannot read or write outside t
484
482
 
485
483
  | Backend | Flag | Notes |
486
484
  |---------|------|-------|
487
- | MLX (local) | `--api noapi` | Default. Runs on-device, no API key needed |
485
+ | MLX-LM (local) | `--api noapi` | Default. Runs on-device, no API key needed |
488
486
  | Claude | `--api claude` | Requires `ANTHROPIC_API_KEY` |
489
487
  | Gemini | `--api gemini` | Requires `GOOGLE_API_KEY` |
490
- | DeepSeek | `--api deepseek` | DeepSeek API or compatible endpoint |
488
+ | DeepSeek | `--api deepseek` | Requires `DEEPSEEK_API_KEY` |
491
489
  | Codex | `--api codex` | OpenAI Codex CLI integration |
492
490
  | OpenAI | `--api openai` | Any OpenAI-compatible endpoint |
493
491
 
@@ -496,10 +494,25 @@ All file tools enforce path sandboxing. The agent cannot read or write outside t
496
494
  The local MLX server speaks OpenAI, Anthropic, and Gemini wire formats simultaneously, so you can use any compatible CLI as the frontend:
497
495
 
498
496
  ```bash
499
- mlc --leash claude # claude CLI routes through local model
500
- mlc --leash codex # codex CLI routes through local model
501
- mlc --leash gemini # gemini CLI routes through local model
502
- mlc --leash none # server only
497
+ mlc # default
498
+ mlc --web # web UI (api.mlx-code.com)
499
+ mlc --bare # no TUI
500
+ mlc --leash none # no harness
501
+ mlc --leash codex # codex CLI
502
+ mlc --leash gemini # gemini CLI
503
+ mlc --leash claude # claude code
504
+ ```
505
+
506
+ #### WebUI
507
+
508
+ ```bash
509
+ [protect & connect]-[networking]-[tunnels]-[add route]-[add published application]:
510
+ - subdomain: jjoe
511
+ - domain: mlx-code.com
512
+ - service url: http://host.containers.internal:8080
513
+ mlc --host 0.0.0.0 --engine batch --web &
514
+ podman run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token $JJ_CFD_TOKEN
515
+ phone http://jjoe.mlx-code.com
503
516
  ```
504
517
 
505
518
  ---
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import datetime
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import logging
9
+ from typing import Callable
10
+ from .repl import Agent, TabModel, CommandEngine, UIAdapter, HELP_TEXT
11
+ from .gits import GitError, get_branch_base_sha, get_diff_between_refs, get_commit_history_with_stats, find_rev_commit, create_worktree, git_new_branch, git_new_branch_at
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class BareAdapter:
15
+
16
+ def __init__(self, repl: 'BareRepl'):
17
+ self.repl = repl
18
+
19
+ def show_error(self, text: str) -> None:
20
+ print(f'\n✗ {text}', flush=True)
21
+
22
+ def show_command_result(self, cmd: str, content: str | object) -> None:
23
+ if isinstance(content, str):
24
+ print(content)
25
+ else:
26
+ print(str(content))
27
+
28
+ def show_diff(self, diff_text: str, ref1_label: str, ref2_label: str) -> None:
29
+ print(diff_text)
30
+
31
+ def show_history_list(self, lines: list[str]) -> None:
32
+ print('\n'.join(lines))
33
+
34
+ def show_history_raw(self, json_text: str) -> None:
35
+ print(json_text)
36
+
37
+ async def add_tab(self, tab: TabModel) -> None:
38
+ pass
39
+
40
+ def remove_tab(self, removed_index: int) -> None:
41
+ if self.repl.engine.active_index >= len(self.repl.engine.tabs):
42
+ self.repl.engine.active_index = len(self.repl.engine.tabs) - 1
43
+
44
+ def switch_to_tab(self, index: int) -> None:
45
+ self.repl._render_tab_delimiter()
46
+ self.repl._print_history_for_tab(self.repl.engine.tabs[index])
47
+
48
+ def refresh_chrome(self) -> None:
49
+ pass
50
+
51
+ def clear_tab_display(self, tab: TabModel) -> None:
52
+ pass
53
+
54
+ def on_agent_event(self, event: dict, tab: TabModel) -> None:
55
+ self.repl._handle_event(event, tab)
56
+
57
+ async def run_captured_shell(self, command: str, cwd: str, env: dict | None) -> str:
58
+ proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env)
59
+ stdout, stderr = await proc.communicate()
60
+ out = stdout.decode(errors='replace').rstrip('\n')
61
+ err = stderr.decode(errors='replace').rstrip('\n')
62
+ body = out
63
+ if err:
64
+ body = (body + '\n' if body else '') + f'[stderr]\n{err}'
65
+ if proc.returncode:
66
+ body += f'\n[exit {proc.returncode}]'
67
+ return body
68
+
69
+ async def run_interactive_shell(self, command: str, cwd: str, env: dict | None) -> int:
70
+ proc = await asyncio.create_subprocess_shell(command, cwd=cwd, stdin=None, stdout=None, stderr=None, env=env)
71
+ await proc.wait()
72
+ return proc.returncode or 0
73
+
74
+ def exit_app(self, summary: list[dict]) -> None:
75
+ raise SystemExit
76
+
77
+ class BareRepl:
78
+
79
+ def __init__(self, engine: CommandEngine, init_prompt: str | None=None):
80
+ self.engine = engine
81
+ self.adapter = BareAdapter(self)
82
+ self.engine.bind(self.adapter)
83
+ self.engine.attach_agent(self.engine.tabs[0])
84
+ self.init_prompt = init_prompt
85
+ self._pending_nls: int = 0
86
+ self._awaiting_content: bool = False
87
+ self._has_output: bool = False
88
+ self._last_stream_type: str | None = None
89
+
90
+ async def run(self) -> None:
91
+ loop = asyncio.get_running_loop()
92
+ if self.init_prompt:
93
+ p, self.init_prompt = (self.init_prompt, None)
94
+ await self.engine.active_tab.agent.run(p)
95
+ while True:
96
+ try:
97
+ line = await loop.run_in_executor(None, self._read_input)
98
+ except KeyboardInterrupt:
99
+ print('\n(Use /exit or Ctrl-D to quit)')
100
+ continue
101
+ except EOFError:
102
+ print()
103
+ break
104
+ if line is None:
105
+ break
106
+ line = line.strip()
107
+ if not line:
108
+ continue
109
+ if line.lower() in {'exit', 'quit'}:
110
+ break
111
+ await self.engine.handle_input(line)
112
+ tab = self.engine.active_tab
113
+ if tab.running_task is not None:
114
+ try:
115
+ await tab.running_task
116
+ except asyncio.CancelledError:
117
+ pass
118
+
119
+ def _read_input(self) -> str | None:
120
+ tab = self.engine.active_tab
121
+ prompt = f'[{tab.title}] ≫ '
122
+ lines: list[str] = []
123
+ while True:
124
+ try:
125
+ line = input(prompt)
126
+ except EOFError:
127
+ return None
128
+ lines.append(line)
129
+ if line.endswith('\\'):
130
+ lines[-1] = line[:-1]
131
+ prompt = '... '
132
+ else:
133
+ break
134
+ return '\n'.join(lines)
135
+
136
+ def _handle_event(self, event: dict, tab: TabModel) -> None:
137
+ t, p = (event['type'], event.get('payload', {}))
138
+ if t in ('text_delta', 'thinking_delta'):
139
+ delta = p.get('delta', '')
140
+ if delta:
141
+ self._write_delta(delta, t)
142
+ elif t == 'tool_start':
143
+ self._pending_nls = 0
144
+ self._awaiting_content = False
145
+ self._has_output = True
146
+ self._last_stream_type = t
147
+ elif t == 'tool_end':
148
+ result_msg = p.get('result', {})
149
+ content = result_msg.get('content')
150
+ is_err = p.get('is_error', False)
151
+ out_text = ''
152
+ if content:
153
+ parts: list[str] = []
154
+ if isinstance(content, str):
155
+ parts.append(content)
156
+ elif isinstance(content, list):
157
+ for block in content:
158
+ if isinstance(block, dict) and block.get('type') == 'text':
159
+ parts.append(block.get('text', ''))
160
+ out_text = '\n'.join(parts).strip('\n')
161
+ if is_err:
162
+ prefix = '✗ '
163
+ if not out_text:
164
+ out_text = f'{p.get('name', '?')} failed'
165
+ else:
166
+ prefix = '→ ' if out_text else ''
167
+ if out_text:
168
+ self._write_delta(prefix + out_text, 'tool_result')
169
+ self._last_stream_type = t
170
+ print()
171
+ elif t == 'commit':
172
+ self._pending_nls = 0
173
+ self._awaiting_content = False
174
+ self._has_output = True
175
+ print(f'\n◇ [{p.get('sha', '')}] committed', flush=True)
176
+ self._last_stream_type = t
177
+ elif t == 'error':
178
+ self._pending_nls = 0
179
+ self._awaiting_content = False
180
+ self._has_output = True
181
+ err = str(p.get('error', p))
182
+ print(f'\n✗ {err}', flush=True)
183
+ self._last_stream_type = t
184
+ elif t in ('agent_start', 'turn_start'):
185
+ self._pending_nls = 0
186
+ self._awaiting_content = False
187
+ self._has_output = False
188
+ self._last_stream_type = None
189
+ elif t == 'agent_end':
190
+ self._pending_nls = 0
191
+ if self._has_output:
192
+ print()
193
+ self._last_stream_type = None
194
+ self._has_output = False
195
+ self._awaiting_content = False
196
+
197
+ def _write_delta(self, text: str, delta_type: str) -> None:
198
+ if delta_type != self._last_stream_type:
199
+ self._pending_nls = 0
200
+ self._awaiting_content = True
201
+ self._last_stream_type = delta_type
202
+ if self._awaiting_content:
203
+ text = text.lstrip('\n')
204
+ if not text:
205
+ return
206
+ if self._awaiting_content:
207
+ if self._has_output:
208
+ print()
209
+ self._awaiting_content = False
210
+ if not self._awaiting_content and self._pending_nls > 0:
211
+ print('\n' * self._pending_nls, end='', flush=True)
212
+ self._pending_nls = 0
213
+ rstripped = text.rstrip('\n')
214
+ if rstripped:
215
+ if delta_type == 'thinking_delta':
216
+ print(f'\x1b[2m{rstripped}\x1b[0m', end='', flush=True)
217
+ else:
218
+ print(rstripped, end='', flush=True)
219
+ self._has_output = True
220
+ self._pending_nls = len(text) - len(rstripped)
221
+
222
+ def _render_tab_delimiter(self) -> None:
223
+ tab_strs: list[str] = []
224
+ for i, t in enumerate(self.engine.tabs):
225
+ if i == self.engine.active_index:
226
+ tab_strs.append(f'\x1b[1m▶ {i + 1}. {t.title}\x1b[0m')
227
+ else:
228
+ tab_strs.append(f'\x1b[2m▷ {i + 1}. {t.title}\x1b[0m')
229
+ print('\n' + '┗━━┫ ' + ' ┃ '.join(tab_strs) + ' ┃')
230
+
231
+ def _print_history_for_tab(self, tab: TabModel) -> None:
232
+ for msg in tab.agent.messages:
233
+ role = msg.get('role', '')
234
+ content = msg.get('content', '')
235
+ is_error = msg.get('is_error', False)
236
+ if isinstance(content, list):
237
+ blocks = content
238
+ elif isinstance(content, str):
239
+ blocks = [{'type': 'text', 'text': content}]
240
+ else:
241
+ continue
242
+ if role == 'toolResult':
243
+ parts: list[str] = []
244
+ for block in blocks:
245
+ if isinstance(block, dict) and block.get('type') == 'text':
246
+ t = block.get('text', '').strip('\n')
247
+ if t:
248
+ parts.append(t)
249
+ if parts:
250
+ prefix = '✗ ' if is_error else '→ '
251
+ print(prefix + '\n'.join(parts))
252
+ continue
253
+ for block in blocks:
254
+ btype = block.get('type', 'text')
255
+ if btype == 'toolCall':
256
+ args = block.get('arguments', {})
257
+ if isinstance(args, dict):
258
+ args = json.dumps(args, ensure_ascii=False)
259
+ print(f'⚙ {block.get('name', '')} {args}')
260
+ continue
261
+ text = block.get('text', '') or block.get('thinking', '') or ''
262
+ text = text.strip('\n')
263
+ if not text:
264
+ continue
265
+ if btype == 'thinking':
266
+ print(f'\x1b[2m{text}\x1b[0m')
267
+ elif is_error:
268
+ print(f'✗ {text}')
269
+ elif role == 'user':
270
+ print(f'≫ {text}')
271
+ elif role == 'commit':
272
+ print(f'◇ {text}')
273
+ elif role == 'toolResult':
274
+ print(f'→ {text}')
275
+ else:
276
+ print(text)
@@ -1,3 +1,4 @@
1
+ _UI_HTML = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>MLX Code</title>\n<style>\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;height:100vh;display:flex;flex-direction:column}\n#hdr{padding:8px 16px;background:#161b22;border-bottom:1px solid #30363d;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}\n#hdr h1{font-size:14px;font-weight:600}\n#hdr .info{display:flex;gap:8px;align-items:center;font-size:12px;color:#8b949e}\n#hdr button{background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12px}\n#hdr button:hover{color:#c9d1d9;border-color:#8b949e}\n#chat{flex:1;overflow-y:auto;padding:16px}\n.chat-inner{max-width:920px;margin:0 auto}\n.msg{margin-bottom:14px}\n.msg-role{font-size:12px;color:#8b949e;margin-bottom:3px}\n.msg-body{padding:10px 14px;border-radius:8px;line-height:1.6;white-space:pre-wrap;word-break:break-word;font-size:14px}\n.msg-user .msg-body{background:#1c2128;border:1px solid #30363d}\n.msg-assistant .msg-body{background:#161b22;border:1px solid #30363d}\n.msg-thinking .msg-body{color:#6e7681;font-style:italic;background:rgba(136,144,150,0.05);border-left:2px solid #30363d;font-size:13px}\n.msg-tool .msg-body{background:rgba(210,153,34,0.08);border-left:2px solid #d29922;font-family:ui-monospace,SFMono-Regular,monospace;font-size:13px}\n.msg-error .msg-body{color:#f85149}\n.cursor{display:inline-block;width:7px;height:15px;background:#58a6ff;animation:blink 1s steps(2) infinite;vertical-align:text-bottom;margin-left:2px;border-radius:1px}\n@keyframes blink{50%{opacity:0}}\n#input-area{padding:12px 16px;background:#161b22;border-top:1px solid #30363d;flex-shrink:0}\n.input-inner{max-width:920px;margin:0 auto;display:flex;gap:8px}\n#input{flex:1;background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:8px;padding:10px 14px;font-family:inherit;font-size:14px;resize:none;height:44px;max-height:200px;line-height:1.5}\n#input:focus{outline:none;border-color:#58a6ff}\n#send{background:#238636;color:#fff;border:none;border-radius:8px;padding:0 20px;cursor:pointer;font-size:14px;font-weight:500;white-space:nowrap}\n#send:hover{background:#2ea043}\n#send:disabled{background:#21262d;color:#484f58;cursor:not-allowed}\n</style>\n</head>\n<body>\n<div id="hdr">\n <h1>⚡ MLX Code</h1>\n <div class="info">\n <span id="status">connecting...</span>\n <button onclick="clearChat()">Clear</button>\n </div>\n</div>\n<div id="chat"><div class="chat-inner" id="chatInner"></div></div>\n<div id="input-area">\n <div class="input-inner">\n <textarea id="input" placeholder="Send a message... (Enter=send, Shift+Enter=newline)" rows="1"></textarea>\n <button id="send" onclick="send()">Send</button>\n </div>\n</div>\n<script>\nconst chatEl=document.getElementById(\'chat\');\nconst innerEl=document.getElementById(\'chatInner\');\nconst inputEl=document.getElementById(\'input\');\nconst sendBtn=document.getElementById(\'send\');\nconst statusEl=document.getElementById(\'status\');\nconst SYSTEM_PROMPT = \'You are a helpful assistant. You are running in a web chat mode with no tool execution capabilities. Answer the user directly and concisely.\';\nlet messages=[{role:\'system\',content:SYSTEM_PROMPT}];\nlet streaming=false;\n\ninputEl.addEventListener(\'keydown\',e=>{\n if(e.key===\'Enter\'&&!e.shiftKey){e.preventDefault();send();}\n});\ninputEl.addEventListener(\'input\',()=>{\n inputEl.style.height=\'auto\';\n inputEl.style.height=Math.min(inputEl.scrollHeight,200)+\'px\';\n});\n\nfunction scrollBottom(){chatEl.scrollTop=chatEl.scrollHeight;}\n\nfunction addMsg(role,label){\n const d=document.createElement(\'div\');\n d.className=\'msg msg-\'+role;\n const r=document.createElement(\'div\');r.className=\'msg-role\';r.textContent=label;\n const b=document.createElement(\'div\');b.className=\'msg-body\';\n d.appendChild(r);d.appendChild(b);\n innerEl.appendChild(d);scrollBottom();return b;\n}\n\nfunction clearChat(){\n if(streaming)return;\n messages=[{role:\'system\',content:SYSTEM_PROMPT}];innerEl.innerHTML=\'\';inputEl.focus();\n}\n\nfunction stripToolXml(text){\n // Remove complete <tool_call>...</tool_call> blocks\n text=text.replace(/<tool_call>[\\s\\S]*?<\\/tool_call>/g,\'\');\n // Handle incomplete <tool_call> at end (closing tag not yet received)\n const idx=text.lastIndexOf(\'<tool_call>\');\n if(idx!==-1&&text.indexOf(\'</tool_call>\',idx)===-1)return text.substring(0,idx);\n // Handle partial opening tag at end (e.g. "<tool", "<tool_c")\n const tag=\'<tool_call>\';\n for(let i=tag.length-1;i>0;i--){\n if(text.endsWith(tag.substring(0,i)))return text.substring(0,text.length-i);\n }\n return text;\n}\n\nfunction checkHealth(){\n fetch(\'/health\').then(r=>r.json()).then(d=>{\n statusEl.textContent=d.model||\'ready\';\n }).catch(()=>{statusEl.textContent=\'offline\';});\n}\n\nasync function send(){\n const text=inputEl.value.trim();\n if(!text||streaming)return;\n inputEl.value=\'\';inputEl.style.height=\'auto\';\n messages.push({role:\'user\',content:text});\n addMsg(\'user\',\'≫ You\').textContent=text;\n streaming=true;sendBtn.disabled=true;statusEl.textContent=\'generating...\';\n\n const aBody=addMsg(\'assistant\',\'○ Assistant\');\n let tBody=null,displayText=\'\',thinkText=\'\',rawText=\'\',toolCalls=[];\n const cursor=document.createElement(\'span\');cursor.className=\'cursor\';aBody.appendChild(cursor);\n\n try{\n const resp=await fetch(\'/v1/chat/completions\',{\n method:\'POST\',\n headers:{\'Content-Type\':\'application/json\'},\n body:JSON.stringify({messages,max_tokens:8192})\n });\n\n if(!resp.ok){\n cursor.remove();\n let msg=\'HTTP \'+resp.status;\n try{const e=await resp.json();msg+=\': \'+(e.error||JSON.stringify(e));}catch(_){try{msg+=\': \'+await resp.text();}catch(_){}}\n aBody.textContent=\'✗ \'+msg;\n aBody.parentElement.classList.add(\'msg-error\');\n messages.pop();return;\n }\n\n const reader=resp.body.getReader();\n const dec=new TextDecoder();\n let buf=\'\';\n\n while(true){\n const{done,value}=await reader.read();\n if(done)break;\n buf+=dec.decode(value,{stream:true});\n const lines=buf.split(\'\\n\');\n buf=lines.pop(); // keep partial line in buffer\n\n for(const line of lines){\n if(!line.startsWith(\'data: \'))continue;\n const data=line.slice(6).trim();\n if(!data||data===\'[DONE]\')continue;\n let ch;try{ch=JSON.parse(data);}catch(_){continue;}\n const delta=ch.choices&&ch.choices[0]&&ch.choices[0].delta;\n if(!delta)continue;\n\n if(delta.reasoning_content){\n if(!tBody){\n cursor.remove();\n tBody=addMsg(\'thinking\',\'◌ Thinking\');\n tBody.appendChild(cursor.cloneNode());\n }\n thinkText+=delta.reasoning_content;\n const c=tBody.querySelector(\'.cursor\');if(c)c.remove();\n tBody.textContent=thinkText;\n tBody.appendChild(cursor.cloneNode());\n scrollBottom();\n }\n\n if(delta.content){\n if(tBody){\n const c=tBody.querySelector(\'.cursor\');if(c)c.remove();\n tBody=null;aBody.appendChild(cursor);\n }\n rawText+=delta.content;\n displayText=stripToolXml(rawText);\n cursor.remove();aBody.textContent=displayText;aBody.appendChild(cursor);\n scrollBottom();\n }\n\n if(delta.tool_calls){\n cursor.remove();\n for(const tc of delta.tool_calls){\n const fn=tc.function||{};\n if(fn.name){\n toolCalls.push({name:fn.name,args:\'\'});\n addMsg(\'tool\',\'⚙ \'+fn.name).textContent=fn.name;\n }\n if(fn.arguments&&toolCalls.length>0){\n toolCalls[toolCalls.length-1].args+=fn.arguments;\n const tbs=innerEl.querySelectorAll(\'.msg-tool .msg-body\');\n if(tbs.length>0){\n let disp=toolCalls[toolCalls.length-1].name+\'\\n\';\n try{disp+=JSON.stringify(JSON.parse(toolCalls[toolCalls.length-1].args),null,2);}\n catch(_){disp+=toolCalls[toolCalls.length-1].args;}\n tbs[tbs.length-1].textContent=disp;\n }\n }\n }\n scrollBottom();\n }\n }\n }\n\n cursor.remove();\n if(displayText.trim()){\n aBody.textContent=displayText;\n messages.push({role:\'assistant\',content:displayText});\n }else{\n aBody.textContent=thinkText?\'(thinking only)\':\'(no output)\';\n messages.push({role:\'assistant\',content:displayText});\n }\n if(toolCalls.length>0){\n addMsg(\'tool\',\'⚠ Note\').textContent=\'Tool calls cannot be executed in the web UI. Use the terminal REPL (--bare) for full tool support.\';\n }\n }catch(e){\n cursor.remove();\n aBody.textContent=\'✗ \'+e.message;\n aBody.parentElement.classList.add(\'msg-error\');\n if(messages.length>0&&messages[messages.length-1].role===\'user\')messages.pop();\n }finally{\n streaming=false;sendBtn.disabled=false;\n checkHealth();inputEl.focus();\n }\n}\n\ncheckHealth();inputEl.focus();\n</script>\n</body>\n</html>'
1
2
  import asyncio
2
3
  import json
3
4
  import queue as _queue
@@ -11,7 +12,7 @@ from pathlib import Path
11
12
  import mlx.core as mx
12
13
  from starlette.applications import Starlette
13
14
  from starlette.requests import Request
14
- from starlette.responses import StreamingResponse, JSONResponse
15
+ from starlette.responses import StreamingResponse, JSONResponse, HTMLResponse
15
16
  from starlette.routing import Route
16
17
  import logging
17
18
  logger = logging.getLogger(__name__)
@@ -944,6 +944,8 @@ def main():
944
944
  parser.add_argument('--skips', nargs='+', default=['(?m)^\\[SUGGESTION MODE[\\s\\S]*', '(?m)^<system-reminder>[\\s\\S]*?^</system-reminder>\\s*'], help='Regex patterns stripped from model output before it is returned to the client')
945
945
  parser.add_argument('--stream', default=None, help='File to stream log into')
946
946
  parser.add_argument('--bare', action='store_true', help='Use simple terminal REPL instead of TUI')
947
+ parser.add_argument('--web', action='store_true', help='Use web UI instead of TUI')
948
+ parser.add_argument('--web-port', type=int, default=None, help='Port for web UI (default: inference port + 80)')
947
949
  args, leash_args = parser.parse_known_args()
948
950
  logger.debug(f'args={args!r} leash_args={leash_args!r}')
949
951
  if args.engine == 'batch' and args.leash not in ('none', 'noapi'):
@@ -979,8 +981,13 @@ def main():
979
981
  if args.engine == 'cache':
980
982
  threading.Thread(target=server.serve_forever, daemon=True).start()
981
983
  if args.leash == 'noapi':
982
- from .repl import run_repl
983
- run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, bare=args.bare)
984
+ if args.web:
985
+ from .web import run_web
986
+ web_port = args.web_port if args.web_port is not None else port + 80
987
+ run_web(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, host=args.host, port=web_port)
988
+ else:
989
+ from .repl import run_repl
990
+ run_repl(base_url=url, api=args.leash, repo=cwd, env=env, system=args.system, tool_names=args.tools, sdir=args.skill, init_prompt=args.prompt, resume=args.resume, stream=args.stream, bare=args.bare)
984
991
  else:
985
992
  env['GOOGLE_GEMINI_BASE_URL'] = url
986
993
  env['GEMINI_API_KEY'] = 'mc'