Files
locode/src/ui/ink/App.tsx
T
kimandClaude Sonnet 5 e572fc5d76 refactor: table-drive the built-in slash-command dispatch
handleSubmit's ~200-line if/return chain becomes a declarative
{test, run}[] list plus one dispatch loop. Same matching semantics
(exact vs. loose startsWith), same order (/permissions before /perm,
/model before /mode), same handler bodies — just uniform structure and
a single place the command set is registered. Handlers still close over
component state; extracting them into an independently testable module
is a larger follow-up.

Also drops a stale comment claiming Ollama can't report context length
for ":cloud" models — /api/show now returns it for every cloud model
tested (glm-5.2, qwen3.5, kimi-k2, minimax, gemma4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:05:45 +09:00

1155 lines
52 KiB
TypeScript

import { Box, Static, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from "ink";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import path from "node:path";
import {
AgentError,
compactSession,
contextUsageRatio,
fireSessionStartHook,
fireUserPromptSubmitHook,
MaxIterationsError,
runTurn,
shouldAutoCompact,
type ChatCompletionUserContent,
} from "../../agent/loop.js";
import {
createSession,
createSessionFromRecord,
resetSession,
setMode,
toSessionRecord,
type Session,
} from "../../agent/session.js";
import { makeClient } from "../../backend/client.js";
import { runHooksForEvent } from "../../hooks/runner.js";
import type { ToolCallMode } from "../../backend/capabilityProbe.js";
import { setCachedMode } from "../../backend/capabilityCache.js";
import { resolveContextWindow } from "../../backend/contextWindow.js";
import { resolveToolCallMode } from "../../backend/resolveMode.js";
import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/config.js";
import { KNOWN_BACKENDS, type BackendName, isSmallLocalModel } from "../../config/defaults.js";
import { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { defaultExportFilename, exportSession, type ExportFormat } from "../../persistence/exportSession.js";
import { loadMergedHooks } from "../../hooks/config.js";
import { expandCommandTemplate } from "../../plugins/expandTemplate.js";
import { getLoadedPlugins, getPluginCommandCollisions } from "../../plugins/registry.js";
import { getGitInfo, type GitInfo } from "../../utils/gitInfo.js";
import { findSkillCollisions } from "../../plugins/skillTool.js";
import { buildImportContent } from "../../utils/importFile.js";
import { extractMentionedFiles } from "../../utils/mentions.js";
import { loadProjectInstructions } from "../../utils/projectInstructions.js";
import {
deriveTitle,
listSessions,
loadSession,
saveSession,
type SessionRecord,
type SessionSummary,
} from "../../persistence/sessionStore.js";
import { buildReplayHistory } from "../../persistence/replayHistory.js";
import { onBackgroundJobDone } from "../../tools/backgroundJobs.js";
import { TOOLS } from "../../tools/index.js";
import type { ToolDef } from "../../tools/types.js";
import { buildToolSet, type ToolSet } from "../../tools/toolset.js";
import { ChatInput } from "./ChatInput.js";
import { makeConfirmFn } from "./confirmFn.js";
import { ExportPrompt } from "./ExportPrompt.js";
import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
import { SessionSelect } from "./SessionSelect.js";
import { StatusBar } from "./StatusBar.js";
import { ThinkingIndicator } from "./ThinkingIndicator.js";
import { ACCENT_HEX } from "../theme.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
// Cap for the input-history ring buffer used for ↑/↓ recall in the chat input.
const MAX_HISTORY = 100;
// Fixed width (in columns) of the file panel (see FilePanel.tsx) when shown — a compromise between
// filenames actually fitting and leaving enough room for the chat column on an 80-col terminal.
const FILE_PANEL_WIDTH = 30;
// Maps a tool name to how the file panel's Activity tab should label a successful call that
// touched a file — see the tool_result handling in submitTurn. Every one of these tools returns
// `{ path: <resolved absolute path>, ... }` from its handler (see readFile.ts/writeFile.ts/
// editFile.ts), which is what makes a single lookup here enough to build a TouchedFile entry.
const FILE_TOUCH_STATUS: Partial<Record<string, TouchedFile["status"]>> = {
read_file: "read",
write_file: "written",
edit_file: "edited",
};
// Shared between /perm's explicit-cycle notice and Shift+Tab's cyclePermMode so the two paths to
// the same action can't drift out of sync with each other.
const PERM_MODE_LABELS: Record<PermissionMode, string> = {
default: "default (ask before mutating tools)",
plan: "plan (research only — all mutating tools blocked)",
"auto-edit": "auto-edit (file edits auto-approved, bash still asks)",
"auto-accept": "auto-accept (all tools auto-approved ⚠)",
};
export interface AppProps {
baseURL: string;
cwd: string;
toolModeOverride?: ToolCallMode;
initialModel?: string;
suggestedModel?: string;
resumeSessionId?: string;
interactiveResume?: boolean;
extraToolsPromise: Promise<ToolDef[]>;
/** Reported once a session actually exists, so index.tsx can print a "resume this later" hint
* on exit — it lives outside the Ink render tree, so it has no other way to know the session id. */
onSessionIdChange?: (id: string) => void;
}
interface PendingPermission {
toolName: string;
args: unknown;
preview?: string;
resolve: (decision: PermissionDecision) => void;
}
type Phase = "starting" | "connecting" | "loading-models" | "model-select" | "session-select" | "input";
export function App({
baseURL: initialBaseURL,
cwd,
toolModeOverride,
initialModel,
suggestedModel,
resumeSessionId,
interactiveResume,
extraToolsPromise,
onSessionIdChange,
}: AppProps) {
const { exit } = useApp();
const { stdin, isRawModeSupported, setRawMode } = useStdin();
const { stdout } = useStdout();
const [staticItems, setStaticItems] = useState<HistoryItem[]>([]);
const [phase, setPhase] = useState<Phase>(
resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models",
);
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
const [modelList, setModelList] = useState<string[]>([]);
const [sessionList, setSessionList] = useState<SessionSummary[]>([]);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const [filePanelVisible, setFilePanelVisible] = useState(false);
const [filePanelTab, setFilePanelTab] = useState<FilePanelTab>("files");
// Whether the file panel currently owns keyboard input — see the Ctrl+F handler below and
// ChatInput's isActive prop, which this disables while true so an arrow/Enter/Escape keystroke
// doesn't simultaneously navigate the tree and edit/submit the chat input.
const [filePanelFocused, setFilePanelFocused] = useState(false);
// Keyed by relPath so repeated touches update the same entry (bumping count) instead of
// duplicating it — see the tool_result handling in submitTurn below.
const [touchedFiles, setTouchedFiles] = useState<Map<string, TouchedFile>>(new Map());
// Most-recently-touched first — the Activity tab's whole point is "what's happened lately".
const touchedFilesList = useMemo(() => Array.from(touchedFiles.values()).sort((a, b) => b.lastTouchedAt - a.lastTouchedAt), [touchedFiles]);
const [history, setHistory] = useState<string[]>([]);
const baseURLRef = useRef(initialBaseURL);
const sessionRef = useRef<Session | null>(null);
// The AbortController for whichever top-level turn is currently in flight — null between turns.
// Escape (see the global useInput handler below) aborts it, which cancels the in-flight backend
// request, kills a running bash child (see gateAndRun/bashTool's ctx.signal), and dismisses/rejects
// a pending permission prompt (via makeConfirmFn) — the same abort plumbing sub-agent timeouts
// already used, just wired up to a top-level turn for the first time.
const turnAbortRef = useRef<AbortController | null>(null);
// Static history items can't be retroactively expanded once printed (Ink's <Static> is
// append-only), so Ctrl+O doesn't edit the collapsed compact notice in place — it prints a new
// item with the full text on demand. This just remembers the most recent one to print.
const lastCompactSummaryRef = useRef<string | null>(null);
// Tracks whether the tool call currently in flight is `bash` (the only backgroundable tool),
// purely so the UI can show a "ctrl+b to background" hint — the actual gate lives on
// session.activeBackground, set by gateAndRun in agent/loop.ts.
const [runningToolIsBash, setRunningToolIsBash] = useState(false);
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
const streamingAccumulatorRef = useRef("");
const thinkingAccumulatorRef = useRef("");
const lastStreamRenderRef = useRef(0);
const streamRafRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable ref for the current phase so the global useInput handler can read it without being
// re-registered on every phase change.
const phaseRef = useRef<Phase>(phase);
phaseRef.current = phase;
const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
// Columns actually left for the chat column once the file panel (see FilePanel.tsx) claims its
// fixed width on the right — ChatInput can't derive this from its own measured width (see the
// comment on its availableColumns prop), so it's computed once here and threaded down.
const chatColumns = terminalColumns - (filePanelVisible ? FILE_PANEL_WIDTH : 0);
// Width every printed history line is held within, so nothing the terminal would soft-wrap
// reaches Ink's <Static> — a soft-wrapped line desyncs Ink's redraw accounting and leaves
// stranded copies of the status bar / input box in scrollback (see renderMarkdown).
const contentWidth = Math.max(20, chatColumns);
const flushStreamingText = useCallback(() => {
const accumulated = streamingAccumulatorRef.current;
if (accumulated) {
setStreamingText(accumulated);
}
}, []);
const push = useCallback((partial: NewHistoryItem) => {
setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]);
}, []);
const persistCurrentSession = useCallback(() => {
const session = sessionRef.current;
if (!session) return;
// saveSession is async and serialized per-id (see sessionStore.ts); fire-and-forget here, with
// flushPendingSaves() on exit guaranteeing the last turn isn't lost. A failed save shouldn't
// crash the UI.
void saveSession(toSessionRecord(session, baseURLRef.current)).catch(() => {});
}, []);
const refreshGitInfo = useCallback(() => {
getGitInfo(cwd).then(setGitInfo).catch(() => setGitInfo(null));
}, [cwd]);
// A backgrounded bash job (see Ctrl+B below) can finish long after the turn that started it has
// ended — this is how its completion still reaches the transcript.
useEffect(() => {
return onBackgroundJobDone((job) => {
const outcome = job.exitCode === 0 ? "finished" : job.signal ? `was killed (${job.signal})` : `finished (exit ${job.exitCode})`;
push({ kind: "notice", text: `Background job ${job.id} ${outcome}: ${job.command}`, isError: job.exitCode !== 0 });
});
}, [push]);
// Mounted for the whole App lifetime (unlike ChatInput's own useInput, which only exists while
// ChatInput is rendered) so both shortcuts work even mid-turn, when ChatInput is unmounted.
useInput((input, key) => {
// Escape interrupts an in-flight turn (like Claude Code) — checked first and unconditionally on
// phase/modal state so it always wins, including while a permission prompt is open (aborting
// dismisses it too, via makeConfirmFn) or the file panel is focused. Gated on isThinking/
// streamingText rather than the phase/permission/exportPrompt guard below, since those describe
// UI modal state, not whether a turn is actually running.
if (key.escape && (isThinking || streamingText !== null)) {
turnAbortRef.current?.abort();
return;
}
// Shift+Tab: cycle permission mode. Handled globally (not just inside ChatInput) so it still
// works while a turn is in flight or a permission prompt is open — both unmount ChatInput (see
// the bottom-section ternary below), which is exactly the gap Escape hit above. Requires an
// active session (phase "input"); excluded only for exportPrompt, where it isn't a meaningful
// action while naming a file.
if (key.shift && key.tab && phaseRef.current === "input" && !exportPrompt) {
cyclePermMode();
return;
}
// Only react to global shortcuts during the actual chat phase; ignore them while a modal
// (permission/export) or a non-input phase (model/session select, connecting) is open.
if (phaseRef.current !== "input" || permission || exportPrompt) return;
if (key.ctrl && input === "f") {
// Three-state cycle: hidden -> open+focused -> open+unfocused (via Escape, not here) -> hidden.
// Pressing Ctrl+F while open-but-unfocused (the Escape state) re-focuses it instead of hiding
// it outright, so "peek without hiding" (Escape) and "close" (Ctrl+F again) stay distinct.
if (!filePanelVisible) {
setFilePanelVisible(true);
setFilePanelFocused(true);
} else if (filePanelFocused) {
setFilePanelVisible(false);
setFilePanelFocused(false);
} else {
setFilePanelFocused(true);
}
return;
}
if (key.ctrl && input === "g") {
setFilePanelTab((t) => (t === "files" ? "activity" : "files"));
return;
}
if (key.ctrl && input === "o") {
const summary = lastCompactSummaryRef.current;
push({
kind: "notice",
text: summary ? `Full compaction summary:\n\n${summary}` : "No compaction summary available yet.",
});
return;
}
if (key.ctrl && input === "b") {
const control = sessionRef.current?.activeBackground;
if (control) {
control.requested = true;
push({ kind: "notice", text: "Backgrounding the running command..." });
} else {
push({ kind: "notice", text: "Nothing backgroundable is running right now." });
}
}
});
const fetchModelsForPicker = useCallback(() => {
const client = makeClient({ baseURL: baseURLRef.current, model: "" });
client.models
.list()
.then((list) => {
const ids = list.data.map((m) => m.id);
setModelList(ids);
if (ids.length === 0) {
push({ kind: "notice", text: "No models found. Pull/load a model first.", isError: true });
setPhase("input");
} else {
setPhase("model-select");
}
})
.catch((err) => {
push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true });
setPhase("input");
});
}, [push]);
// When neither backend reports a context length (model not loaded in LM Studio, /api/show
// unreachable, a model type Ollama has no metadata for) contextWindow silently falls back to a
// small hardcoded default (see backend/contextWindow.ts), which makes auto-compaction trigger far
// more often than the model's real limit would require, burning extra summarization round-trips.
// Surface it so the user can set the real value instead of silently eating that cost every session.
const notifyIfContextWindowGuessed = useCallback(
(model: string, contextWindow: { value: number; isEstimate: boolean }) => {
if (!contextWindow.isEstimate) return;
push({
kind: "notice",
text:
`Could not detect "${model}"'s real context window — using a default of ${contextWindow.value} tokens, ` +
`which may be much smaller than its actual limit and cause auto-compaction to trigger too often. ` +
`Set it manually with: locode config set contextWindow <tokens>`,
});
},
[push],
);
const initSession = useCallback(
async (model: string) => {
setPhase("connecting");
try {
const client = makeClient({ baseURL: baseURLRef.current, model });
// Default to "native" mode immediately — no blocking probe on startup.
// The probe runs lazily on the first turn if needed.
const mode: ToolCallMode = toolModeOverride ?? "native";
const confirmFn = makeConfirmFn(setPermission);
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
extraToolsPromise,
resolveContextWindow(baseURLRef.current, model),
loadProjectInstructions(cwd),
]);
sessionRef.current = createSession(
client,
model,
cwd,
confirmFn,
mode,
[...TOOLS, ...extraTools],
contextWindow.value,
contextWindow.isEstimate,
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isSmallLocalModel(baseURLRef.current, model),
);
onSessionIdChange?.(sessionRef.current.id);
push({ kind: "banner", cwd, model, backend: baseURLRef.current });
if (projectInstructions) push({ kind: "notice", text: "Loaded project instructions from CLAUDE.md/AGENTS.md." });
notifyIfContextWindowGuessed(model, contextWindow);
for (const warning of await fireSessionStartHook(sessionRef.current)) {
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
setPhase("input");
} catch (err) {
push({
kind: "notice",
text: `Failed to connect to "${model}": ${(err as Error).message}`,
isError: true,
});
if (modelList.length > 0) {
setPhase("model-select");
} else {
setTimeout(() => exit(new Error("Failed to connect")), 100);
}
}
},
[cwd, toolModeOverride, modelList, exit, push, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
);
const initSessionFromRecord = useCallback(
async (record: SessionRecord) => {
setPhase("connecting");
try {
baseURLRef.current = record.baseURL;
const client = makeClient({ baseURL: record.baseURL, model: record.model });
const confirmFn = makeConfirmFn(setPermission);
const [extraTools, contextWindow, projectInstructions] = await Promise.all([
extraToolsPromise,
resolveContextWindow(record.baseURL, record.model),
loadProjectInstructions(cwd),
]);
sessionRef.current = createSessionFromRecord(
client,
record,
cwd,
confirmFn,
[...TOOLS, ...extraTools],
contextWindow.value,
contextWindow.isEstimate,
resolveMaxIterations(),
resolveAutoCompactThreshold(),
projectInstructions,
isSmallLocalModel(record.baseURL, record.model),
);
onSessionIdChange?.(sessionRef.current.id);
push({
kind: "banner",
cwd,
model: record.model,
backend: record.baseURL,
resumedTitle: deriveTitle(record.messages),
});
notifyIfContextWindowGuessed(record.model, contextWindow);
for (const warning of await fireSessionStartHook(sessionRef.current)) {
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
// Replay the saved turns (including tool_call/tool_result lines) so the transcript
// looks the same as it did live, not just the user/assistant text.
setStaticItems((prev) => [...prev, ...buildReplayHistory(record.messages)]);
setPhase("input");
} catch (err) {
push({ kind: "notice", text: `Failed to resume session: ${(err as Error).message}`, isError: true });
fetchModelsForPicker();
}
},
[cwd, push, fetchModelsForPicker, extraToolsPromise, notifyIfContextWindowGuessed, onSessionIdChange],
);
// 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.
//
// Deliberately depends only on the primitive props that decide *which* path to take, not on
// initSession/initSessionFromRecord/fetchModelsForPicker themselves — those are useCallbacks whose
// own deps include React state (e.g. initSession depends on `modelList`, which fetchModelsForPicker
// sets). Including them here used to mean: fetchModelsForPicker sets modelList -> initSession gets a
// new identity -> this effect's deps change -> it re-fires -> fetchModelsForPicker/initSession run
// again -> modelList changes again -> ... an infinite loop that reconnected (pushing a fresh welcome
// banner each time) and/or re-fetched the model list forever, which is exactly what "run once on
// mount" was never supposed to do.
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) {
void initSession(initialModel);
return;
}
fetchModelsForPicker();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resumeSessionId, interactiveResume, initialModel]);
async function handleModelSelect(model: string) {
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) {
const session = sessionRef.current;
if (!session) return;
const previousModel = session.model;
session.model = name;
setIsThinking(true);
try {
const [newMode, newContextWindow] = await Promise.all([
resolveToolCallMode(session.client, baseURLRef.current, name),
resolveContextWindow(baseURLRef.current, name),
]);
// Recompute before setMode's rebuild (see setMode) so switching e.g. a small local model ->
// glm-5.2:cloud (or the reverse) mid-session gets the right system-prompt branch immediately,
// not just on the next full session restart.
session.isLocal = isSmallLocalModel(baseURLRef.current, name);
setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` });
notifyIfContextWindowGuessed(name, newContextWindow);
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "model", previous: previousModel, value: name }).catch(
() => {},
);
} catch (err) {
session.model = previousModel;
push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
async function switchBackend(name: BackendName) {
const session = sessionRef.current;
if (!session) return;
const previousBaseURL = baseURLRef.current;
const previousClient = session.client;
baseURLRef.current = KNOWN_BACKENDS[name];
session.client = makeClient({ baseURL: baseURLRef.current, model: session.model });
setIsThinking(true);
try {
const [newMode, newContextWindow] = await Promise.all([
resolveToolCallMode(session.client, baseURLRef.current, session.model),
resolveContextWindow(baseURLRef.current, session.model),
]);
// See the same recompute in switchModel — a backend switch changes isSmallLocalModel()'s
// input just as much as a model switch changes isCloudRoutedModelName()'s.
session.isLocal = isSmallLocalModel(baseURLRef.current, session.model);
setMode(session, newMode);
session.contextWindow = newContextWindow.value;
session.contextWindowIsEstimate = newContextWindow.isEstimate;
push({
kind: "notice",
text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`,
});
notifyIfContextWindowGuessed(session.model, newContextWindow);
persistCurrentSession();
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "backend", previous: previousBaseURL, value: baseURLRef.current }).catch(
() => {},
);
} catch (err) {
baseURLRef.current = previousBaseURL;
session.client = previousClient;
push({ kind: "notice", text: `Failed to switch backend: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
async function submitTurn(session: Session, input: string | ChatCompletionUserContent, toolset?: ToolSet) {
const rollbackLength = session.messages.length;
const ac = new AbortController();
turnAbortRef.current = ac;
setIsThinking(true);
setStreamingText(null);
streamingAccumulatorRef.current = "";
try {
const text = await runTurn(session, input, (event) => {
if (event.type === "text_delta") {
setIsThinking(false);
streamingAccumulatorRef.current += event.delta;
// Throttle renders to ~30fps
const now = Date.now();
if (now - lastStreamRenderRef.current > 33) {
lastStreamRenderRef.current = now;
setStreamingText(streamingAccumulatorRef.current);
} else {
if (streamRafRef.current === null) {
streamRafRef.current = setTimeout(flushStreamingText, 33);
}
}
} else if (event.type === "text_done") {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
// A native tool-call-only turn (no preceding prose) still emits text_done with an empty
// fullText purely to flush streaming state above — skip adding a blank assistant line for it.
if (event.fullText.trim().length > 0) {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "assistant", text: event.fullText } as HistoryItem]);
}
} else if (event.type === "stream_discard") {
// The turn is being retried non-streaming after a partially-streamed malformed tool call —
// drop the partial text without committing it as an assistant message (it was never added to
// session.messages, and the retry's output replaces it).
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
} else if (event.type === "tool_call") {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
setIsThinking(true);
setRunningToolIsBash(event.label.startsWith("Bash("));
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_call", label: event.label } as HistoryItem]);
} else if (event.type === "tool_result") {
setRunningToolIsBash(false);
setStaticItems((prev) => [...prev, { id: nextId(), kind: "tool_result", summary: event.summary, isError: event.isError } as HistoryItem]);
const touchStatus = event.name && !event.isError ? FILE_TOUCH_STATUS[event.name] : undefined;
const resultPath = touchStatus ? (event.result as { path?: unknown } | undefined)?.path : undefined;
if (touchStatus && typeof resultPath === "string") {
const relPath = path.relative(cwd, resultPath) || resultPath;
setTouchedFiles((prev) => {
const next = new Map(prev);
next.set(relPath, { relPath, status: touchStatus, count: (next.get(relPath)?.count ?? 0) + 1, lastTouchedAt: Date.now() });
return next;
});
}
} else if (event.type === "hook_notice" || event.type === "notice") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "notice", text: event.text, isError: event.isError } as HistoryItem]);
} else if (event.type === "todos_update") {
setStaticItems((prev) => [...prev, { id: nextId(), kind: "todos", todos: event.todos } as HistoryItem]);
}
}, toolset, ac.signal);
// text_done already added the assistant message to staticItems
// No fallback needed — the streaming loop always emits text_done
void text;
persistCurrentSession();
refreshGitInfo();
if (shouldAutoCompact(session)) {
const percentBefore = Math.round(contextUsageRatio(session) * 100);
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({
kind: "notice",
text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation. (ctrl+o to see full summary)`,
});
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
}
} catch (err) {
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
setStreamingText(null);
streamingAccumulatorRef.current = "";
// Roll back the uncommitted tail of the turn — but preserve history through the last
// successfully-applied mutating tool result (session.mutationCommitLength), so a backend
// failure that comes *after* a mutation already hit disk doesn't erase the record of that
// mutation. Erasing it would leave the model blind to its own change and cause it to blindly
// retry the edit on the next turn (which then fails with "old_string not found", etc.).
const commitLength = session.mutationCommitLength;
session.messages.length = commitLength ?? rollbackLength;
if (ac.signal.aborted) {
// The user pressed Escape (see the global useInput handler) — not a failure, so no
// "Request failed" framing. Whatever error actually surfaced (a raw fetch AbortError, or
// makeConfirmFn's "prompt cancelled" rejection if Escape landed while a permission prompt
// was open) is irrelevant here; `ac` is ours, so its own aborted flag is the one source of
// truth for "this was the user stopping it" regardless of which code path threw.
push({ kind: "notice", text: "Interrupted.", isError: false });
} else if (err instanceof MaxIterationsError) {
// Not a failure — the model just ran out of per-turn budget. No "Request failed" framing,
// no isError styling, since nothing actually broke and the work done so far is intact.
push({ kind: "notice", text: err.message, isError: false });
} else {
const reason = err instanceof AgentError ? err.message : (err as Error).message;
push({ kind: "notice", text: `Request failed: ${reason}`, isError: true });
}
} finally {
if (turnAbortRef.current === ac) turnAbortRef.current = null;
setIsThinking(false);
setStreamingText(null);
streamingAccumulatorRef.current = "";
if (streamRafRef.current !== null) {
clearTimeout(streamRafRef.current);
streamRafRef.current = null;
}
}
}
async function handleSubmit(raw: string) {
// A turn is already in flight (streaming output or waiting for the first chunk). Keep the
// input box mounted so the user can type ahead, but ignore Enter so a second turn can't
// overlap onto the same session. The in-flight turn is the source of truth; the typed text
// stays in the box for when the turn finishes.
if (isThinking || streamingText !== null) return;
setInputValue("");
const trimmed = raw.trim();
if (!trimmed) return;
const session = sessionRef.current;
if (!session) return;
// Record into the input history (most-recent-last) for ↑/↓ recall. Skip exact
// consecutive duplicates and cap the length so a long session doesn't grow
// the array unbounded — same UX convention as a typical shell.
setHistory((h) => {
if (h.length > 0 && h[h.length - 1] === trimmed) return h;
const next = [...h, trimmed];
return next.length > MAX_HISTORY ? next.slice(next.length - MAX_HISTORY) : next;
});
push({ kind: "user", text: trimmed });
// Built-in slash commands, checked in order before plugin commands / skills / @mentions get a
// look. `test` gets the trimmed input; `run` gets it too and slices its own argument (a prefix
// match like `/model` deliberately stays loose — `startsWith` — to match historical behavior).
// Order matters where prefixes overlap: `/permissions` sits before `/perm`, `/model` before
// `/mode`.
const builtinCommands: { test: (t: string) => boolean; run: (t: string) => void | Promise<void> }[] = [
{ test: (t) => t === "/exit" || t === "/quit", run: () => exit() },
{ test: (t) => t === "/help", run: () => push({ kind: "help" }) },
{
test: (t) => t === "/clear",
run: () => {
resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." });
persistCurrentSession();
},
},
{
test: (t) => t === "/status",
run: () =>
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,
}),
},
{
test: (t) => t === "/dashboard" || t === "/stats",
run: () =>
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
}),
},
{
test: (t) => t === "/compact",
run: async () => {
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
},
},
{
test: (t) => t.startsWith("/export"),
run: (t) => {
const rest = t.slice("/export".length).trim();
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
let fmt: ExportFormat = "markdown";
let arg = rest;
if (rest === "json" || rest.startsWith("json ")) {
fmt = "json";
arg = rest === "json" ? "" : rest.slice("json".length).trim();
}
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
},
},
{
test: (t) => t.startsWith("/import"),
run: async (t) => {
const rest = t.slice("/import".length).trim();
if (!rest) {
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
return;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} catch (err) {
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
}
},
},
{ test: (t) => t === "/tools", run: () => push({ kind: "tools", tools: session.toolset.tools }) },
{ test: (t) => t === "/permissions", run: () => push({ kind: "permissions", allowed: session.permissions.listAllowed() }) },
{ test: (t) => t === "/sessions", run: () => push({ kind: "sessions", sessions: listSessions() }) },
{
test: (t) => t === "/mcp" || t === "/mcp reconnect",
run: async (t) => {
if (t === "/mcp reconnect" && sessionRef.current) {
try {
const newMcpTools = await reconnectMcpServers(cwd);
// Rebuild the session's toolset: keep non-MCP tools (built-ins + plugin tools already in
// the toolset), drop the old MCP tools (namespaced `mcp__...`), and add the fresh ones.
const kept = sessionRef.current.toolset.tools.filter((tool) => !tool.name.startsWith("mcp__"));
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
} catch {
// fall through to just re-rendering current statuses
}
}
push({ kind: "mcp", statuses: getMcpStatuses() });
},
},
{
test: (t) => t === "/plugins",
run: () => push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() }),
},
{ test: (t) => t === "/hooks", run: () => push({ kind: "hooks", config: loadMergedHooks(cwd) }) },
{
test: (t) => t === "/skills",
run: () => {
const skills = getLoadedPlugins().flatMap((p) => p.skills);
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
},
},
{
test: (t) => t.startsWith("/model"),
run: async (t) => {
const name = t.slice("/model".length).trim();
if (!name) push({ kind: "notice", text: `Current model: ${session.model}` });
else await switchModel(name);
},
},
{
test: (t) => t.startsWith("/backend"),
run: async (t) => {
const name = t.slice("/backend".length).trim();
if (!name) {
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
} else if (name !== "ollama" && name !== "lmstudio") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
},
},
{
test: (t) => t.startsWith("/perm"),
run: (t) => {
const name = t.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = {
default: "default",
plan: "plan",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({
kind: "notice",
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
isError: true,
});
}
},
},
{
test: (t) => t.startsWith("/mode"),
run: (t) => {
const name = t.slice("/mode".length).trim();
if (!name) {
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
setCachedMode(baseURLRef.current, session.model, name);
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
persistCurrentSession();
runHooksForEvent(
"ConfigChange",
{ sessionId: session.id, cwd },
{ key: "toolMode", previous: previousMode, value: name },
).catch(() => {});
}
},
},
];
for (const command of builtinCommands) {
if (command.test(trimmed)) {
await command.run(trimmed);
return;
}
}
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
// resolution — a hook can block the message outright, or inject extra context (appended
// below wherever the message actually ends up going).
const promptHook = await fireUserPromptSubmitHook(session, trimmed);
for (const warning of promptHook.warnings) {
push({ kind: "notice", text: `Hook warning: ${warning}` });
}
if (promptHook.blocked) {
push({ kind: "notice", text: `Blocked by hook: ${promptHook.reason}`, isError: true });
return;
}
const extraContextParts: string[] = [];
if (promptHook.additionalContext) extraContextParts.push(promptHook.additionalContext);
if (promptHook.jsonContext?.length) {
extraContextParts.push(`Hook context (JSON):\n${promptHook.jsonContext.map((j) => JSON.stringify(j)).join("\n")}`);
}
const extraContext = extraContextParts.join("\n\n");
// Plugin-provided slash commands (commands/*.md), then skills (skills/*/SKILL.md) — checked
// only after every built-in above has had a chance to match, so a plugin can never shadow a
// built-in command name; commands win over a same-named skill (the model can still always
// reach a skill itself via the `skill` tool regardless of this ordering).
if (trimmed.startsWith("/")) {
const spaceIdx = trimmed.indexOf(" ");
const cmdName = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).slice(1);
const argsText = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
const plugins = getLoadedPlugins();
const pluginCommand = plugins.flatMap((p) => p.commands).find((c) => c.name === cmdName);
if (pluginCommand) {
const expanded = expandCommandTemplate(pluginCommand.template, argsText);
// A command's `allowed-tools` frontmatter restricts only this one turn — build a
// narrowed ToolSet from the session's full one rather than mutating session.toolset,
// the same pattern runSubAgentTurn (agent/loop.ts) uses for a plugin agent's `tools:`.
const restrictedToolset = pluginCommand.allowedTools
? buildToolSet(session.toolset.tools.filter((t) => pluginCommand.allowedTools!.includes(t.name)))
: undefined;
await submitTurn(session, extraContext ? `${expanded}\n\n${extraContext}` : expanded, restrictedToolset);
return;
}
const pluginSkill = plugins.flatMap((p) => p.skills).find((s) => s.name === cmdName);
if (pluginSkill) {
// Real skills come in two flavors (confirmed against Anthropic's own official example
// plugin): "user-invoked" ones written just like a command, with $ARGUMENTS/$1../$9
// placeholders — those need actual template expansion, not just an appended trailer — and
// purely contextual ones with no placeholder at all, where appending the user's text is
// the only sensible thing to do with it.
const hasPlaceholder = /\$ARGUMENTS|\$[1-9]/.test(pluginSkill.content);
const expanded = hasPlaceholder ? expandCommandTemplate(pluginSkill.content, argsText) : pluginSkill.content;
const body = !hasPlaceholder && argsText ? `${expanded}\n\n---\n\nUser's request: ${argsText}` : expanded;
await submitTurn(session, extraContext ? `${body}\n\n${extraContext}` : body);
return;
}
}
const mentionedFiles = extractMentionedFiles(trimmed, cwd);
if (mentionedFiles.length > 0) {
try {
const attachments = await Promise.all(mentionedFiles.map((f) => buildImportContent(cwd, f, "")));
const parts = [{ type: "text" as const, text: trimmed }, ...attachments.flat()];
if (extraContext) parts.push({ type: "text" as const, text: extraContext });
await submitTurn(session, parts);
} catch (err) {
push({ kind: "notice", text: `Failed to load @mention: ${(err as Error).message}`, isError: true });
}
return;
}
await submitTurn(session, extraContext ? `${trimmed}\n\n${extraContext}` : trimmed);
}
// handleSubmit is a plain function recreated every render (it's 300 lines closing over dozens of
// things, including isThinking/streamingText — both of which change during every streaming frame,
// so a naive useCallback with a correct dependency list would still get a new identity constantly
// while streaming, exactly when we want it stable). Instead, the same ref-indirection this file
// already uses for phaseRef: keep a ref pointing at the latest handleSubmit, and hand ChatInput a
// permanently-stable wrapper that just calls through it — this is what actually makes ChatInput's
// memo() below effective during streaming, without needing handleSubmit's own identity to be stable.
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const stableHandleSubmit = useCallback((raw: string) => {
void handleSubmitRef.current(raw);
}, []);
function handlePermissionSelect(decision: PermissionDecision) {
const pending = permission;
setPermission(null);
setIsThinking(true);
pending?.resolve(decision);
}
async function handleExportSubmit(filename: string) {
setExportPrompt(null);
const session = sessionRef.current;
if (!session) return;
const trimmedName = filename.trim();
if (!trimmedName) {
push({ kind: "notice", text: "Export cancelled (empty filename)." });
return;
}
try {
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName, exportPrompt?.format ?? "markdown");
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
} catch (err) {
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
}
}
function handleExportCancel() {
setExportPrompt(null);
push({ kind: "notice", text: "Export cancelled." });
}
function cyclePermMode() {
const session = sessionRef.current;
if (!session) return;
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
}
return (
<>
{/* Finished history prints once, directly to the terminal's real scrollback — never
* re-rendered, never height-clipped — which is what makes the terminal's own native mouse
* wheel scroll and click-drag text selection/copy work with zero app-side mouse tracking or
* virtual-scroll code (see index.tsx for why locode also stays off the alternate screen). */}
<Static items={staticItems}>
{(item) => (
<Box key={item.id} width={contentWidth} flexShrink={0}>
<HistoryItemView item={item} width={contentWidth} />
</Box>
)}
</Static>
<Box flexDirection="row" width="100%" alignItems="flex-end">
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
{streamingText !== null && (
<Box width={contentWidth} flexShrink={0}>
<HistoryItemView item={{ id: "streaming", kind: "streaming_text", text: streamingText }} width={contentWidth} />
</Box>
)}
{isThinking && streamingText === null && !permission && !exportPrompt && (
<ThinkingIndicator label={runningToolIsBash ? "thinking... (ctrl+b to background)" : undefined} />
)}
{permission ? (
<PermissionPrompt
toolName={permission.toolName}
args={permission.args}
preview={permission.preview}
onSelect={handlePermissionSelect}
/>
) : exportPrompt ? (
<ExportPrompt defaultName={exportPrompt.defaultName} onSubmit={handleExportSubmit} onCancel={handleExportCancel} />
) : phase === "starting" ? (
<ThinkingIndicator label="starting..." />
) : phase === "connecting" ? (
<ThinkingIndicator label="connecting..." />
) : phase === "loading-models" ? (
<ThinkingIndicator label="loading models..." />
) : phase === "session-select" ? (
<SessionSelect sessions={sessionList} onSelect={handleSessionSelect} />
) : phase === "model-select" ? (
<ModelSelect models={modelList} currentModel={suggestedModel} onSelect={handleModelSelect} />
) : (
<>
{sessionRef.current && phaseRef.current === "input" && (
<StatusBar
model={sessionRef.current.model}
mode={sessionRef.current.mode}
permMode={permMode}
cwd={cwd}
contextTokens={sessionRef.current.lastContextTokens}
contextWindow={sessionRef.current.contextWindow}
contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate}
sessionId={sessionRef.current.id}
createdAt={sessionRef.current.createdAt}
inputTokens={sessionRef.current.stats.inputTokens}
outputTokens={sessionRef.current.stats.outputTokens}
modelTimeMs={sessionRef.current.stats.modelTimeMs}
gitInfo={gitInfo}
/>
)}
{(isThinking || streamingText !== null) && (
<Text dimColor> Waiting for response… (esc to interrupt) — type ahead, Enter sends when done.</Text>
)}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={stableHandleSubmit}
cwd={cwd}
history={history}
availableColumns={chatColumns}
isActive={!filePanelFocused}
/>
</>
)}
</Box>
<FilePanel
visible={filePanelVisible}
activeTab={filePanelTab}
cwd={cwd}
touchedFiles={touchedFilesList}
width={FILE_PANEL_WIDTH}
height={terminalRows}
focused={filePanelFocused}
onExitFocus={() => setFilePanelFocused(false)}
/>
</Box>
</>
);
}