mlx-code 0.0.26__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.26 → mlx_code-0.0.28}/PKG-INFO +45 -32
  2. {mlx_code-0.0.26 → mlx_code-0.0.28}/README.md +44 -31
  3. mlx_code-0.0.28/mlx_code/bare.py +276 -0
  4. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/bats.py +2 -1
  5. {mlx_code-0.0.26 → 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.26 → mlx_code-0.0.28}/mlx_code.egg-info/PKG-INFO +45 -32
  10. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/SOURCES.txt +2 -0
  11. {mlx_code-0.0.26 → mlx_code-0.0.28}/setup.py +1 -2
  12. mlx_code-0.0.26/mlx_code/bare.py +0 -484
  13. mlx_code-0.0.26/mlx_code/repl.py +0 -1122
  14. {mlx_code-0.0.26 → mlx_code-0.0.28}/LICENSE +0 -0
  15. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/__init__.py +0 -0
  16. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/apis.py +0 -0
  17. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/gits.py +0 -0
  18. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/lsp_tool.py +0 -0
  19. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/mcb.py +0 -0
  20. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/mcb_tool.py +0 -0
  21. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/stream_log.py +0 -0
  22. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/tools.py +0 -0
  23. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/util.py +0 -0
  24. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/view_git.py +0 -0
  25. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/view_log.py +0 -0
  26. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/dependency_links.txt +0 -0
  27. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/entry_points.txt +0 -0
  28. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/requires.txt +0 -0
  29. {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/top_level.txt +0 -0
  30. {mlx_code-0.0.26 → mlx_code-0.0.28}/setup.cfg +0 -0
  31. {mlx_code-0.0.26 → mlx_code-0.0.28}/tests/__init__.py +0 -0
  32. {mlx_code-0.0.26 → 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.26
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,16 +40,16 @@ 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
 
47
47
  ## Architecture
48
48
 
49
49
  ```
50
- Conversation tree (nodes = git commits with embedded chat history)
50
+ Worktrees:
51
51
 
52
- main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●
52
+ main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat hx
53
53
  │ │
54
54
  │ └── branch-1 ──●──●──●
55
55
  │ │ ┌────────────┐
@@ -57,31 +57,29 @@ Conversation tree (nodes = git commits with embedded chat history)
57
57
  │ └─────┬──────┘
58
58
  └── branch-0 ──●──●──● │
59
59
 
60
+ Tabs: ├────────────► Tab = git branch + Agent
60
61
 
61
- REPL tabs (each tab = a git branch + agent) │
62
-
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
- ├─────────────────────────────────────────► Each tab is an independent Agent
69
+ Agents: ├─────────────────────────────────────────► Each tab runs its own Agent
72
70
 
73
71
  ┌────┴─────────────────────────────────────┐
74
72
  │ Agent │
75
73
  │ ┌────────────────┐ ┌────────────────┐ │
76
74
  │ │ API: │ │ Tools: │ │
77
75
  │ │ Local (mlx-lm) │ │ Read Write │ │
78
- │ │ Claude │ │ Edit Bash │ │
79
- │ │ Gemini │ │ Grep Find │ │
80
- │ │ OpenAI │ │ Ls Skill │ │
81
- └────────────────┘ │ Agent ─────────┼──┼───► Spawns child Agent
82
- └────────────────┘ │ (each with own tools + worktree + etc)
83
- │ Git worktree │
84
- │ (isolation + session state) │
76
+ │ │ Gemini │ │ Edit Bash │ │
77
+ │ │ Claude │ │ Grep Find │ │
78
+ │ │ Codex │ │ Ls Skill │ │
79
+ │ DeepSeek │ │ Agent ─────────┼──┼───► Recursively spawns sub-Agents
80
+ └────────────────┘ └────────────────┘
81
+ │ Git worktree │
82
+ │ (isolation + session state) │
85
83
  └──────────────────────────────────────────┘
86
84
  ```
87
85
 
@@ -97,6 +95,15 @@ result = await agent.run('refactor utils.py to use dataclasses')
97
95
 
98
96
  ---
99
97
 
98
+ ## Core ideas
99
+
100
+ - **Git is the state machine.** Every file-changing agent step is committed with the conversation that produced it, so you can inspect, resume, and branch from any checkpoint.
101
+ - **Branches are alternative futures.** A branch is not just a Git branch; it is a different reasoning path with its own worktree and session state.
102
+ - **Agents are the primitive.** Tabs, branches, and delegated subtasks are all instances of the same `Agent` abstraction.
103
+ - **Worktrees provide isolation.** The agent edits in a separate worktree, so your main checkout stays clean and recoverable.
104
+
105
+ ---
106
+
100
107
  ## Quick start
101
108
 
102
109
  ```bash
@@ -115,15 +122,6 @@ That's it. The first run starts a local inference server and drops you into the
115
122
 
116
123
  ---
117
124
 
118
- ## Core ideas
119
-
120
- - **Git is the state machine.** Every file-changing agent step is committed with the conversation that produced it, so you can inspect, resume, and branch from any checkpoint.
121
- - **Branches are alternative futures.** A branch is not just a Git branch; it is a different reasoning path with its own worktree and session state.
122
- - **Agents are the primitive.** Tabs, branches, and delegated subtasks are all instances of the same `Agent` abstraction.
123
- - **Worktrees provide isolation.** The agent edits in a separate worktree, so your main checkout stays clean and recoverable.
124
-
125
- ---
126
-
127
125
  ## Why mlx-code
128
126
 
129
127
  **Agents as reusable workflow atoms.** Tabs, branches, and tasks are all managed within instances of `Agent`. Each one gets its own conversation, its own tools, and its own worktree. Agents can spawn sub-agents to delegate subtasks, and each child is a full agent with its own scoped tool set.
@@ -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,16 +2,16 @@
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
 
9
9
  ## Architecture
10
10
 
11
11
  ```
12
- Conversation tree (nodes = git commits with embedded chat history)
12
+ Worktrees:
13
13
 
14
- main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●
14
+ main ──●──●──●──●──●──●──●──●──●──●──●──●──●──●───────────► Node = git commit + chat hx
15
15
  │ │
16
16
  │ └── branch-1 ──●──●──●
17
17
  │ │ ┌────────────┐
@@ -19,31 +19,29 @@ Conversation tree (nodes = git commits with embedded chat history)
19
19
  │ └─────┬──────┘
20
20
  └── branch-0 ──●──●──● │
21
21
 
22
+ Tabs: ├────────────► Tab = git branch + Agent
22
23
 
23
- REPL tabs (each tab = a git branch + agent) │
24
-
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
- ├─────────────────────────────────────────► Each tab is an independent Agent
31
+ Agents: ├─────────────────────────────────────────► Each tab runs its own Agent
34
32
 
35
33
  ┌────┴─────────────────────────────────────┐
36
34
  │ Agent │
37
35
  │ ┌────────────────┐ ┌────────────────┐ │
38
36
  │ │ API: │ │ Tools: │ │
39
37
  │ │ Local (mlx-lm) │ │ Read Write │ │
40
- │ │ Claude │ │ Edit Bash │ │
41
- │ │ Gemini │ │ Grep Find │ │
42
- │ │ OpenAI │ │ Ls Skill │ │
43
- └────────────────┘ │ Agent ─────────┼──┼───► Spawns child Agent
44
- └────────────────┘ │ (each with own tools + worktree + etc)
45
- │ Git worktree │
46
- │ (isolation + session state) │
38
+ │ │ Gemini │ │ Edit Bash │ │
39
+ │ │ Claude │ │ Grep Find │ │
40
+ │ │ Codex │ │ Ls Skill │ │
41
+ │ DeepSeek │ │ Agent ─────────┼──┼───► Recursively spawns sub-Agents
42
+ └────────────────┘ └────────────────┘
43
+ │ Git worktree │
44
+ │ (isolation + session state) │
47
45
  └──────────────────────────────────────────┘
48
46
  ```
49
47
 
@@ -59,6 +57,15 @@ result = await agent.run('refactor utils.py to use dataclasses')
59
57
 
60
58
  ---
61
59
 
60
+ ## Core ideas
61
+
62
+ - **Git is the state machine.** Every file-changing agent step is committed with the conversation that produced it, so you can inspect, resume, and branch from any checkpoint.
63
+ - **Branches are alternative futures.** A branch is not just a Git branch; it is a different reasoning path with its own worktree and session state.
64
+ - **Agents are the primitive.** Tabs, branches, and delegated subtasks are all instances of the same `Agent` abstraction.
65
+ - **Worktrees provide isolation.** The agent edits in a separate worktree, so your main checkout stays clean and recoverable.
66
+
67
+ ---
68
+
62
69
  ## Quick start
63
70
 
64
71
  ```bash
@@ -77,15 +84,6 @@ That's it. The first run starts a local inference server and drops you into the
77
84
 
78
85
  ---
79
86
 
80
- ## Core ideas
81
-
82
- - **Git is the state machine.** Every file-changing agent step is committed with the conversation that produced it, so you can inspect, resume, and branch from any checkpoint.
83
- - **Branches are alternative futures.** A branch is not just a Git branch; it is a different reasoning path with its own worktree and session state.
84
- - **Agents are the primitive.** Tabs, branches, and delegated subtasks are all instances of the same `Agent` abstraction.
85
- - **Worktrees provide isolation.** The agent edits in a separate worktree, so your main checkout stays clean and recoverable.
86
-
87
- ---
88
-
89
87
  ## Why mlx-code
90
88
 
91
89
  **Agents as reusable workflow atoms.** Tabs, branches, and tasks are all managed within instances of `Agent`. Each one gets its own conversation, its own tools, and its own worktree. Agents can spawn sub-agents to delegate subtasks, and each child is a full agent with its own scoped tool set.
@@ -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'