first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
Generated
+4531
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface AppConfig {
|
||||
baseURL: string;
|
||||
model: string;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.`;
|
||||
@@ -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";
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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]));
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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") };
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ACCENT_HEX = "#D97757";
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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]`;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user