Add web search/fetch, sub-agents, MCP support, git tools, session persistence, and context compaction

Substantially expands locode's tool loop toward feature parity with Claude Code:

- web_search / web_fetch: DuckDuckGo-backed search and URL fetching (HTML stripped to text)
- git_status / git_commit: structured git tools with real diff/status/commit-list previews
  before any mutating operation is confirmed
- agent tool: sub-agents run an isolated, headless tool loop sharing the parent's permissions;
  bounded by an explicit depth guard (no nested sub-agents) and a 120s hard timeout, independent
  of the toolset already excluding `agent` for sub-agent sessions
- MCP client support (stdio + streamable HTTP transports) via the official SDK: locode connects
  to servers configured in `.mcp.json` or via `locode mcp add`, and exposes their tools alongside
  the built-in ones, namespaced as `mcp__<server>__<tool>`
- Session persistence: conversations auto-save after every turn; `--continue`/`--resume`,
  `locode sessions list/rm`, and an interactive resume picker
- Context compaction: tracks real token usage via `stream_options.include_usage` (falling back to
  a char-based estimate), auto-detects the model's context window (Ollama/LM Studio native APIs,
  cached, else a configurable default), and auto-summarizes the conversation at 85% usage or on
  demand via /compact
- Status bar and welcome banner: added a context-usage indicator and fixed several dim-by-default
  colors that were hard to read

Session/loop architecture changed to support all of this: Session now carries its own toolset
(tools + registry + OpenAI schemas) built from local + MCP tools, so runTurn no longer depends on
a fixed global tool list — the same mechanism that lets sub-agents inherit MCP tools while
excluding `agent` itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-06 17:53:27 +09:00
co-authored by Claude Sonnet 5
parent 68f2242984
commit 3f1527375c
34 changed files with 2751 additions and 77 deletions
+44 -2
View File
@@ -34,13 +34,48 @@ List models available from the configured backend:
locode models locode models
``` ```
Every conversation is auto-saved as you go. Resume later:
```sh
locode --continue # resume the most recent conversation
locode --resume # pick from a list of saved conversations
locode --resume <id> # resume a specific one
locode sessions list # see saved conversations (id, model, title) without starting the UI
locode sessions rm <id> # delete a saved conversation
```
locode can also use tools from external [MCP](https://modelcontextprotocol.io) servers:
```sh
locode mcp add my-server --command npx --arg -y --arg @some/mcp-server # stdio server
locode mcp add my-remote --url https://example.com/mcp # remote (streamable HTTP) server
locode mcp list # see configured servers (user-level + project .mcp.json)
locode mcp remove <name>
```
Project-level servers can also be checked into a repo via a `.mcp.json` file in its root:
```json
{
"mcpServers": {
"my-server": { "command": "npx", "args": ["-y", "@some/mcp-server"] }
}
}
```
locode connects to every configured server on startup; their tools show up alongside the built-in ones, namespaced as `mcp__<server>__<tool>`.
## How it works ## 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. 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. - **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. - **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status` run automatically. `write_file`, `edit_file`, `bash`, and `git_commit` 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.
- **Git**: `git_status` covers read-only inspection (`status`, `diff`, `log`, `show`, `branches`) and runs automatically. `git_commit` covers `add`, `commit`, `create_branch`, `checkout`, and `push` — each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send).
- **Sub-agents**: the `agent` tool lets the model delegate a self-contained task to a fresh, isolated tool loop (same tools, minus `agent` itself — no nested sub-agents) and get back only the final answer, keeping the main conversation's context focused. It shows up as a single `⏺ Agent(description)` / `⎿ Sub-agent finished (...)` line — the sub-agent's own intermediate steps aren't displayed. Any mutating tool calls it makes still go through the same permission prompts as the main conversation.
- **MCP servers**: locode connects to any [MCP](https://modelcontextprotocol.io) servers configured via `locode mcp add` or a project's `.mcp.json` (stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced as `mcp__<server>__<tool>`. A server's tool is treated as mutating (confirmation required) unless it declares itself read-only via the MCP `readOnlyHint` annotation. One misconfigured server doesn't block the others — check `/mcp` for per-server connection status.
- **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. - **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.
- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow <n>`, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`.
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`. 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`.
@@ -53,6 +88,9 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/status show current model, backend, tool-call mode, and cwd /status show current model, backend, tool-call mode, and cwd
/tools list available tools /tools list available tools
/permissions list mutating tools allowed for the rest of this session /permissions list mutating tools allowed for the rest of this session
/sessions list saved conversations you can resume with --resume
/mcp show connected MCP servers and their tool counts
/compact summarize the conversation now to free up context
/clear clear conversation history /clear clear conversation history
/help show this help /help show this help
/exit, /quit exit /exit, /quit exit
@@ -65,6 +103,7 @@ Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCO
```sh ```sh
locode config set backend ollama locode config set backend ollama
locode config set model qwen3-coder:30b locode config set model qwen3-coder:30b
locode config set contextWindow 32768 # fallback size when auto-detection fails
locode config get locode config get
locode config path locode config path
``` ```
@@ -74,6 +113,9 @@ locode config path
- Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script. - 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). - 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 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. - Session resume replays prior user/assistant text so you can see it, but it doesn't re-display prior tool-call/tool-result lines from before the resume (the model still has that history — it's just not re-rendered).
- 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). - 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. - Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting.
- `git_commit` covers add/commit/create_branch/checkout/push only — no reset, stash, merge, rebase, or branch deletion. Use `bash` for anything beyond that.
- MCP tool results only support text content — image/audio/resource content blocks are shown as a placeholder note rather than rendered. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows.
- Compaction (`/compact` or automatic at 85%) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The 85% threshold isn't currently configurable.
+1053 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@vscode/ripgrep": "^1.18.0", "@vscode/ripgrep": "^1.18.0",
"commander": "^13.0.0", "commander": "^13.0.0",
"diff": "^9.0.0", "diff": "^9.0.0",
+156 -12
View File
@@ -1,19 +1,89 @@
import { randomUUID } from "node:crypto";
import type { ChatCompletionChunk } from "openai/resources/chat/completions"; import type { ChatCompletionChunk } from "openai/resources/chat/completions";
import type { CompletionUsage } from "openai/resources/completions";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { AgentEventHandler } from "./events.js"; import type { AgentEventHandler } from "./events.js";
import { TOOL_REGISTRY, TOOLS } from "../tools/index.js"; import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { SubAgentTask } from "../tools/types.js";
import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js"; import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js";
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js"; import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
import { resolveToolCall, toOpenAITools } from "../toolcalling/nativeAdapter.js"; import { resolveToolCall } from "../toolcalling/nativeAdapter.js";
import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js"; import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js";
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js"; import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js";
import type { Session } from "./session.js"; import type { Session } from "./session.js";
export class AgentError extends Error {} export class AgentError extends Error {}
const openaiTools = toOpenAITools(TOOLS);
const MAX_MALFORMED_RETRIES = 2; const MAX_MALFORMED_RETRIES = 2;
// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't
// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops
// that hold even if that filtering were ever bypassed or a future change loosened it.
const MAX_SUBAGENT_DEPTH = 1;
const SUBAGENT_TIMEOUT_MS = 120_000;
/** Fraction of the context window at which the caller should auto-compact (see shouldAutoCompact). */
export const AUTO_COMPACT_THRESHOLD = 0.85;
function updateContextTracking(session: Session, promptTokens: number | null | undefined): void {
if (typeof promptTokens === "number") {
session.lastContextTokens = promptTokens;
session.lastContextTokensIsEstimate = false;
} else {
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
}
}
export function contextUsageRatio(session: Session): number {
return session.lastContextTokens / session.contextWindow;
}
export function shouldAutoCompact(session: Session): boolean {
return contextUsageRatio(session) >= AUTO_COMPACT_THRESHOLD;
}
/**
* Replaces the conversation history with a model-generated summary of everything so far, to free
* up context. Runs as a plain (non-tool-calling) request so the model just produces prose, not
* more tool calls. The new history is just [system, a synthetic assistant "recap"] — framing the
* summary as an assistant turn keeps proper role alternation with whatever real user message
* follows next.
*/
export async function compactSession(session: Session): Promise<void> {
const requestMessages: ChatCompletionMessageParam[] = [
...session.messages,
{
role: "user",
content:
"Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " +
"made, files created/changed and why, current task state, and anything still outstanding. Write it as " +
"background context for continuing the conversation — plain prose, no meta-commentary about summarizing.",
},
];
const res = await session.client.chat.completions.create({
model: session.model,
messages: requestMessages,
stream: false,
max_tokens: 1024,
});
const summary = res.choices[0]?.message?.content;
if (!summary) {
throw new AgentError("Compaction failed: the model returned no summary.");
}
session.messages = [
{ role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode) },
{ role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` },
];
// res.usage describes the old (now-discarded) prompt, not the new shorter history — estimate fresh.
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
}
/** Accumulates streaming tool-call deltas into complete tool calls. */ /** Accumulates streaming tool-call deltas into complete tool calls. */
interface AccumulatedToolCall { interface AccumulatedToolCall {
id: string; id: string;
@@ -35,7 +105,7 @@ async function gateAndRun(
const { tool, args } = resolved; const { tool, args } = resolved;
emit({ type: "tool_call", label: formatCallLabel(tool.name, args) }); emit({ type: "tool_call", label: formatCallLabel(tool.name, args) });
const ctx = { cwd: session.cwd }; const ctx = { cwd: session.cwd, runSubAgent: (task: SubAgentTask) => runSubAgentTurn(session, task) };
if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) { if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) {
const preview = tool.preview ? await tool.preview(args, ctx) : undefined; const preview = tool.preview ? await tool.preview(args, ctx) : undefined;
@@ -55,6 +125,64 @@ async function gateAndRun(
return result; return result;
} }
/**
* Runs a sub-agent as a fresh, isolated turn loop that shares the parent's client/model/cwd/
* permissions, but starts with no conversation history beyond the delegated task. Runs headless —
* its own streaming text and tool calls aren't surfaced to the UI, only its final text is (via the
* `agent` tool's result) — so mutating tool calls it makes still go through the shared
* PermissionManager/confirm and will still prompt the user, they just aren't shown mid-flight.
*
* Bounded by two independent safety limits (see MAX_SUBAGENT_DEPTH/SUBAGENT_TIMEOUT_MS above):
* an explicit depth check (on top of the toolset already excluding `agent` for sub-agents) and a
* hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely.
*/
async function runSubAgentTurn(parent: Session, task: SubAgentTask): Promise<string> {
if (parent.subAgentDepth >= MAX_SUBAGENT_DEPTH) {
throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${MAX_SUBAGENT_DEPTH}).`);
}
// Derived from the parent's own toolset (so MCP tools carry over) minus `agent` itself —
// sub-agents can't spawn further sub-agents, keeping recursion bounded to one level.
const subToolset = buildToolSet(parent.toolset.tools.filter((t) => t.name !== "agent"));
const subMessages: ChatCompletionMessageParam[] = [
{
role: "system",
content: `${buildSystemPrompt(subToolset.tools, parent.mode)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`,
},
];
const subSession: Session = {
id: randomUUID(),
createdAt: new Date().toISOString(),
client: parent.client,
model: parent.model,
cwd: parent.cwd,
mode: parent.mode,
messages: subMessages,
maxIterations: parent.maxIterations,
permissions: parent.permissions,
confirm: parent.confirm,
toolset: subToolset,
subAgentDepth: parent.subAgentDepth + 1,
contextWindow: parent.contextWindow,
contextWindowIsEstimate: parent.contextWindowIsEstimate,
lastContextTokens: estimateTokens(subMessages),
lastContextTokensIsEstimate: true,
};
let timeoutId: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Sub-agent timed out after ${SUBAGENT_TIMEOUT_MS / 1000}s.`)),
SUBAGENT_TIMEOUT_MS,
);
});
try {
return await Promise.race([runTurn(subSession, task.prompt, () => {}, subToolset), timeout]);
} finally {
clearTimeout(timeoutId!);
}
}
/** /**
* Process a completed (non-streaming) response — handles both native tool_calls * Process a completed (non-streaming) response — handles both native tool_calls
* and plain text, shared by the streaming path (after accumulation) and the * and plain text, shared by the streaming path (after accumulation) and the
@@ -64,6 +192,7 @@ async function handleCompletedMessage(
message: any, message: any,
session: Session, session: Session,
emit: AgentEventHandler, emit: AgentEventHandler,
toolset: ToolSet,
): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> { ): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> {
// Native tool calls // Native tool calls
if (session.mode === "native" && message.tool_calls?.length) { if (session.mode === "native" && message.tool_calls?.length) {
@@ -74,7 +203,7 @@ async function handleCompletedMessage(
} as ChatCompletionMessageParam); } as ChatCompletionMessageParam);
for (const call of message.tool_calls) { for (const call of message.tool_calls) {
const resolved = resolveToolCall(call as any, TOOL_REGISTRY); const resolved = resolveToolCall(call as any, toolset.registry);
const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type; const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type;
const result = await gateAndRun(resolved, label, session, emit); const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({ session.messages.push({
@@ -96,7 +225,7 @@ async function handleCompletedMessage(
if (parsed.calls.length) { if (parsed.calls.length) {
session.messages.push({ role: "assistant", content: text }); session.messages.push({ role: "assistant", content: text });
for (const call of parsed.calls) { for (const call of parsed.calls) {
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY); const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
const label = `${call.name}(${JSON.stringify(call.arguments)})`; const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit); const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({ session.messages.push({
@@ -116,7 +245,12 @@ async function handleCompletedMessage(
return { text, hadToolCalls: false }; return { text, hadToolCalls: false };
} }
export async function runTurn(session: Session, userInput: string, emit: AgentEventHandler): Promise<string> { export async function runTurn(
session: Session,
userInput: string,
emit: AgentEventHandler,
toolset: ToolSet = session.toolset,
): Promise<string> {
session.messages.push({ role: "user", content: userInput }); session.messages.push({ role: "user", content: userInput });
let malformedRetries = 0; let malformedRetries = 0;
@@ -126,16 +260,23 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
let finishReason: string | null = null; let finishReason: string | null = null;
const accumulatedToolCalls: AccumulatedToolCall[] = []; const accumulatedToolCalls: AccumulatedToolCall[] = [];
let usage: CompletionUsage | undefined;
try { try {
const stream = await session.client.chat.completions.create({ const stream = await session.client.chat.completions.create({
model: session.model, model: session.model,
messages: session.messages, messages: session.messages,
tools: session.mode === "native" ? openaiTools : undefined, tools: session.mode === "native" ? toolset.openaiTools : undefined,
stream: true, stream: true,
stream_options: { include_usage: true },
max_tokens: 4096, max_tokens: 4096,
}); });
for await (const chunk of stream) { for await (const chunk of stream) {
// The usage-carrying final chunk has an empty `choices` array per spec — read it before
// the early-continue below would otherwise skip straight past it.
if (chunk.usage) usage = chunk.usage;
const choice = chunk.choices[0]; const choice = chunk.choices[0];
if (!choice) continue; if (!choice) continue;
@@ -177,6 +318,8 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
throw streamErr; throw streamErr;
} }
updateContextTracking(session, usage?.prompt_tokens);
// --- Handle native tool calls from streaming --- // --- Handle native tool calls from streaming ---
if (session.mode === "native" && accumulatedToolCalls.length > 0) { if (session.mode === "native" && accumulatedToolCalls.length > 0) {
// Validate accumulated arguments — if any fail to parse (Ollama streaming bug), // Validate accumulated arguments — if any fail to parse (Ollama streaming bug),
@@ -196,12 +339,13 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
const res = await session.client.chat.completions.create({ const res = await session.client.chat.completions.create({
model: session.model, model: session.model,
messages: session.messages, messages: session.messages,
tools: openaiTools, tools: toolset.openaiTools,
stream: false, stream: false,
max_tokens: 4096, max_tokens: 4096,
}); });
const message = res.choices[0]?.message; const message = res.choices[0]?.message;
if (!message) throw new AgentError("Empty response from model."); if (!message) throw new AgentError("Empty response from model.");
updateContextTracking(session, res.usage?.prompt_tokens);
// If we streamed partial text, we need to tell the UI the text is done // 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 // (it may have been partially displayed). The non-streaming response
@@ -210,7 +354,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
// The partial stream text is superseded by the non-streaming response // The partial stream text is superseded by the non-streaming response
} }
const result = await handleCompletedMessage(message, session, emit); const result = await handleCompletedMessage(message, session, emit, toolset);
if (result.hadToolCalls) continue; if (result.hadToolCalls) continue;
// Non-tool-call text from the retry // Non-tool-call text from the retry
@@ -235,7 +379,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
for (const tc of accumulatedToolCalls) { for (const tc of accumulatedToolCalls) {
const resolved = resolveToolCall( const resolved = resolveToolCall(
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any, { id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any,
TOOL_REGISTRY, toolset.registry,
); );
const label = `${tc.name}(${tc.arguments})`; const label = `${tc.name}(${tc.arguments})`;
const result = await gateAndRun(resolved, label, session, emit); const result = await gateAndRun(resolved, label, session, emit);
@@ -258,7 +402,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv
emit({ type: "text_done", fullText }); emit({ type: "text_done", fullText });
session.messages.push({ role: "assistant", content: fullText }); session.messages.push({ role: "assistant", content: fullText });
for (const call of parsed.calls) { for (const call of parsed.calls) {
const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY); const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry);
const label = `${call.name}(${JSON.stringify(call.arguments)})`; const label = `${call.name}(${JSON.stringify(call.arguments)})`;
const result = await gateAndRun(resolved, label, session, emit); const result = await gateAndRun(resolved, label, session, emit);
session.messages.push({ session.messages.push({
+89 -2
View File
@@ -1,12 +1,20 @@
import { randomUUID } from "node:crypto";
import type OpenAI from "openai"; import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js"; import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { DEFAULT_CONTEXT_WINDOW } from "../config/defaults.js";
import type { SessionRecord } from "../persistence/sessionStore.js";
import { PermissionManager } from "../permissions/permissionManager.js"; import { PermissionManager } from "../permissions/permissionManager.js";
import type { ConfirmFn } from "../permissions/types.js"; import type { ConfirmFn } from "../permissions/types.js";
import { TOOLS } from "../tools/index.js"; import { TOOLS } from "../tools/index.js";
import { buildToolSet, type ToolSet } from "../tools/toolset.js";
import type { ToolDef } from "../tools/types.js";
import { estimateTokens } from "../utils/tokens.js";
import { buildSystemPrompt } from "./systemPrompt.js"; import { buildSystemPrompt } from "./systemPrompt.js";
export interface Session { export interface Session {
id: string;
createdAt: string;
client: OpenAI; client: OpenAI;
model: string; model: string;
cwd: string; cwd: string;
@@ -15,6 +23,21 @@ export interface Session {
maxIterations: number; maxIterations: number;
permissions: PermissionManager; permissions: PermissionManager;
confirm: ConfirmFn; confirm: ConfirmFn;
/** Local tools plus any dynamically-discovered ones (currently: MCP) available for this session. */
toolset: ToolSet;
/** 0 for a normal session; incremented for each level of sub-agent nesting (capped at
* MAX_SUBAGENT_DEPTH in agent/loop.ts, independent of the toolset already excluding `agent`). */
subAgentDepth: number;
/** The model's context window in tokens — auto-detected where possible (backend/contextWindow.ts),
* otherwise a configured/hardcoded fallback (see contextWindowIsEstimate). */
contextWindow: number;
/** True when `contextWindow` is a fallback rather than an actual value read from the backend. */
contextWindowIsEstimate: boolean;
/** Tokens consumed by the last request's prompt (system + history), i.e. current context usage.
* From real `usage.prompt_tokens` when the backend reports it, else a char-based estimate. */
lastContextTokens: number;
/** True when `lastContextTokens` is a char-based estimate rather than real usage stats. */
lastContextTokensIsEstimate: boolean;
} }
export function createSession( export function createSession(
@@ -23,24 +46,88 @@ export function createSession(
cwd: string, cwd: string,
confirm: ConfirmFn, confirm: ConfirmFn,
mode: ToolCallMode, mode: ToolCallMode,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindowIsEstimate: boolean = true,
): Session { ): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [{ role: "system", content: buildSystemPrompt(toolset.tools, mode) }];
return { return {
id: randomUUID(),
createdAt: new Date().toISOString(),
client, client,
model, model,
cwd, cwd,
mode, mode,
messages: [{ role: "system", content: buildSystemPrompt(TOOLS, mode) }], messages,
maxIterations: 8, maxIterations: 8,
permissions: new PermissionManager(), permissions: new PermissionManager(),
confirm, confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
};
}
/** Rebuilds the system prompt from the current tool list rather than trusting the saved one,
* so a resumed session always reflects whatever tools locode has today (including MCP tools). */
export function createSessionFromRecord(
client: OpenAI,
record: SessionRecord,
cwd: string,
confirm: ConfirmFn,
tools: ToolDef[] = TOOLS,
contextWindow: number = DEFAULT_CONTEXT_WINDOW,
contextWindowIsEstimate: boolean = true,
): Session {
const toolset = buildToolSet(tools);
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode) },
...record.messages,
];
return {
id: record.id,
createdAt: record.createdAt,
client,
model: record.model,
cwd,
mode: record.mode,
messages,
maxIterations: 8,
permissions: new PermissionManager(),
confirm,
toolset,
subAgentDepth: 0,
contextWindow,
contextWindowIsEstimate,
lastContextTokens: estimateTokens(messages),
lastContextTokensIsEstimate: true,
};
}
export function toSessionRecord(session: Session, baseURL: string): SessionRecord {
return {
id: session.id,
createdAt: session.createdAt,
updatedAt: new Date().toISOString(),
cwd: session.cwd,
baseURL,
model: session.model,
mode: session.mode,
messages: session.messages.slice(1),
}; };
} }
export function resetSession(session: Session): void { export function resetSession(session: Session): void {
session.messages = [session.messages[0] as ChatCompletionMessageParam]; session.messages = [session.messages[0] as ChatCompletionMessageParam];
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
} }
export function setMode(session: Session, mode: ToolCallMode): void { export function setMode(session: Session, mode: ToolCallMode): void {
session.mode = mode; session.mode = mode;
session.messages[0] = { role: "system", content: buildSystemPrompt(TOOLS, mode) }; session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode) };
} }
+74
View File
@@ -0,0 +1,74 @@
import { resolveContextWindowDefault } from "../config/config.js";
import { getCachedContextWindow, setCachedContextWindow } from "./contextWindowCache.js";
function stripV1(baseURL: string): string {
return baseURL.replace(/\/v1\/?$/, "");
}
/** Ollama's native (non-OpenAI-compatible) endpoint exposes the model's real context length,
* nested under an architecture-prefixed key (e.g. "llama.context_length") that we have to look
* up dynamically via the architecture name it also reports. */
async function detectOllamaContextWindow(baseURL: string, model: string): Promise<number | null> {
try {
const res = await fetch(`${stripV1(baseURL)}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) return null;
const data = (await res.json()) as { model_info?: Record<string, unknown> };
const info = data.model_info;
if (!info) return null;
const architecture = info["general.architecture"];
if (typeof architecture !== "string") return null;
const contextLength = info[`${architecture}.context_length`];
return typeof contextLength === "number" ? contextLength : null;
} catch {
return null;
}
}
/** LM Studio's native REST API (distinct from its OpenAI-compatible one) reports the context
* length a loaded model was actually started with, or its maximum otherwise. */
async function detectLmStudioContextWindow(baseURL: string, model: string): Promise<number | null> {
try {
const res = await fetch(`${stripV1(baseURL)}/api/v0/models`);
if (!res.ok) return null;
const data = (await res.json()) as { data?: Array<Record<string, unknown>> };
const match = data.data?.find((m) => m.id === model);
const length = match?.loaded_context_length ?? match?.max_context_length;
return typeof length === "number" ? length : null;
} catch {
return null;
}
}
/** Best-effort: tries each backend's own way of reporting context length in turn, since we can't
* assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather
* than guessing) if neither responds usefully — callers should fall back to a configured default. */
export async function detectContextWindow(baseURL: string, model: string): Promise<number | null> {
const ollama = await detectOllamaContextWindow(baseURL, model);
if (ollama !== null) return ollama;
return detectLmStudioContextWindow(baseURL, model);
}
export interface ResolvedContextWindow {
value: number;
/** false only when this came from an actual backend lookup, not the configured/hardcoded default. */
isEstimate: boolean;
}
/** Cache-then-detect-then-default resolution, so repeat sessions with the same backend+model skip
* the network round-trip. */
export async function resolveContextWindow(baseURL: string, model: string): Promise<ResolvedContextWindow> {
const cached = getCachedContextWindow(baseURL, model);
if (cached !== undefined) return { value: cached, isEstimate: false };
const detected = await detectContextWindow(baseURL, model);
if (detected !== null) {
setCachedContextWindow(baseURL, model, detected);
return { value: detected, isEstimate: false };
}
return { value: resolveContextWindowDefault(), isEstimate: true };
}
+45
View File
@@ -0,0 +1,45 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
const paths = envPaths("locode", { suffix: "" });
const cacheFile = path.join(paths.config, "context-windows.json");
type Cache = Record<string, number>;
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 getCachedContextWindow(baseURL: string, model: string): number | undefined {
return load()[keyFor(baseURL, model)];
}
export function setCachedContextWindow(baseURL: string, model: string, contextWindow: number): void {
const cache = load();
cache[keyFor(baseURL, model)] = contextWindow;
save(cache);
}
+162 -5
View File
@@ -2,6 +2,9 @@ import { Command } from "commander";
import { makeClient } from "./backend/client.js"; import { makeClient } from "./backend/client.js";
import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js"; import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js";
import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js"; import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js";
import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js";
import { isHttpServerConfig } from "./mcp/types.js";
import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js";
import { runInkApp } from "./ui/ink/index.js"; import { runInkApp } from "./ui/ink/index.js";
const program = new Command(); const program = new Command();
@@ -24,6 +27,8 @@ addBackendOptions(program)
"native | fallback | auto — auto defaults to native, probing only on /model and /backend", "native | fallback | auto — auto defaults to native, probing only on /model and /backend",
"auto", "auto",
) )
.option("-c, --continue", "resume the most recently saved conversation")
.option("-r, --resume [id]", "resume a saved conversation by id, or pick interactively if no id is given")
.action(async (opts) => { .action(async (opts) => {
try { try {
const { baseURL } = resolveBackendConfig(opts); const { baseURL } = resolveBackendConfig(opts);
@@ -31,7 +36,32 @@ addBackendOptions(program)
const model: string | undefined = opts.model; const model: string | undefined = opts.model;
const suggestedModel = model ? undefined : resolveModel(undefined); const suggestedModel = model ? undefined : resolveModel(undefined);
const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined; const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined;
await runInkApp({ baseURL, model, suggestedModel, cwd: process.cwd(), toolModeOverride });
let resumeSessionId: string | undefined;
let interactiveResume = false;
if (opts.continue) {
resumeSessionId = mostRecentSessionId();
if (!resumeSessionId) {
console.error("No saved sessions found.");
process.exit(1);
}
} else if (opts.resume !== undefined) {
if (typeof opts.resume === "string" && opts.resume.length > 0) {
resumeSessionId = opts.resume;
} else {
interactiveResume = true;
}
}
await runInkApp({
baseURL,
model,
suggestedModel,
cwd: process.cwd(),
toolModeOverride,
resumeSessionId,
interactiveResume,
});
} catch (err) { } catch (err) {
if (err instanceof ConfigError) { if (err instanceof ConfigError) {
console.error(err.message); console.error(err.message);
@@ -78,14 +108,23 @@ configCmd
configCmd configCmd
.command("set <key> <value>") .command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl)") .description("Persist a config value (backend, model, baseUrl, contextWindow)")
.action((key: string, value: string) => { .action((key: string, value: string) => {
if (key !== "backend" && key !== "model" && key !== "baseUrl") { if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow") {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl`); console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow`);
process.exit(1); process.exit(1);
} }
const stored = loadStoredConfig(); const stored = loadStoredConfig();
stored[key] = value; if (key === "contextWindow") {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) {
console.error(`contextWindow must be a positive number, got "${value}".`);
process.exit(1);
}
stored.contextWindow = n;
} else {
stored[key] = value;
}
saveStoredConfig(stored); saveStoredConfig(stored);
console.log(`Set ${key} = ${value}`); console.log(`Set ${key} = ${value}`);
}); });
@@ -97,4 +136,122 @@ configCmd
console.log(configFilePath()); console.log(configFilePath());
}); });
const sessionsCmd = program.command("sessions").description("Manage saved conversations");
sessionsCmd
.command("list")
.description("List saved conversations, most recent first")
.action(() => {
const sessions = listSessions();
if (sessions.length === 0) {
console.log("No saved sessions found.");
return;
}
for (const s of sessions) {
console.log(`${s.id} [${s.updatedAt}] (${s.model}, ${s.messageCount} msgs) ${s.title}`);
}
});
sessionsCmd
.command("rm <id>")
.description("Delete a saved conversation")
.action((id: string) => {
if (deleteSession(id)) {
console.log(`Deleted session ${id}.`);
} else {
console.error(`No saved session found with id "${id}".`);
process.exit(1);
}
});
sessionsCmd
.command("path")
.description("Print the directory where sessions are stored")
.action(() => {
console.log(sessionsDir());
});
function collect(value: string, previous: string[]): string[] {
previous.push(value);
return previous;
}
function parseKeyValueList(pairs: string[], separator: string): Record<string, string> {
const result: Record<string, string> = {};
for (const pair of pairs) {
const idx = pair.indexOf(separator);
if (idx === -1) {
console.error(`Expected "KEY${separator}VALUE", got "${pair}".`);
process.exit(1);
}
result[pair.slice(0, idx)] = pair.slice(idx + 1);
}
return result;
}
const mcpCmd = program
.command("mcp")
.description("Manage MCP servers (add/remove apply to your user-level config; project .mcp.json is read too)");
mcpCmd
.command("add <name>")
.description("Add an MCP server: stdio (--command) or remote (--url)")
.option("--command <cmd>", "command to spawn for a stdio server")
.option("--arg <value>", "argument to pass to the command (repeatable)", collect, [] as string[])
.option("--env <KEY=VALUE>", "environment variable for the spawned command (repeatable)", collect, [] as string[])
.option("--url <url>", "URL for a remote (streamable HTTP) server")
.option("--header <KEY:VALUE>", "HTTP header for a remote server (repeatable)", collect, [] as string[])
.action((name: string, opts) => {
if (opts.command && opts.url) {
console.error("Specify either --command (stdio) or --url (remote), not both.");
process.exit(1);
}
if (opts.url) {
saveUserServer(name, { url: opts.url, headers: parseKeyValueList(opts.header, ":") });
} else if (opts.command) {
saveUserServer(name, { command: opts.command, args: opts.arg, env: parseKeyValueList(opts.env, "=") });
} else {
console.error("Specify --command <cmd> for a stdio server or --url <url> for a remote server.");
process.exit(1);
}
console.log(`Added MCP server "${name}".`);
});
mcpCmd
.command("remove <name>")
.description("Remove a user-level MCP server")
.action((name: string) => {
if (removeUserServer(name)) {
console.log(`Removed MCP server "${name}".`);
} else {
console.error(`No user-level MCP server found with name "${name}".`);
process.exit(1);
}
});
mcpCmd
.command("list")
.description("List configured MCP servers (user-level + project .mcp.json)")
.action(() => {
const servers = loadMergedServers(process.cwd());
const names = Object.keys(servers);
if (names.length === 0) {
console.log("No MCP servers configured.");
return;
}
for (const name of names) {
const cfg = servers[name]!;
console.log(
isHttpServerConfig(cfg) ? `${name} [http] ${cfg.url}` : `${name} [stdio] ${cfg.command} ${(cfg.args ?? []).join(" ")}`,
);
}
});
mcpCmd
.command("path")
.description("Print the path to the user-level MCP config file")
.action(() => {
console.log(userMcpFilePath());
});
program.parseAsync(); program.parseAsync();
+10 -1
View File
@@ -1,4 +1,4 @@
import { KNOWN_BACKENDS, type BackendName } from "./defaults.js"; import { DEFAULT_CONTEXT_WINDOW, KNOWN_BACKENDS, type BackendName } from "./defaults.js";
import { loadStoredConfig } from "./store.js"; import { loadStoredConfig } from "./store.js";
export class ConfigError extends Error {} export class ConfigError extends Error {}
@@ -32,3 +32,12 @@ export function resolveModel(cliModel?: string): string | undefined {
const stored = loadStoredConfig(); const stored = loadStoredConfig();
return cliModel ?? process.env.LOCODE_MODEL ?? stored.model; return cliModel ?? process.env.LOCODE_MODEL ?? stored.model;
} }
/** The fallback context window size to use when it can't be auto-detected from the backend. */
export function resolveContextWindowDefault(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW);
if (Number.isFinite(envValue) && envValue > 0) return envValue;
if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow;
return DEFAULT_CONTEXT_WINDOW;
}
+4
View File
@@ -7,3 +7,7 @@ export const KNOWN_BACKENDS = {
} as const; } as const;
export type BackendName = keyof typeof KNOWN_BACKENDS; export type BackendName = keyof typeof KNOWN_BACKENDS;
/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts)
* and the user hasn't configured one — a conservative size common among smaller local models. */
export const DEFAULT_CONTEXT_WINDOW = 8192;
+2
View File
@@ -6,6 +6,8 @@ export interface StoredConfig {
backend?: string; backend?: string;
model?: string; model?: string;
baseUrl?: string; baseUrl?: string;
/** Fallback context window size (in tokens) used when it can't be auto-detected from the backend. */
contextWindow?: number;
} }
const paths = envPaths("locode", { suffix: "" }); const paths = envPaths("locode", { suffix: "" });
+39
View File
@@ -0,0 +1,39 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { isHttpServerConfig, type McpServerConfig } from "./types.js";
export interface McpToolInfo {
name: string;
description?: string;
inputSchema: Record<string, unknown>;
annotations?: { readOnlyHint?: boolean };
}
export interface ConnectedMcpServer {
name: string;
client: Client;
transport: Transport;
tools: McpToolInfo[];
}
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedMcpServer> {
const transport: Transport = isHttpServerConfig(config)
? new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined,
})
: new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
// Default is "inherit", which would leak the child's stderr straight into the terminal
// and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded.
stderr: "pipe",
});
const client = new Client({ name: "locode", version: "0.1.0" });
await client.connect(transport);
const { tools } = await client.listTools();
return { name, client, transport, tools: tools as McpToolInfo[] };
}
+51
View File
@@ -0,0 +1,51 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { McpServerConfig, McpServersFile } from "./types.js";
const paths = envPaths("locode", { suffix: "" });
const userFile = path.join(paths.config, "mcp.json");
export function userMcpFilePath(): string {
return userFile;
}
function readServersFile(file: string): Record<string, McpServerConfig> {
if (!existsSync(file)) return {};
try {
const parsed = JSON.parse(readFileSync(file, "utf-8")) as McpServersFile;
return parsed.mcpServers ?? {};
} catch {
return {};
}
}
/** User-level servers (`locode mcp add`), available in every project. */
export function loadUserServers(): Record<string, McpServerConfig> {
return readServersFile(userFile);
}
/** Project-level `.mcp.json` in the given directory — meant to be hand-edited/checked into a repo. */
export function loadProjectServers(cwd: string): Record<string, McpServerConfig> {
return readServersFile(path.join(cwd, ".mcp.json"));
}
/** User-level servers plus project-level ones, with project entries winning on name collisions. */
export function loadMergedServers(cwd: string): Record<string, McpServerConfig> {
return { ...loadUserServers(), ...loadProjectServers(cwd) };
}
export function saveUserServer(name: string, config: McpServerConfig): void {
const servers = loadUserServers();
servers[name] = config;
mkdirSync(paths.config, { recursive: true });
writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2));
}
export function removeUserServer(name: string): boolean {
const servers = loadUserServers();
if (!(name in servers)) return false;
delete servers[name];
writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2));
return true;
}
+54
View File
@@ -0,0 +1,54 @@
import type { ToolDef } from "../tools/types.js";
import { connectMcpServer, type ConnectedMcpServer } from "./client.js";
import { loadMergedServers } from "./config.js";
import { mcpToolToToolDef } from "./toolAdapter.js";
export interface McpServerStatus {
name: string;
status: "connected" | "error";
toolCount: number;
error?: string;
}
let connections: ConnectedMcpServer[] = [];
let statuses: McpServerStatus[] = [];
/** Connects to every configured MCP server in parallel (one bad server doesn't block the rest)
* and returns the flattened, ready-to-use tools from whichever servers connected successfully. */
export async function connectConfiguredMcpServers(cwd: string): Promise<ToolDef[]> {
const servers = loadMergedServers(cwd);
const entries = Object.entries(servers);
if (entries.length === 0) return [];
const results = await Promise.allSettled(entries.map(([name, config]) => connectMcpServer(name, config)));
const allTools: ToolDef[] = [];
const nextStatuses: McpServerStatus[] = [];
const nextConnections: ConnectedMcpServer[] = [];
results.forEach((result, i) => {
const name = entries[i]![0];
if (result.status === "fulfilled") {
const server = result.value;
nextConnections.push(server);
const toolDefs = server.tools.map((t) => mcpToolToToolDef(name, server.client, t));
allTools.push(...toolDefs);
nextStatuses.push({ name, status: "connected", toolCount: toolDefs.length });
} else {
nextStatuses.push({ name, status: "error", toolCount: 0, error: (result.reason as Error).message });
}
});
statuses = nextStatuses;
connections = nextConnections;
return allTools;
}
export function getMcpStatuses(): McpServerStatus[] {
return statuses;
}
export async function disconnectAllMcpServers(): Promise<void> {
await Promise.allSettled(connections.map((c) => c.transport.close()));
connections = [];
}
+43
View File
@@ -0,0 +1,43 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import type { McpToolInfo } from "./client.js";
function sanitize(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
}
/** MCP tool names are namespaced as `mcp__<server>__<tool>` (matching the convention Claude Code
* itself uses) so tools from different servers can't collide with each other or with local tools. */
export function mcpToolName(serverName: string, toolName: string): string {
return `mcp__${sanitize(serverName)}__${sanitize(toolName)}`;
}
const argsSchema = z.record(z.string(), z.unknown());
export function mcpToolToToolDef(serverName: string, client: Client, mcpTool: McpToolInfo): ToolDef<Record<string, unknown>> {
return {
name: mcpToolName(serverName, mcpTool.name),
description: `[MCP: ${serverName}] ${mcpTool.description ?? mcpTool.name}`,
schema: argsSchema,
rawInputSchema: mcpTool.inputSchema,
// MCP tools can do anything server-side; only trust the spec's readOnlyHint to skip confirmation.
mutating: mcpTool.annotations?.readOnlyHint !== true,
preview: async (args) => "```json\n" + JSON.stringify(args, null, 2) + "\n```",
handler: async (args) => {
const result = await client.callTool({ name: mcpTool.name, arguments: args });
const blocks = Array.isArray(result.content) ? result.content : [];
const text = blocks
.map((block) =>
block && typeof block === "object" && "type" in block && block.type === "text"
? (block as { text: string }).text
: `[unsupported MCP content type: ${(block as { type?: string })?.type ?? "unknown"}]`,
)
.join("\n");
if (result.isError) {
return { error: text || "MCP tool call failed." };
}
return { content: text };
},
};
}
+20
View File
@@ -0,0 +1,20 @@
export interface McpStdioServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
}
export interface McpHttpServerConfig {
url: string;
headers?: Record<string, string>;
}
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
export interface McpServersFile {
mcpServers: Record<string, McpServerConfig>;
}
export function isHttpServerConfig(cfg: McpServerConfig): cfg is McpHttpServerConfig {
return "url" in cfg;
}
+93
View File
@@ -0,0 +1,93 @@
import envPaths from "env-paths";
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ToolCallMode } from "../backend/capabilityProbe.js";
/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the
* current tool list on load, so a saved session always reflects whatever tools locode has today. */
export interface SessionRecord {
id: string;
createdAt: string;
updatedAt: string;
cwd: string;
baseURL: string;
model: string;
mode: ToolCallMode;
messages: ChatCompletionMessageParam[];
}
export interface SessionSummary {
id: string;
updatedAt: string;
title: string;
model: string;
baseURL: string;
messageCount: number;
}
const paths = envPaths("locode", { suffix: "" });
const dir = path.join(paths.data, "sessions");
export function sessionsDir(): string {
return dir;
}
function filePath(id: string): string {
return path.join(dir, `${id}.json`);
}
export function deriveTitle(messages: ChatCompletionMessageParam[]): string {
const first = messages.find((m) => m.role === "user");
const text = first && typeof first.content === "string" ? first.content.trim() : "";
if (!text) return "(empty conversation)";
return text.length > 60 ? `${text.slice(0, 60)}...` : text;
}
export function saveSession(record: SessionRecord): void {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath(record.id), JSON.stringify(record, null, 2));
}
export function loadSession(id: string): SessionRecord | undefined {
const file = filePath(id);
if (!existsSync(file)) return undefined;
try {
return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord;
} catch {
return undefined;
}
}
export function listSessions(): SessionSummary[] {
if (!existsSync(dir)) return [];
const summaries: SessionSummary[] = [];
for (const entry of readdirSync(dir)) {
if (!entry.endsWith(".json")) continue;
try {
const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord;
summaries.push({
id: record.id,
updatedAt: record.updatedAt,
title: deriveTitle(record.messages),
model: record.model,
baseURL: record.baseURL,
messageCount: record.messages.length,
});
} catch {
// Skip corrupt/partial session files
}
}
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function mostRecentSessionId(): string | undefined {
return listSessions()[0]?.id;
}
export function deleteSession(id: string): boolean {
const file = filePath(id);
if (!existsSync(file)) return false;
unlinkSync(file);
return true;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] {
function: { function: {
name: t.name, name: t.name,
description: t.description, description: t.description,
parameters: z.toJSONSchema(t.schema) as Record<string, unknown>, parameters: t.rawInputSchema ?? (z.toJSONSchema(t.schema) as Record<string, unknown>),
}, },
})); }));
} }
+30
View File
@@ -0,0 +1,30 @@
import { z } from "zod";
import type { ToolDef } from "./types.js";
const schema = z.object({
description: z.string().describe("A short (3-6 word) label for what this sub-agent is doing, shown in the UI."),
prompt: z
.string()
.describe(
"The full task for the sub-agent to carry out. Be specific and self-contained — the sub-agent starts with no " +
"memory of this conversation beyond what you put here, and it cannot ask you follow-up questions.",
),
});
export const agentTool: ToolDef<z.infer<typeof schema>> = {
name: "agent",
description:
"Delegate a self-contained task to a fresh sub-agent with its own tool loop (read_file, list_files, grep, " +
"web_search, web_fetch, write_file, edit_file, bash — but not another agent). Use for multi-step research or " +
"exploration you want to run in isolation, getting back only the final answer so your own context stays " +
"focused. Only the sub-agent's final text is returned to you, not its intermediate steps.",
schema,
mutating: false,
handler: async (args, ctx) => {
if (!ctx.runSubAgent) {
throw new Error("Sub-agents are not available in this context.");
}
const result = await ctx.runSubAgent(args);
return { description: args.description, result };
},
};
+150
View File
@@ -0,0 +1,150 @@
import { execa } from "execa";
import { z } from "zod";
import { truncate } from "../utils/truncate.js";
import type { ToolDef } from "./types.js";
async function tryGit(args: string[], cwd: string): Promise<string> {
try {
const result = await execa("git", args, { cwd, reject: false });
return result.exitCode === 0 ? result.stdout : "";
} catch {
return "";
}
}
// --- git_status: read-only inspection, auto-runs like grep/read_file ---
const statusSchema = z.object({
operation: z.enum(["status", "diff", "log", "show", "branches"]),
paths: z.array(z.string()).optional().describe("Limit `status`/`diff` to these paths, relative to the working directory."),
staged: z.boolean().optional().describe("For `diff`: show staged changes (--cached) instead of the working tree diff."),
ref: z.string().optional().describe("For `show`: the commit/ref to inspect (default HEAD). For `log`: start from this ref."),
maxCount: z.number().int().min(1).max(200).optional().describe("For `log`: maximum number of commits to return (default 20)."),
});
export const gitStatusTool: ToolDef<z.infer<typeof statusSchema>> = {
name: "git_status",
description:
"Inspect the git repository at the working directory: `status` (working tree state), `diff` (unstaged or, with staged:true, " +
"staged changes), `log` (recent commits), `show` (a specific commit/ref, default HEAD), or `branches` (local branches with " +
"upstream tracking info). Read-only — runs automatically without confirmation.",
schema: statusSchema,
mutating: false,
handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => {
const pathArgs = paths?.length ? ["--", ...paths] : [];
const args: string[] = (() => {
switch (operation) {
case "status":
return ["status", "--short", "--branch", ...pathArgs];
case "diff":
return ["diff", ...(staged ? ["--cached"] : []), ...pathArgs];
case "log":
return ["log", `--max-count=${maxCount ?? 20}`, "--oneline", "--decorate", ...(ref ? [ref] : [])];
case "show":
return ["show", ref ?? "HEAD"];
case "branches":
return ["branch", "--list", "-vv"];
}
})();
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
return { output: truncate(result.stdout) };
},
};
// --- git_commit: mutating operations, gated behind a confirmation prompt ---
const commitSchema = z.object({
operation: z.enum(["add", "commit", "create_branch", "checkout", "push"]),
paths: z.array(z.string()).optional().describe("For `add`: paths to stage, relative to the working directory. Omit to stage all changes."),
message: z.string().optional().describe("Commit message. Required for `commit`."),
branchName: z.string().optional().describe("Branch name. Required for `create_branch`/`checkout`."),
createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."),
remote: z.string().optional().describe("For `push`: remote name (default \"origin\")."),
setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."),
});
export const gitCommitTool: ToolDef<z.infer<typeof commitSchema>> = {
name: "git_commit",
description:
"Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " +
"(requires `message`; commit the currently staged changes), `create_branch` (requires `branchName`; branch at current HEAD " +
"without switching), `checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), or " +
"`push` (push the current branch, optionally with setUpstream for a new branch's first push). Each use requires the user's " +
"confirmation, which shows the actual diff/status/commits affected.",
schema: commitSchema,
mutating: true,
preview: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
switch (operation) {
case "add": {
const target = paths?.length ? paths.join(", ") : "all changes";
const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], ctx.cwd);
return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`;
}
case "commit": {
const staged = await tryGit(["diff", "--cached"], ctx.cwd);
if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first.";
return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`;
}
case "create_branch":
return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`;
case "checkout": {
const status = await tryGit(["status", "--short"], ctx.cwd);
const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`;
return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`;
}
case "push": {
const remoteName = remote ?? "origin";
const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], ctx.cwd)).trim() || "HEAD";
// `@{u}..` only resolves once upstream tracking exists — on a branch's first push
// (exactly when setUpstream is used) it doesn't, so fall back to recent commits on HEAD.
const ahead =
(await tryGit(["log", "--oneline", "@{u}.."], ctx.cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], ctx.cwd));
const upstreamNote = setUpstream ? " (setting upstream tracking)" : "";
return ahead
? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}`
: `Push "${branch}" to "${remoteName}"${upstreamNote}.`;
}
}
},
handler: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => {
const args: string[] = (() => {
switch (operation) {
case "add":
return ["add", ...(paths?.length ? paths : ["-A"])];
case "commit":
if (!message) throw new Error("`message` is required for the commit operation.");
return ["commit", "-m", message];
case "create_branch":
if (!branchName) throw new Error("`branchName` is required for the create_branch operation.");
return ["branch", branchName];
case "checkout":
if (!branchName) throw new Error("`branchName` is required for the checkout operation.");
return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName];
case "push":
return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"];
}
})();
const result = await execa("git", args, { cwd: ctx.cwd, reject: false });
if (result.exitCode !== 0) {
return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) };
}
// `git push` writes its useful progress/summary output to stderr even on success.
const output = truncate(result.stdout || result.stderr);
switch (operation) {
case "add":
return { staged: paths?.length ? paths : ["(all changes)"] };
case "create_branch":
return { created: branchName };
case "checkout":
return { switchedTo: branchName };
default:
return { output };
}
},
};
+17 -1
View File
@@ -1,11 +1,27 @@
import { agentTool } from "./agentTool.js";
import { bashTool } from "./bash.js"; import { bashTool } from "./bash.js";
import { editFileTool } from "./editFile.js"; import { editFileTool } from "./editFile.js";
import { gitCommitTool, gitStatusTool } from "./git.js";
import { grepTool } from "./grep.js"; import { grepTool } from "./grep.js";
import { listFilesTool } from "./listFiles.js"; import { listFilesTool } from "./listFiles.js";
import { readFileTool } from "./readFile.js"; import { readFileTool } from "./readFile.js";
import { webFetchTool } from "./webFetch.js";
import { webSearchTool } from "./webSearch.js";
import { writeFileTool } from "./writeFile.js"; import { writeFileTool } from "./writeFile.js";
import type { ToolDef } from "./types.js"; import type { ToolDef } from "./types.js";
export const TOOLS: ToolDef[] = [readFileTool, listFilesTool, grepTool, writeFileTool, editFileTool, bashTool]; export const TOOLS: ToolDef[] = [
readFileTool,
listFilesTool,
grepTool,
webSearchTool,
webFetchTool,
gitStatusTool,
writeFileTool,
editFileTool,
bashTool,
gitCommitTool,
agentTool,
];
export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t])); export const TOOL_REGISTRY: Map<string, ToolDef> = new Map(TOOLS.map((t) => [t.name, t]));
+15
View File
@@ -0,0 +1,15 @@
import type { ChatCompletionTool } from "openai/resources/chat/completions";
import { toOpenAITools } from "../toolcalling/nativeAdapter.js";
import type { ToolDef } from "./types.js";
/** A tool list bundled with its lookup registry and pre-built OpenAI schemas — built once per
* session (whose tools may include dynamically-discovered MCP tools) rather than per-call. */
export interface ToolSet {
tools: ToolDef[];
registry: Map<string, ToolDef>;
openaiTools: ChatCompletionTool[];
}
export function buildToolSet(tools: ToolDef[]): ToolSet {
return { tools, registry: new Map(tools.map((t) => [t.name, t])), openaiTools: toOpenAITools(tools) };
}
+12
View File
@@ -1,13 +1,25 @@
import type { z } from "zod"; import type { z } from "zod";
export interface SubAgentTask {
/** Short (3-6 word) label shown in the UI while the sub-agent runs. */
description: string;
/** The full, self-contained task — the sub-agent has no memory of the parent conversation. */
prompt: string;
}
export interface ToolContext { export interface ToolContext {
cwd: string; cwd: string;
/** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */
runSubAgent?: (task: SubAgentTask) => Promise<string>;
} }
export interface ToolDef<T = any> { export interface ToolDef<T = any> {
name: string; name: string;
description: string; description: string;
schema: z.ZodType<T>; schema: z.ZodType<T>;
/** If set, used verbatim as the JSON Schema shown to the model instead of one derived from `schema` —
* needed for tools (e.g. MCP-provided ones) whose real parameter schema isn't expressible as a Zod type. */
rawInputSchema?: Record<string, unknown>;
mutating: boolean; mutating: boolean;
/** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */ /** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */
preview?: (args: T, ctx: ToolContext) => Promise<string>; preview?: (args: T, ctx: ToolContext) => Promise<string>;
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod";
import { truncate } from "../utils/truncate.js";
import { htmlToText, USER_AGENT, withFetchTimeout } from "../utils/html.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
url: z.string().describe("URL to fetch. Must include the scheme, e.g. 'https://example.com'."),
});
const MAX_CHARS = 20_000;
export const webFetchTool: ToolDef<z.infer<typeof schema>> = {
name: "web_fetch",
description:
"Fetch a URL and return its content as readable text (HTML is stripped of tags/scripts/styles). Use to read a specific page, e.g. one found via web_search or linked from documentation.",
schema,
mutating: false,
handler: async ({ url }) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Unsupported URL scheme: ${parsed.protocol}`);
}
const { signal, clear } = withFetchTimeout(15_000);
try {
const response = await fetch(parsed, {
headers: { "User-Agent": USER_AGENT },
signal,
redirect: "follow",
});
if (!response.ok) {
throw new Error(`Fetch failed with status ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
const body = await response.text();
const content = contentType.includes("html") ? htmlToText(body) : body;
return {
url: parsed.toString(),
finalUrl: response.url,
status: response.status,
contentType,
content: truncate(content, MAX_CHARS),
};
} finally {
clear();
}
},
};
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod";
import { stripTags, USER_AGENT, withFetchTimeout } from "../utils/html.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
query: z.string().describe("Search query."),
max_results: z.number().int().min(1).max(20).optional(),
});
interface SearchResult {
title: string;
url: string;
snippet: string;
}
function resolveResultUrl(href: string): string {
const absolute = href.startsWith("//") ? `https:${href}` : href;
try {
const url = new URL(absolute);
const uddg = url.searchParams.get("uddg");
return uddg ? uddg : absolute;
} catch {
return absolute;
}
}
function parseResults(html: string, limit: number): SearchResult[] {
const results: SearchResult[] = [];
const resultRegex =
/<a rel="nofollow" class="result__a" href="([^"]+)">([\s\S]*?)<\/a>[\s\S]*?<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
let match: RegExpExecArray | null;
while ((match = resultRegex.exec(html)) && results.length < limit) {
const [, href, titleHtml, snippetHtml] = match;
if (!href || !titleHtml || !snippetHtml) continue;
results.push({
title: stripTags(titleHtml),
url: resolveResultUrl(href),
snippet: stripTags(snippetHtml),
});
}
return results;
}
export const webSearchTool: ToolDef<z.infer<typeof schema>> = {
name: "web_search",
description:
"Search the web via DuckDuckGo and return matching results (title, url, snippet). Use for information not available in the local codebase, e.g. current docs, library APIs, or general knowledge.",
schema,
mutating: false,
handler: async ({ query, max_results }) => {
const limit = max_results ?? 5;
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const { signal, clear } = withFetchTimeout(10_000);
try {
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
signal,
});
if (!response.ok) {
throw new Error(`Search request failed with status ${response.status}`);
}
const html = await response.text();
const results = parseResults(html, limit);
return { query, results };
} finally {
clear();
}
},
};
+229 -33
View File
@@ -1,17 +1,37 @@
import { Box, Static, useApp } from "ink"; import { Box, Static, useApp } from "ink";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AgentError, runTurn } from "../../agent/loop.js"; import { AgentError, compactSession, contextUsageRatio, runTurn, shouldAutoCompact } from "../../agent/loop.js";
import { createSession, resetSession, setMode, type Session } from "../../agent/session.js"; import {
createSession,
createSessionFromRecord,
resetSession,
setMode,
toSessionRecord,
type Session,
} from "../../agent/session.js";
import { makeClient } from "../../backend/client.js"; import { makeClient } from "../../backend/client.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js"; import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { setCachedMode } from "../../backend/capabilityCache.js"; import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveContextWindow } from "../../backend/contextWindow.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js"; import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js"; import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import { getMcpStatuses } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js"; import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import {
deriveTitle,
listSessions,
loadSession,
saveSession,
type SessionRecord,
type SessionSummary,
} from "../../persistence/sessionStore.js";
import { TOOLS } from "../../tools/index.js";
import type { ToolDef } from "../../tools/types.js";
import { ChatInput } from "./ChatInput.js"; import { ChatInput } from "./ChatInput.js";
import { HistoryItemView } from "./HistoryItemView.js"; import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js"; import { ModelSelect } from "./ModelSelect.js";
import { PermissionPrompt } from "./PermissionPrompt.js"; import { PermissionPrompt } from "./PermissionPrompt.js";
import { SessionSelect } from "./SessionSelect.js";
import { StatusBar } from "./StatusBar.js"; import { StatusBar } from "./StatusBar.js";
import { ThinkingIndicator } from "./ThinkingIndicator.js"; import { ThinkingIndicator } from "./ThinkingIndicator.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js"; import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
@@ -22,6 +42,9 @@ export interface AppProps {
toolModeOverride?: ToolCallMode; toolModeOverride?: ToolCallMode;
initialModel?: string; initialModel?: string;
suggestedModel?: string; suggestedModel?: string;
resumeSessionId?: string;
interactiveResume?: boolean;
mcpToolsPromise: Promise<ToolDef[]>;
} }
interface PendingPermission { interface PendingPermission {
@@ -31,7 +54,7 @@ interface PendingPermission {
resolve: (decision: PermissionDecision) => void; resolve: (decision: PermissionDecision) => void;
} }
type Phase = "connecting" | "loading-models" | "model-select" | "input"; type Phase = "starting" | "connecting" | "loading-models" | "model-select" | "session-select" | "input";
export function App({ export function App({
baseURL: initialBaseURL, baseURL: initialBaseURL,
@@ -39,16 +62,22 @@ export function App({
toolModeOverride, toolModeOverride,
initialModel, initialModel,
suggestedModel, suggestedModel,
resumeSessionId,
interactiveResume,
mcpToolsPromise,
}: AppProps) { }: AppProps) {
const { exit } = useApp(); const { exit } = useApp();
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]); const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
const [phase, setPhase] = useState<Phase>(initialModel ? "connecting" : "loading-models"); const [phase, setPhase] = useState<Phase>(
resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models",
);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null); const [permission, setPermission] = useState<PendingPermission | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null); const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false); const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default"); const [permMode, setPermMode] = useState<PermissionMode>("default");
const [modelList, setModelList] = useState<string[]>([]); const [modelList, setModelList] = useState<string[]>([]);
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
const baseURLRef = useRef(initialBaseURL); const baseURLRef = useRef(initialBaseURL);
const sessionRef = useRef<Session | null>(null); const sessionRef = useRef<Session | null>(null);
@@ -69,10 +98,17 @@ export function App({
setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]); setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]);
}, []); }, []);
// Fetch models on mount when no --model is specified const persistCurrentSession = useCallback(() => {
useEffect(() => { const session = sessionRef.current;
if (initialModel) return; // skip model listing if --model was provided if (!session) return;
try {
saveSession(toSessionRecord(session, baseURLRef.current));
} catch {
// Best-effort — a failed save shouldn't crash the UI.
}
}, []);
const fetchModelsForPicker = useCallback(() => {
const client = makeClient({ baseURL: baseURLRef.current, model: "" }); const client = makeClient({ baseURL: baseURLRef.current, model: "" });
client.models client.models
.list() .list()
@@ -90,8 +126,7 @@ export function App({
push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true }); push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true });
setPhase("input"); setPhase("input");
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps }, [push]);
}, []);
const initSession = useCallback( const initSession = useCallback(
async (model: string) => { async (model: string) => {
@@ -105,7 +140,17 @@ export function App({
new Promise<PermissionDecision>((resolve) => { new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve }); setPermission({ ...opts, resolve });
}); });
sessionRef.current = createSession(client, model, cwd, confirmFn, mode); const [mcpTools, contextWindow] = await Promise.all([mcpToolsPromise, resolveContextWindow(baseURLRef.current, model)]);
sessionRef.current = createSession(
client,
model,
cwd,
confirmFn,
mode,
[...TOOLS, ...mcpTools],
contextWindow.value,
contextWindow.isEstimate,
);
push({ kind: "banner", cwd, model, backend: baseURLRef.current }); push({ kind: "banner", cwd, model, backend: baseURLRef.current });
setPhase("input"); setPhase("input");
} catch (err) { } catch (err) {
@@ -121,13 +166,92 @@ export function App({
} }
} }
}, },
[cwd, toolModeOverride, modelList, exit, push], [cwd, toolModeOverride, modelList, exit, push, mcpToolsPromise],
); );
const initSessionFromRecord = useCallback(
async (record: SessionRecord) => {
setPhase("connecting");
try {
baseURLRef.current = record.baseURL;
const client = makeClient({ baseURL: record.baseURL, model: record.model });
const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) =>
new Promise<PermissionDecision>((resolve) => {
setPermission({ ...opts, resolve });
});
const [mcpTools, contextWindow] = await Promise.all([
mcpToolsPromise,
resolveContextWindow(record.baseURL, record.model),
]);
sessionRef.current = createSessionFromRecord(
client,
record,
cwd,
confirmFn,
[...TOOLS, ...mcpTools],
contextWindow.value,
contextWindow.isEstimate,
);
push({
kind: "banner",
cwd,
model: record.model,
backend: record.baseURL,
resumedTitle: deriveTitle(record.messages),
});
// Replay the saved user/assistant turns so the transcript isn't blank — tool
// call/result lines aren't replayed since we don't persist their display labels.
const replayed: HistoryItem[] = [];
for (const m of record.messages) {
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
replayed.push({ id: nextId(), kind: "user", text: m.content });
} else if (m.role === "assistant" && typeof m.content === "string" && m.content) {
replayed.push({ id: nextId(), kind: "assistant", text: m.content });
}
}
setStaticItems((prev) => [...prev, ...replayed]);
setPhase("input");
} catch (err) {
push({ kind: "notice", text: `Failed to resume session: ${(err as Error).message}`, isError: true });
fetchModelsForPicker();
}
},
[cwd, push, fetchModelsForPicker, mcpToolsPromise],
);
// Decide the startup path once on mount: resume a specific session, show a resume
// picker, jump straight to a given model, or fall back to the model picker.
useEffect(() => { useEffect(() => {
if (resumeSessionId) {
const record = loadSession(resumeSessionId);
if (record) {
void initSessionFromRecord(record);
} else {
push({ kind: "notice", text: `No saved session found with id "${resumeSessionId}".`, isError: true });
if (initialModel) void initSession(initialModel);
else fetchModelsForPicker();
}
return;
}
if (interactiveResume) {
const list = listSessions();
if (list.length === 0) {
push({ kind: "notice", text: "No saved sessions found — starting a new conversation." });
if (initialModel) void initSession(initialModel);
else fetchModelsForPicker();
} else {
setSessionList(list);
setPhase("session-select");
}
return;
}
if (initialModel) { if (initialModel) {
void initSession(initialModel); void initSession(initialModel);
return;
} }
fetchModelsForPicker();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -135,6 +259,21 @@ export function App({
await initSession(model); await initSession(model);
} }
function handleSessionSelect(id: string | null) {
if (id === null) {
if (initialModel) void initSession(initialModel);
else fetchModelsForPicker();
return;
}
const record = loadSession(id);
if (record) {
void initSessionFromRecord(record);
} else {
push({ kind: "notice", text: `No saved session found with id "${id}".`, isError: true });
fetchModelsForPicker();
}
}
async function switchModel(name: string) { async function switchModel(name: string) {
const session = sessionRef.current; const session = sessionRef.current;
if (!session) return; if (!session) return;
@@ -142,9 +281,15 @@ export function App({
session.model = name; session.model = name;
setIsThinking(true); setIsThinking(true);
try { try {
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, name); const [newMode, newContextWindow] = await Promise.all([
resolveToolCallMode(session.client, baseURLRef.current, name),
resolveContextWindow(baseURLRef.current, name),
]);
setMode(session, newMode); setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` }); push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
persistCurrentSession();
} catch (err) { } catch (err) {
session.model = previousModel; session.model = previousModel;
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true }); push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
@@ -162,12 +307,18 @@ export function App({
session.client = makeClient({ baseURL: baseURLRef.current, model: session.model }); session.client = makeClient({ baseURL: baseURLRef.current, model: session.model });
setIsThinking(true); setIsThinking(true);
try { try {
const newMode = await resolveToolCallMode(session.client, baseURLRef.current, session.model); const [newMode, newContextWindow] = await Promise.all([
resolveToolCallMode(session.client, baseURLRef.current, session.model),
resolveContextWindow(baseURLRef.current, session.model),
]);
setMode(session, newMode); setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
push({ push({
kind: "notice", kind: "notice",
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`, text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
}); });
persistCurrentSession();
} catch (err) { } catch (err) {
baseURLRef.current = previousBaseURL; baseURLRef.current = previousBaseURL;
session.client = previousClient; session.client = previousClient;
@@ -198,20 +349,53 @@ export function App({
if (trimmed === "/clear") { if (trimmed === "/clear") {
resetSession(session); resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." }); push({ kind: "notice", text: "Conversation history cleared." });
persistCurrentSession();
return; return;
} }
if (trimmed === "/status") { if (trimmed === "/status") {
push({ kind: "status", model: session.model, baseURL: baseURLRef.current, mode: session.mode, cwd }); push({
kind: "status",
model: session.model,
baseURL: baseURLRef.current,
mode: session.mode,
cwd,
sessionId: session.id,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/compact") {
setIsThinking(true);
try {
await compactSession(session);
push({ kind: "notice", text: "Conversation compacted to save context." });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
return; return;
} }
if (trimmed === "/tools") { if (trimmed === "/tools") {
push({ kind: "tools" }); push({ kind: "tools", tools: session.toolset.tools });
return; return;
} }
if (trimmed === "/permissions") { if (trimmed === "/permissions") {
push({ kind: "permissions", allowed: session.permissions.listAllowed() }); push({ kind: "permissions", allowed: session.permissions.listAllowed() });
return; return;
} }
if (trimmed === "/sessions") {
push({ kind: "sessions", sessions: listSessions() });
return;
}
if (trimmed === "/mcp") {
push({ kind: "mcp", statuses: getMcpStatuses() });
return;
}
if (trimmed.startsWith("/model")) { if (trimmed.startsWith("/model")) {
const name = trimmed.slice("/model".length).trim(); const name = trimmed.slice("/model".length).trim();
if (!name) { if (!name) {
@@ -232,21 +416,6 @@ export function App({
} }
return; 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")) { if (trimmed.startsWith("/perm")) {
const name = trimmed.slice("/perm".length).trim(); const name = trimmed.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" }; const validModes: Record<string, PermissionMode> = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" };
@@ -254,7 +423,7 @@ export function App({
// Cycle // Cycle
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"]; const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode); const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]; const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode); setPermMode(nextMode);
session.permissions.setMode(nextMode); session.permissions.setMode(nextMode);
const labels: Record<PermissionMode, string> = { const labels: Record<PermissionMode, string> = {
@@ -283,6 +452,7 @@ export function App({
setMode(session, name); setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name); setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` }); push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
persistCurrentSession();
} }
return; return;
} }
@@ -325,6 +495,25 @@ export function App({
}); });
// text_done already added the assistant message to staticItems // text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done // No fallback needed — the streaming loop always emits text_done
void text;
persistCurrentSession();
if (shouldAutoCompact(session)) {
const percentBefore = Math.round(contextUsageRatio(session) * 100);
setIsThinking(true);
try {
await compactSession(session);
push({
kind: "notice",
text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation.`,
});
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
} catch (err) { } catch (err) {
if (streamRafRef.current !== null) { if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current); clearTimeout(streamRafRef.current);
@@ -354,7 +543,7 @@ export function App({
if (!session) return; if (!session) return;
const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"]; const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode); const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]; const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode); setPermMode(nextMode);
session.permissions.setMode(nextMode); session.permissions.setMode(nextMode);
const labels: Record<PermissionMode, string> = { const labels: Record<PermissionMode, string> = {
@@ -385,10 +574,14 @@ export function App({
preview={permission.preview} preview={permission.preview}
onSelect={handlePermissionSelect} onSelect={handlePermissionSelect}
/> />
) : phase === "starting" ? (
<ThinkingIndicator label="starting..." />
) : phase === "connecting" ? ( ) : phase === "connecting" ? (
<ThinkingIndicator label="connecting..." /> <ThinkingIndicator label="connecting..." />
) : phase === "loading-models" ? ( ) : phase === "loading-models" ? (
<ThinkingIndicator label="loading models..." /> <ThinkingIndicator label="loading models..." />
) : phase === "session-select" ? (
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
) : phase === "model-select" ? ( ) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} /> <ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : ( ) : (
@@ -400,8 +593,11 @@ export function App({
mode={sessionRef.current.mode} mode={sessionRef.current.mode}
permMode={permMode} permMode={permMode}
cwd={cwd} cwd={cwd}
contextTokens={sessionRef.current.lastContextTokens}
contextWindow={sessionRef.current.contextWindow}
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
/> />
)} )}
</Box> </Box>
); );
} }
+59 -6
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import { TOOLS } from "../../tools/index.js";
import { renderMarkdown } from "../render.js"; import { renderMarkdown } from "../render.js";
import { ACCENT_HEX } from "../theme.js"; import { ACCENT_HEX } from "../theme.js";
import type { HistoryItem } from "./types.js"; import type { HistoryItem } from "./types.js";
@@ -13,6 +12,9 @@ const HELP_LINES = [
" /status show current model, backend, tool-call mode, and cwd", " /status show current model, backend, tool-call mode, and cwd",
" /tools list available tools", " /tools list available tools",
" /permissions list mutating tools allowed for the rest of this session", " /permissions list mutating tools allowed for the rest of this session",
" /sessions list saved conversations you can resume with --resume",
" /mcp show connected MCP servers and their tool counts",
" /compact summarize the conversation now to free up context",
" /clear clear conversation history", " /clear clear conversation history",
" /help show this help", " /help show this help",
" /exit, /quit exit", " /exit, /quit exit",
@@ -32,22 +34,28 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
<Text> </Text> <Text> </Text>
{item.model ? ( {item.model ? (
<Text> <Text>
<Text dimColor> model: </Text> <Text color="yellow"> model: </Text>
<Text bold>{item.model}</Text> <Text bold>{item.model}</Text>
</Text> </Text>
) : null} ) : null}
{item.backend ? ( {item.backend ? (
<Text> <Text>
<Text dimColor> backend: </Text> <Text color="yellow"> backend: </Text>
{item.backend} {item.backend}
</Text> </Text>
) : null} ) : null}
<Text> <Text>
<Text dimColor> cwd: </Text> <Text color="yellow"> cwd: </Text>
{item.cwd} {item.cwd}
</Text> </Text>
{item.resumedTitle ? (
<Text>
<Text color="yellow"> resumed: </Text>
{item.resumedTitle}
</Text>
) : null}
<Text> </Text> <Text> </Text>
<Text dimColor> /help for commands · /perm to change permission mode · Shift+Tab to cycle modes</Text> <Text color="yellow"> /help for commands · /perm to change permission mode · Shift+Tab to cycle modes</Text>
</Box> </Box>
); );
@@ -72,6 +80,17 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
<Text dimColor>cwd: </Text> <Text dimColor>cwd: </Text>
{item.cwd} {item.cwd}
</Text> </Text>
<Text>
<Text dimColor>session: </Text>
{item.sessionId}
</Text>
<Text>
<Text dimColor>context: </Text>
{item.contextTokens.toLocaleString()}
{item.contextTokensIsEstimate ? " (est.)" : ""} / {item.contextWindow.toLocaleString()}
{item.contextWindowIsEstimate ? " (default)" : ""} tokens (
{Math.round((item.contextTokens / item.contextWindow) * 100)}%)
</Text>
</Box> </Box>
); );
@@ -123,7 +142,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Text>Available tools:</Text> <Text>Available tools:</Text>
{TOOLS.map((t) => ( {item.tools.map((t) => (
<Text key={t.name}> <Text key={t.name}>
{" "} {" "}
{t.name} {t.name}
@@ -141,5 +160,39 @@ export function HistoryItemView({ item }: { item: HistoryItem }) {
: "No mutating tools have been allowed for the rest of this session yet."} : "No mutating tools have been allowed for the rest of this session yet."}
</Text> </Text>
); );
case "sessions":
return (
<Box flexDirection="column">
<Text>Saved conversations (resume with `locode --resume &lt;id&gt;`):</Text>
{item.sessions.length === 0 ? (
<Text dimColor> (none yet)</Text>
) : (
item.sessions.map((s) => (
<Text key={s.id} dimColor>
{" "}
{s.id} {s.title} ({s.model}, {s.messageCount} msgs)
</Text>
))
)}
</Box>
);
case "mcp":
return (
<Box flexDirection="column">
<Text>MCP servers:</Text>
{item.statuses.length === 0 ? (
<Text dimColor> (none configured — see .mcp.json or `locode mcp add`)</Text>
) : (
item.statuses.map((s) => (
<Text key={s.name} color={s.status === "error" ? "red" : undefined} dimColor={s.status !== "error"}>
{" "}
{s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`}
</Text>
))
)}
</Box>
);
} }
} }
+43
View File
@@ -0,0 +1,43 @@
import { Box, Text } from "ink";
import SelectInput from "ink-select-input";
import type { SessionSummary } from "../../persistence/sessionStore.js";
import { ACCENT_HEX } from "../theme.js";
interface Props {
sessions: SessionSummary[];
onSelect: (id: string | null) => void;
}
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
export function SessionSelect({ sessions, onSelect }: Props) {
const items = [
{ label: "Start a new conversation", value: "__new__" },
...sessions.map((s) => ({
label: `${s.title} (${s.model}, ${s.messageCount} msgs, ${relativeTime(s.updatedAt)})`,
value: s.id,
})),
];
return (
<Box flexDirection="column" justifyContent="center" alignItems="center" height="100%" width="100%">
<Box borderStyle="round" borderColor={ACCENT_HEX} flexDirection="column" paddingX={2} width={80}>
<Text bold color={ACCENT_HEX}>
✻ locode
</Text>
<Text> </Text>
<Text dimColor>Resume a conversation, or start fresh:</Text>
<Text> </Text>
<SelectInput items={items} onSelect={(item) => onSelect(item.value === "__new__" ? null : item.value)} />
</Box>
</Box>
);
}
+30 -7
View File
@@ -9,7 +9,7 @@ const MODE_LABELS: Record<PermissionMode, string> = {
}; };
const MODE_COLORS: Record<PermissionMode, string> = { const MODE_COLORS: Record<PermissionMode, string> = {
default: "gray", default: "yellow",
"auto-edit": "yellow", "auto-edit": "yellow",
"auto-accept": "red", "auto-accept": "red",
}; };
@@ -19,24 +19,47 @@ interface Props {
mode: string; mode: string;
permMode: PermissionMode; permMode: PermissionMode;
cwd: string; cwd: string;
contextTokens: number;
contextWindow: number;
contextIsEstimate: boolean;
} }
export function StatusBar({ model, mode, permMode, cwd }: Props) { // Always a legible, visible color — never dimmed, since this is one of the most-referenced fields.
function contextColor(percent: number): string {
if (percent >= 85) return "red";
if (percent >= 60) return "yellow";
return "green";
}
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return String(n);
}
export function StatusBar({ model, mode, permMode, cwd, contextTokens, contextWindow, contextIsEstimate }: Props) {
// Show just the last segment of cwd for brevity // Show just the last segment of cwd for brevity
const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd; const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd;
const percent = Math.round((contextTokens / contextWindow) * 100);
const approx = contextIsEstimate ? "~" : "";
return ( return (
<Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}> <Box flexDirection="row" width="100%" justifyContent="space-between" paddingX={1}>
<Box gap={1}> <Box gap={1}>
<Text dimColor>{model}</Text> <Text color="yellow">{model}</Text>
<Text dimColor>·</Text> <Text dimColor>·</Text>
<Text dimColor>{mode}</Text> <Text color="yellow">{mode}</Text>
<Text dimColor>·</Text> <Text dimColor>·</Text>
<Text dimColor>{shortCwd}</Text> <Text color="yellow">{shortCwd}</Text>
<Text dimColor>·</Text> <Text dimColor>·</Text>
<Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text> <Text color={MODE_COLORS[permMode]}>{MODE_LABELS[permMode]}</Text>
<Text dimColor>·</Text>
<Text color={contextColor(percent)}>
ctx {approx}
{formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} ({percent}%)
</Text>
</Box> </Box>
<Text dimColor>/perm · /help · /exit</Text> <Text color="yellow">/compact · /perm · /help · /exit</Text>
</Box> </Box>
); );
} }
+13 -1
View File
@@ -1,6 +1,8 @@
import { render } from "ink"; import { render } from "ink";
import { makeClient } from "../../backend/client.js"; import { makeClient } from "../../backend/client.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js"; import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { connectConfiguredMcpServers, disconnectAllMcpServers } from "../../mcp/manager.js";
import type { ToolDef } from "../../tools/types.js";
import { App } from "./App.js"; import { App } from "./App.js";
export interface RunInkAppOptions { export interface RunInkAppOptions {
@@ -9,6 +11,8 @@ export interface RunInkAppOptions {
suggestedModel?: string; suggestedModel?: string;
cwd: string; cwd: string;
toolModeOverride?: ToolCallMode; toolModeOverride?: ToolCallMode;
resumeSessionId?: string;
interactiveResume?: boolean;
} }
export async function runInkApp(opts: RunInkAppOptions): Promise<void> { export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
@@ -19,6 +23,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
process.exit(1); process.exit(1);
} }
// Kick off MCP server connections immediately so they overlap with model listing/picking —
// by the time a session is actually created, this is usually already settled.
const mcpToolsPromise: Promise<ToolDef[]> = connectConfiguredMcpServers(opts.cwd).catch(() => []);
// Start the UI immediately — no blocking network calls before rendering. // 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. // Model listing happens inside the App component so the user sees the UI right away.
const instance = render( const instance = render(
@@ -28,7 +36,11 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
toolModeOverride={opts.toolModeOverride} toolModeOverride={opts.toolModeOverride}
initialModel={opts.model} initialModel={opts.model}
suggestedModel={opts.suggestedModel} suggestedModel={opts.suggestedModel}
resumeSessionId={opts.resumeSessionId}
interactiveResume={opts.interactiveResume}
mcpToolsPromise={mcpToolsPromise}
/>, />,
); );
await instance.waitUntilExit(); await instance.waitUntilExit();
} await disconnectAllMcpServers();
}
+18 -4
View File
@@ -1,6 +1,18 @@
export type HistoryItem = export type HistoryItem =
| { id: string; kind: "banner"; cwd: string; model?: string; backend?: string } | { id: string; kind: "banner"; cwd: string; model?: string; backend?: string; resumedTitle?: string }
| { id: string; kind: "status"; model: string; baseURL: string; mode: string; cwd: string } | {
id: string;
kind: "status";
model: string;
baseURL: string;
mode: string;
cwd: string;
sessionId: string;
contextTokens: number;
contextWindow: number;
contextTokensIsEstimate: boolean;
contextWindowIsEstimate: boolean;
}
| { id: string; kind: "user"; text: string } | { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string } | { id: string; kind: "assistant"; text: string }
| { id: string; kind: "streaming_text"; text: string } | { id: string; kind: "streaming_text"; text: string }
@@ -8,8 +20,10 @@ export type HistoryItem =
| { id: string; kind: "tool_result"; summary: string; isError: boolean } | { id: string; kind: "tool_result"; summary: string; isError: boolean }
| { id: string; kind: "notice"; text: string; isError?: boolean } | { id: string; kind: "notice"; text: string; isError?: boolean }
| { id: string; kind: "help" } | { id: string; kind: "help" }
| { id: string; kind: "tools" } | { id: string; kind: "tools"; tools: import("../../tools/types.js").ToolDef[] }
| { id: string; kind: "permissions"; allowed: string[] }; | { id: string; kind: "permissions"; allowed: string[] }
| { id: string; kind: "sessions"; sessions: import("../../persistence/sessionStore.js").SessionSummary[] }
| { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] };
/** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */ /** Plain Omit<Union, K> collapses to keys common to every member; this distributes over each branch instead. */
export type NewHistoryItem = HistoryItem extends infer T export type NewHistoryItem = HistoryItem extends infer T
+22
View File
@@ -7,6 +7,15 @@ export function formatCallLabel(name: string, args: unknown): string {
return `List(${a.pattern ?? ""})`; return `List(${a.pattern ?? ""})`;
case "grep": case "grep":
return `Grep(${a.pattern ?? ""})`; return `Grep(${a.pattern ?? ""})`;
case "web_search":
return `WebSearch(${a.query ?? ""})`;
case "web_fetch":
return `WebFetch(${a.url ?? ""})`;
case "agent":
return `Agent(${a.description ?? ""})`;
case "git_status":
case "git_commit":
return `Git(${a.operation ?? ""})`;
case "write_file": case "write_file":
return `Write(${a.path ?? ""})`; return `Write(${a.path ?? ""})`;
case "edit_file": case "edit_file":
@@ -33,6 +42,19 @@ export function summarizeToolResult(toolName: string, result: unknown): string {
return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files"; return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files";
case "grep": case "grep":
return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched"; return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched";
case "web_search":
return Array.isArray(r.results) ? `Found ${r.results.length} result(s)` : "Searched web";
case "web_fetch":
return typeof r.status === "number" ? `Fetched (HTTP ${r.status})` : "Fetched page";
case "agent":
return typeof r.result === "string" ? `Sub-agent finished (${r.result.length} chars)` : "Sub-agent finished";
case "git_status":
return typeof r.output === "string" ? `${r.output.split("\n").filter(Boolean).length} line(s)` : "Ran git query";
case "git_commit":
if (Array.isArray(r.staged)) return `Staged ${r.staged.length} path(s)`;
if (typeof r.created === "string") return `Created branch "${r.created}"`;
if (typeof r.switchedTo === "string") return `Switched to "${r.switchedTo}"`;
return "Done";
case "write_file": case "write_file":
return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file"; return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file";
case "edit_file": case "edit_file":
+39
View File
@@ -0,0 +1,39 @@
export const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36";
export function decodeEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ");
}
export function stripTags(html: string): string {
return decodeEntities(html.replace(/<[^>]+>/g, "")).trim();
}
/** Strips script/style/head content and tags from an HTML document, collapsing it to readable text. */
export function htmlToText(html: string): string {
const withoutNonContent = html
.replace(/<head[\s\S]*?<\/head>/gi, "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "");
const withBreaks = withoutNonContent
.replace(/<\/(p|div|li|h[1-6]|tr|section|article|br)>/gi, "\n")
.replace(/<br\s*\/?>/gi, "\n");
return stripTags(withBreaks)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
}
export function withFetchTimeout(ms: number): { signal: AbortSignal; clear: () => void } {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
return { signal: controller.signal, clear: () => clearTimeout(timeout) };
}
+8
View File
@@ -0,0 +1,8 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
/** Rough token estimate (~4 chars/token) used when the backend doesn't report real usage stats
* (via `stream_options: { include_usage: true }`) or before any turn has run yet. */
export function estimateTokens(messages: ChatCompletionMessageParam[]): number {
const chars = JSON.stringify(messages).length;
return Math.ceil(chars / 4);
}