first commit

This commit is contained in:
kim
2026-07-06 12:41:15 +09:00
commit 68f2242984
47 changed files with 11348 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.env
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
# locode
An agentic coding CLI, in the spirit of Claude Code, for models running locally via [Ollama](https://ollama.com) or [LM Studio](https://lmstudio.ai). It talks to either backend's OpenAI-compatible `/v1/chat/completions` endpoint, so any model you can serve from either one works here.
## Install
```sh
npm install
npm run build
npm link # makes the `locode` command available globally
```
## Quick start
Make sure Ollama (`ollama serve`, default `http://localhost:11434`) or LM Studio (with a model loaded, default `http://localhost:1234`) is running, then:
```sh
locode --model qwen3-coder:30b
```
If you omit `--model`, locode lists the models available from the backend and lets you pick one with the arrow keys (Enter to confirm). Your saved default (via config or `$LOCODE_MODEL`) is pre-highlighted.
Pass `--model` explicitly to skip the picker entirely. Or persist your defaults so you don't need flags every time:
```sh
locode config set backend ollama
locode config set model qwen3-coder:30b
locode
```
List models available from the configured backend:
```sh
locode models
```
## How it works
locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit.
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
- **Tools**: `read_file`, `list_files`, `grep` run automatically. `write_file`, `edit_file`, and `bash` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
## Slash commands
```
/model <name> switch the model used for the current backend
/backend <name> switch backend (ollama | lmstudio), keeps current model
/mode <name> view or force tool-call mode (native | fallback)
/status show current model, backend, tool-call mode, and cwd
/tools list available tools
/permissions list mutating tools allowed for the rest of this session
/clear clear conversation history
/help show this help
/exit, /quit exit
```
## Config
Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`) > persisted config file > defaults.
```sh
locode config set backend ollama
locode config set model qwen3-coder:30b
locode config get
locode config path
```
## Known limitations
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script.
- Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above).
- No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand.
- No session save/resume yet — each `locode` run starts a fresh conversation.
- No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read).
- Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
+4531
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "locode",
"version": "0.1.0",
"description": "Agentic coding CLI for local models served via Ollama and LM Studio",
"type": "module",
"bin": {
"locode": "dist/cli.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsup",
"dev": "tsx src/cli.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@vscode/ripgrep": "^1.18.0",
"commander": "^13.0.0",
"diff": "^9.0.0",
"env-paths": "^4.0.0",
"execa": "^9.6.1",
"fast-glob": "^3.3.3",
"ink": "^7.1.0",
"ink-select-input": "^6.2.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"openai": "^6.45.0",
"react": "^19.2.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/marked-terminal": "^6.1.1",
"@types/node": "^22.10.0",
"@types/react": "^19.2.17",
"tsup": "^8.3.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
export type AgentEvent =
| { type: "text_delta"; delta: string }
| { type: "text_done"; fullText: string }
| { type: "tool_call"; label: string }
| { type: "tool_result"; summary: string; isError: boolean };
export type AgentEventHandler = (event: AgentEvent) => void;
+292
View File
@@ -0,0 +1,292 @@
import type { ChatCompletionChunk } from "openai/resources/chat/completions";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { AgentEventHandler } from "./events.js";
import { TOOL_REGISTRY, TOOLS } from "../tools/index.js";
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { resolveToolCall, toOpenAITools } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import type { Session } from "./session.js";
export class AgentError extends Error {}
const openaiTools = toOpenAITools(TOOLS);
const MAX_MALFORMED_RETRIES = 2;
/** Accumulates streaming tool-call deltas into complete tool calls. */
interface AccumulatedToolCall {
id: string;
name: string;
arguments: string;
}
async function gateAndRun(
resolved: ResolvedToolCall,
rawLabel: string,
session: Session,
emit: AgentEventHandler,
): Promise<unknown> {
if ("error" in resolved) {
emit({ type: "tool_call", label: rawLabel });
emit({ type: "tool_result", summary: resolved.error, isError: true });
return { error: resolved.error };
}
const { tool, args } = resolved;
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
const ctx = { cwd: session.cwd };
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
const decision = await session.confirm({ toolName: tool.name, args, preview });
if (decision === "deny") {
emit({ type: "tool_result", summary: "Denied by user", isError: true });
return { error: "Denied by user." };
}
if (decision === "session") {
session.permissions.allowForSession(tool.name);
}
}
const result = await runTool(tool, args, ctx);
const isError = !!(result && typeof result === "object" && "error" in (result as object));
emit({ type: "tool_result", summary: summarizeToolResult(tool.name, result), isError });
return result;
}
/**
* Process a completed (non-streaming) response — handles both native tool_calls
* and plain text, shared by the streaming path (after accumulation) and the
* non-streaming fallback.
*/
async function handleCompletedMessage(
message: any,
session: Session,
emit: AgentEventHandler,
): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> {
// Native tool calls
if (session.mode === "native" && message.tool_calls?.length) {
session.messages.push({
role: "assistant",
content: message.content ?? null,
tool_calls: message.tool_calls,
} as ChatCompletionMessageParam);
for (const call of message.tool_calls) {
const resolved = resolveToolCall(call as any, TOOL_REGISTRY);
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
return { text: "", hadToolCalls: true };
}
// Plain text response
const text = message.content ?? "";
// Fallback mode: check for tool_call blocks in text
if (session.mode === "fallback") {
const parsed = parseFallbackToolCalls(text);
if (parsed.calls.length) {
session.messages.push({ role: "assistant", content: text });
for (const call of parsed.calls) {
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY);
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({
role: "user",
content: `\`\`\`tool_result\n${JSON.stringify({ name: call.name, result })}\n\`\`\``,
});
}
return { text: "", hadToolCalls: true };
}
if (parsed.malformed) {
// Will be retried with nudge in the caller
return { text, hadToolCalls: false, malformed: true };
}
}
return { text, hadToolCalls: false };
}
export async function runTurn(session: Session, userInput: string, emit: AgentEventHandler): Promise<string> {
session.messages.push({ role: "user", content: userInput });
let malformedRetries = 0;
for (let i = 0; i < session.maxIterations; i++) {
// --- Streaming path ---
let fullText = "";
let finishReason: string | null = null;
const accumulatedToolCalls: AccumulatedToolCall[] = [];
try {
const stream = await session.client.chat.completions.create({
model: session.model,
messages: session.messages,
tools: session.mode === "native" ? openaiTools : undefined,
stream: true,
max_tokens: 4096,
});
for await (const chunk of stream) {
const choice = chunk.choices[0];
if (!choice) continue;
const delta = choice.delta;
// Stream text content
if (delta?.content) {
fullText += delta.content;
emit({ type: "text_delta", delta: delta.content });
}
// Accumulate native tool call deltas
if (session.mode === "native" && delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!accumulatedToolCalls[idx]) {
accumulatedToolCalls[idx] = {
id: tc.id ?? "",
name: tc.function?.name ?? "",
arguments: tc.function?.arguments ?? "",
};
} else {
if (tc.id) accumulatedToolCalls[idx].id = tc.id;
if (tc.function?.name) accumulatedToolCalls[idx].name = tc.function.name;
if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments;
}
}
}
if (choice.finish_reason) {
finishReason = choice.finish_reason;
}
}
} catch (streamErr) {
// Stream error — emit whatever text we have and re-throw
if (fullText) {
emit({ type: "text_done", fullText });
}
throw streamErr;
}
// --- Handle native tool calls from streaming ---
if (session.mode === "native" && accumulatedToolCalls.length > 0) {
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
// retry the entire turn non-streaming
let allValid = true;
for (const tc of accumulatedToolCalls) {
try {
JSON.parse(tc.arguments);
} catch {
allValid = false;
break;
}
}
if (!allValid) {
// Retry non-streaming for this turn
const res = await session.client.chat.completions.create({
model: session.model,
messages: session.messages,
tools: openaiTools,
stream: false,
max_tokens: 4096,
});
const message = res.choices[0]?.message;
if (!message) throw new AgentError("Empty response from model.");
// If we streamed partial text, we need to tell the UI the text is done
// (it may have been partially displayed). The non-streaming response
// will have the complete text.
if (fullText) {
// The partial stream text is superseded by the non-streaming response
}
const result = await handleCompletedMessage(message, session, emit);
if (result.hadToolCalls) continue;
// Non-tool-call text from the retry
emit({ type: "text_done", fullText: result.text });
session.messages.push({ role: "assistant", content: result.text });
return result.text;
}
// Accumulated tool calls are valid — execute them
emit({ type: "text_done", fullText }); // finalize any text before tool calls
session.messages.push({
role: "assistant",
content: fullText || null,
tool_calls: accumulatedToolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
})),
} as ChatCompletionMessageParam);
for (const tc of accumulatedToolCalls) {
const resolved = resolveToolCall(
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
TOOL_REGISTRY,
);
const label = `${tc.name}(${tc.arguments})`;
const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(result),
});
}
continue;
}
// --- Plain text response (streaming completed) ---
if (fullText || finishReason === "stop") {
// Fallback mode: check for tool_call blocks in streamed text
if (session.mode === "fallback") {
const parsed = parseFallbackToolCalls(fullText);
if (parsed.calls.length) {
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
for (const call of parsed.calls) {
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY);
const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({
role: "user",
content: `\`\`\`tool_result\n${JSON.stringify({ name: call.name, result })}\n\`\`\``,
});
}
continue;
}
if (parsed.malformed && malformedRetries < MAX_MALFORMED_RETRIES) {
malformedRetries++;
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
session.messages.push({ role: "user", content: FALLBACK_RETRY_NUDGE });
continue;
}
}
// Final text answer
emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText });
return fullText;
}
// Empty response with no tool calls and no text — this shouldn't happen normally
throw new AgentError("Empty response from model.");
}
throw new AgentError("Max tool-call iterations reached without a final answer.");
}
+46
View File
@@ -0,0 +1,46 @@
import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { PermissionManager } from "../permissions/permissionManager.js";
import type { ConfirmFn } from "../permissions/types.js";
import { TOOLS } from "../tools/index.js";
import { buildSystemPrompt } from "./systemPrompt.js";
export interface Session {
client: OpenAI;
model: string;
cwd: string;
mode: ToolCallMode;
messages: ChatCompletionMessageParam[];
maxIterations: number;
permissions: PermissionManager;
confirm: ConfirmFn;
}
export function createSession(
client: OpenAI,
model: string,
cwd: string,
confirm: ConfirmFn,
mode: ToolCallMode,
): Session {
return {
client,
model,
cwd,
mode,
messages: [{ role: "system", content: buildSystemPrompt(TOOLS, mode) }],
maxIterations: 8,
permissions: new PermissionManager(),
confirm,
};
}
export function resetSession(session: Session): void {
session.messages = [session.messages[0] as ChatCompletionMessageParam];
}
export function setMode(session: Session, mode: ToolCallMode): void {
session.mode = mode;
session.messages[0] = { role: "system", content: buildSystemPrompt(TOOLS, mode) };
}
+22
View File
@@ -0,0 +1,22 @@
import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
import type { ToolDef } from "../tools/types.js";
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode): string {
const toolList = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
const base = `You are a helpful local coding assistant with access to tools for exploring a codebase on the user's machine.
Available tools:
${toolList}
Guidelines:
- Use tools to inspect real files before answering questions about code; don't guess at file contents.
- Prefer read_file and grep over asking the user to paste code.
- Call at most one tool at a time and use its result before deciding on the next step.
- write_file, edit_file, and bash require the user's explicit confirmation before they run — expect that some requests may be denied.
- Prefer edit_file for small changes to existing files; use write_file for new files or full rewrites.
- When you have enough information, respond with a normal text answer instead of calling a tool.
- Keep answers concise and focused on the user's question.`;
return mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
}
+46
View File
@@ -0,0 +1,46 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { ToolCallMode } from "./capabilityProbe.js";
const paths = envPaths("locode", { suffix: "" });
const cacheFile = path.join(paths.config, "model-capabilities.json");
type Cache = Record<string, ToolCallMode>;
function keyFor(baseURL: string, model: string): string {
return `${baseURL}::${model}`;
}
let memoryCache: Cache | null = null;
function load(): Cache {
if (memoryCache) return memoryCache;
if (!existsSync(cacheFile)) {
memoryCache = {};
return memoryCache;
}
try {
memoryCache = JSON.parse(readFileSync(cacheFile, "utf-8")) as Cache;
return memoryCache;
} catch {
memoryCache = {};
return memoryCache;
}
}
function save(cache: Cache): void {
memoryCache = cache;
mkdirSync(paths.config, { recursive: true });
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
}
export function getCachedMode(baseURL: string, model: string): ToolCallMode | undefined {
return load()[keyFor(baseURL, model)];
}
export function setCachedMode(baseURL: string, model: string, mode: ToolCallMode): void {
const cache = load();
cache[keyFor(baseURL, model)] = mode;
save(cache);
}
+36
View File
@@ -0,0 +1,36 @@
import type OpenAI from "openai";
import { z } from "zod";
export type ToolCallMode = "native" | "fallback";
const PING_TOOL = {
type: "function" as const,
function: {
name: "ping",
description: "Call this tool with no arguments to confirm you support tool calling.",
parameters: z.toJSONSchema(z.object({})) as Record<string, unknown>,
},
};
export async function probeToolCallSupport(client: OpenAI, model: string): Promise<ToolCallMode> {
try {
const res = await client.chat.completions.create({
model,
messages: [
{
role: "system",
content: "You must call the ping tool now, with no arguments, to confirm tool support. Do not respond with text.",
},
{ role: "user", content: "ping" },
],
tools: [PING_TOOL],
stream: false,
max_tokens: 200,
});
const msg = res.choices[0]?.message;
const called = msg?.tool_calls?.some((c) => c.type === "function" && c.function.name === "ping");
return called ? "native" : "fallback";
} catch {
return "fallback";
}
}
+14
View File
@@ -0,0 +1,14 @@
import OpenAI from "openai";
import type { AppConfig } from "../config/types.js";
export function makeClient(cfg: AppConfig): OpenAI {
return new OpenAI({
baseURL: cfg.baseURL,
apiKey: "local",
// The SDK defaults to a 10-minute timeout with 2 retries (up to 30 min before a request ever
// fails). For local backends a slow response almost always means the model is genuinely stuck,
// not a transient network blip, so retrying just compounds the wait — fail faster instead.
timeout: 180_000,
maxRetries: 0,
});
}
+17
View File
@@ -0,0 +1,17 @@
import type OpenAI from "openai";
import { getCachedMode, setCachedMode } from "./capabilityCache.js";
import { probeToolCallSupport, type ToolCallMode } from "./capabilityProbe.js";
export async function resolveToolCallMode(
client: OpenAI,
baseURL: string,
model: string,
override?: ToolCallMode,
): Promise<ToolCallMode> {
if (override) return override;
const cached = getCachedMode(baseURL, model);
if (cached) return cached;
const probed = await probeToolCallSupport(client, model);
setCachedMode(baseURL, model, probed);
return probed;
}
+100
View File
@@ -0,0 +1,100 @@
import { Command } from "commander";
import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
import { runInkApp } from "./ui/ink/index.js";
const program = new Command();
function addBackendOptions(cmd: Command): Command {
return cmd
.option("-b, --backend <name>", "backend to use: ollama or lmstudio")
.option("--base-url <url>", "override the backend's base URL");
}
program
.name("locode")
.description("Agentic coding CLI for local models via Ollama and LM Studio")
.version("0.1.0");
addBackendOptions(program)
.option("-m, --model <name>", "model name as known to the backend")
.option(
"--tool-mode <mode>",
"native | fallback | auto — auto defaults to native, probing only on /model and /backend",
"auto",
)
.action(async (opts) => {
try {
const { baseURL } = resolveBackendConfig(opts);
// Only an explicit --model flag skips the picker; a saved/env default is offered as a suggestion instead.
const model: string | undefined = opts.model;
const suggestedModel = model ? undefined : resolveModel(undefined);
const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined;
await runInkApp({ baseURL, model, suggestedModel, cwd: process.cwd(), toolModeOverride });
} catch (err) {
if (err instanceof ConfigError) {
console.error(err.message);
process.exit(1);
}
console.error((err as Error).message);
process.exit(1);
}
});
addBackendOptions(program.command("models"))
.description("List models available from the configured backend")
.action(async (opts) => {
try {
const { baseURL } = resolveBackendConfig(opts);
const client = makeClient({ baseURL, model: "" });
const list = await client.models.list();
for (const m of list.data) {
console.log(m.id);
}
} catch (err) {
if (err instanceof ConfigError) {
console.error(err.message);
process.exit(1);
}
console.error(`Failed to list models: ${(err as Error).message}`);
process.exit(1);
}
});
const configCmd = program.command("config").description("Manage persisted config");
configCmd
.command("get [key]")
.description("Print the whole config, or one key (backend, model, baseUrl)")
.action((key?: string) => {
const stored = loadStoredConfig();
if (!key) {
console.log(JSON.stringify(stored, null, 2));
return;
}
console.log(stored[key as keyof StoredConfig] ?? "(not set)");
});
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl)")
.action((key: string, value: string) => {
if (key !== "backend" && key !== "model" && key !== "baseUrl") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl`);
process.exit(1);
}
const stored = loadStoredConfig();
stored[key] = value;
saveStoredConfig(stored);
console.log(`Set ${key} = ${value}`);
});
configCmd
.command("path")
.description("Print the path to the persisted config file")
.action(() => {
console.log(configFilePath());
});
program.parseAsync();
+34
View File
@@ -0,0 +1,34 @@
import { KNOWN_BACKENDS, type BackendName } from "./defaults.js";
import { loadStoredConfig } from "./store.js";
export class ConfigError extends Error {}
export interface CliBackendOpts {
backend?: string;
baseUrl?: string;
}
/** Precedence: CLI flags > env vars > persisted config file > defaults. */
export function resolveBackendConfig(cliOpts: CliBackendOpts): {
backendName: string;
baseURL: string;
} {
const stored = loadStoredConfig();
const backend = cliOpts.backend ?? process.env.LOCODE_BACKEND ?? stored.backend ?? "ollama";
const explicitBaseUrl = cliOpts.baseUrl ?? process.env.LOCODE_BASE_URL ?? stored.baseUrl;
if (backend !== "ollama" && backend !== "lmstudio" && !explicitBaseUrl) {
throw new ConfigError(
`Unknown backend "${backend}". Use --backend ollama|lmstudio, or pass --base-url for a custom endpoint.`,
);
}
const baseURL = explicitBaseUrl ?? KNOWN_BACKENDS[backend as BackendName];
return { backendName: backend, baseURL };
}
/** Returns undefined (rather than throwing) when no model is configured, so callers can prompt interactively. */
export function resolveModel(cliModel?: string): string | undefined {
const stored = loadStoredConfig();
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
}
+9
View File
@@ -0,0 +1,9 @@
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
export const DEFAULT_LMSTUDIO_BASE_URL = "http://localhost:1234/v1";
export const KNOWN_BACKENDS = {
ollama: DEFAULT_OLLAMA_BASE_URL,
lmstudio: DEFAULT_LMSTUDIO_BASE_URL,
} as const;
export type BackendName = keyof typeof KNOWN_BACKENDS;
+30
View File
@@ -0,0 +1,30 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export interface StoredConfig {
backend?: string;
model?: string;
baseUrl?: string;
}
const paths = envPaths("locode", { suffix: "" });
const configFile = path.join(paths.config, "config.json");
export function configFilePath(): string {
return configFile;
}
export function loadStoredConfig(): StoredConfig {
if (!existsSync(configFile)) return {};
try {
return JSON.parse(readFileSync(configFile, "utf-8")) as StoredConfig;
} catch {
return {};
}
}
export function saveStoredConfig(cfg: StoredConfig): void {
mkdirSync(paths.config, { recursive: true });
writeFileSync(configFile, JSON.stringify(cfg, null, 2));
}
+4
View File
@@ -0,0 +1,4 @@
export interface AppConfig {
baseURL: string;
model: string;
}
+35
View File
@@ -0,0 +1,35 @@
import type { PermissionMode } from "./types.js";
import { AUTO_EDIT_TOOLS } from "./types.js";
export class PermissionManager {
private allowedForSession = new Set<string>();
private mode: PermissionMode = "default";
getMode(): PermissionMode {
return this.mode;
}
setMode(mode: PermissionMode): void {
this.mode = mode;
}
/** Check whether a mutating tool should be auto-approved (no confirmation needed). */
isAutoApproved(toolName: string): boolean {
if (this.mode === "auto-accept") return true;
if (this.mode === "auto-edit" && AUTO_EDIT_TOOLS.has(toolName)) return true;
// "default" — check session-allowed list
return this.allowedForSession.has(toolName);
}
isAllowed(toolName: string): boolean {
return this.allowedForSession.has(toolName);
}
allowForSession(toolName: string): void {
this.allowedForSession.add(toolName);
}
listAllowed(): string[] {
return [...this.allowedForSession];
}
}
+12
View File
@@ -0,0 +1,12 @@
export type PermissionDecision = "once" | "session" | "deny";
export type PermissionMode = "default" | "auto-edit" | "auto-accept";
/** Tools that are auto-accepted in "auto-edit" mode. */
export const AUTO_EDIT_TOOLS = new Set(["write_file", "edit_file"]);
export type ConfirmFn = (opts: {
toolName: string;
args: unknown;
preview?: string;
}) => Promise<PermissionDecision>;
+32
View File
@@ -0,0 +1,32 @@
export interface FallbackToolCall {
name: string;
arguments: Record<string, unknown>;
}
export interface FallbackParseResult {
calls: FallbackToolCall[];
malformed: boolean;
}
const BLOCK_RE = /```tool_call\s*([\s\S]*?)```/g;
export function parseFallbackToolCalls(content: string): FallbackParseResult {
const calls: FallbackToolCall[] = [];
let malformed = false;
for (const match of content.matchAll(BLOCK_RE)) {
const raw = match[1]?.trim() ?? "";
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.name === "string" && typeof parsed.arguments === "object") {
calls.push({ name: parsed.name, arguments: parsed.arguments ?? {} });
} else {
malformed = true;
}
} catch {
malformed = true;
}
}
return { calls, malformed };
}
+21
View File
@@ -0,0 +1,21 @@
export const FALLBACK_TOOL_INSTRUCTIONS = `This model does not have native function calling. Instead, call a tool by writing a fenced code block in exactly this format:
\`\`\`tool_call
{"name": "read_file", "arguments": {"path": "src/index.ts"}}
\`\`\`
Rules:
- Call at most one tool per response. Wait for its result before calling another.
- The block must contain valid JSON with exactly "name" and "arguments" keys.
- If you don't need a tool, just answer normally with no fenced block.
Example:
User: What does math.js contain?
Assistant:
\`\`\`tool_call
{"name": "read_file", "arguments": {"path": "math.js"}}
\`\`\`
The tool's result will be given back to you in a \`\`\`tool_result\`\`\` block. You can then answer normally or call another tool the same way.`;
export const FALLBACK_RETRY_NUDGE = `Your last message contained a \`\`\`tool_call\`\`\` block that wasn't valid JSON with "name" and "arguments" keys. Please try again using exactly the format shown earlier, or answer without a tool call.`;
+33
View File
@@ -0,0 +1,33 @@
import type { ChatCompletionMessageToolCall, ChatCompletionTool } from "openai/resources/chat/completions";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import { resolveToolInvocation, type ResolvedToolCall } from "./resolve.js";
export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
return tools.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: z.toJSONSchema(t.schema) as Record<string, unknown>,
},
}));
}
export function resolveToolCall(
call: ChatCompletionMessageToolCall,
registry: Map<string, ToolDef>,
): ResolvedToolCall {
if (call.type !== "function") {
return { error: `unsupported tool call type: ${call.type}` };
}
let parsedArgs: unknown;
try {
parsedArgs = JSON.parse(call.function.arguments || "{}");
} catch {
return { error: "arguments were not valid JSON" };
}
return resolveToolInvocation(call.function.name, parsedArgs, registry);
}
export { runTool } from "./resolve.js";
+27
View File
@@ -0,0 +1,27 @@
import type { ToolContext, ToolDef } from "../tools/types.js";
export type ResolvedToolCall = { tool: ToolDef; args: unknown } | { error: string };
export function resolveToolInvocation(
name: string,
rawArgs: unknown,
registry: Map<string, ToolDef>,
): ResolvedToolCall {
const tool = registry.get(name);
if (!tool) {
return { error: `unknown tool: ${name}` };
}
const validated = tool.schema.safeParse(rawArgs);
if (!validated.success) {
return { error: `invalid arguments: ${JSON.stringify(validated.error.issues)}` };
}
return { tool, args: validated.data };
}
export async function runTool(tool: ToolDef, args: unknown, ctx: ToolContext): Promise<unknown> {
try {
return await tool.handler(args, ctx);
} catch (err) {
return { error: (err as Error).message ?? String(err) };
}
}
+34
View File
@@ -0,0 +1,34 @@
import path from "node:path";
import { execa } from "execa";
import { z } from "zod";
import { truncate } from "../utils/truncate.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
command: z.string().describe("Shell command to run."),
cwd: z.string().optional().describe("Working directory, relative to the session's working directory."),
timeout_ms: z.number().int().min(1).max(300_000).optional().describe("Timeout in milliseconds (default 30000)."),
});
export const bashTool: ToolDef<z.infer<typeof schema>> = {
name: "bash",
description: "Run a shell command and return its stdout, stderr, and exit code.",
schema,
mutating: true,
preview: async ({ command, cwd }) => `Run shell command: ${command}${cwd ? ` (cwd: ${cwd})` : ""}`,
handler: async ({ command, cwd, timeout_ms }, ctx) => {
const workDir = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
const result = await execa(command, {
shell: true,
cwd: workDir,
timeout: timeout_ms ?? 30_000,
reject: false,
});
return {
exitCode: result.exitCode,
stdout: truncate(result.stdout ?? ""),
stderr: truncate(result.stderr ?? ""),
timedOut: result.timedOut ?? false,
};
},
};
+64
View File
@@ -0,0 +1,64 @@
import { createPatch } from "diff";
import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { ToolDef } from "./types.js";
const schema = z.object({
path: z.string().describe("File path to edit, relative to the working directory or absolute."),
old_string: z.string().describe("Exact text to replace. Must match the file content exactly, including whitespace."),
new_string: z.string().describe("Text to replace it with."),
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
function countOccurrences(haystack: string, needle: string): number {
return needle === "" ? 0 : haystack.split(needle).length - 1;
}
function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
return replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString);
}
export const editFileTool: ToolDef<z.infer<typeof schema>> = {
name: "edit_file",
description:
"Replace an exact string in a file with a new string. old_string must match the file content exactly and, unless replace_all is set, must appear exactly once — include enough surrounding context to make it unique.",
schema,
mutating: true,
preview: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
let original: string;
try {
original = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
const occurrences = countOccurrences(original, old_string);
if (occurrences === 0) {
return `Warning: old_string not found in ${resolved} — this edit will fail.`;
}
if (occurrences > 1 && !replace_all) {
return `Warning: old_string appears ${occurrences} times in ${resolved} — this edit will fail unless replace_all is set.`;
}
const updated = applyEdit(original, old_string, new_string, replace_all);
return createPatch(resolved, original, updated, "", "");
},
handler: async ({ path: filePath, old_string, new_string, replace_all }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const occurrences = countOccurrences(original, old_string);
if (occurrences === 0) {
throw new Error(
`old_string not found in ${filePath}. Make sure it matches the file exactly, including whitespace.`,
);
}
if (occurrences > 1 && !replace_all) {
throw new Error(
`old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
const updated = applyEdit(original, old_string, new_string, replace_all);
await fsWriteFile(resolved, updated, "utf-8");
return { path: resolved, replacements: replace_all ? occurrences : 1 };
},
};
+41
View File
@@ -0,0 +1,41 @@
import path from "node:path";
import { rgPath } from "@vscode/ripgrep";
import { execa } from "execa";
import { z } from "zod";
import type { ToolDef } from "./types.js";
const schema = z.object({
pattern: z.string().describe("Regular expression to search for."),
path: z.string().optional().describe("File or directory to search, relative to the working directory."),
glob: z.string().optional().describe("Restrict search to files matching this glob, e.g. '*.ts'."),
case_insensitive: z.boolean().optional(),
max_results: z.number().int().min(1).max(500).optional(),
});
export const grepTool: ToolDef<z.infer<typeof schema>> = {
name: "grep",
description: "Search file contents for a regular expression pattern using ripgrep.",
schema,
mutating: false,
handler: async ({ pattern, path: searchPath, glob, case_insensitive, max_results }, ctx) => {
const args = ["--line-number", "--no-heading", "--color", "never"];
if (case_insensitive) args.push("--ignore-case");
if (glob) args.push("--glob", glob);
args.push(pattern);
args.push(searchPath ? path.resolve(ctx.cwd, searchPath) : ctx.cwd);
try {
const { stdout } = await execa(rgPath, args);
const lines = stdout.split("\n").filter(Boolean);
const limit = max_results ?? 100;
return { matches: lines.slice(0, limit), truncated: lines.length > limit };
} catch (err) {
const exitCode = (err as { exitCode?: number }).exitCode;
if (exitCode === 1) {
// ripgrep exit code 1 means "no matches found", not an error
return { matches: [], truncated: false };
}
throw err;
}
},
};
+11
View File
@@ -0,0 +1,11 @@
import { bashTool } from "./bash.js";
import { editFileTool } from "./editFile.js";
import { grepTool } from "./grep.js";
import { listFilesTool } from "./listFiles.js";
import { readFileTool } from "./readFile.js";
import { writeFileTool } from "./writeFile.js";
import type { ToolDef } from "./types.js";
export const TOOLS: ToolDef[] = [readFileTool, listFilesTool, grepTool, writeFileTool, editFileTool, bashTool];
export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t]));
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path";
import fg from "fast-glob";
import { z } from "zod";
import type { ToolDef } from "./types.js";
const schema = z.object({
pattern: z.string().describe("Glob pattern, e.g. 'src/**/*.ts'."),
cwd: z.string().optional().describe("Directory to search from, relative to the working directory."),
});
const MAX_MATCHES = 500;
export const listFilesTool: ToolDef<z.infer<typeof schema>> = {
name: "list_files",
description: "List files matching a glob pattern.",
schema,
mutating: false,
handler: async ({ pattern, cwd }, ctx) => {
const base = cwd ? path.resolve(ctx.cwd, cwd) : ctx.cwd;
const matches = await fg(pattern, { cwd: base, dot: false, onlyFiles: true, absolute: false });
return { matches: matches.slice(0, MAX_MATCHES), truncated: matches.length > MAX_MATCHES };
},
};
+29
View File
@@ -0,0 +1,29 @@
import { readFile as fsReadFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { truncate } from "../utils/truncate.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
path: z.string().describe("File path to read, relative to the working directory or absolute."),
offset: z.number().int().min(1).optional().describe("1-indexed line number to start reading from."),
limit: z.number().int().min(1).max(2000).optional().describe("Maximum number of lines to read."),
});
export const readFileTool: ToolDef<z.infer<typeof schema>> = {
name: "read_file",
description:
"Read a text file from the local filesystem, optionally a specific line range. Returns content with 1-indexed line numbers.",
schema,
mutating: false,
handler: async ({ path: filePath, offset, limit }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const content = await fsReadFile(resolved, "utf-8");
const lines = content.split("\n");
const start = offset ? offset - 1 : 0;
const end = limit ? start + limit : lines.length;
const slice = lines.slice(start, end);
const numbered = slice.map((line, i) => `${start + i + 1}\t${line}`).join("\n");
return { path: resolved, totalLines: lines.length, content: truncate(numbered) };
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { z } from "zod";
export interface ToolContext {
cwd: string;
}
export interface ToolDef<T = any> {
name: string;
description: string;
schema: z.ZodType<T>;
mutating: boolean;
/** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */
preview?: (args: T, ctx: ToolContext) => Promise<string>;
handler: (args: T, ctx: ToolContext) => Promise<unknown>;
}
+39
View File
@@ -0,0 +1,39 @@
import { createPatch } from "diff";
import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { ToolDef } from "./types.js";
const schema = z.object({
path: z.string().describe("File path to write, relative to the working directory or absolute."),
content: z.string().describe("Full file content to write."),
});
async function readExisting(resolved: string): Promise<string | null> {
try {
return await fsReadFile(resolved, "utf-8");
} catch {
return null;
}
}
export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
name: "write_file",
description: "Create or overwrite a file with the given content.",
schema,
mutating: true,
preview: async ({ path: filePath, content }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
const existing = await readExisting(resolved);
if (existing === null) {
return `Create new file ${resolved} (${content.length} chars)`;
}
return createPatch(resolved, existing, content, "", "");
},
handler: async ({ path: filePath, content }, ctx) => {
const resolved = path.resolve(ctx.cwd, filePath);
await mkdir(path.dirname(resolved), { recursive: true });
await fsWriteFile(resolved, content, "utf-8");
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
},
};
+407
View File
@@ -0,0 +1,407 @@
import { Box, Static, useApp } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { AgentError, runTurn } from "../../agent/loop.js";
import { createSession, resetSession, setMode, type Session } from "../../agent/session.js";
import { makeClient } from "../../backend/client.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { ChatInput } from "./ChatInput.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
import { StatusBar } from "./StatusBar.js";
import { ThinkingIndicator } from "./ThinkingIndicator.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
export interface AppProps {
baseURL: string;
cwd: string;
toolModeOverride?: ToolCallMode;
initialModel?: string;
suggestedModel?: string;
}
interface PendingPermission {
toolName: string;
args: unknown;
preview?: string;
resolve: (decision: PermissionDecision) => void;
}
type Phase = "connecting" | "loading-models" | "model-select" | "input";
export function App({
baseURL: initialBaseURL,
cwd,
toolModeOverride,
initialModel,
suggestedModel,
}: AppProps) {
const { exit } = useApp();
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
const [phase, setPhase] = useState<Phase>(initialModel ? "connecting" : "loading-models");
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
const [modelList, setModelList] = useState<string[]>([]);
const baseURLRef = useRef(initialBaseURL);
const sessionRef = useRef<Session | null>(null);
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
const streamingAccumulatorRef = useRef("");
const lastStreamRenderRef = useRef(0);
const streamRafRef = useRef<number | null>(null);
const flushStreamingText = useCallback(() => {
const accumulated = streamingAccumulatorRef.current;
if (accumulated) {
setStreamingText(accumulated);
}
}, []);
const push = useCallback((partial: NewHistoryItem) => {
setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]);
}, []);
// Fetch models on mount when no --model is specified
useEffect(() => {
if (initialModel) return; // skip model listing if --model was provided
const client = makeClient({ baseURL: baseURLRef.current, model: "" });
client.models
.list()
.then((list) => {
const ids = list.data.map((m) => m.id);
setModelList(ids);
if (ids.length === 0) {
push({ kind: "notice", text: "No models found. Pull/load a model first.", isError: true });
setPhase("input");
} else {
setPhase("model-select");
}
})
.catch((err) => {
push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true });
setPhase("input");
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initSession = useCallback(
async (model: string) => {
setPhase("connecting");
try {
const client = makeClient({ baseURL: baseURLRef.current, model });
// Default to "native" mode immediately — no blocking probe on startup.
// The probe runs lazily on the first turn if needed.
const mode: ToolCallMode = toolModeOverride ?? "native";
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
sessionRef.current = createSession(client, model, cwd, confirmFn, mode);
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
setPhase("input");
} catch (err) {
push({
kind: "notice",
text: `Failed to connect to "${model}": ${(err as Error).message}`,
isError: true,
});
if (modelList.length > 0) {
setPhase("model-select");
} else {
setTimeout(() => exit(new Error("Failed to connect")), 100);
}
}
},
[cwd, toolModeOverride, modelList, exit, push],
);
useEffect(() => {
if (initialModel) {
void initSession(initialModel);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleModelSelect(model: string) {
await initSession(model);
}
async function switchModel(name: string) {
const session = sessionRef.current;
if (!session) return;
const previousModel = session.model;
session.model = name;
setIsThinking(true);
try {
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, name);
setMode(session, newMode);
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
} catch (err) {
session.model = previousModel;
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
async function switchBackend(name: BackendName) {
const session = sessionRef.current;
if (!session) return;
const previousBaseURL = baseURLRef.current;
const previousClient = session.client;
baseURLRef.current = KNOWN_BACKENDS[name];
session.client = makeClient({ baseURL: baseURLRef.current, model: session.model });
setIsThinking(true);
try {
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, session.model);
setMode(session, newMode);
push({
kind: "notice",
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
});
} catch (err) {
baseURLRef.current = previousBaseURL;
session.client = previousClient;
push({ kind: "notice", text: `Failed to switch backend: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
async function handleSubmit(raw: string) {
setInputValue("");
const trimmed = raw.trim();
if (!trimmed) return;
const session = sessionRef.current;
if (!session) return;
push({ kind: "user", text: trimmed });
if (trimmed === "/exit" || trimmed === "/quit") {
exit();
return;
}
if (trimmed === "/help") {
push({ kind: "help" });
return;
}
if (trimmed === "/clear") {
resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." });
return;
}
if (trimmed === "/status") {
push({ kind: "status", model: session.model, baseURL: baseURLRef.current, mode: session.mode, cwd });
return;
}
if (trimmed === "/tools") {
push({ kind: "tools" });
return;
}
if (trimmed === "/permissions") {
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
return;
}
if (trimmed.startsWith("/model")) {
const name = trimmed.slice("/model".length).trim();
if (!name) {
push({ kind: "notice", text: `Current model: ${session.model}` });
} else {
await switchModel(name);
}
return;
}
if (trimmed.startsWith("/backend")) {
const name = trimmed.slice("/backend".length).trim();
if (!name) {
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
} else if (name !== "ollama" && name !== "lmstudio") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
return;
}
if (trimmed === "/perm") {
// Cycle: default → auto-edit → auto-accept → default
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length];
setPermMode(nextMode);
session.permissions.setMode(nextMode);
const labels: Record<PermissionMode, string> = {
default: "default (ask before mutating tools)",
"auto-edit": "auto-edit (file edits auto-approved, bash still asks)",
"auto-accept": "auto-accept (all tools auto-approved ⚠)",
};
push({ kind: "notice", text: `Permission mode: ${labels[nextMode]}` });
return;
}
if (trimmed.startsWith("/perm")) {
const name = trimmed.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" };
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length];
setPermMode(nextMode);
session.permissions.setMode(nextMode);
const labels: Record<PermissionMode, string> = {
default: "default (ask before mutating tools)",
"auto-edit": "auto-edit (file edits auto-approved, bash still asks)",
"auto-accept": "auto-accept (all tools auto-approved ⚠)",
};
push({ kind: "notice", text: `Permission mode: ${labels[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({ kind: "notice", text: `Unknown permission mode "${name}". Use "default", "auto-edit", or "auto-accept".`, isError: true });
}
return;
}
if (trimmed.startsWith("/mode")) {
const name = trimmed.slice("/mode".length).trim();
if (!name) {
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
}
return;
}
const rollbackLength = session.messages.length;
setIsThinking(true);
setStreamingText(null);
streamingAccumulatorRef.current = "";
try {
const text = await runTurn(session, trimmed, (event) => {
if (event.type === "text_delta") {
setIsThinking(false);
streamingAccumulatorRef.current += event.delta;
// Throttle renders to ~30fps
const now = Date.now();
if (now - lastStreamRenderRef.current > 33) {
lastStreamRenderRef.current = now;
setStreamingText(streamingAccumulatorRef.current);
} else {
if (streamRafRef.current === null) {
streamRafRef.current = setTimeout(flushStreamingText, 33) as any;
}
}
} else if (event.type === "text_done") {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
setStaticItems((prev) => [...prev, { id: nextId(), kind: "assistant", text: event.fullText } as HistoryItem]);
} else if (event.type === "tool_call") {
setStreamingText(null);
setIsThinking(true);
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_call", label: event.label } as HistoryItem]);
} else if (event.type === "tool_result") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
}
});
// text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done
} catch (err) {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
session.messages.length = rollbackLength;
const reason = err instanceof AgentError ? err.message : (err as Error).message;
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
} finally {
setIsThinking(false);
setStreamingText(null);
streamingAccumulatorRef.current = "";
}
}
function handlePermissionSelect(decision: PermissionDecision) {
const pending = permission;
setPermission(null);
setIsThinking(true);
pending?.resolve(decision);
}
function cyclePermMode() {
const session = sessionRef.current;
if (!session) return;
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length];
setPermMode(nextMode);
session.permissions.setMode(nextMode);
const labels: Record<PermissionMode, string> = {
default: "default (ask before mutating tools)",
"auto-edit": "auto-edit (file edits auto-approved, bash still asks)",
"auto-accept": "auto-accept (all tools auto-approved ⚠)",
};
push({ kind: "notice", text: `Permission mode: ${labels[nextMode]}` });
}
return (
<Box flexDirection="column" width="100%" height="100%">
<Static items={staticItems}>
{(item) => <HistoryItemView key={item.id} item={item} />}
</Static>
{streamingText !== null && (
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} />
)}
{isThinking && streamingText === null && !permission && (
<ThinkingIndicator />
)}
{permission ? (
<PermissionPrompt
toolName={permission.toolName}
args={permission.args}
preview={permission.preview}
onSelect={handlePermissionSelect}
/>
) : phase === "connecting" ? (
<ThinkingIndicator label="connecting..." />
) : phase === "loading-models" ? (
<ThinkingIndicator label="loading models..." />
) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<ChatInput value={inputValue} onChange={setInputValue} onSubmit={handleSubmit} onCyclePermMode={cyclePermMode} />
)}
{sessionRef.current && phase === "input" && (
<StatusBar
model={sessionRef.current.model}
mode={sessionRef.current.mode}
permMode={permMode}
cwd={cwd}
/>
)}
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import { ACCENT_HEX } from "../theme.js";
interface Props {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
onCyclePermMode?: () => void;
}
export function ChatInput({ value, onChange, onSubmit, onCyclePermMode }: Props) {
useInput((_input: string, key: { shift?: boolean; tab?: boolean; backTab?: boolean }) => {
// Shift+Tab: cycle permission mode
if ((key.shift && key.tab) || key.backTab) {
onCyclePermMode?.();
}
});
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text color={ACCENT_HEX}>{"> "}</Text>
<TextInput value={value} onChange={onChange} onSubmit={onSubmit} />
</Box>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { Box, Text } from "ink";
import { TOOLS } from "../../tools/index.js";
import { renderMarkdown } from "../render.js";
import { ACCENT_HEX } from "../theme.js";
import type { HistoryItem } from "./types.js";
const HELP_LINES = [
"Slash commands:",
" /model <name> switch the model used for the current backend",
" /backend <name> switch backend (ollama | lmstudio), keeps current model",
" /mode <name> view or force tool-call mode (native | fallback)",
" /perm [mode] cycle or set permission mode (default | auto-edit | auto-accept)",
" /status show current model, backend, tool-call mode, and cwd",
" /tools list available tools",
" /permissions list mutating tools allowed for the rest of this session",
" /clear clear conversation history",
" /help show this help",
" /exit, /quit exit",
"",
"Keyboard shortcuts:",
" Shift+Tab cycle permission mode",
];
export function HistoryItemView({ item }: { item: HistoryItem }) {
switch (item.kind) {
case "banner":
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold color={ACCENT_HEX}>
✻ locode
</Text>
<Text> </Text>
{item.model ? (
<Text>
<Text dimColor> model: </Text>
<Text bold>{item.model}</Text>
</Text>
) : null}
{item.backend ? (
<Text>
<Text dimColor> backend: </Text>
{item.backend}
</Text>
) : null}
<Text>
<Text dimColor> cwd: </Text>
{item.cwd}
</Text>
<Text> </Text>
<Text dimColor> /help for commands · /perm to change permission mode · Shift+Tab to cycle modes</Text>
</Box>
);
case "status":
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold>Status</Text>
<Text> </Text>
<Text>
<Text dimColor>model: </Text>
{item.model}
</Text>
<Text>
<Text dimColor>backend: </Text>
{item.baseURL}
</Text>
<Text>
<Text dimColor>tools: </Text>
{item.mode}
</Text>
<Text>
<Text dimColor>cwd: </Text>
{item.cwd}
</Text>
</Box>
);
case "user":
return (
<Text>
<Text color={ACCENT_HEX}>{"> "}</Text>
{item.text}
</Text>
);
case "assistant":
case "streaming_text":
return <Text>{renderMarkdown(item.text)}</Text>;
case "tool_call":
return (
<Text color={ACCENT_HEX}>
{"⏺ "}
{item.label}
</Text>
);
case "tool_result":
return (
<Text color={item.isError ? "red" : undefined} dimColor={!item.isError}>
{" ⎿ "}
{item.summary}
</Text>
);
case "notice":
return (
<Text color={item.isError ? "red" : undefined} dimColor={!item.isError}>
{item.text}
</Text>
);
case "help":
return (
<Box flexDirection="column">
{HELP_LINES.map((line) => (
<Text key={line}>{line}</Text>
))}
</Box>
);
case "tools":
return (
<Box flexDirection="column">
<Text>Available tools:</Text>
{TOOLS.map((t) => (
<Text key={t.name}>
{" "}
{t.name}
{t.mutating ? " (requires confirmation)" : ""}: {t.description}
</Text>
))}
</Box>
);
case "permissions":
return (
<Text dimColor>
{item.allowed.length
? `Allowed for the rest of this session: ${item.allowed.join(", ")}`
: "No mutating tools have been allowed for the rest of this session yet."}
</Text>
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import { ACCENT_HEX } from "../theme.js";
interface Props {
models: string[];
currentModel?: string;
onSelect: (model: string) => void;
}
export function ModelSelect({ models, currentModel, onSelect }: Props) {
const items = models.map((m) => ({ label: m === currentModel ? `${m} (current)` : m, value: m }));
const initialIndex = currentModel ? Math.max(models.indexOf(currentModel), 0) : 0;
return (
<Box flexDirection="column" justifyContent="center" alignItems="center" height="100%" width="100%">
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={2} width={60}>
<Text bold color={ACCENT_HEX}>
✻ locode
</Text>
<Text> </Text>
<Text dimColor>Select a model to start:</Text>
<Text> </Text>
<SelectInput items={items} initialIndex={initialIndex} onSelect={(item) => onSelect(item.value)} />
</Box>
</Box>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import type { PermissionDecision } from "../../permissions/types.js";
import { ACCENT_HEX } from "../theme.js";
interface Props {
toolName: string;
args: unknown;
preview?: string;
onSelect: (decision: PermissionDecision) => void;
}
const OPTIONS: Array<{ label: string; value: PermissionDecision }> = [
{ label: "Yes", value: "once" },
{ label: "Yes, and don't ask again this session", value: "session" },
{ label: "No, and tell it what to do differently", value: "deny" },
];
export function PermissionPrompt({ toolName, args, preview, onSelect }: Props) {
const previewLines = (preview ?? JSON.stringify(args)).split("\n");
return (
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={1} width="100%">
<Text bold>{toolName}</Text>
<Text> </Text>
{previewLines.map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Text> </Text>
<Text>Do you want to proceed?</Text>
<SelectInput items={OPTIONS} onSelect={(item) => onSelect(item.value)} />
</Box>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Box, Text } from "ink";
import { ACCENT_HEX } from "../theme.js";
import type { PermissionMode } from "../../permissions/types.js";
const MODE_LABELS: Record<PermissionMode, string> = {
default: "default",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
const MODE_COLORS: Record<PermissionMode, string> = {
default: "gray",
"auto-edit": "yellow",
"auto-accept": "red",
};
interface Props {
model: string;
mode: string;
permMode: PermissionMode;
cwd: string;
}
export function StatusBar({ model, mode, permMode, cwd }: Props) {
// Show just the last segment of cwd for brevity
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
return (
<Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}>
<Box gap={1}>
<Text dimColor>{model}</Text>
<Text dimColor>·</Text>
<Text dimColor>{mode}</Text>
<Text dimColor>·</Text>
<Text dimColor>{shortCwd}</Text>
<Text dimColor>·</Text>
<Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text>
</Box>
<Text dimColor>/perm · /help · /exit</Text>
</Box>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { Text } from "ink";
import Spinner from "ink-spinner";
import { ACCENT_HEX } from "../theme.js";
export function ThinkingIndicator({ label = "thinking..." }: { label?: string }) {
return (
<Text color={ACCENT_HEX}>
<Spinner type="dots" /> {label}
</Text>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { render } from "ink";
import { makeClient } from "../../backend/client.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { App } from "./App.js";
export interface RunInkAppOptions {
baseURL: string;
model?: string;
suggestedModel?: string;
cwd: string;
toolModeOverride?: ToolCallMode;
}
export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
if (!process.stdin.isTTY) {
console.error(
"locode needs an interactive terminal (it can't read piped or redirected input). Run it directly in your terminal.",
);
process.exit(1);
}
// Start the UI immediately — no blocking network calls before rendering.
// Model listing happens inside the App component so the user sees the UI right away.
const instance = render(
<App
baseURL={opts.baseURL}
cwd={opts.cwd}
toolModeOverride={opts.toolModeOverride}
initialModel={opts.model}
suggestedModel={opts.suggestedModel}
/>,
);
await instance.waitUntilExit();
}
+25
View File
@@ -0,0 +1,25 @@
export type HistoryItem =
| { id: string; kind: "banner"; cwd: string; model?: string; backend?: string }
| { id: string; kind: "status"; model: string; baseURL: string; mode: string; cwd: string }
| { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string }
| { id: string; kind: "streaming_text"; text: string }
| { id: string; kind: "tool_call"; label: string }
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
| { id: string; kind: "notice"; text: string; isError?: boolean }
| { id: string; kind: "help" }
| { id: string; kind: "tools" }
| { id: string; kind: "permissions"; allowed: string[] };
/** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */
export type NewHistoryItem = HistoryItem extends infer T
? T extends { id: string }
? Omit<T, "id">
: never
: never;
let idCounter = 0;
export function nextId(): string {
idCounter += 1;
return `h${idCounter}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { marked } from "marked";
import { markedTerminal } from "marked-terminal";
// @types/marked-terminal's MarkedExtension shape doesn't line up with the installed marked version;
// the two packages are functionally compatible at runtime per marked-terminal's own peer range.
marked.use(markedTerminal() as Parameters<typeof marked.use>[0]);
export function renderMarkdown(text: string): string {
const rendered = marked.parse(text);
return typeof rendered === "string" ? rendered.trimEnd() : text;
}
+1
View File
@@ -0,0 +1 @@
export const ACCENT_HEX = "#D97757";
+45
View File
@@ -0,0 +1,45 @@
export function formatCallLabel(name: string, args: unknown): string {
const a = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
switch (name) {
case "read_file":
return `Read(${a.path ?? ""})`;
case "list_files":
return `List(${a.pattern ?? ""})`;
case "grep":
return `Grep(${a.pattern ?? ""})`;
case "write_file":
return `Write(${a.path ?? ""})`;
case "edit_file":
return `Edit(${a.path ?? ""})`;
case "bash":
return `Bash(${a.command ?? ""})`;
default:
return `${name}(${JSON.stringify(args)})`;
}
}
export function summarizeToolResult(toolName: string, result: unknown): string {
if (result && typeof result === "object" && "error" in result) {
return String((result as { error: unknown }).error);
}
if (!result || typeof result !== "object") {
return "done";
}
const r = result as Record<string, unknown>;
switch (toolName) {
case "read_file":
return typeof r.totalLines === "number" ? `Read ${r.totalLines} lines` : "Read file";
case "list_files":
return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files";
case "grep":
return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched";
case "write_file":
return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file";
case "edit_file":
return typeof r.replacements === "number" ? `Applied ${r.replacements} replacement(s)` : "Edited file";
case "bash":
return typeof r.exitCode === "number" ? `Exit code ${r.exitCode}` : "Ran command";
default:
return "done";
}
}
+4
View File
@@ -0,0 +1,4 @@
export function truncate(text: string, maxChars = 20_000): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n... [truncated ${text.length - maxChars} more characters]`;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
target: "node20",
clean: true,
banner: {
js: "#!/usr/bin/env node",
},
});