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.
- {mlx_code-0.0.26 → mlx_code-0.0.28}/PKG-INFO +45 -32
- {mlx_code-0.0.26 → mlx_code-0.0.28}/README.md +44 -31
- mlx_code-0.0.28/mlx_code/bare.py +276 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/bats.py +2 -1
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/main.py +9 -2
- mlx_code-0.0.28/mlx_code/repl.py +808 -0
- mlx_code-0.0.28/mlx_code/tui.py +576 -0
- mlx_code-0.0.28/mlx_code/web.py +485 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/PKG-INFO +45 -32
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/SOURCES.txt +2 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/setup.py +1 -2
- mlx_code-0.0.26/mlx_code/bare.py +0 -484
- mlx_code-0.0.26/mlx_code/repl.py +0 -1122
- {mlx_code-0.0.26 → mlx_code-0.0.28}/LICENSE +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/__init__.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/apis.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/gits.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/lsp_tool.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/mcb.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/mcb_tool.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/stream_log.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/tools.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/util.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/view_git.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code/view_log.py +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/dependency_links.txt +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/entry_points.txt +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/requires.txt +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/mlx_code.egg-info/top_level.txt +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/setup.cfg +0 -0
- {mlx_code-0.0.26 → mlx_code-0.0.28}/tests/__init__.py +0 -0
- {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.
|
|
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
|
-
[](https://youtube.com/shorts/1LuifKFKixc)
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
47
|
## Architecture
|
|
48
48
|
|
|
49
49
|
```
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
│ │
|
|
79
|
-
│ │
|
|
80
|
-
│ │
|
|
81
|
-
│
|
|
82
|
-
│
|
|
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` |
|
|
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
|
|
538
|
-
mlc --
|
|
539
|
-
mlc --
|
|
540
|
-
mlc --leash none #
|
|
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
|
-
[](https://youtube.com/shorts/1LuifKFKixc)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Architecture
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
│ │
|
|
41
|
-
│ │
|
|
42
|
-
│ │
|
|
43
|
-
│
|
|
44
|
-
│
|
|
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` |
|
|
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
|
|
500
|
-
mlc --
|
|
501
|
-
mlc --
|
|
502
|
-
mlc --leash none #
|
|
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
|
-
|
|
983
|
-
|
|
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'
|