lcode-cli 0.1.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/repl.py ADDED
@@ -0,0 +1,281 @@
1
+ """The interactive prompt: input handling, slash commands and the status bar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import os
7
+ import re
8
+ import threading
9
+ from pathlib import Path
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.completion import Completer, Completion
13
+ from prompt_toolkit.formatted_text import HTML
14
+ from prompt_toolkit.history import FileHistory
15
+ from prompt_toolkit.key_binding import KeyBindings
16
+ from rich.console import Group
17
+ from rich.panel import Panel
18
+ from rich.rule import Rule
19
+ from rich.text import Text
20
+
21
+ from lcode import __version__, catalog
22
+ from lcode.agent import INIT_PROMPT, Agent
23
+ from lcode.catalog import MIN_USEFUL_CONTEXT
24
+ from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
25
+ from lcode.hardware import Hardware
26
+ from lcode.ollama import OllamaError
27
+ from lcode.tools import IGNORE_DIRS
28
+
29
+ COMMANDS = {
30
+ "/help": "Show this help",
31
+ "/init": "Analyze the repo and write an AGENTS.md guide (loaded at every start)",
32
+ "/clear": "Start a fresh conversation",
33
+ "/compact": "Summarize the conversation to free context (optional: what to focus on)",
34
+ "/context": "Show context-window usage",
35
+ "/ctx": "Show or change the context window, e.g. /ctx 128k",
36
+ "/model": "Show or switch model, e.g. /model qwen3.5-9b",
37
+ "/models": "List models and how they fit this machine",
38
+ "/think": "Toggle model reasoning on/off",
39
+ "/verbose": "Toggle showing the model's reasoning text",
40
+ "/mode": "Permission mode: ask | auto-edit | yolo (Shift+Tab cycles)",
41
+ "/cd": "Change the working directory",
42
+ "/todos": "Show the current todo list",
43
+ "/exit": "Quit",
44
+ }
45
+
46
+
47
+ class InputCompleter(Completer):
48
+ """Completes slash commands and @file mentions."""
49
+
50
+ def __init__(self, agent: Agent):
51
+ self.agent = agent
52
+
53
+ def get_completions(self, document, complete_event):
54
+ text = document.text_before_cursor
55
+ if text.startswith("/") and " " not in text:
56
+ for cmd, desc in COMMANDS.items():
57
+ if cmd.startswith(text):
58
+ yield Completion(cmd, start_position=-len(text), display_meta=desc)
59
+ return
60
+ m = re.search(r"(?:^|\s)@([\w./~\-]*)$", text)
61
+ if not m:
62
+ return
63
+ fragment = m.group(1)
64
+ base = self.agent.cwd / os.path.expanduser(os.path.dirname(fragment) or ".")
65
+ prefix = os.path.basename(fragment)
66
+ try:
67
+ entries = sorted(base.iterdir())
68
+ except OSError:
69
+ return
70
+ for entry in entries:
71
+ hidden = entry.name.startswith(".") and not prefix.startswith(".")
72
+ if entry.name.startswith(prefix) and entry.name not in IGNORE_DIRS and not hidden:
73
+ completion = os.path.join(os.path.dirname(fragment), entry.name) + ("/" if entry.is_dir() else "")
74
+ yield Completion(completion, start_position=-len(fragment))
75
+
76
+
77
+ def build_session(agent: Agent) -> PromptSession:
78
+ kb = KeyBindings()
79
+
80
+ @kb.add("enter")
81
+ def _submit(event):
82
+ buf = event.current_buffer
83
+ if buf.complete_state and buf.complete_state.current_completion:
84
+ buf.apply_completion(buf.complete_state.current_completion)
85
+ elif buf.text.endswith("\\"):
86
+ buf.delete_before_cursor(1)
87
+ buf.insert_text("\n")
88
+ else:
89
+ buf.validate_and_handle()
90
+
91
+ @kb.add("escape", "enter")
92
+ def _newline(event):
93
+ event.current_buffer.insert_text("\n")
94
+
95
+ @kb.add("s-tab")
96
+ def _cycle_mode(event):
97
+ agent.perms.cycle()
98
+ event.app.invalidate()
99
+
100
+ def toolbar():
101
+ s = agent.settings
102
+ pct = 100 * agent.ctx_used / s.context
103
+ color = {"ask": "ansigreen", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
104
+ return HTML(
105
+ f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
106
+ f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
107
+ f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
108
+ )
109
+
110
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
111
+ return PromptSession(
112
+ history=FileHistory(str(STATE_DIR / "history")),
113
+ key_bindings=kb,
114
+ multiline=True,
115
+ completer=InputCompleter(agent),
116
+ complete_while_typing=True,
117
+ bottom_toolbar=toolbar,
118
+ prompt_continuation=" ",
119
+ )
120
+
121
+
122
+ def banner(agent: Agent) -> None:
123
+ s = agent.settings
124
+ agent.console.print(
125
+ Panel(
126
+ Group(
127
+ Text.from_markup(f"[bold cyan]lcode[/] [dim]v{__version__}[/] — local coding agent\n"),
128
+ Text.from_markup(
129
+ f"[dim]model[/] {s.model}\n"
130
+ f"[dim]context[/] {format_tokens(s.context)} tokens\n"
131
+ f"[dim]cwd[/] {agent.cwd}\n"
132
+ f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n\n"
133
+ "[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
134
+ ),
135
+ ),
136
+ border_style="cyan",
137
+ expand=False,
138
+ )
139
+ )
140
+
141
+
142
+ def run_safely(agent: Agent, text: str) -> None:
143
+ try:
144
+ agent.run_turn(text)
145
+ except KeyboardInterrupt:
146
+ pass
147
+ except OllamaError as e:
148
+ agent.console.print(f"[red]{e}[/]")
149
+ finally:
150
+ agent.save()
151
+
152
+
153
+ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
154
+ """Run a slash command. Returns False when the REPL should exit."""
155
+ from lcode.cli import NotInstalled, print_models, resolve_model
156
+
157
+ cmd, _, arg = line.partition(" ")
158
+ arg = arg.strip()
159
+ c, s = agent.console, agent.settings
160
+ if cmd in ("/exit", "/quit", "/q"):
161
+ return False
162
+ if cmd == "/help":
163
+ rows = "\n".join(f" [cyan]{k:<10}[/] {v}" for k, v in COMMANDS.items())
164
+ keys = (
165
+ "Enter = send · Esc+Enter or trailing \\ = newline · Ctrl+C = interrupt · Ctrl+D = quit · "
166
+ "@path = attach a file · Shift+Tab = cycle permission mode"
167
+ )
168
+ c.print(Panel(f"{rows}\n\n [dim]{keys}[/]", title="lcode commands", border_style="cyan"))
169
+ elif cmd == "/clear":
170
+ agent.reset()
171
+ agent.session_id = agent.new_session_id()
172
+ c.print("[green]Conversation cleared.[/]")
173
+ elif cmd == "/compact":
174
+ agent.compact(arg)
175
+ elif cmd == "/context":
176
+ c.print(
177
+ f"Context: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
178
+ f"({100 * agent.ctx_used / s.context:.1f}%) in {len(agent.messages)} messages; "
179
+ "auto-compacts at 85%."
180
+ )
181
+ elif cmd == "/ctx":
182
+ if not arg:
183
+ spec = catalog.find(s.model)
184
+ extra = f" · model maximum {format_tokens(spec.max_context)}" if spec else ""
185
+ c.print(f"Context window: {format_tokens(s.context)} tokens{extra}. Change it with /ctx 128k")
186
+ else:
187
+ try:
188
+ c.print(agent.set_context(parse_context(arg)))
189
+ except ConfigError as e:
190
+ c.print(f"[red]{e}[/]")
191
+ spec = catalog.find(s.model)
192
+ if spec and spec.memory_gib(s.context) > hardware.budget_gib:
193
+ c.print(
194
+ f"[yellow]~{spec.memory_gib(s.context):.0f} GB needed but ~{hardware.budget_gib:.0f} GB "
195
+ "available; this may run out of memory.[/]"
196
+ )
197
+ elif cmd == "/models":
198
+ print_models(agent.ollama, hardware, s.model)
199
+ elif cmd == "/model":
200
+ if not arg:
201
+ c.print(f"Model: {s.model}. Switch with /model <key or Ollama tag>; list with /models.")
202
+ else:
203
+ try:
204
+ name, spec = resolve_model(agent.ollama, arg)
205
+ except (NotInstalled, OllamaError) as e:
206
+ c.print(f"[red]{e}[/]")
207
+ return True
208
+ s.model = name
209
+ s.num_batch = spec.num_batch if spec and name == spec.local_name else None
210
+ if spec: # size the context for the new model: largest window that fits this machine
211
+ s.context = spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT
212
+ agent.messages[0]["content"] = agent.system_prompt()
213
+ c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
214
+ elif cmd == "/think":
215
+ s.think = (arg == "on") if arg in ("on", "off") else not s.think
216
+ c.print(f"Reasoning {'on' if s.think else 'off'}.")
217
+ elif cmd == "/verbose":
218
+ s.show_thinking = not s.show_thinking
219
+ c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
220
+ elif cmd == "/mode":
221
+ if arg in PERMISSION_MODES:
222
+ agent.perms.mode = arg
223
+ elif arg:
224
+ c.print(f"[red]Unknown mode; choose from {', '.join(PERMISSION_MODES)}[/]")
225
+ c.print(f"Permission mode: {agent.perms.mode}")
226
+ elif cmd == "/cd":
227
+ p = Path(os.path.expanduser(arg or "~"))
228
+ p = (p if p.is_absolute() else agent.cwd / p).resolve()
229
+ if p.is_dir():
230
+ agent.cwd = p
231
+ agent.messages[0]["content"] = agent.system_prompt()
232
+ c.print(f"Working directory: {p}")
233
+ else:
234
+ c.print(f"[red]Not a directory: {p}[/]")
235
+ elif cmd == "/todos":
236
+ agent.tools.show_todos()
237
+ elif cmd == "/init":
238
+ run_safely(agent, INIT_PROMPT)
239
+ else:
240
+ c.print(f"[red]Unknown command {cmd}.[/] Type /help.")
241
+ return True
242
+
243
+
244
+ def repl(agent: Agent, prompt: str | None, resume: bool, hardware: Hardware) -> None:
245
+ if resume and agent.load_latest():
246
+ agent.console.print(f"[green]Resumed session {agent.session_id} ({len(agent.messages)} messages).[/]")
247
+ if prompt:
248
+ run_safely(agent, prompt)
249
+ return
250
+ preload(agent)
251
+ banner(agent)
252
+ session = build_session(agent)
253
+ while True:
254
+ try:
255
+ agent.console.print()
256
+ line = session.prompt(HTML("<ansicyan><b>❯ </b></ansicyan>")).strip()
257
+ except KeyboardInterrupt:
258
+ continue
259
+ except EOFError:
260
+ break
261
+ if not line:
262
+ continue
263
+ if line.startswith("/") and not line.startswith("//"):
264
+ if not handle_command(agent, line, hardware):
265
+ break
266
+ continue
267
+ agent.console.print(Rule(style="dim"))
268
+ run_safely(agent, line)
269
+ agent.console.print("[dim]Bye.[/]")
270
+
271
+
272
+ def preload(agent: Agent) -> None:
273
+ """Start loading the model in the background so the first answer comes sooner."""
274
+
275
+ def load() -> None:
276
+ try:
277
+ agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
278
+ except OllamaError:
279
+ pass
280
+
281
+ threading.Thread(target=load, daemon=True).start()