lcode-cli 0.32.0__py3-none-any.whl → 0.33.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +1 -1
- lcode/agent.py +50 -14
- lcode/permissions.py +3 -1
- lcode/repl.py +30 -4
- lcode/sessions.py +3 -1
- lcode/steer.py +287 -0
- lcode/tools.py +4 -0
- {lcode_cli-0.32.0.dist-info → lcode_cli-0.33.1.dist-info}/METADATA +11 -10
- {lcode_cli-0.32.0.dist-info → lcode_cli-0.33.1.dist-info}/RECORD +12 -11
- {lcode_cli-0.32.0.dist-info → lcode_cli-0.33.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.32.0.dist-info → lcode_cli-0.33.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.32.0.dist-info → lcode_cli-0.33.1.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -52,6 +52,7 @@ from lcode.permissions import Permissions
|
|
|
52
52
|
from lcode.render import MarkdownStreamer
|
|
53
53
|
from lcode.repeats import Repeats
|
|
54
54
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
55
|
+
from lcode.steer import NOTE_INTRO, Steering
|
|
55
56
|
from lcode.tools import (
|
|
56
57
|
LSP_SCHEMA,
|
|
57
58
|
MEMORY_SCHEMA,
|
|
@@ -224,10 +225,12 @@ class Progress:
|
|
|
224
225
|
arrives, for example while the model writes a long file into a tool call.
|
|
225
226
|
"""
|
|
226
227
|
|
|
227
|
-
def __init__(self, phase: str = "Thinking") -> None:
|
|
228
|
+
def __init__(self, phase: str = "Thinking", steering: Steering | None = None, tokens: bool = True) -> None:
|
|
228
229
|
self.started = self.last_chunk = time.time()
|
|
229
230
|
self.phase = phase
|
|
230
231
|
self.chars = 0
|
|
232
|
+
self.steering = steering # the note being typed and the notes waiting, shown on a second line
|
|
233
|
+
self.tokens = tokens # False for a command running: no tokens to count
|
|
231
234
|
|
|
232
235
|
def update(self, phase: str, text: str) -> None:
|
|
233
236
|
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
@@ -240,9 +243,11 @@ class Progress:
|
|
|
240
243
|
now = time.time()
|
|
241
244
|
streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
|
|
242
245
|
phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
+
tokens = f"{self.chars // 4} tokens, " if self.tokens else ""
|
|
247
|
+
keys = "Ctrl+C to stop" if self.steering is None else "type a note · Esc pauses · Ctrl+C stops"
|
|
248
|
+
line = Text.from_markup(f"[cyan]{phase}…[/] [dim]({tokens}{now - self.started:.0f}s · {keys})[/]")
|
|
249
|
+
below = self.steering.status() if self.steering is not None else None
|
|
250
|
+
return line if below is None else Text("\n").join([line, below])
|
|
246
251
|
|
|
247
252
|
|
|
248
253
|
@dataclass
|
|
@@ -321,6 +326,9 @@ class Agent:
|
|
|
321
326
|
self.read_only = False # only read-only shell commands
|
|
322
327
|
self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
|
|
323
328
|
self.cancel: threading.Event | None = None # set from another thread to stop
|
|
329
|
+
self.steering: Steering | None = None # notes typed and pauses asked for during a request (lcode.repl)
|
|
330
|
+
self.turn_requests: list[str] = [] # the request, then the notes added to it while it ran
|
|
331
|
+
self.unsent = "" # typed during the last request but never sent to the model: back in the prompt (lcode.repl)
|
|
324
332
|
self.on_tool = None # called with (name, arguments) before each tool runs
|
|
325
333
|
self.jobs = Jobs() # background commands (shared with subagents)
|
|
326
334
|
self.turn_started = 0.0 # when the running request started (time.monotonic)
|
|
@@ -749,7 +757,7 @@ class Agent:
|
|
|
749
757
|
"""Stream one model response, rendering thinking/content live. Returns the assistant message."""
|
|
750
758
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
751
759
|
md = MarkdownStreamer(self.console)
|
|
752
|
-
progress = Progress()
|
|
760
|
+
progress = Progress(steering=self.steering)
|
|
753
761
|
watch = self.before_request(progress)
|
|
754
762
|
status = None
|
|
755
763
|
printed_thinking = False
|
|
@@ -845,15 +853,10 @@ class Agent:
|
|
|
845
853
|
diff, changed = review.diff_of(self.tools.before, self.current_text, budget)
|
|
846
854
|
if changed < review.MIN_CHANGED_LINES:
|
|
847
855
|
return []
|
|
848
|
-
request =
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
if m.get("role") == "user"
|
|
853
|
-
and not str(m.get("content", "")).startswith(("[lcode]", sessions.SUMMARY_PREFIX))
|
|
854
|
-
),
|
|
855
|
-
"",
|
|
856
|
-
)
|
|
856
|
+
request = self.turn_requests[0] if self.turn_requests else ""
|
|
857
|
+
if len(self.turn_requests) > 1:
|
|
858
|
+
added = "\n".join(f"- {note}" for note in self.turn_requests[1:])
|
|
859
|
+
request += f"\n\nWhat the user added while you worked:\n{added}"
|
|
857
860
|
try:
|
|
858
861
|
with self.console.status("Reviewing the change against your request…", spinner="dots"):
|
|
859
862
|
problems = review.run(self, request, answer, diff)
|
|
@@ -1069,6 +1072,7 @@ class Agent:
|
|
|
1069
1072
|
self.turn_status = "running"
|
|
1070
1073
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
1071
1074
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1075
|
+
self.turn_requests = [user_text.strip()]
|
|
1072
1076
|
self.repeats.reset()
|
|
1073
1077
|
self.check_results = []
|
|
1074
1078
|
self.review_results = None
|
|
@@ -1081,6 +1085,8 @@ class Agent:
|
|
|
1081
1085
|
for step in range(self.max_steps):
|
|
1082
1086
|
if self.cancel is not None and self.cancel.is_set():
|
|
1083
1087
|
raise KeyboardInterrupt
|
|
1088
|
+
if step and self.steering is not None:
|
|
1089
|
+
self.steer()
|
|
1084
1090
|
if step and self.autosave:
|
|
1085
1091
|
self.save() # a crash or a closed terminal loses at most this step
|
|
1086
1092
|
self.maybe_compact()
|
|
@@ -1126,6 +1132,8 @@ class Agent:
|
|
|
1126
1132
|
)
|
|
1127
1133
|
continue
|
|
1128
1134
|
if not calls:
|
|
1135
|
+
if self.steering is not None and self.steering.waiting():
|
|
1136
|
+
continue # the user added a note while the model wrote its answer: it reads the note first
|
|
1129
1137
|
check = self.project_check() if changed and not verified else None
|
|
1130
1138
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1131
1139
|
check_rounds += 1
|
|
@@ -1241,6 +1249,34 @@ class Agent:
|
|
|
1241
1249
|
self.turn_status = "success"
|
|
1242
1250
|
self.print_stats()
|
|
1243
1251
|
|
|
1252
|
+
def steer(self) -> None:
|
|
1253
|
+
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1254
|
+
steering = self.steering
|
|
1255
|
+
if steering is None:
|
|
1256
|
+
return
|
|
1257
|
+
if steering.take_pause():
|
|
1258
|
+
self.console.print(
|
|
1259
|
+
Text(
|
|
1260
|
+
"⏸ Paused. Press Enter to go on, type a note to go on with it, or press Ctrl+C to stop the request.",
|
|
1261
|
+
style="yellow",
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
note = steering.ask(" ⏸ ").strip()
|
|
1265
|
+
if note:
|
|
1266
|
+
steering.add(note)
|
|
1267
|
+
notes = steering.take()
|
|
1268
|
+
if not notes:
|
|
1269
|
+
return
|
|
1270
|
+
self.turn_requests += notes
|
|
1271
|
+
self.messages.append({"role": "user", "content": "\n\n".join(notes) + "\n\n" + NOTE_INTRO})
|
|
1272
|
+
for note in notes:
|
|
1273
|
+
shown = note if len(note) <= 200 else note[:200] + "…"
|
|
1274
|
+
self.console.print(Text(f"✎ Note for the model: {shown}", style="cyan"))
|
|
1275
|
+
|
|
1276
|
+
def activity(self, phase: str):
|
|
1277
|
+
"""A spinner with the elapsed time while something runs (a command), with the note being typed under it."""
|
|
1278
|
+
return self.console.status(Progress(phase, self.steering, tokens=False), spinner="dots")
|
|
1279
|
+
|
|
1244
1280
|
def print_stats(self) -> None:
|
|
1245
1281
|
pct = 100 * self.ctx_used / self.settings.context
|
|
1246
1282
|
self.console.print(
|
lcode/permissions.py
CHANGED
|
@@ -198,6 +198,7 @@ class Permissions:
|
|
|
198
198
|
self.always: set[str] = set()
|
|
199
199
|
self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
|
|
200
200
|
self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
|
|
201
|
+
self.read_line: Callable[[str], str] | None = None # asks instead of input() (lcode.steer, during a request)
|
|
201
202
|
# Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
|
|
202
203
|
self.approve: Callable[[dict], bool | str] | None = None
|
|
203
204
|
|
|
@@ -243,7 +244,8 @@ class Permissions:
|
|
|
243
244
|
if kind == "mcp":
|
|
244
245
|
scope = scope.replace(":", " › ", 1)
|
|
245
246
|
try:
|
|
246
|
-
|
|
247
|
+
question = f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): "
|
|
248
|
+
answer = (self.read_line or input)(question)
|
|
247
249
|
except EOFError:
|
|
248
250
|
return False, "The user denied this action (no input available)."
|
|
249
251
|
answer = answer.strip()
|
lcode/repl.py
CHANGED
|
@@ -36,6 +36,7 @@ from lcode.checkpoints import Checkpoint, CheckpointError, Restore
|
|
|
36
36
|
from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
|
|
37
37
|
from lcode.hardware import Hardware
|
|
38
38
|
from lcode.ollama import OllamaError
|
|
39
|
+
from lcode.steer import KeyReader, Steering
|
|
39
40
|
from lcode.tools import IGNORE_DIRS
|
|
40
41
|
|
|
41
42
|
COMMANDS = {
|
|
@@ -874,6 +875,7 @@ def git_command(agent: Agent, cmd: str, arg: str) -> None:
|
|
|
874
875
|
|
|
875
876
|
def run_safely(agent: Agent, text: str) -> None:
|
|
876
877
|
started = time.monotonic()
|
|
878
|
+
reader = start_steering(agent) if agent.interactive else None
|
|
877
879
|
try:
|
|
878
880
|
agent.run_turn(text)
|
|
879
881
|
except KeyboardInterrupt:
|
|
@@ -881,10 +883,33 @@ def run_safely(agent: Agent, text: str) -> None:
|
|
|
881
883
|
except OllamaError as e:
|
|
882
884
|
agent.console.print(f"[red]{e}[/]")
|
|
883
885
|
finally:
|
|
886
|
+
if reader is not None:
|
|
887
|
+
stop_steering(agent, reader)
|
|
884
888
|
agent.save()
|
|
885
889
|
agent.finished(text, time.monotonic() - started)
|
|
886
890
|
|
|
887
891
|
|
|
892
|
+
def start_steering(agent: Agent) -> KeyReader | None:
|
|
893
|
+
"""Read the keyboard while the request runs: a typed line becomes a note for the model, Esc pauses."""
|
|
894
|
+
steering = Steering()
|
|
895
|
+
reader = KeyReader(steering)
|
|
896
|
+
if not reader.start():
|
|
897
|
+
return None # not a terminal lcode can read keys from (or Windows): the request runs as before
|
|
898
|
+
agent.steering = steering
|
|
899
|
+
agent.perms.read_line = steering.ask # questions take the keyboard back meanwhile
|
|
900
|
+
return reader
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def stop_steering(agent: Agent, reader: KeyReader) -> None:
|
|
904
|
+
reader.stop()
|
|
905
|
+
agent.steering = None
|
|
906
|
+
agent.perms.read_line = None
|
|
907
|
+
unsent = reader.steering.leftover()
|
|
908
|
+
if unsent:
|
|
909
|
+
agent.unsent = unsent
|
|
910
|
+
agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
|
|
911
|
+
|
|
912
|
+
|
|
888
913
|
def session_start(agent: Agent) -> None:
|
|
889
914
|
"""session_start hooks: their output is shown, and with feedback = true also given to the model."""
|
|
890
915
|
for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
|
|
@@ -1115,13 +1140,13 @@ def resume_session(agent: Agent, info: sessions.SessionInfo) -> None:
|
|
|
1115
1140
|
|
|
1116
1141
|
def print_recap(agent: Agent) -> None:
|
|
1117
1142
|
"""Show the last request and the start of the last answer, so it's clear where things left off."""
|
|
1118
|
-
|
|
1143
|
+
requests = context_tools.user_requests(agent.messages) # the user's own words, not lcode's notes to the model
|
|
1119
1144
|
last_answer = next(
|
|
1120
1145
|
(m for m in reversed(agent.messages) if m.get("role") == "assistant" and m.get("content", "").strip()), None
|
|
1121
1146
|
)
|
|
1122
|
-
if not
|
|
1147
|
+
if not requests:
|
|
1123
1148
|
return
|
|
1124
|
-
lines = [f"[bold]You:[/] {escape(sessions.title_from([
|
|
1149
|
+
lines = [f"[bold]You:[/] {escape(sessions.title_from([{'role': 'user', 'content': requests[-1]}]))}"]
|
|
1125
1150
|
if last_answer:
|
|
1126
1151
|
answer = " ".join(last_answer["content"].split())
|
|
1127
1152
|
answer = answer if len(answer) <= 300 else answer[:299].rstrip() + "…"
|
|
@@ -1153,7 +1178,8 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1153
1178
|
while True:
|
|
1154
1179
|
try:
|
|
1155
1180
|
agent.console.print()
|
|
1156
|
-
|
|
1181
|
+
unsent, agent.unsent = agent.unsent, ""
|
|
1182
|
+
line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>"), default=unsent).strip()
|
|
1157
1183
|
except KeyboardInterrupt:
|
|
1158
1184
|
continue
|
|
1159
1185
|
except EOFError:
|
lcode/sessions.py
CHANGED
|
@@ -45,7 +45,9 @@ def title_from(messages: list[dict]) -> str:
|
|
|
45
45
|
if content.startswith(SUMMARY_PREFIX):
|
|
46
46
|
compacted = True
|
|
47
47
|
continue
|
|
48
|
-
|
|
48
|
+
if content.startswith("[lcode]"):
|
|
49
|
+
continue # lcode's own message to the model (a failed check, a review), not the user's words
|
|
50
|
+
text = re.split(r"\n\n(?:<(?:file|directory|image) path=|\[lcode[:\]] )", content, maxsplit=1)[0]
|
|
49
51
|
text = " ".join(text.split())
|
|
50
52
|
if text:
|
|
51
53
|
return text if len(text) <= TITLE_LENGTH else text[: TITLE_LENGTH - 1].rstrip() + "…"
|
lcode/steer.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Steer a request while it runs: type a note for the model, or press Esc to pause after the step.
|
|
2
|
+
|
|
3
|
+
With a local model a request can take minutes. Ctrl+C throws away the step in progress; a note doesn't
|
|
4
|
+
interrupt anything: a line typed while lcode works is kept and handed to the model before its next step, and the
|
|
5
|
+
spinner line shows what's being typed and what's waiting. Esc asks lcode to stop after the current step, so you
|
|
6
|
+
can look at what it did and then go on, with or without a note.
|
|
7
|
+
|
|
8
|
+
`Steering` holds the state and turns keystrokes into notes; it knows nothing about terminals, so it's easy to
|
|
9
|
+
test. `KeyReader` reads the keys from the terminal in the background, without echoing them (that would garble
|
|
10
|
+
the output), and hands the keyboard back whenever lcode asks a question (`Steering.ask`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import codecs
|
|
16
|
+
import contextlib
|
|
17
|
+
import os
|
|
18
|
+
import select
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Iterator
|
|
23
|
+
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import termios
|
|
28
|
+
except ImportError: # Windows: no steering, everything else works
|
|
29
|
+
termios = None # type: ignore[assignment]
|
|
30
|
+
|
|
31
|
+
MAX_NOTE = 4000 # characters in one note
|
|
32
|
+
SHOWN = 45 # characters of the line being typed shown under the spinner (the line fits 80 columns)
|
|
33
|
+
ESC_WAIT = 0.03 # an Esc followed by more bytes this soon is an arrow or function key, not the Esc key
|
|
34
|
+
TOO_QUICK = 0.4 # an Enter this soon after a question appears was meant for the note being typed
|
|
35
|
+
NOTE_INTRO = (
|
|
36
|
+
"[lcode] The user wrote this while you were working. Take it into account from here on; it doesn't mean "
|
|
37
|
+
"starting over unless it says so."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Steering:
|
|
42
|
+
"""Notes typed during a request, the line being typed, and whether to pause after the step."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self.lock = threading.Lock()
|
|
46
|
+
self.typed = "" # the line being typed
|
|
47
|
+
self.notes: list[str] = [] # finished lines, waiting for the model's next step
|
|
48
|
+
self.pause = False # stop after the current step
|
|
49
|
+
self.reader: KeyReader | None = None
|
|
50
|
+
self.read_line: Callable[[str], str] | None = None # input() when None (looked up when asking)
|
|
51
|
+
|
|
52
|
+
# Input ------------------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def feed(self, keys: str) -> None:
|
|
55
|
+
"""Keystrokes as text: printable characters, Enter, Backspace, Ctrl+U/Ctrl+W, Esc and escape sequences.
|
|
56
|
+
|
|
57
|
+
A lone "\\x1b" is the Esc key (KeyReader waits a moment for the rest of a sequence before it hands one
|
|
58
|
+
over). Everything else that starts with it is skipped: the sequences arrow and function keys send,
|
|
59
|
+
Alt+key, and a terminal's answers to queries (cursor position, colours).
|
|
60
|
+
"""
|
|
61
|
+
with self.lock:
|
|
62
|
+
i = 0
|
|
63
|
+
while i < len(keys):
|
|
64
|
+
ch = keys[i]
|
|
65
|
+
i += 1
|
|
66
|
+
if ch == "\x1b":
|
|
67
|
+
after = keys[i] if i < len(keys) else ""
|
|
68
|
+
if after == "[": # CSI: parameters, then a final byte in @..~
|
|
69
|
+
i += 1
|
|
70
|
+
while i < len(keys) and not ("@" <= keys[i] <= "~"):
|
|
71
|
+
i += 1
|
|
72
|
+
i += 1
|
|
73
|
+
elif after == "O": # SS3: one more character (F1-F4, keypad)
|
|
74
|
+
i += 2
|
|
75
|
+
elif after in ("]", "P", "_", "^"): # a string (OSC, DCS…), ended by BEL or ESC \
|
|
76
|
+
end = min(
|
|
77
|
+
(j for j in (keys.find("\x07", i), keys.find("\x1b\\", i)) if j >= 0), default=len(keys)
|
|
78
|
+
)
|
|
79
|
+
i = end + (2 if keys.startswith("\x1b\\", end) else 1)
|
|
80
|
+
elif after in ("", "\x1b"): # the Esc key (twice in a row: two presses)
|
|
81
|
+
self.pause = not self.pause
|
|
82
|
+
else:
|
|
83
|
+
i += 1 # Alt+key
|
|
84
|
+
elif ch in "\r\n":
|
|
85
|
+
note = self.typed.strip()
|
|
86
|
+
self.typed = ""
|
|
87
|
+
if note:
|
|
88
|
+
self.notes.append(note[:MAX_NOTE])
|
|
89
|
+
elif ch in "\x7f\x08":
|
|
90
|
+
self.typed = self.typed[:-1]
|
|
91
|
+
elif ch == "\x15": # Ctrl+U: clear the line
|
|
92
|
+
self.typed = ""
|
|
93
|
+
elif ch == "\x17": # Ctrl+W: delete the last word
|
|
94
|
+
self.typed = self.typed.rstrip()
|
|
95
|
+
self.typed = self.typed[: self.typed.rfind(" ") + 1] if " " in self.typed else ""
|
|
96
|
+
elif ch == "\t":
|
|
97
|
+
self.typed += " "
|
|
98
|
+
elif ch >= " " and len(self.typed) < MAX_NOTE:
|
|
99
|
+
self.typed += ch
|
|
100
|
+
|
|
101
|
+
# For the agent ----------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def add(self, note: str) -> None:
|
|
104
|
+
with self.lock:
|
|
105
|
+
self.notes.append(note[:MAX_NOTE])
|
|
106
|
+
|
|
107
|
+
def waiting(self) -> bool:
|
|
108
|
+
with self.lock:
|
|
109
|
+
return bool(self.notes)
|
|
110
|
+
|
|
111
|
+
def take(self) -> list[str]:
|
|
112
|
+
"""The notes waiting for the model (and forget them)."""
|
|
113
|
+
with self.lock:
|
|
114
|
+
notes, self.notes = self.notes, []
|
|
115
|
+
return notes
|
|
116
|
+
|
|
117
|
+
def take_pause(self) -> bool:
|
|
118
|
+
"""Whether a pause was asked for (and clear the request)."""
|
|
119
|
+
with self.lock:
|
|
120
|
+
asked, self.pause = self.pause, False
|
|
121
|
+
return asked
|
|
122
|
+
|
|
123
|
+
def leftover(self) -> str:
|
|
124
|
+
"""What never reached the model: notes still waiting and the unfinished line, for the next prompt."""
|
|
125
|
+
with self.lock:
|
|
126
|
+
text = "\n".join([*self.notes, self.typed.strip()]).strip()
|
|
127
|
+
self.notes, self.typed = [], ""
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
def ask(self, question: str) -> str:
|
|
131
|
+
"""Ask the user a question while the keyboard is being read for notes (permission prompts, the pause).
|
|
132
|
+
|
|
133
|
+
Keys typed before the question appeared are dropped, and an Enter pressed right as it appeared asks
|
|
134
|
+
once more: it was meant to finish a note, not to answer (an empty answer often means yes).
|
|
135
|
+
"""
|
|
136
|
+
hold = self.reader.suspended() if self.reader is not None else contextlib.nullcontext()
|
|
137
|
+
with hold:
|
|
138
|
+
asked = time.monotonic()
|
|
139
|
+
answer = (self.read_line or input)(question)
|
|
140
|
+
if not answer.strip() and time.monotonic() - asked < TOO_QUICK:
|
|
141
|
+
answer = (self.read_line or input)(question)
|
|
142
|
+
return answer
|
|
143
|
+
|
|
144
|
+
def status(self) -> Text | None:
|
|
145
|
+
"""The line under the spinner: what's being typed, notes waiting, a pause coming."""
|
|
146
|
+
with self.lock:
|
|
147
|
+
typed, waiting, pause = self.typed, len(self.notes), self.pause
|
|
148
|
+
parts: list[Text] = []
|
|
149
|
+
if typed:
|
|
150
|
+
shown = typed if len(typed) <= SHOWN else "…" + typed[-SHOWN:]
|
|
151
|
+
parts.append(Text.assemble(("✎ ", "cyan"), shown, ("▏", "cyan"), (" Enter adds it as a note", "dim")))
|
|
152
|
+
if waiting:
|
|
153
|
+
what = "a note" if waiting == 1 else f"{waiting} notes"
|
|
154
|
+
parts.append(Text(f"✎ {what} waiting for the model's next step", style="cyan"))
|
|
155
|
+
if pause:
|
|
156
|
+
parts.append(Text("⏸ Pausing after this step (Esc again to go on without pausing)", style="yellow"))
|
|
157
|
+
if not parts:
|
|
158
|
+
return None
|
|
159
|
+
return Text("\n").join(parts)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class KeyReader:
|
|
163
|
+
"""Reads the keyboard in the background while a request runs, and gives the keys to a Steering.
|
|
164
|
+
|
|
165
|
+
The terminal stays in "cbreak" mode meanwhile: keys arrive one by one and aren't echoed, while Ctrl+C still
|
|
166
|
+
interrupts. `suspended()` hands the keyboard back, in its usual mode, for a question.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, steering: Steering, fd: int | None = None) -> None:
|
|
170
|
+
self.steering = steering
|
|
171
|
+
self.fd = fd # stdin when None
|
|
172
|
+
self.saved: list | None = None
|
|
173
|
+
self.thread: threading.Thread | None = None
|
|
174
|
+
self.stopping = threading.Event()
|
|
175
|
+
self.hold = threading.Event() # asked to stop reading for a while
|
|
176
|
+
self.held = threading.Event() # ...and the thread has stopped
|
|
177
|
+
self.resume = threading.Event()
|
|
178
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
179
|
+
|
|
180
|
+
def start(self) -> bool:
|
|
181
|
+
"""Start reading; False when this isn't a terminal lcode can read keys from."""
|
|
182
|
+
if termios is None:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
if self.fd is None:
|
|
186
|
+
self.fd = sys.stdin.fileno()
|
|
187
|
+
if not os.isatty(self.fd):
|
|
188
|
+
return False
|
|
189
|
+
except (OSError, ValueError): # no real stdin (tests, some IDE consoles)
|
|
190
|
+
return False
|
|
191
|
+
try:
|
|
192
|
+
self.saved = termios.tcgetattr(self.fd)
|
|
193
|
+
self._cbreak()
|
|
194
|
+
except termios.error:
|
|
195
|
+
self.saved = None
|
|
196
|
+
return False
|
|
197
|
+
self.steering.reader = self
|
|
198
|
+
self.thread = threading.Thread(target=self._run, name="lcode-keys", daemon=True)
|
|
199
|
+
self.thread.start()
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def stop(self) -> None:
|
|
203
|
+
self.stopping.set()
|
|
204
|
+
self.resume.set()
|
|
205
|
+
if self.thread is not None:
|
|
206
|
+
self.thread.join(timeout=1)
|
|
207
|
+
self._restore()
|
|
208
|
+
self.steering.reader = None
|
|
209
|
+
|
|
210
|
+
def __enter__(self) -> KeyReader:
|
|
211
|
+
self.start()
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
def __exit__(self, *exc) -> None:
|
|
215
|
+
self.stop()
|
|
216
|
+
|
|
217
|
+
@contextlib.contextmanager
|
|
218
|
+
def suspended(self) -> Iterator[None]:
|
|
219
|
+
"""The keyboard back in its usual mode (echo, line editing) for a question, with typed-ahead keys dropped."""
|
|
220
|
+
if self.thread is None or not self.thread.is_alive():
|
|
221
|
+
yield
|
|
222
|
+
return
|
|
223
|
+
self.resume.clear()
|
|
224
|
+
self.hold.set()
|
|
225
|
+
self.held.wait(timeout=1)
|
|
226
|
+
self._restore()
|
|
227
|
+
with contextlib.suppress(termios.error):
|
|
228
|
+
termios.tcflush(self.fd, termios.TCIFLUSH)
|
|
229
|
+
try:
|
|
230
|
+
yield
|
|
231
|
+
finally:
|
|
232
|
+
with contextlib.suppress(termios.error):
|
|
233
|
+
self._cbreak()
|
|
234
|
+
self.hold.clear()
|
|
235
|
+
self.held.clear()
|
|
236
|
+
self.resume.set()
|
|
237
|
+
|
|
238
|
+
def _cbreak(self) -> None:
|
|
239
|
+
mode = termios.tcgetattr(self.fd)
|
|
240
|
+
mode[3] &= ~(termios.ECHO | termios.ICANON) # lflags; ISIG stays, so Ctrl+C still interrupts
|
|
241
|
+
mode[6][termios.VMIN] = 1
|
|
242
|
+
mode[6][termios.VTIME] = 0
|
|
243
|
+
termios.tcsetattr(self.fd, termios.TCSANOW, mode)
|
|
244
|
+
|
|
245
|
+
def _restore(self) -> None:
|
|
246
|
+
if self.saved is not None:
|
|
247
|
+
with contextlib.suppress(termios.error):
|
|
248
|
+
termios.tcsetattr(self.fd, termios.TCSANOW, self.saved)
|
|
249
|
+
|
|
250
|
+
def _run(self) -> None:
|
|
251
|
+
while not self.stopping.is_set():
|
|
252
|
+
if self.hold.is_set():
|
|
253
|
+
self.held.set()
|
|
254
|
+
self.resume.wait()
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
ready, _, _ = select.select([self.fd], [], [], 0.1)
|
|
258
|
+
if not ready or self.hold.is_set():
|
|
259
|
+
continue
|
|
260
|
+
data = os.read(self.fd, 1024)
|
|
261
|
+
if not data:
|
|
262
|
+
return # the terminal is gone
|
|
263
|
+
while incomplete_escape(data):
|
|
264
|
+
more, _, _ = select.select([self.fd], [], [], ESC_WAIT) # the rest of an escape sequence?
|
|
265
|
+
if not more:
|
|
266
|
+
break # a lone "\x1b" is then the Esc key
|
|
267
|
+
data += os.read(self.fd, 1024)
|
|
268
|
+
except (OSError, ValueError):
|
|
269
|
+
return
|
|
270
|
+
self.steering.feed(self.decoder.decode(data))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def incomplete_escape(data: bytes) -> bool:
|
|
274
|
+
"""Whether the bytes end in the middle of an escape sequence (or with a "\\x1b" that may start one)."""
|
|
275
|
+
start = data.rfind(b"\x1b")
|
|
276
|
+
if start < 0:
|
|
277
|
+
return False
|
|
278
|
+
tail = data[start:]
|
|
279
|
+
if tail == b"\x1b":
|
|
280
|
+
return True
|
|
281
|
+
if tail.startswith(b"\x1b["):
|
|
282
|
+
return not any(0x40 <= b <= 0x7E for b in tail[2:])
|
|
283
|
+
if tail.startswith(b"\x1bO"):
|
|
284
|
+
return len(tail) < 3
|
|
285
|
+
if tail[1:2] in (b"]", b"P", b"_", b"^"):
|
|
286
|
+
return b"\x07" not in tail # (one ended by ESC \ has its last ESC there: complete)
|
|
287
|
+
return False
|
lcode/tools.py
CHANGED
|
@@ -1121,6 +1121,8 @@ class Toolbox:
|
|
|
1121
1121
|
deadline = time.time() + int(timeout or 180)
|
|
1122
1122
|
finished = 0.0 # when the command itself ended (its marker arrived)
|
|
1123
1123
|
held_open = False # something it started still holds the output open
|
|
1124
|
+
spinner = self.agent.activity("Running the command") # the elapsed time, and a note being typed
|
|
1125
|
+
spinner.start()
|
|
1124
1126
|
try:
|
|
1125
1127
|
while True:
|
|
1126
1128
|
if self.agent.cancel is not None and self.agent.cancel.is_set():
|
|
@@ -1154,6 +1156,8 @@ class Toolbox:
|
|
|
1154
1156
|
reading.clear()
|
|
1155
1157
|
stop()
|
|
1156
1158
|
raise
|
|
1159
|
+
finally:
|
|
1160
|
+
spinner.stop()
|
|
1157
1161
|
reading.clear()
|
|
1158
1162
|
code = proc.wait()
|
|
1159
1163
|
if held_open and sandbox and token:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.33.1
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -35,11 +35,12 @@ Requires-Dist: encord>=0.1.200; extra == 'encord'
|
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
36
36
|
|
|
37
37
|
<p align="center">
|
|
38
|
-
<
|
|
38
|
+
<picture>
|
|
39
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/nasser1941/lcode/main/docs/assets/logo-dark.png">
|
|
40
|
+
<img src="https://raw.githubusercontent.com/nasser1941/lcode/main/docs/assets/logo.png" width="360" alt="lcode">
|
|
41
|
+
</picture>
|
|
39
42
|
</p>
|
|
40
43
|
|
|
41
|
-
<h1 align="center">lcode</h1>
|
|
42
|
-
|
|
43
44
|
<p align="center">
|
|
44
45
|
<b>A coding agent that runs entirely on your own machine.</b><br>
|
|
45
46
|
Open-weight models · up to 256K tokens of context · your NVIDIA GPU or Apple Silicon Mac · your code stays local
|
|
@@ -49,7 +50,7 @@ Description-Content-Type: text/markdown
|
|
|
49
50
|
<a href="https://github.com/nasser1941/lcode/actions/workflows/ci.yml"><img src="https://github.com/nasser1941/lcode/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
50
51
|
<a href="https://nasser1941.github.io/lcode/"><img src="https://github.com/nasser1941/lcode/actions/workflows/docs.yml/badge.svg" alt="Docs"></a>
|
|
51
52
|
<a href="https://pypi.org/project/lcode-cli/"><img src="https://img.shields.io/pypi/v/lcode-cli.svg" alt="PyPI"></a>
|
|
52
|
-
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
53
|
+
<a href="https://github.com/nasser1941/lcode/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
53
54
|
<img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10+">
|
|
54
55
|
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20(Apple%20Silicon)-lightgrey.svg" alt="Linux and macOS">
|
|
55
56
|
</p>
|
|
@@ -58,11 +59,11 @@ Description-Content-Type: text/markdown
|
|
|
58
59
|
<a href="https://nasser1941.github.io/lcode/"><b>Documentation</b></a> ·
|
|
59
60
|
<a href="https://nasser1941.github.io/lcode/installation/">Install</a> ·
|
|
60
61
|
<a href="https://nasser1941.github.io/lcode/models/">Models</a> ·
|
|
61
|
-
<a href="CONTRIBUTING.md">Contributing</a>
|
|
62
|
+
<a href="https://github.com/nasser1941/lcode/blob/main/CONTRIBUTING.md">Contributing</a>
|
|
62
63
|
</p>
|
|
63
64
|
|
|
64
65
|
<p align="center">
|
|
65
|
-
<img src="docs/assets/demo.svg" alt="lcode finding and fixing a bug, then running the tests" width="820">
|
|
66
|
+
<img src="https://raw.githubusercontent.com/nasser1941/lcode/main/docs/assets/demo.svg" alt="lcode finding and fixing a bug, then running the tests" width="820">
|
|
66
67
|
</p>
|
|
67
68
|
|
|
68
69
|
Point lcode at a repository and talk to it in your terminal. It explores the code, answers
|
|
@@ -222,8 +223,8 @@ See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). N
|
|
|
222
223
|
## Contributing
|
|
223
224
|
|
|
224
225
|
Contributions are welcome: bug reports, model test results, docs and code. See
|
|
225
|
-
[CONTRIBUTING.md](CONTRIBUTING.md). Please follow the [code of conduct](CODE_OF_CONDUCT.md), and
|
|
226
|
-
report security issues privately as described in [SECURITY.md](SECURITY.md).
|
|
226
|
+
[CONTRIBUTING.md](https://github.com/nasser1941/lcode/blob/main/CONTRIBUTING.md). Please follow the [code of conduct](https://github.com/nasser1941/lcode/blob/main/CODE_OF_CONDUCT.md), and
|
|
227
|
+
report security issues privately as described in [SECURITY.md](https://github.com/nasser1941/lcode/blob/main/SECURITY.md).
|
|
227
228
|
|
|
228
229
|
## Acknowledgements
|
|
229
230
|
|
|
@@ -234,4 +235,4 @@ the open-weight models from Qwen, Poolside, NVIDIA and OpenAI,
|
|
|
234
235
|
|
|
235
236
|
## License
|
|
236
237
|
|
|
237
|
-
[MIT](LICENSE) © 2026 Naser Derakhshan
|
|
238
|
+
[MIT](https://github.com/nasser1941/lcode/blob/main/LICENSE) © 2026 Naser Derakhshan
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=n_tK4I5-3-ii3F5D31XFOFGkqv0EUglLYce81_yaD-Q,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=fs40oIivqNV6R_Y2Yp8jHbQUywK55czn3wRHrdViyQ0,75086
|
|
6
6
|
lcode/api.py,sha256=YTfXVMSYSp1EC3tNI_fW-JcNBgrdFTN7V57HNIzB7Kc,15861
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=KDgPnkceZOS3gDDEl5j6-k8Ser2es4-IuV_S8p_46Zo,41517
|
|
@@ -29,19 +29,20 @@ lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
|
29
29
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
30
30
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
31
31
|
lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
32
|
-
lcode/permissions.py,sha256=
|
|
32
|
+
lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
33
33
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
34
34
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
35
35
|
lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
|
|
36
|
-
lcode/repl.py,sha256=
|
|
36
|
+
lcode/repl.py,sha256=_krcqKqHKsGUKo4JfQS9KQNBxA47qE1k__TAkDTusRc,53242
|
|
37
37
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
38
38
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
39
39
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
40
40
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
41
41
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
42
|
-
lcode/sessions.py,sha256=
|
|
42
|
+
lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
|
|
43
|
+
lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
|
|
43
44
|
lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
|
|
44
|
-
lcode/tools.py,sha256=
|
|
45
|
+
lcode/tools.py,sha256=Af4CYJUB7hCmsrNWAn5dIfI11YZFBTLclYaoEZ4Tsm4,61599
|
|
45
46
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
46
47
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
47
48
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -61,8 +62,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
61
62
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
62
63
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
63
64
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
64
|
-
lcode_cli-0.
|
|
65
|
-
lcode_cli-0.
|
|
66
|
-
lcode_cli-0.
|
|
67
|
-
lcode_cli-0.
|
|
68
|
-
lcode_cli-0.
|
|
65
|
+
lcode_cli-0.33.1.dist-info/METADATA,sha256=LlI-m-ksKjOELjiauEwss3yyFgw_1TX0uvyF-ZwhqVk,13141
|
|
66
|
+
lcode_cli-0.33.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
67
|
+
lcode_cli-0.33.1.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
68
|
+
lcode_cli-0.33.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
69
|
+
lcode_cli-0.33.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|