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>
1155 lines
52 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|