diff --git a/src/tui/app.tsx b/src/tui/app.tsx index c01222901..5e4ac6270 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1,77 +1,34 @@ import { Box, Text, useApp } from "ink"; -import type { AgentStatus, ContentBlockData } from "./use-stream.js"; -import { - classifyAgentSendFailure, - resolveSessionSpinnerLabel, - shouldSettleUiAfterSendFailure, -} from "./session-chrome.js"; +import type { ContentBlockData } from "./use-stream.js"; +import { resolveSessionSpinnerLabel } from "./session-chrome.js"; import type { EventEmitter } from "node:events"; import type { Agent } from "@intx/agent"; -import type { InboundMessage } from "@intx/types/runtime"; -import { useState, useMemo, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; +import { useState, useMemo, useEffect, useRef, type ReactNode } from "react"; import { useAgentStream } from "./use-stream.js"; import { Header } from "./components/header.js"; -import { - EventLog, - buildLinesIncremental, - buildResourceBanner, - clearMarkdownLineCache, - DEFAULT_MAX_RENDERED_LOG_LINES, - maxLineOffset, - TEXT_GUTTER, - resolveViewportExpandIds, - type IncrementalLinesState, - type RenderableBlock, -} from "./components/event-log.js"; -import type { StyledLine } from "./view/index.js"; +import { EventLog, TEXT_GUTTER, resolveViewportExpandIds } from "./components/event-log.js"; import { StatusBar } from "./components/status-bar.js"; import { useGitBranch } from "./git-branch.js"; -import { buildCostSummary, formatCostCommandOutput, formatStatusBarSegments } from "../cost/cost-summary.js"; -import { getActivePricingCache } from "../cost/cost-visibility.js"; +import { formatStatusBarSegments } from "../cost/cost-summary.js"; import { OnboardingAnimation } from "./components/onboarding-animation.js"; import { ChatInput } from "./components/chat-input.js"; import { createSentHistoryBrowse, - resetSentHistoryBrowse, sentHistoryOnEdit, stepSentHistoryDown, stepSentHistoryUp, type SentHistoryBrowse, } from "./sent-message-history.js"; -import { appendSentMessage, loadSentMessages } from "../session/sent-messages.js"; import { TaskView } from "./components/task-view.js"; import { GoalView } from "./components/goal-view.js"; import { hasActiveTasks } from "../agent/tasks.js"; -import { - goalShowsAcceptancePanel, - goalShowsWorkPrimary, -} from "../agent/goal.js"; -import { - INFERENCE_ABORT_INTERNAL_RECOVERY, - INFERENCE_ABORT_USER_STOP, - type InferenceAbortReason, -} from "../inference-abort.js"; -import { - activeStripSessions, - AgentsStrip, - agentsStripRowCount, - computeAgentsStripWindow, - DEFAULT_STRIP_MAX_VISIBLE, - mergeInFlightSubAgents, - shouldShowAgentsStrip, -} from "./components/agents-strip.js"; -import { - SubAgentSessionView, - subAgentScrollWindow, - subAgentTranscriptWidth, - renderTranscriptLines, -} from "./components/subagent-session-view.js"; +import { AgentsStrip } from "./components/agents-strip.js"; +import { SubAgentSessionView } from "./components/subagent-session-view.js"; import { ExitConfirm } from "./components/exit-confirm.js"; import { AgentModal, toAgentProviders, type ProviderFormSubmission } from "./components/agent-modal.js"; import { ModalStack } from "./components/modal-stack.js"; -import { PermissionsManager } from "./components/permissions-manager.js"; -import { SettingsOverlay, type CompactionMode } from "./components/settings-overlay.js"; -import { PluginsManager, type PluginsAdmin } from "./components/plugins-manager.js"; +import type { CompactionMode } from "./components/settings-overlay.js"; +import type { PluginsAdmin } from "./components/plugins-manager.js"; import type { PermissionsAdmin, ScopedApproval } from "../permission/admin.js"; import { InFlightIndicator } from "./components/in-flight-indicator.js"; import type { ProviderCatalogEntry } from "../config/index.js"; @@ -79,194 +36,57 @@ import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import { markOnboarded, resolveMaxConcurrentSubAgents, - tierDefinitionAt, type Settings, } from "../config/settings.js"; import { getLogger } from "@intx/log"; import type { SubAgentProvider, SubAgentSessionStore } from "../subagent/index.js"; import { useSpinner } from "./hooks/use-spinner.js"; -import { extraPromptChromeRows } from "./prompt-layout.js"; import { chromeDividerLine } from "./chrome-zones.js"; -import { shouldAutoRetryQuota } from "./quota-retry.js"; +import { useQuotaRetry } from "./hooks/use-quota-retry.js"; import { useSessionClock } from "./hooks/use-session-clock.js"; import { useRevolvingVerb } from "./hooks/use-revolving-verb.js"; import { color } from "./theme.js"; import { useTerminalSize } from "./hooks/use-terminal-size.js"; import { useGates } from "./hooks/use-gates.js"; -import { useScroll } from "./hooks/use-scroll.js"; import { useKeymap } from "./hooks/use-keymap.js"; import { useMouseScroll } from "./hooks/use-mouse-scroll.js"; -import { isExitCommand } from "./exit-command.js"; import { useMCPStatus } from "./hooks/use-mcp-status.js"; import { removeAgentProfile, upsertAgentProfile } from "./agent-profiles.js"; import { McpAuthPrompt } from "./components/mcp-auth-prompt.js"; -import { CodexLoginModal } from "./components/codex-login-modal.js"; -import { LoginProviderPicker } from "./components/login-provider-picker.js"; import { writeClipboard } from "./util/clipboard.js"; import { copyTargets, transcriptMarkdown, type CopyTarget } from "./copy.js"; import { useProviderManager } from "./hooks/use-provider-manager.js"; -import { startCodexLogin } from "../auth/codex/login.js"; -import { getValidCodexToken, CodexAuthError } from "../auth/codex/session.js"; -import { refreshCodexInstructions } from "../auth/codex/instructions.js"; -import { removeCodexProfile } from "../auth/codex/store.js"; -import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js"; -import { startXaiLogin } from "../auth/xai/login.js"; -import { getValidXaiToken, XaiAuthError } from "../auth/xai/session.js"; -import { removeXaiProfile } from "../auth/xai/store.js"; -import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../auth/xai/constants.js"; -import { codexProviderName, codexProfileFromProviderName } from "../config/codex-providers.js"; -import { xaiProviderName, xaiProfileFromProviderName } from "../config/xai-providers.js"; -import { fetchCodexUsage, fetchCodexModels, formatCodexUsage } from "../auth/codex/usage.js"; +import { fetchCodexUsage, formatCodexUsage } from "../auth/codex/usage.js"; import { fetchXaiUsage, formatXaiUsage } from "../auth/xai/usage.js"; import { useLayoutGeometry } from "./hooks/use-layout-geometry.js"; -import type { CommandResult } from "./commands/registry.js"; import { listCommands } from "./commands/registry.js"; import type { AgentProfile } from "../agent/profiles.js"; -import { writeFile, mkdir, unlink, readFile, opendir, realpath, stat } from "node:fs/promises"; -import { resolve, isAbsolute } from "node:path"; +import { writeFile, mkdir, unlink } from "node:fs/promises"; import type { LifecycleHookStatus } from "../session/hooks.js"; import type { WorkflowStatus, WorkflowControllerState } from "./workflow-controller.js"; import type { CapabilityName } from "../workflows/types.js"; -import { workflowKickoffUserMessage } from "../workflows/kickoff.js"; -import { goalKickoffUserMessage } from "../agent/goal.js"; -import { isSensitivePath } from "../plugins/secret-guard-plugin.js"; -import { createPathRestriction, type PathRestriction } from "../permission/path-restriction.js"; -import { createWorktreeRootsProvider } from "../permission/worktrees.js"; -import { - extractPastedImagePaths, - findImagePathMentions, - formatAttachmentSummary, - imageAttachmentFromPath, - readClipboardImage, - type PendingImageAttachment, -} from "./image-attachments.js"; +import { formatAttachmentSummary } from "./image-attachments.js"; import { setConfiguredTiers } from "./commands/built-in.js"; +import { useImageAttach } from "./hooks/use-image-attach.js"; +import { useAgentsStrip } from "./hooks/use-agents-strip.js"; +import { useCommandDispatch } from "./hooks/use-command-dispatch.js"; +import { useMessagePipeline } from "./hooks/use-message-pipeline.js"; +import { useCommandContext } from "./hooks/use-command-context.js"; +import { useTranscriptLayout } from "./hooks/use-transcript-layout.js"; +import { useProviderAuth } from "./hooks/use-provider-auth.js"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; - -const MAX_MENTION_FILE_BYTES = 200_000; -const MAX_MENTION_TOTAL_BYTES = 400_000; -const MAX_MENTION_COUNT = 5; -const MAX_DIRECTORY_SUMMARY_ENTRIES = 200; -const MAX_DIRECTORY_NAMES = 20; -type OutboundUserMessage = { - text: string; - attachments: PendingImageAttachment[]; -}; - -async function resolveMentionPath( - cwd: string, - path: string, - pathRestriction: PathRestriction, -): Promise<{ ok: true; abs: string } | { ok: false; reason: string }> { - if (path === "~" || path.startsWith("~/")) { - return { ok: false, reason: "home-relative paths are not supported" }; - } - - let abs: string; - try { - abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path)); - } catch { - return { ok: false, reason: "not found" }; - } - - if (pathRestriction.isRestricted(abs, false)) { - return { ok: false, reason: "outside workspace" }; - } - - return { ok: true, abs }; -} - -async function summarizeDir(abs: string): Promise { - let scanned = 0; - let files = 0; - let dirs = 0; - const dirNames: string[] = []; - const directory = await opendir(abs).catch(() => null); - if (directory === null) return "unreadable directory"; - - for await (const entry of directory) { - if (scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES) break; - scanned++; - if (entry.isFile()) files++; - if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { - dirs++; - if (dirNames.length < MAX_DIRECTORY_NAMES) dirNames.push(`${entry.name}/`); - } - } - - const dirList = dirNames.join(", "); - const parts: string[] = []; - if (files > 0) parts.push(`${files}${scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES ? "+" : ""} file${files === 1 ? "" : "s"}`); - if (dirs > 0) parts.push(`${dirs}${scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES ? "+" : ""} subdirector${dirs === 1 ? "y" : "ies"}${dirList ? ` (${dirList})` : ""}`); - return parts.length > 0 ? parts.join(", ") : "empty directory"; -} - -export async function resolveAtMentions(message: string, cwd: string): Promise { - const pattern = /@("([^"]+)"|(\S+))/g; - const mentions: Array<{ full: string; path: string }> = []; - let m: RegExpExecArray | null; - while ((m = pattern.exec(message)) !== null) { - const path = m[2] ?? m[3] ?? ""; - if (path.length > 0) mentions.push({ full: m[0], path }); - } - if (mentions.length === 0) return message; - - // Mirrors the permission gate's own containment check (see gate.ts): the - // gate resolves paths against cwd plus every registered git worktree of - // this session, so an @mention into a sibling worktree must resolve the - // same way rather than being wrongly rejected as an escape. - const pathRestriction = createPathRestriction(cwd, createWorktreeRootsProvider(cwd)); - const replacements: Array<{ full: string; replacement: string }> = []; - let totalBytes = 0; - - for (const [index, { full, path }] of mentions.entries()) { - if (index >= MAX_MENTION_COUNT) { - replacements.push({ full, replacement: `${full} (blocked: too many @mentions; max ${MAX_MENTION_COUNT})` }); - continue; - } - if (isSensitivePath(path)) { - replacements.push({ full, replacement: `${full} (blocked: sensitive path)` }); - continue; - } - const resolved = await resolveMentionPath(cwd, path, pathRestriction); - if (!resolved.ok) { - replacements.push({ full, replacement: `${full} (blocked: ${resolved.reason})` }); - continue; - } - if (isSensitivePath(resolved.abs)) { - replacements.push({ full, replacement: `${full} (blocked: sensitive path)` }); - continue; - } - try { - const info = await stat(resolved.abs); - if (info.isDirectory()) { - const summary = await summarizeDir(resolved.abs); - replacements.push({ full, replacement: `\`${path}\` (directory - ${summary})` }); - continue; - } - if (info.size > MAX_MENTION_FILE_BYTES) { - replacements.push({ full, replacement: `${full} (blocked: file is too large; max ${MAX_MENTION_FILE_BYTES} bytes)` }); - continue; - } - if (totalBytes + info.size > MAX_MENTION_TOTAL_BYTES) { - replacements.push({ full, replacement: `${full} (blocked: total @mention content is too large; max ${MAX_MENTION_TOTAL_BYTES} bytes)` }); - continue; - } - const content = await readFile(resolved.abs, "utf-8"); - totalBytes += info.size; - const ext = resolved.abs.split(".").pop() ?? ""; - replacements.push({ full, replacement: `\`${path}\`:\n\`\`\`${ext}\n${content}\n\`\`\`` }); - } catch { - replacements.push({ full, replacement: `${full} (not found)` }); - } - } - - let result = message; - for (const { full, replacement } of replacements) { - result = result.replace(full, () => replacement); - } - return result; -} +import { resolveAtMentions } from "./mention-resolution.js"; +import { STALL_TIMEOUT_MS, shouldAbortForStall, applyStallRecovery } from "./stall-watchdog.js"; +import { QuotaErrorBanner, GatewayRetryBanner } from "./components/retry-banners.js"; +import { OverlayStack } from "./components/overlay-stack.js"; +import { + resolveGoalChrome, + goalChromeRowCount, + taskChromeRowCount, + pluginChromeRowCount, + extraChromeRowCount, +} from "./chrome-geometry.js"; +import type { OutboundUserMessage } from "./message-types.js"; const EMPTY_WORKFLOW_STATUS: WorkflowStatus = { active: false, @@ -278,59 +98,6 @@ const EMPTY_WORKFLOW_STATUS: WorkflowStatus = { capabilities: [], }; -// How long the run can be continuously awaiting a response with no new content -// before the watchdog fires and aborts the in-flight request. -export const STALL_TIMEOUT_MS = 900_000; - - -export type ShouldAbortForStallArgs = { - status: AgentStatus; - awaitingResponse: boolean; - lastActivityAt: number; - nowMs: number; - stallTimeoutMs: number; - isProcessing: boolean; - streamingType: "text" | "thinking" | "tool" | null; -}; - -// Pure decision helper: returns true when the run is genuinely stuck and should -// be aborted. Extracted so the timeout logic is unit-testable without a React harness. -export function shouldAbortForStall({ - status, - awaitingResponse, - lastActivityAt, - nowMs, - stallTimeoutMs, - isProcessing, - streamingType, -}: ShouldAbortForStallArgs): boolean { - if (status !== "running") return false; - const stalled = nowMs - lastActivityAt >= stallTimeoutMs; - if (!stalled) return false; - if (awaitingResponse) return true; - // Mid-stream hang: model stream stalled after first token. Long - // in-flight tool runs do not emit parent stream events; do not abort those. - if ( - isProcessing && - streamingType !== null && - streamingType !== "tool" - ) { - return true; - } - return false; -} - -export type ApplyStallRecoveryDeps = { - abortInFlight: (reason: InferenceAbortReason) => void; - setCommandMessage: (message: string) => void; -}; - -/** Abort the in-flight send; ChatDirector continues via infer() on internal-recovery. */ -export function applyStallRecovery(deps: ApplyStallRecoveryDeps): void { - deps.abortInFlight(INFERENCE_ABORT_INTERNAL_RECOVERY); - deps.setCommandMessage("Recovering after an internal stall..."); -} - async function writeProfileFile(dir: string, profile: AgentProfile): Promise { await mkdir(dir, { recursive: true }); await writeFile(`${dir}/${profile.id}.json`, JSON.stringify(profile, null, 2), "utf8"); @@ -340,67 +107,6 @@ async function deleteProfileFile(dir: string, id: string): Promise { await unlink(`${dir}/${id}.json`).catch(() => {}); } -function sortedSetKey(ids: ReadonlySet): string { - const values: string[] = []; - ids.forEach((id) => { - values.push(id); - }); - return values.sort().join("\x1f"); -} - -function sameStringSet(a: ReadonlySet, b: ReadonlySet): boolean { - if (a.size !== b.size) return false; - return sortedSetKey(a) === sortedSetKey(b); -} - -function formatCountdown(ms: number): string { - if (ms <= 0) return "now"; - const totalSeconds = Math.ceil(ms / 1000); - const days = Math.floor(totalSeconds / 86400); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (days > 0) return `${String(days)}d ${String(hours)}h`; - if (hours > 0) return `${String(hours)}h ${String(minutes)}m`; - if (minutes > 0) return `${String(minutes)}m ${String(seconds)}s`; - return `${String(seconds)}s`; -} - -function QuotaErrorBanner({ retryAt }: { retryAt: number }): ReactNode { - const remaining = retryAt - Date.now(); - const expired = remaining <= 0; - return ( - - - {expired - ? "Rate limit reached — retrying…" - : `Rate limit reached — auto-retry in ${formatCountdown(remaining)}`} - - {"[/agent] Switch provider"} - - ); -} - -function GatewayRetryBanner({ - attempt, - retryAt, -}: { - attempt: number; - retryAt: number; -}): ReactNode { - const remaining = retryAt - Date.now(); - const expired = remaining <= 0; - return ( - - - {expired - ? `Inference gateway overloaded — retrying (attempt ${attempt})…` - : `Inference gateway overloaded — retrying (attempt ${attempt}) in ${formatCountdown(remaining)}`} - - - ); -} - export type AppProps = { eventEmitter: EventEmitter; agent: Agent; @@ -605,41 +311,6 @@ export function App({ const [enteredSessionId, setEnteredSessionId] = useState(null); const [agentModalOpen, setAgentModalOpen] = useState(false); const [agentModalUsage, setAgentModalUsage] = useState(null); - const [unauthedProviders, setUnauthedProviders] = useState>(() => new Set()); - const [loginModal, setLoginModal] = useState<"codex" | "xai" | "choose" | null>(null); - const [autoLoginProfile, setAutoLoginProfile] = useState(undefined); - // Updated every render so the stream callback always sees the current provider. - onCredentialFailureRef.current = () => { - if (loginModal !== null) return; - const xaiName = xaiProfileFromProviderName(provider); - const codexName = codexProfileFromProviderName(provider); - if (xaiName !== undefined) { - void getValidXaiToken(xaiName).then( - () => { - // Token is locally valid but the proxy returned 403 — subscription or - // account-level access issue, not a bad token. Re-authing won't help. - setCommandMessage( - `Grok 403: "${xaiName}" has a valid token but the proxy rejected the request. ` + - `Check your SuperGrok or X Premium+ subscription at grok.com.`, - ); - }, - (err: unknown) => { - if (err instanceof XaiAuthError) { - setAutoLoginProfile(xaiName); - setLoginModal("xai"); - } - }, - ); - } else if (codexName !== undefined) { - // Access token rejected by the provider (or refresh already dead): open - // the browser re-auth flow instead of leaving the user on a 401 banner. - setAutoLoginProfile(codexName); - setLoginModal("codex"); - } else { - setAutoLoginProfile(undefined); - setLoginModal("choose"); - } - }; const [permissionsOpen, setPermissionsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [liveTelemetryEnabled, setLiveTelemetryEnabled] = useState(telemetryEnabled); @@ -671,13 +342,11 @@ export function App({ const [goalSnapshot, setGoalSnapshot] = useState( () => goalApi?.get() ?? null, ); - // Messages queued while the agent is processing. Drained one-at-a-time when - // isProcessing goes false (connector.reply fires). Lives in React state so - // the drain path goes through sendMessage(), which correctly sets isProcessing. const pendingQueueRef = useRef([]); const tryDrainQueuedMessageRef = useRef<() => void>(() => {}); - const [queuedCount, setQueuedCount] = useState(0); - const [pendingImages, setPendingImages] = useState([]); + const sendMessageRef = useRef<(message: OutboundUserMessage) => void>(null!); + const { pendingImages, setPendingImages, handlePasteImage, handlePasteText } = + useImageAttach({ cwd, setCommandMessage }); const providerManager = useProviderManager({ initialProvider, @@ -728,128 +397,32 @@ export function App({ setConfiguredTiers(tiers); }, [tiers]); - const codexProfileNames = useMemo( - () => - providerCatalog - .map((p) => p.codexProfile) - .filter((name): name is string => name !== undefined), - [providerCatalog], - ); - const xaiProfileNames = useMemo( - () => - providerCatalog - .map((p) => p.xaiProfile) - .filter((name): name is string => name !== undefined), - [providerCatalog], - ); - - // Check which OAuth providers currently have valid tokens and update the - // unauthedProviders set. Called after login/logout and when the agent modal opens. - const refreshAuthState = (): void => { - const checks = providerCatalog.flatMap((p) => { - if (p.xaiProfile !== undefined) { - const profile = p.xaiProfile; - const providerName = p.name; - return [getValidXaiToken(profile).then( - () => ({ providerName, ok: true }), - () => ({ providerName, ok: false }), - )]; - } - return []; - }); - void Promise.all(checks).then((results) => { - const unauthed = new Set(results.filter((r) => !r.ok).map((r) => r.providerName)); - setUnauthedProviders(unauthed); - }); - }; - - - // Open the OAuth re-login modal for a dead/missing profile instead of dumping - // the raw 401. The modal's autoLoginProfile path starts the browser flow - // immediately so the user does not have to dig through a profile list first. - const promptCodexRelogin = (name: string): void => { - setAutoLoginProfile(name); - setLoginModal("codex"); - }; - const promptXaiRelogin = (name: string): void => { - setAutoLoginProfile(name); - setLoginModal("xai"); - }; - - const switchToCodexProfile = (name: string): void => { - void refreshCodexInstructions().catch(() => {}); - void Promise.all([getValidCodexToken(name), fetchCodexModels(name).catch(() => [])]).then( - ([token, liveModels]) => { - const accountId = token.accountId; - // Prefer the account's live model catalog; fall back to the current - // default set when empty (e.g. while rate-limited the catalog is empty). - const models = liveModels.length > 0 ? liveModels : [...CODEX_DEFAULT_MODELS]; - const defaultModel = models[0] ?? CODEX_DEFAULT_MODELS[0]; - registerCodexProvider({ - name: codexProviderName(name), - baseURL: CODEX_BASE_URL, - apiKey: token.access, - models, - defaultModel, - codexProfile: name, - ...(accountId !== undefined ? { codexAccountId: accountId } : {}), - }); - }, - (err: unknown) => { - // Refresh/token missing: drop the user into the browser re-auth flow - // rather than surfacing the provider's 401 JSON as a status line. - if (err instanceof CodexAuthError) { - promptCodexRelogin(name); - return; - } - setCommandMessage( - `Could not use Codex profile "${name}": ${err instanceof Error ? err.message : String(err)}`, - ); - }, - ); - }; - - const removeCodexProfileEverywhere = (name: string): void => { - removeCodexProvider(codexProviderName(name)); - void removeCodexProfile(name).then( - () => setCommandMessage(`Removed Codex profile "${name}".`), - (err: unknown) => setCommandMessage(`Failed to remove Codex profile "${name}": ${err instanceof Error ? err.message : String(err)}`), - ); - }; - - const switchToXaiProfile = (name: string): void => { - void getValidXaiToken(name).then( - (token) => { - const defaultModel = XAI_DEFAULT_MODELS[0]; - registerXaiProvider({ - name: xaiProviderName(name), - baseURL: XAI_BASE_URL, - apiKey: token.access, - models: [...XAI_DEFAULT_MODELS], - defaultModel, - xaiProfile: name, - }); - refreshAuthState(); - }, - (err: unknown) => { - if (err instanceof XaiAuthError) { - promptXaiRelogin(name); - return; - } - setCommandMessage( - `Could not use xAI profile "${name}": ${err instanceof Error ? err.message : String(err)}`, - ); - }, - ); - }; - - const removeXaiProfileEverywhere = (name: string): void => { - removeXaiProvider(xaiProviderName(name)); - void removeXaiProfile(name).then( - () => setCommandMessage(`Removed xAI profile "${name}".`), - (err: unknown) => setCommandMessage(`Failed to remove xAI profile "${name}": ${err instanceof Error ? err.message : String(err)}`), - ); - }; + const { + unauthedProviders, + setUnauthedProviders, + loginModal, + setLoginModal, + autoLoginProfile, + setAutoLoginProfile, + codexProfileNames, + xaiProfileNames, + refreshAuthState, + promptCodexRelogin, + promptXaiRelogin, + switchToCodexProfile, + switchToXaiProfile, + removeCodexProfileEverywhere, + removeXaiProfileEverywhere, + } = useProviderAuth({ + provider, + providerCatalog, + registerCodexProvider, + registerXaiProvider, + removeCodexProvider, + removeXaiProvider, + setCommandMessage, + onCredentialFailureRef, + }); const [profiles, setProfiles] = useState(initialProfiles); @@ -905,58 +478,38 @@ export function App({ return () => { eventEmitter.off("goal", onGoal); }; }, [eventEmitter]); - useEffect(() => { - if (subAgentSessions === undefined) return; - return subAgentSessions.subscribe(() => { - setSessionsTick((n) => n + 1); - tryDrainQueuedMessageRef.current(); - }); - }, [subAgentSessions]); - - // The strip reflects only active work: an agent leaves the visible list the - // moment it reaches a terminal state. Completed sessions stay in the store - // for later inspection but no longer occupy the strip. - const agentSessions = useMemo(() => { - void sessionsTick; - const merged = mergeInFlightSubAgents( - subAgentSessions?.listForStrip() ?? [], - state.subAgents, - ); - return activeStripSessions(merged); - }, [subAgentSessions, sessionsTick, state.subAgents]); - - // Ctrl+E browses the full strip surface (running + recent completed). The - // chrome strip filters to running only; nav must still reach finished sessions - // for inspection — otherwise a just-finished child vanishes from Ctrl+E while - // the live-progress fallback can still paint a ghost "doing" row. - const browseSessions = useMemo(() => { - void sessionsTick; - return subAgentSessions?.listForStrip() ?? []; - }, [subAgentSessions, sessionsTick]); - - // A running agent can reach a terminal state while agents-nav is open, which - // shortens the strip list under the persisted selection index. Clamp at read - // time so the highlight lands on a real row instead of drifting out of range. - const agentsNavList = agentsNavOpen ? browseSessions : agentSessions; - const agentsNavIndexClamped = - agentsNavList.length === 0 ? 0 : Math.min(agentsNavIndex, agentsNavList.length - 1); - - const enteredSession = useMemo(() => { - void sessionsTick; - if (enteredSessionId === null || subAgentSessions === undefined) return undefined; - return subAgentSessions.get(enteredSessionId); - }, [enteredSessionId, subAgentSessions, sessionsTick]); + const { + agentSessions, + browseSessions, + agentsNavList, + agentsNavIndexClamped, + enteredSession, + activeSubAgents, + activeSubAgentsRef, + queuedCount, + setQueuedCount, + hasRunningSubAgentSessions, + steerOnEnter, + agentsStripVisible, + agentsStripScrollWindow, + agentsStripRows, + } = useAgentsStrip({ + eventEmitter, + subAgentSessions, + sessionsTick, + setSessionsTick, + state, + stateRef, + sendMessageRef, + pendingQueueRef, + tryDrainQueuedMessageRef, + agentsNavOpen, + agentsNavIndex, + enteredSessionId, + }); + const subAgentChromeRows = agentsStripRows; - // Goal chrome follows lifecycle phase: - // planning / reviewing / completed → Acceptance panel - // implementing → Work primary (Acceptance compact; header shows phase) - const goalActive = - goalSnapshot !== null && - goalSnapshot.status !== "inactive" && - goalSnapshot.status !== "cleared"; - const goalPhase = goalActive ? goalSnapshot!.phase : null; - const showAcceptance = goalPhase !== null && goalShowsAcceptancePanel(goalPhase); - const workPrimary = goalPhase !== null && goalShowsWorkPrimary(goalPhase); + const { goalActive, goalPhase, showAcceptance, workPrimary } = resolveGoalChrome({ goalSnapshot }); // Default-expand Work when entering implementing; Ctrl+T can still collapse. const wasWorkPrimary = useRef(false); useEffect(() => { @@ -966,79 +519,31 @@ export function App({ wasWorkPrimary.current = workPrimary; }, [workPrimary]); const workExpanded = tasksExpanded; - const goalChromeRows = !goalActive - ? 0 - : showAcceptance - ? (goalSnapshot!.criteria.length === 0 - ? 2 - : goalSnapshot!.criteria.length + 2) + 2 - : 3; // compact phase strip during implementing - // The task strip renders above the in-flight indicator: one line when compact, - // the full checklist plus its heading when expanded. +1 is the marginTop wrapper. - const taskChromeRows = - !hasActiveTasks(state.tasks) - ? 0 - : (workExpanded ? state.tasks.length + 1 : 1) + 1; - - // The plugins overlay renders outside the modal-stack accounting (like the - // permissions overlay), so reserve rows for its box: chrome + one row per - // plugin + the selected plugin's credential rows. - const pluginChromeRows = (() => { - if (!pluginsOpen || pluginsAdmin === undefined) return 0; - const list = pluginsAdmin.list(); - const widestCreds = list.reduce((n, p) => Math.max(n, p.credentials.length), 0); - return 6 + list.length + widestCreds + 2; - })(); - - // Agents strip (session store) + live progress fallback for chrome height. - // Prefer the session store list once anything has been spawned this session. - const activeSubAgents = useMemo( - () => state.subAgents.filter((a) => a.status !== "done" && a.status !== "cancelled"), - [state.subAgents], - ); - const activeSubAgentsRef = useRef(activeSubAgents); - activeSubAgentsRef.current = activeSubAgents; - const hasRunningSubAgentSessions = (): boolean => - subAgentSessions?.list().some((session) => session.status === "running") ?? false; - const steerOnEnter = - state.isProcessing && activeSubAgents.length === 0 && !hasRunningSubAgentSessions(); - // The strip caps rendered rows so retained history never crowds out the - // transcript; +1 accounts for the surrounding marginTop wrapper. When nav is - // open the list may include completed sessions, so size against browseSessions. - const agentsStripVisible = shouldShowAgentsStrip({ - chromeSessions: agentSessions, - browseSessions, - agentsNavOpen, + const goalChromeRows = goalChromeRowCount({ + goalActive, + showAcceptance, + criteriaCount: goalSnapshot?.criteria.length ?? 0, + }); + const taskChromeRows = taskChromeRowCount({ + hasActiveTasks: hasActiveTasks(state.tasks), + taskCount: state.tasks.length, + workExpanded, + }); + const pluginChromeRows = pluginChromeRowCount({ pluginsOpen, pluginsAdmin }); + + const extraChromeRows = extraChromeRowCount({ + mcpNeedsAuthCount: mcpStatus.needsAuth.length, + commandMessagePresent: commandMessage !== null, + goalChromeRows, + taskChromeRows, + pluginChromeRows, + quotaErrorPresent: state.quotaError !== null, + inferenceRetryPresent: state.inferenceRetry !== null, + subAgentChromeRows, + inputValue, + columns, + rows, }); - const agentsStripScrollWindow = - agentsNavOpen && browseSessions.length > DEFAULT_STRIP_MAX_VISIBLE - ? computeAgentsStripWindow( - browseSessions.length, - agentsNavIndexClamped, - DEFAULT_STRIP_MAX_VISIBLE, - ) - : undefined; - const agentsStripRows = agentsStripVisible - ? agentsNavOpen && browseSessions.length > 0 - ? agentsStripRowCount( - browseSessions.length, - DEFAULT_STRIP_MAX_VISIBLE, - agentsStripScrollWindow, - ) + 1 - : agentsStripRowCount(agentSessions.length, DEFAULT_STRIP_MAX_VISIBLE) + 1 - : 0; - const subAgentChromeRows = agentsStripRows; - - const extraChromeRows = - (mcpStatus.needsAuth.length > 0 ? 1 : 0) + - (commandMessage !== null ? 1 : 0) + - goalChromeRows + - taskChromeRows + - pluginChromeRows + - (state.quotaError !== null ? 1 : 0) + - (state.inferenceRetry !== null ? 1 : 0) + - subAgentChromeRows + - extraPromptChromeRows(inputValue, columns ?? 80, rows ?? 24); const activePermission = gates.activeApproval?.kind === "permission" ? gates.activeApproval.request @@ -1073,206 +578,31 @@ export function App({ // Cleared when layout width or thinking expand change — those affect all blocks. // Verbose no longer invalidates the cache: each block already keys collapsed vs // expanded layouts separately, and Ctrl+O only expands a viewport-local subset. - const lineCacheRef = useRef(new Map()); - const baseLinesRef = useRef(undefined); - const incrementalLinesRef = useRef(undefined); - const lineCacheKeysRef = useRef({ contentWidth, thinkingExpanded }); - if ( - lineCacheKeysRef.current.contentWidth !== contentWidth || - lineCacheKeysRef.current.thinkingExpanded !== thinkingExpanded - ) { - lineCacheRef.current.clear(); - clearMarkdownLineCache(); - baseLinesRef.current = undefined; - incrementalLinesRef.current = undefined; - lineCacheKeysRef.current = { contentWidth, thinkingExpanded }; - } - - // Tools Ctrl+O expands for the current viewport (± buffer). Refreshed after - // scroll in a layout effect so line layout can depend on a stable Set. - const [viewportExpandedIds, setViewportExpandedIds] = useState>(() => new Set()); - - const explicitExpandKey = useMemo( - () => sortedSetKey(expandedTools), - [expandedTools], - ); - - const baseLayoutKey = useMemo( - () => [ - contentWidth, - thinkingExpanded ? "1" : "0", - explicitExpandKey, - String(state.currentPlanStep), - state.planDeviated ? "1" : "0", - ].join("|"), - [contentWidth, thinkingExpanded, explicitExpandKey, state.currentPlanStep, state.planDeviated], - ); - - const isExplicitlyExpanded = useMemo( - () => (block: RenderableBlock) => expandedTools.has(block.id), - [expandedTools], - ); - - // Collapsed layout (explicit Ctrl+R expands only). Reused as the display when - // verbose is off so toggling Ctrl+O does not throw away the warm incremental state. - const membershipBase = useMemo( - () => { - const next = buildLinesIncremental( - baseLinesRef.current, - state.contentBlocks, - contentWidth, - thinkingExpanded, - isExplicitlyExpanded, - lineCacheRef.current, - { currentStep: state.currentPlanStep, deviated: state.planDeviated }, - baseLayoutKey, - DEFAULT_MAX_RENDERED_LOG_LINES, - ); - baseLinesRef.current = next; - return next; - }, - // lineCacheRef is a stable ref — intentionally not in the dep array. - // eslint-disable-next-line react-hooks/exhaustive-deps - [state.displayRevision, baseLayoutKey, contentWidth, thinkingExpanded, isExplicitlyExpanded, state.currentPlanStep, state.planDeviated], - ); - - const resourceBanner = useMemo( - () => buildResourceBanner(loadedSkills ?? [], activePlugins ?? [], contentWidth, cwd, telemetryNotice), - [loadedSkills, activePlugins, contentWidth, cwd, telemetryNotice], - ); - - const prefixLineCount = - resourceBanner.length + (state.trimmedBlockCount > 0 ? 2 : 0); - - const viewportExpandKey = useMemo( - () => { - if (!verbose || viewportExpandedIds.size === 0) return ""; - return sortedSetKey(viewportExpandedIds); - }, - [verbose, viewportExpandedIds], - ); - - const linesLayoutKey = useMemo( - () => [ - baseLayoutKey, - verbose ? "1" : "0", - viewportExpandKey, - ].join("|"), - [baseLayoutKey, verbose, viewportExpandKey], - ); - - const isViewportExpanded = useMemo( - () => { - if (!verbose || viewportExpandedIds.size === 0) return isExplicitlyExpanded; - return (block: RenderableBlock) => - expandedTools.has(block.id) || viewportExpandedIds.has(block.id); - }, - [verbose, viewportExpandedIds, expandedTools, isExplicitlyExpanded], - ); - - const eventLogLines = useMemo( - () => { - let next: IncrementalLinesState; - if (!verbose) { - next = membershipBase; - incrementalLinesRef.current = next; - } else { - next = buildLinesIncremental( - incrementalLinesRef.current, - state.contentBlocks, - contentWidth, - thinkingExpanded, - isViewportExpanded, - lineCacheRef.current, - { currentStep: state.currentPlanStep, deviated: state.planDeviated }, - linesLayoutKey, - DEFAULT_MAX_RENDERED_LOG_LINES, - ); - incrementalLinesRef.current = next; - } - return state.trimmedBlockCount > 0 - ? [ - ...resourceBanner, - [ - { text: `↑ ${state.trimmedBlockCount} earlier message${state.trimmedBlockCount === 1 ? "" : "s"} trimmed to keep the session responsive`, dim: true }, - ] satisfies StyledLine, - [], - ...next.lines, - ] - : [...resourceBanner, ...next.lines]; - }, - // lineCacheRef is a stable ref — intentionally not in the dep array. - // eslint-disable-next-line react-hooks/exhaustive-deps - [state.displayRevision, state.trimmedBlockCount, membershipBase, linesLayoutKey, contentWidth, thinkingExpanded, verbose, isViewportExpanded, state.currentPlanStep, state.planDeviated, resourceBanner], - ); - const scrollMaxOffset = maxLineOffset(eventLogLines, visibleRows); - - const lastToolId = useMemo(() => { - const blocks = state.contentBlocks; - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i]?.type === "tool_call") return blocks[i]!.id; - } - return null; - }, [state.contentBlocks]); - - const scroll = useScroll({ maxOffset: scrollMaxOffset }); - - // The entered child view owns its own scroll: the parent transcript and the - // child transcript have unrelated line counts, so one shared offset would - // scroll the hidden parent while the child stayed pinned to its newest rows. - const enteredTranscriptLineCount = useMemo(() => { - if (enteredSession === undefined) return 0; - return renderTranscriptLines( - enteredSession.entries, - subAgentTranscriptWidth(contentWidth), - ).length; - }, [enteredSession, contentWidth]); - const enteredScrollMaxOffset = subAgentScrollWindow( - enteredTranscriptLineCount, - visibleRows, - 0, - ).maxOffset; - const enteredScroll = useScroll({ maxOffset: enteredScrollMaxOffset }); - const activeScroll = enteredSession !== undefined ? enteredScroll : scroll; - - // Ctrl+O expands tools intersecting the visible window. Membership uses the - // *display* layout (same line space as scrollOffset) so mid-scroll tracking - // stays correct after tools grow. Sticky hold + tool-count cap keep the set - // from thrashing or exploding under dense tool rows. Toggle seeds the set - // synchronously so the first verbose paint is already expanded. - useLayoutEffect(() => { - if (!verbose) { - if (viewportExpandedIds.size > 0) setViewportExpandedIds(new Set()); - return; - } - - const layout = incrementalLinesRef.current; - if (layout === undefined) return; - - const nextIds = resolveViewportExpandIds({ - blocks: layout.blocks, - blockLineStarts: layout.blockLineStarts, - lineCount: layout.lines.length, - prefixLineCount, - visibleRows, - scrollOffset: scroll.scrollOffset, - atBottom: scroll.atBottom, - previousIds: viewportExpandedIds, - }); - - if (sameStringSet(nextIds, viewportExpandedIds)) return; - setViewportExpandedIds(nextIds); - }, [ - verbose, - scroll.scrollOffset, - scroll.atBottom, - visibleRows, - // Recompute when either layout changes (content, expand set, prefix). - membershipBase, + const { eventLogLines, - prefixLineCount, + scrollMaxOffset, + scroll, + enteredScroll, + activeScroll, + lastToolId, viewportExpandedIds, - ]); + setViewportExpandedIds, + prefixLineCount, + incrementalLinesRef, + baseLinesRef, + } = useTranscriptLayout({ + state, + contentWidth, + thinkingExpanded, + expandedTools, + verbose, + visibleRows, + loadedSkills, + activePlugins, + cwd, + telemetryNotice, + enteredSession, + }); // Scanning every block on each render walks the whole transcript on keystrokes // and scroll ticks, so the stream state tracks this incrementally instead. @@ -1308,206 +638,71 @@ export function App({ const copyTargetsRef = useRef([]); const copyTargetList = copyModeOpen ? copyTargetsRef.current : []; - // One controller per in-flight send so Ctrl+C / double-Esc can abort the - // active run. Aborting rejects the send promise; the reactor's current cycle - // finishes but no new cycle starts, which is the "Stopping" → "Stopped" path. - const sendAbortRef = useRef(null); const [, forceRender] = useState(0); - const didSendInitial = useRef(false); - const firstUserMessageFired = useRef(false); - // Incremented on every send so useSpinner can reset its elapsed clock per turn. - const sendCounterRef = useRef(0); - const lastSentMessageRef = useRef(""); - const quotaAutoRetryFiredRef = useRef(false); - - const sendMessageRef = useRef<(message: OutboundUserMessage) => void>(null!); - sendMessageRef.current = (message: OutboundUserMessage) => { - lastSentMessageRef.current = message.text; - const trimmed = message.text.trim(); - if (trimmed.length > 0 && getSessionId !== undefined) { - const sid = getSessionId(); - void appendSentMessage(cwd, sid, trimmed).then(() => { - setSentHistoryBrowse((prev) => resetSentHistoryBrowse([...prev.sent, trimmed])); - }); - } - quotaAutoRetryFiredRef.current = false; - sendCounterRef.current += 1; - state.markRunning(); - scroll.scrollToBottom(); - - // Append the user message to the transcript immediately (optimistic echo). - // This ensures the input is visible even when send() is delayed by pre-send - // work such as Codex/XAI token refresh. The subsequent message.received will - // no-op the duplicate push. - const displayContent = message.text.length > 0 ? message.text : "Please inspect the attached image."; - const attachmentText = message.attachments.length > 0 - ? `\n[Attached ${message.attachments.length} image${message.attachments.length === 1 ? "" : "s"}: ${message.attachments.map((att) => att.name).join(", ")}]` - : ""; - state.appendUserMessage(`${displayContent}${attachmentText}`); - - // Nudge a re-render so the in-flight indicator and interval timer activate - // immediately rather than waiting for the first event from the new run. - forceRender((n) => n + 1); - const controller = new AbortController(); - sendAbortRef.current = controller; - const inbound: InboundMessage = { - ref: { uid: 1, mailbox: "INBOX" }, - headers: { - from: "user@local", - to: ["agent@local"], - date: new Date().toISOString(), - messageId: `<${crypto.randomUUID()}@local>`, - interchangeType: "conversation.message", - }, - flags: [], - signatureStatus: "missing", - content: message.text.length > 0 ? message.text : "Please inspect the attached image.", - ...(message.attachments.length > 0 ? { attachments: message.attachments } : {}), - }; - agent.send(inbound, { signal: controller.signal }).catch((err: unknown) => { - const kind = classifyAgentSendFailure( - err, - controller.signal.aborted, - (e): e is CodexAuthError => e instanceof CodexAuthError, - (e): e is XaiAuthError => e instanceof XaiAuthError, - ); - if (kind === "abort") return; - if (shouldSettleUiAfterSendFailure(kind)) { - state.requestStop(); - gates.resetGates(); - forceRender((n) => n + 1); - } - if (kind === "codex_auth") { - promptCodexRelogin((err as CodexAuthError).profile); - return; - } - if (kind === "xai_auth") { - promptXaiRelogin((err as XaiAuthError).profile); - return; - } - onAgentError?.(err); - }); - }; - const sendMessage = (message: OutboundUserMessage) => sendMessageRef.current(message); - - tryDrainQueuedMessageRef.current = () => { - if (stateRef.current.status === "blocked") return; - if (stateRef.current.isProcessing) return; - if (activeSubAgentsRef.current.length > 0) return; - if (hasRunningSubAgentSessions()) return; - if (pendingQueueRef.current.length === 0) return; - const next = pendingQueueRef.current.shift()!; - setQueuedCount((c) => Math.max(0, c - 1)); - sendMessageRef.current(next); - }; - - const requestStop = () => { - quotaAutoRetryFiredRef.current = true; - sendAbortRef.current?.abort(INFERENCE_ABORT_USER_STOP); - // Parent stop must cancel live children too: aborting the parent send signal - // is linked into each task's child controller, and cancelAll flips session - // status + fires registerCancel hooks that close child agents. - subAgentSessions?.cancelAll("Parent stop"); - onInterrupt?.(); - state.requestStop(); - gates.resetGates(); - // Discard queued messages — a stopped run should not silently replay them - // into the next session's first turn when connector.reply eventually fires. - pendingQueueRef.current.length = 0; - setQueuedCount(0); - // Clear the last-sent prompt so the quota auto-retry loop cannot resubmit - // the interrupted turn once its retry-after window elapses; the agent is - // rebuilt from the persisted store on interrupt, and replaying the prompt - // on top of that would duplicate the turn's tool executions. - lastSentMessageRef.current = ""; - forceRender((n) => n + 1); - }; - - requestStopRef.current = requestStop; - - const startNewSessionRef = useRef<() => void>(() => undefined); - startNewSessionRef.current = () => { - sendAbortRef.current?.abort(); - // Cancel live workers before clearing the strip so child reactors close - // instead of continuing after /clear. - subAgentSessions?.cancelAll("New session"); - state.clear(); - gates.resetGates(); - setExpandedTools(new Set()); - pendingQueueRef.current.length = 0; - setQueuedCount(0); - // Same guard as requestStop: a cleared session must not auto-resubmit a - // prior prompt when the quota retry interval is still polling. - lastSentMessageRef.current = ""; - quotaAutoRetryFiredRef.current = true; - setWorkflowHistory([]); - setInputValue(""); - setSessionStartedAt(Date.now()); - setEnteredSessionId(null); - setAgentsNavOpen(false); - setAgentsNavIndex(0); - subAgentSessions?.clear(); - onNewSession?.(); - if (getSessionId !== undefined) { - void loadSentMessages(cwd, getSessionId()).then((sent) => { - setSentHistoryBrowse(createSentHistoryBrowse(sent)); - }); - } else { - setSentHistoryBrowse(createSentHistoryBrowse([])); - } - scroll.scrollToBottom(); - forceRender((n) => n + 1); - }; - const startNewSession = () => startNewSessionRef.current(); + // Whole-session timer for the status bar. Held in state so /new can zero it. + const [sessionStartedAt, setSessionStartedAt] = useState(sessionStartedAtProp ?? Date.now()); - const getCostSummary = () => { - const activeProvider = providerCatalog.find((p) => p.name === provider); - return buildCostSummary({ - modelId: modelRef.current, - baseURL: activeProvider?.baseURL, - providerFree: activeProvider?.free, - pricingCache: getActivePricingCache(), - totalCost: state.totalCost, - formattedCost: state.formattedCost, - inputTokens: state.inputTokens, - outputTokens: state.outputTokens, - cacheReadTokens: state.cacheReadTokens, - contextTokens: state.contextTokens, - }); - }; - // commandContext below is memoized, so it would otherwise capture a stale - // getCostSummary closure (provider/state from an old render). Routing the - // call through a ref updated every render keeps the memoized context reading - // live values, matching the signalClear/startNewSessionRef pattern. - const getCostSummaryRef = useRef(getCostSummary); - getCostSummaryRef.current = getCostSummary; + const { + sendMessage, + requestStop, + startNewSessionRef, + prepareOutboundMessage, + handleSend, + handleInterrupt, + sendAbortRef, + sendCounterRef, + lastSentMessageRef, + quotaAutoRetryFiredRef, + } = useMessagePipeline({ + cwd, + agent, + getSessionId, + exit, + onFirstUserMessage, + onInterrupt, + onNewSession, + onAgentError, + skipInitialTask, + initialTask, + state, + stateRef, + scroll, + gates, + subAgentSessions, + activeSubAgentsRef, + hasRunningSubAgentSessions, + pendingQueueRef, + setQueuedCount, + pendingImages, + setPendingImages, + setCommandMessage, + setSentHistoryBrowse, + promptCodexRelogin, + promptXaiRelogin, + setExpandedTools, + setWorkflowHistory, + setInputValue, + setSessionStartedAt, + setEnteredSessionId, + setAgentsNavOpen, + setAgentsNavIndex, + forceRender, + sendMessageRef, + requestStopRef, + }); - const commandContext = useMemo(() => ({ - signalClear: () => startNewSessionRef.current(), - getMCPServers: () => mcpStatus.servers, - getCostSummary: () => getCostSummaryRef.current(), - ...(onStartWorkflow !== undefined ? { startWorkflow: onStartWorkflow } : {}), - ...(onRenameSession !== undefined ? { renameSession: onRenameSession } : {}), - ...(goalApi !== undefined - ? { - goal: { - get: goalApi.get, - set: goalApi.set, - pause: goalApi.pause, - resume: goalApi.resume, - clear: goalApi.clear, - kickoff: (condition: string, phase: "set" | "resume" = "set") => { - // Start a turn immediately so the agent works without a second prompt. - // Set path forces clarify-first for vague goals; resume continues. - sendMessageRef.current({ - text: goalKickoffUserMessage(condition, phase), - attachments: [], - }); - }, - }, - } - : {}), - }), [mcpStatus.servers, onStartWorkflow, onRenameSession, goalApi]); + const { getCostSummary, commandContext } = useCommandContext({ + provider, + providerCatalog, + modelRef, + state, + mcpServers: mcpStatus.servers, + startNewSessionRef, + onStartWorkflow, + onRenameSession, + goalApi, + sendMessageRef, + }); // Watchdog: if the run stays in the awaiting-response gap beyond STALL_TIMEOUT_MS // with no new content, abort the in-flight request and surface a message so the @@ -1536,155 +731,7 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.status, state.awaitingResponse]); - // When a quota error is active, poll once per second and auto-resubmit the - // last prompt as soon as the provider's retry-after window expires. - useEffect(() => { - if (state.quotaError === null) return; - const interval = setInterval(() => { - if ( - !shouldAutoRetryQuota({ - quotaError: stateRef.current.quotaError, - alreadyFired: quotaAutoRetryFiredRef.current, - nowMs: Date.now(), - lastSentMessage: lastSentMessageRef.current, - }) - ) { - return; - } - quotaAutoRetryFiredRef.current = true; - sendMessageRef.current({ text: lastSentMessageRef.current, attachments: [] }); - }, 1000); - return () => clearInterval(interval); - // `state` is a stable mutable object — only `quotaError` drives re-subscription. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.quotaError]); - - // Drain one queued message when the orchestrator is idle and no sub-agents run. - useEffect(() => { - const onEvent = (event: { type: string }) => { - if (event.type !== "connector.reply") return; - tryDrainQueuedMessageRef.current(); - }; - eventEmitter.on("event", onEvent); - return () => { eventEmitter.off("event", onEvent); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Send the initial task once the App (and its gate listeners) is mounted, so - // the run is driven through the same abortable path as interactive sends. - useEffect(() => { - if (getSessionId === undefined) return; - void loadSentMessages(cwd, getSessionId()).then((sent) => { - setSentHistoryBrowse(createSentHistoryBrowse(sent)); - }); - }, [cwd, getSessionId]); - - useEffect(() => { - if (didSendInitial.current) return; - didSendInitial.current = true; - if (skipInitialTask) return; - if (initialTask.length > 0) sendMessage({ text: initialTask, attachments: [] }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const addPendingImage = (attachment: PendingImageAttachment): void => { - setPendingImages((prev) => [...prev, attachment]); - setCommandMessage(`Attached image: ${attachment.name}`); - }; - - const handlePasteImage = (): void => { - setCommandMessage("Reading clipboard image..."); - void readClipboardImage().then((result) => { - if (!result.ok) { - setCommandMessage(`Image paste failed: ${result.reason}`); - return; - } - addPendingImage(result.attachment); - }); - }; - - const handlePasteText = (text: string): boolean => { - const paths = extractPastedImagePaths(text, cwd); - if (paths.length === 0) return false; - setCommandMessage(`Attaching ${paths.length} image${paths.length === 1 ? "" : "s"}...`); - void Promise.all(paths.map((path) => imageAttachmentFromPath(path))).then((results) => { - const attached = results.filter((result): result is { ok: true; attachment: PendingImageAttachment } => result.ok); - const failed = results.length - attached.length; - if (attached.length > 0) { - setPendingImages((prev) => [...prev, ...attached.map((result) => result.attachment)]); - } - setCommandMessage( - failed > 0 - ? `Attached ${attached.length} image${attached.length === 1 ? "" : "s"}; ${failed} failed.` - : `Attached ${attached.length} image${attached.length === 1 ? "" : "s"}.`, - ); - }); - return true; - }; - - const prepareOutboundMessage = async ( - message: string, - baseAttachments: PendingImageAttachment[], - ): Promise => { - let text = message; - const mentions = findImagePathMentions(message, cwd); - const loaded = await Promise.all(mentions.map((mention) => imageAttachmentFromPath(mention.path))); - const attachments = [...baseAttachments]; - for (let i = 0; i < mentions.length; i++) { - const mention = mentions[i]; - const result = loaded[i]; - if (mention === undefined || result === undefined || !result.ok) continue; - attachments.push(result.attachment); - text = text.replace(mention.raw, `[Attached image: ${result.attachment.name}]`); - } - return { text: await resolveAtMentions(text, cwd), attachments }; - }; - - const handleSend = (message: string) => { - if (isExitCommand(message)) { - exit(); - return; - } - if (!firstUserMessageFired.current) { - firstUserMessageFired.current = true; - onFirstUserMessage?.(); - } - setCommandMessage(null); - const attachments = pendingImages; - setPendingImages([]); - void prepareOutboundMessage(message, attachments).then((outbound) => { - // Read live state from the ref — prepareOutboundMessage is async (it does - // @-mention resolution + disk I/O), so the closed-over state.isProcessing - // can be stale by the time this resolves. A previous turn can finish and - // drain the queue during the async window; reading the stale value would - // then queue a message nothing will ever drain, leaving the UI stuck. - const childWorkActive = - activeSubAgentsRef.current.length > 0 || hasRunningSubAgentSessions(); - if (stateRef.current.isProcessing || childWorkActive) { - pendingQueueRef.current.push(outbound); - setQueuedCount((c) => c + 1); - return; - } - sendMessage(outbound); - }); - }; - - const handleInterrupt = (message: string) => { - if (isExitCommand(message)) { - requestStop(); - exit(); - return; - } - setCommandMessage(null); - // requestStop must fire synchronously before any async work so the abort - // signal reaches the in-flight HTTP request before at-mention resolution - // has a chance to yield, preventing a stale connector.reply from racing - // the new turn's state. - requestStop(); - const attachments = pendingImages; - setPendingImages([]); - void prepareOutboundMessage(message, attachments).then(sendMessage); - }; + useQuotaRetry({ state, stateRef, lastSentMessageRef, quotaAutoRetryFiredRef, sendMessageRef }); // Spin for the full duration of a send cycle (markRunning → connector.reply). // The label tracks the live phase so "Thinking…" is reserved for reasoning @@ -1699,8 +746,6 @@ export function App({ streamingType: state.streamingType, }); - // Whole-session timer for the status bar. Held in state so /new can zero it. - const [sessionStartedAt, setSessionStartedAt] = useState(sessionStartedAtProp ?? Date.now()); const sessionElapsedMs = useSessionClock(sessionStartedAt); // Persistent status bar segment: refreshes on an interval, never // blocks render on the git process. @@ -1917,112 +962,30 @@ export function App({ (ticks) => activeScroll.scrollDown(ticks * 3), ); - const handleCommand = (result: CommandResult) => { - if (result.type === "send") { - handleSend(result.text); - return; - } - if (result.type === "message") { - setCommandMessage(result.text); - return; - } - if (result.type === "tier") { - // Resolve strictly against the named tier (no fast→standard→clever - // fallback walk) so /fast means "the fast tier's model", not "whatever - // resolves." Provider names come from the live catalog so a tier assigned - // this session is recognised without a restart. - const settings: Settings = { - providers: Object.fromEntries(providerCatalog.map((p) => [p.name, p])), - tiers, - }; - const leg = tierDefinitionAt(result.tier, settings)?.order[0]; - if (leg === undefined) { - setCommandMessage(`The ${result.tier} tier is not configured. Assign it in /model.`); - return; - } - applySelection(leg.provider, leg.model, reasoningEffort); - setCommandMessage(`Switched to ${result.tier} tier (${leg.model}).`); - return; - } - if (result.type === "view") { - setTasksExpanded(true); - return; - } - if (result.type === "overlay") { - if (result.overlay === "permissions") { - refreshPermissions(); - setPermissionsOpen(true); - } else if (result.overlay === "settings") { - refreshPermissions(); - setSettingsOpen(true); - } else if (result.overlay === "plugins") { - if (pluginsAdmin === undefined) { - setCommandMessage("Plugins are not available in this context."); - } else { - setPluginsOpen(true); - } - } else { - setHelpOpen(true); - } - return; - } - if (result.type === "modal" && result.modal === "agent") { - setAgentModalOpen(true); - refreshAuthState(); - const codexName = codexProfileFromProviderName(provider); - const xaiName = xaiProfileFromProviderName(provider); - setAgentModalUsage(null); - if (codexName !== undefined) { - void fetchCodexUsage(codexName).then( - (usage) => { - setAgentModalUsage(formatCodexUsage(usage)); - }, - () => setAgentModalUsage(null), - ); - } else if (xaiName !== undefined) { - const entry = providerCatalog.find((e) => e.name === provider); - void fetchXaiUsage(xaiName, entry?.baseURL).then( - (usage) => { - setAgentModalUsage(formatXaiUsage(usage)); - }, - () => setAgentModalUsage(null), - ); - } else { - setAgentModalUsage(null); - } - } - if (result.type === "modal" && (result.modal === "codex-login" || result.modal === "xai-login" || result.modal === "login")) { - if (result.modal === "login") setLoginModal("choose"); - else setLoginModal(result.modal === "xai-login" ? "xai" : "codex"); - } - if (result.type === "paste-image") { - handlePasteImage(); - return; - } - if (result.type === "workflow") { - if (onStartWorkflow === undefined) { - setCommandMessage("Workflows are not available in this context."); - } else { - const msg = onStartWorkflow(result.name); - if (msg.startsWith("Started")) { - sendMessage({ text: workflowKickoffUserMessage(result.args), attachments: [] }); - } else { - setCommandMessage(msg); - } - } - } - }; - - const refreshPermissions = () => { - if (permissionsAdmin === undefined) return; - void permissionsAdmin.list().then(setPermissionEntries); - }; - - - const handleRevokePermission = (entry: ScopedApproval) => { - if (permissionsAdmin === undefined) return; - void permissionsAdmin.revoke(entry).then(refreshPermissions); - }; + const { handleCommand, refreshPermissions, handleRevokePermission } = useCommandDispatch({ + handleSend, + setCommandMessage, + providerCatalog, + tiers, + applySelection, + reasoningEffort, + setTasksExpanded, + permissionsAdmin, + setPermissionEntries, + setPermissionsOpen, + setSettingsOpen, + pluginsAdmin, + setPluginsOpen, + setHelpOpen, + setAgentModalOpen, + refreshAuthState, + provider, + setAgentModalUsage, + setLoginModal, + handlePasteImage, + onStartWorkflow, + sendMessage, + }); const handleOnboardingComplete = () => { setOnboardingDone(true); @@ -2168,89 +1131,58 @@ export function App({ width={columns} /> - {permissionsOpen && ( - setPermissionsOpen(false)} - maxHeight={permissionsOverlayRows} - /> - )} - {settingsOpen && ( - { - setCompactionMode(mode); - void onChangeCompactionMode?.(mode); - }} - maxConcurrentSubAgents={maxConcurrentSubAgents} - onChangeMaxConcurrentSubAgents={(limit) => { - setMaxConcurrentSubAgents(limit); - void onChangeMaxConcurrentSubAgents?.(limit); - }} - sessionMode={sessionMode} - {...(savedGlobalSessionMode !== undefined - ? { savedGlobalSessionMode } - : {})} - {...(savedLocalSessionMode !== undefined - ? { savedLocalSessionMode } - : {})} - onChangeSessionMode={(mode, scope) => { - if (scope === "global") setSavedGlobalSessionMode(mode); - else setSavedLocalSessionMode(mode); - void onChangeSessionMode?.(mode, scope); - }} - telemetryEnabled={liveTelemetryEnabled} - onChangeTelemetryEnabled={(enabled) => { - setLiveTelemetryEnabled(enabled); - onChangeTelemetryEnabled?.(enabled); - }} - waitForApproval={waitForApproval} - onChangeWaitForApproval={(value) => { - setWaitForApproval(value); - void onChangeWaitForApproval?.(value); - }} - onClose={() => setSettingsOpen(false)} - maxHeight={permissionsOverlayRows} - /> - )} - {pluginsOpen && pluginsAdmin !== undefined && ( - setPluginsOpen(false)} cwd={cwd} /> - )} - {loginModal === "choose" && ( - setLoginModal(provider)} - onClose={() => setLoginModal(null)} - /> - )} - {(loginModal === "codex" || loginModal === "xai") && ( - { - const controller = new AbortController(); - const start = loginModal === "xai" ? startXaiLogin : startCodexLogin; - return start({ profile: name, signal: controller.signal }).then((handle) => ({ - authorizeUrl: handle.authorizeUrl, - completed: handle.completed, - cancel: () => { - controller.abort(); - handle.cancel(); - }, - })); - }} - autoLoginProfile={autoLoginProfile} - onSwitchProfile={loginModal === "xai" ? switchToXaiProfile : switchToCodexProfile} - onRemoveProfile={loginModal === "xai" ? removeXaiProfileEverywhere : removeCodexProfileEverywhere} - onClose={() => { setLoginModal(null); setAutoLoginProfile(undefined); }} - /> - )} + setPermissionsOpen(false)} + permissionsOverlayRows={permissionsOverlayRows} + settingsOpen={settingsOpen} + compactionMode={compactionMode} + onChangeCompactionMode={(mode) => { + setCompactionMode(mode); + void onChangeCompactionMode?.(mode); + }} + maxConcurrentSubAgents={maxConcurrentSubAgents} + onChangeMaxConcurrentSubAgents={(limit) => { + setMaxConcurrentSubAgents(limit); + void onChangeMaxConcurrentSubAgents?.(limit); + }} + sessionMode={sessionMode} + {...(savedGlobalSessionMode !== undefined ? { savedGlobalSessionMode } : {})} + {...(savedLocalSessionMode !== undefined ? { savedLocalSessionMode } : {})} + onChangeSessionMode={(mode, scope) => { + if (scope === "global") setSavedGlobalSessionMode(mode); + else setSavedLocalSessionMode(mode); + void onChangeSessionMode?.(mode, scope); + }} + telemetryEnabled={liveTelemetryEnabled} + onChangeTelemetryEnabled={(enabled) => { + setLiveTelemetryEnabled(enabled); + onChangeTelemetryEnabled?.(enabled); + }} + waitForApproval={waitForApproval} + onChangeWaitForApproval={(value) => { + setWaitForApproval(value); + void onChangeWaitForApproval?.(value); + }} + onCloseSettings={() => setSettingsOpen(false)} + pluginsOpen={pluginsOpen} + pluginsAdmin={pluginsAdmin} + onClosePlugins={() => setPluginsOpen(false)} + cwd={cwd} + loginModal={loginModal} + onSelectLoginProvider={(provider) => setLoginModal(provider)} + onCloseLoginModal={() => { setLoginModal(null); setAutoLoginProfile(undefined); }} + xaiProfileNames={xaiProfileNames} + codexProfileNames={codexProfileNames} + activeProvider={provider} + autoLoginProfile={autoLoginProfile} + switchToXaiProfile={switchToXaiProfile} + switchToCodexProfile={switchToCodexProfile} + removeXaiProfileEverywhere={removeXaiProfileEverywhere} + removeCodexProfileEverywhere={removeCodexProfileEverywhere} + /> {mcpStatus.needsAuth.length > 0 && } {commandMessage !== null && ( diff --git a/src/tui/chrome-geometry.ts b/src/tui/chrome-geometry.ts new file mode 100644 index 000000000..bfff9c47a --- /dev/null +++ b/src/tui/chrome-geometry.ts @@ -0,0 +1,95 @@ +import { goalShowsAcceptancePanel, goalShowsWorkPrimary } from "../agent/goal.js"; +import { extraPromptChromeRows } from "./prompt-layout.js"; +import type { PluginsAdmin } from "./components/plugins-manager.js"; + +export type GoalChromeArgs = { + goalSnapshot: import("../agent/goal.js").GoalSnapshot | null; +}; + +export type GoalChromeResult = { + goalActive: boolean; + goalPhase: import("../agent/goal.js").GoalPhase | null; + showAcceptance: boolean; + workPrimary: boolean; +}; + +/** Goal chrome follows lifecycle phase: planning / reviewing / completed → + * Acceptance panel; implementing → Work primary (Acceptance compact; header + * shows phase). */ +export function resolveGoalChrome({ goalSnapshot }: GoalChromeArgs): GoalChromeResult { + const goalActive = + goalSnapshot !== null && + goalSnapshot.status !== "inactive" && + goalSnapshot.status !== "cleared"; + const goalPhase = goalActive ? goalSnapshot!.phase : null; + return { + goalActive, + goalPhase, + showAcceptance: goalPhase !== null && goalShowsAcceptancePanel(goalPhase), + workPrimary: goalPhase !== null && goalShowsWorkPrimary(goalPhase), + }; +} + +export function goalChromeRowCount(args: { + goalActive: boolean; + showAcceptance: boolean; + criteriaCount: number; +}): number { + const { goalActive, showAcceptance, criteriaCount } = args; + if (!goalActive) return 0; + // compact phase strip during implementing + if (!showAcceptance) return 3; + return (criteriaCount === 0 ? 2 : criteriaCount + 2) + 2; +} + +// The task strip renders above the in-flight indicator: one line when compact, +// the full checklist plus its heading when expanded. +1 is the marginTop wrapper. +export function taskChromeRowCount(args: { + hasActiveTasks: boolean; + taskCount: number; + workExpanded: boolean; +}): number { + const { hasActiveTasks, taskCount, workExpanded } = args; + if (!hasActiveTasks) return 0; + return (workExpanded ? taskCount + 1 : 1) + 1; +} + +// The plugins overlay renders outside the modal-stack accounting (like the +// permissions overlay), so reserve rows for its box: chrome + one row per +// plugin + the selected plugin's credential rows. +export function pluginChromeRowCount(args: { + pluginsOpen: boolean; + pluginsAdmin: PluginsAdmin | undefined; +}): number { + const { pluginsOpen, pluginsAdmin } = args; + if (!pluginsOpen || pluginsAdmin === undefined) return 0; + const list = pluginsAdmin.list(); + const widestCreds = list.reduce((n, p) => Math.max(n, p.credentials.length), 0); + return 6 + list.length + widestCreds + 2; +} + +export function extraChromeRowCount(args: { + mcpNeedsAuthCount: number; + commandMessagePresent: boolean; + goalChromeRows: number; + taskChromeRows: number; + pluginChromeRows: number; + quotaErrorPresent: boolean; + inferenceRetryPresent: boolean; + subAgentChromeRows: number; + inputValue: string; + columns: number | undefined; + rows: number | undefined; +}): number { + return ( + (args.mcpNeedsAuthCount > 0 ? 1 : 0) + + (args.commandMessagePresent ? 1 : 0) + + args.goalChromeRows + + args.taskChromeRows + + args.pluginChromeRows + + (args.quotaErrorPresent ? 1 : 0) + + (args.inferenceRetryPresent ? 1 : 0) + + args.subAgentChromeRows + + extraPromptChromeRows(args.inputValue, args.columns ?? 80, args.rows ?? 24) + ); +} diff --git a/src/tui/components/overlay-stack.tsx b/src/tui/components/overlay-stack.tsx new file mode 100644 index 000000000..64fe5c609 --- /dev/null +++ b/src/tui/components/overlay-stack.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from "react"; +import { PermissionsManager } from "./permissions-manager.js"; +import { SettingsOverlay, type CompactionMode } from "./settings-overlay.js"; +import { PluginsManager, type PluginsAdmin } from "./plugins-manager.js"; +import { LoginProviderPicker } from "./login-provider-picker.js"; +import { CodexLoginModal } from "./codex-login-modal.js"; +import type { ScopedApproval } from "../../permission/admin.js"; +import type { LoginModal } from "../hooks/use-provider-auth.js"; +import { startCodexLogin } from "../../auth/codex/login.js"; +import { startXaiLogin } from "../../auth/xai/login.js"; + +export type OverlayStackProps = { + permissionsOpen: boolean; + permissionEntries: ScopedApproval[]; + onRevokePermission: (entry: ScopedApproval) => void; + onClosePermissions: () => void; + permissionsOverlayRows: number; + + settingsOpen: boolean; + compactionMode: CompactionMode; + onChangeCompactionMode: (mode: CompactionMode) => void; + maxConcurrentSubAgents: number; + onChangeMaxConcurrentSubAgents: (limit: number) => void; + sessionMode: import("../../config/session-mode.js").SessionMode; + savedGlobalSessionMode?: import("../../config/session-mode.js").SessionMode; + savedLocalSessionMode?: import("../../config/session-mode.js").SessionMode; + onChangeSessionMode: ( + mode: import("../../config/session-mode.js").SessionMode, + scope: "global" | "local", + ) => void; + telemetryEnabled: boolean; + onChangeTelemetryEnabled: (enabled: boolean) => void; + waitForApproval: boolean; + onChangeWaitForApproval: (value: boolean) => void; + onCloseSettings: () => void; + + pluginsOpen: boolean; + pluginsAdmin: PluginsAdmin | undefined; + onClosePlugins: () => void; + cwd: string; + + loginModal: LoginModal; + onSelectLoginProvider: (provider: LoginModal) => void; + onCloseLoginModal: () => void; + xaiProfileNames: string[]; + codexProfileNames: string[]; + activeProvider: string; + autoLoginProfile: string | undefined; + switchToXaiProfile: (profile: string) => void; + switchToCodexProfile: (profile: string) => void; + removeXaiProfileEverywhere: (profile: string) => void; + removeCodexProfileEverywhere: (profile: string) => void; +}; + +/** Renders the overlays that sit outside the modal-stack's own accounting: + * permissions manager, settings, plugins manager, and the login flow modals. */ +export function OverlayStack(props: OverlayStackProps): ReactNode { + return ( + <> + {props.permissionsOpen && ( + + )} + {props.settingsOpen && ( + + )} + {props.pluginsOpen && props.pluginsAdmin !== undefined && ( + + )} + {props.loginModal === "choose" && ( + + )} + {(props.loginModal === "codex" || props.loginModal === "xai") && ( + { + const controller = new AbortController(); + const start = props.loginModal === "xai" ? startXaiLogin : startCodexLogin; + return start({ profile: name, signal: controller.signal }).then((handle) => ({ + authorizeUrl: handle.authorizeUrl, + completed: handle.completed, + cancel: () => { + controller.abort(); + handle.cancel(); + }, + })); + }} + autoLoginProfile={props.autoLoginProfile} + onSwitchProfile={props.loginModal === "xai" ? props.switchToXaiProfile : props.switchToCodexProfile} + onRemoveProfile={props.loginModal === "xai" ? props.removeXaiProfileEverywhere : props.removeCodexProfileEverywhere} + onClose={props.onCloseLoginModal} + /> + )} + + ); +} diff --git a/src/tui/components/retry-banners.tsx b/src/tui/components/retry-banners.tsx new file mode 100644 index 000000000..b57cd02f3 --- /dev/null +++ b/src/tui/components/retry-banners.tsx @@ -0,0 +1,50 @@ +import { Box, Text } from "ink"; +import type { ReactNode } from "react"; + +function formatCountdown(ms: number): string { + if (ms <= 0) return "now"; + const totalSeconds = Math.ceil(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (days > 0) return `${String(days)}d ${String(hours)}h`; + if (hours > 0) return `${String(hours)}h ${String(minutes)}m`; + if (minutes > 0) return `${String(minutes)}m ${String(seconds)}s`; + return `${String(seconds)}s`; +} + +export function QuotaErrorBanner({ retryAt }: { retryAt: number }): ReactNode { + const remaining = retryAt - Date.now(); + const expired = remaining <= 0; + return ( + + + {expired + ? "Rate limit reached — retrying…" + : `Rate limit reached — auto-retry in ${formatCountdown(remaining)}`} + + {"[/agent] Switch provider"} + + ); +} + +export function GatewayRetryBanner({ + attempt, + retryAt, +}: { + attempt: number; + retryAt: number; +}): ReactNode { + const remaining = retryAt - Date.now(); + const expired = remaining <= 0; + return ( + + + {expired + ? `Inference gateway overloaded — retrying (attempt ${attempt})…` + : `Inference gateway overloaded — retrying (attempt ${attempt}) in ${formatCountdown(remaining)}`} + + + ); +} diff --git a/src/tui/hooks/use-agents-strip.ts b/src/tui/hooks/use-agents-strip.ts new file mode 100644 index 000000000..962e5fe67 --- /dev/null +++ b/src/tui/hooks/use-agents-strip.ts @@ -0,0 +1,162 @@ +import { useMemo, useRef, type Dispatch, type SetStateAction } from "react"; +import type { EventEmitter } from "node:events"; +import type { AgentStreamView } from "../use-stream.js"; +import type { SubAgentSession, SubAgentSessionStore } from "../../subagent/index.js"; +import type { Task } from "../../agent/tasks.js"; +import type { OutboundUserMessage } from "../message-types.js"; +import { + activeStripSessions, + agentsStripRowCount, + computeAgentsStripWindow, + DEFAULT_STRIP_MAX_VISIBLE, + mergeInFlightSubAgents, + shouldShowAgentsStrip, + type AgentsStripWindow, +} from "../components/agents-strip.js"; +import { useDrainLogic } from "./use-drain-logic.js"; + +export type UseAgentsStripArgs = { + eventEmitter: EventEmitter; + subAgentSessions: SubAgentSessionStore | undefined; + sessionsTick: number; + setSessionsTick: Dispatch>; + state: AgentStreamView; + stateRef: { current: AgentStreamView }; + sendMessageRef: { current: (message: OutboundUserMessage) => void }; + pendingQueueRef: { current: OutboundUserMessage[] }; + tryDrainQueuedMessageRef: { current: () => void }; + agentsNavOpen: boolean; + agentsNavIndex: number; + enteredSessionId: string | null; +}; + +export type AgentsStripState = { + agentSessions: readonly SubAgentSession[]; + browseSessions: readonly SubAgentSession[]; + agentsNavList: readonly SubAgentSession[]; + agentsNavIndexClamped: number; + enteredSession: SubAgentSession | undefined; + activeSubAgents: Task[]; + activeSubAgentsRef: { current: Task[] }; + queuedCount: number; + setQueuedCount: Dispatch>; + hasRunningSubAgentSessions: () => boolean; + steerOnEnter: boolean; + agentsStripVisible: boolean; + agentsStripScrollWindow: AgentsStripWindow | undefined; + agentsStripRows: number; +}; + +/** Derives the Agents strip's session lists, nav windowing, and chrome row + * count from the sub-agent session store and live stream state. */ +export function useAgentsStrip({ + eventEmitter, + subAgentSessions, + sessionsTick, + setSessionsTick, + state, + stateRef, + sendMessageRef, + pendingQueueRef, + tryDrainQueuedMessageRef, + agentsNavOpen, + agentsNavIndex, + enteredSessionId, +}: UseAgentsStripArgs): AgentsStripState { + // The strip reflects only active work: an agent leaves the visible list the + // moment it reaches a terminal state. Completed sessions stay in the store + // for later inspection but no longer occupy the strip. + const agentSessions = useMemo(() => { + void sessionsTick; + const merged = mergeInFlightSubAgents( + subAgentSessions?.listForStrip() ?? [], + state.subAgents, + ); + return activeStripSessions(merged); + }, [subAgentSessions, sessionsTick, state.subAgents]); + + // Ctrl+E browses the full strip surface (running + recent completed). The + // chrome strip filters to running only; nav must still reach finished sessions + // for inspection — otherwise a just-finished child vanishes from Ctrl+E while + // the live-progress fallback can still paint a ghost "doing" row. + const browseSessions = useMemo(() => { + void sessionsTick; + return subAgentSessions?.listForStrip() ?? []; + }, [subAgentSessions, sessionsTick]); + + // A running agent can reach a terminal state while agents-nav is open, which + // shortens the strip list under the persisted selection index. Clamp at read + // time so the highlight lands on a real row instead of drifting out of range. + const agentsNavList = agentsNavOpen ? browseSessions : agentSessions; + const agentsNavIndexClamped = + agentsNavList.length === 0 ? 0 : Math.min(agentsNavIndex, agentsNavList.length - 1); + + const enteredSession = useMemo(() => { + void sessionsTick; + if (enteredSessionId === null || subAgentSessions === undefined) return undefined; + return subAgentSessions.get(enteredSessionId); + }, [enteredSessionId, subAgentSessions, sessionsTick]); + + // Agents strip (session store) + live progress fallback for chrome height. + // Prefer the session store list once anything has been spawned this session. + const activeSubAgents = useMemo( + () => state.subAgents.filter((a) => a.status !== "done" && a.status !== "cancelled"), + [state.subAgents], + ); + const activeSubAgentsRef = useRef(activeSubAgents); + activeSubAgentsRef.current = activeSubAgents; + const { queuedCount, setQueuedCount, hasRunningSubAgentSessions } = useDrainLogic({ + eventEmitter, + subAgentSessions, + setSessionsTick, + stateRef, + activeSubAgentsRef, + sendMessageRef, + pendingQueueRef, + tryDrainQueuedMessageRef, + }); + const steerOnEnter = + state.isProcessing && activeSubAgents.length === 0 && !hasRunningSubAgentSessions(); + // The strip caps rendered rows so retained history never crowds out the + // transcript; +1 accounts for the surrounding marginTop wrapper. When nav is + // open the list may include completed sessions, so size against browseSessions. + const agentsStripVisible = shouldShowAgentsStrip({ + chromeSessions: agentSessions, + browseSessions, + agentsNavOpen, + }); + const agentsStripScrollWindow = + agentsNavOpen && browseSessions.length > DEFAULT_STRIP_MAX_VISIBLE + ? computeAgentsStripWindow( + browseSessions.length, + agentsNavIndexClamped, + DEFAULT_STRIP_MAX_VISIBLE, + ) + : undefined; + const agentsStripRows = agentsStripVisible + ? agentsNavOpen && browseSessions.length > 0 + ? agentsStripRowCount( + browseSessions.length, + DEFAULT_STRIP_MAX_VISIBLE, + agentsStripScrollWindow, + ) + 1 + : agentsStripRowCount(agentSessions.length, DEFAULT_STRIP_MAX_VISIBLE) + 1 + : 0; + + return { + agentSessions, + browseSessions, + agentsNavList, + agentsNavIndexClamped, + enteredSession, + activeSubAgents, + activeSubAgentsRef, + queuedCount, + setQueuedCount, + hasRunningSubAgentSessions, + steerOnEnter, + agentsStripVisible, + agentsStripScrollWindow, + agentsStripRows, + }; +} diff --git a/src/tui/hooks/use-command-context.ts b/src/tui/hooks/use-command-context.ts new file mode 100644 index 000000000..e1ecdfc94 --- /dev/null +++ b/src/tui/hooks/use-command-context.ts @@ -0,0 +1,98 @@ +import { useMemo, useRef } from "react"; +import { buildCostSummary, type CostSummary } from "../../cost/cost-summary.js"; +import { getActivePricingCache } from "../../cost/cost-visibility.js"; +import { goalKickoffUserMessage, type GoalResumeOpts, type GoalSetOpts, type GoalSnapshot } from "../../agent/goal.js"; +import type { ProviderCatalogEntry } from "../../config/index.js"; +import type { AgentStreamView } from "../use-stream.js"; +import type { CommandContext } from "../commands/registry.js"; +import type { OutboundUserMessage } from "../message-types.js"; + +export type UseCommandContextArgs = { + provider: string; + providerCatalog: ProviderCatalogEntry[]; + modelRef: { current: string }; + state: AgentStreamView; + mcpServers: Array<{ name: string; tools: string[] }>; + startNewSessionRef: { current: () => void }; + onStartWorkflow: ((name: string) => string) | undefined; + onRenameSession: ((name: string) => string | undefined) | undefined; + goalApi: + | { + get: () => GoalSnapshot | null; + set: (condition: string, opts?: GoalSetOpts) => GoalSnapshot; + pause: () => GoalSnapshot | null; + resume: (opts?: GoalResumeOpts) => GoalSnapshot | null; + clear: () => void; + } + | undefined; + sendMessageRef: { current: (message: OutboundUserMessage) => void }; +}; + +export type CommandContextController = { + getCostSummary: () => CostSummary; + commandContext: CommandContext; +}; + +export function useCommandContext({ + provider, + providerCatalog, + modelRef, + state, + mcpServers, + startNewSessionRef, + onStartWorkflow, + onRenameSession, + goalApi, + sendMessageRef, +}: UseCommandContextArgs): CommandContextController { + const getCostSummary = () => { + const activeProvider = providerCatalog.find((p) => p.name === provider); + return buildCostSummary({ + modelId: modelRef.current, + baseURL: activeProvider?.baseURL, + providerFree: activeProvider?.free, + pricingCache: getActivePricingCache(), + totalCost: state.totalCost, + formattedCost: state.formattedCost, + inputTokens: state.inputTokens, + outputTokens: state.outputTokens, + cacheReadTokens: state.cacheReadTokens, + contextTokens: state.contextTokens, + }); + }; + // commandContext below is memoized, so it would otherwise capture a stale + // getCostSummary closure (provider/state from an old render). Routing the + // call through a ref updated every render keeps the memoized context reading + // live values, matching the signalClear/startNewSessionRef pattern. + const getCostSummaryRef = useRef(getCostSummary); + getCostSummaryRef.current = getCostSummary; + + const commandContext = useMemo(() => ({ + signalClear: () => startNewSessionRef.current(), + getMCPServers: () => mcpServers, + getCostSummary: () => getCostSummaryRef.current(), + ...(onStartWorkflow !== undefined ? { startWorkflow: onStartWorkflow } : {}), + ...(onRenameSession !== undefined ? { renameSession: onRenameSession } : {}), + ...(goalApi !== undefined + ? { + goal: { + get: goalApi.get, + set: goalApi.set, + pause: goalApi.pause, + resume: goalApi.resume, + clear: goalApi.clear, + kickoff: (condition: string, phase: "set" | "resume" = "set") => { + // Start a turn immediately so the agent works without a second prompt. + // Set path forces clarify-first for vague goals; resume continues. + sendMessageRef.current({ + text: goalKickoffUserMessage(condition, phase), + attachments: [], + }); + }, + }, + } + : {}), + }), [mcpServers, onStartWorkflow, onRenameSession, goalApi]); + + return { getCostSummary, commandContext }; +} diff --git a/src/tui/hooks/use-command-dispatch.ts b/src/tui/hooks/use-command-dispatch.ts new file mode 100644 index 000000000..a14537dbe --- /dev/null +++ b/src/tui/hooks/use-command-dispatch.ts @@ -0,0 +1,182 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { CommandResult } from "../commands/registry.js"; +import type { PermissionsAdmin, ScopedApproval } from "../../permission/admin.js"; +import type { PluginsAdmin } from "../components/plugins-manager.js"; +import type { ProviderCatalogEntry } from "../../config/index.js"; +import type { ReasoningEffort } from "../../provider/reasoning-effort.js"; +import type { Settings, ProviderTier, TierConfig } from "../../config/settings.js"; +import { tierDefinitionAt } from "../../config/settings.js"; +import type { LoginModal } from "./use-provider-auth.js"; +import type { OutboundUserMessage } from "../message-types.js"; +import { codexProfileFromProviderName } from "../../config/codex-providers.js"; +import { xaiProfileFromProviderName } from "../../config/xai-providers.js"; +import { fetchCodexUsage, formatCodexUsage } from "../../auth/codex/usage.js"; +import { fetchXaiUsage, formatXaiUsage } from "../../auth/xai/usage.js"; +import { workflowKickoffUserMessage } from "../../workflows/kickoff.js"; + +export type UseCommandDispatchArgs = { + handleSend: (text: string) => void; + setCommandMessage: (message: string | null) => void; + providerCatalog: ProviderCatalogEntry[]; + tiers: Partial>; + applySelection: (provider: string, model: string, reasoningEffort?: ReasoningEffort) => void; + reasoningEffort: ReasoningEffort | undefined; + setTasksExpanded: Dispatch>; + permissionsAdmin: PermissionsAdmin | undefined; + setPermissionEntries: Dispatch>; + setPermissionsOpen: Dispatch>; + setSettingsOpen: Dispatch>; + pluginsAdmin: PluginsAdmin | undefined; + setPluginsOpen: Dispatch>; + setHelpOpen: Dispatch>; + setAgentModalOpen: Dispatch>; + refreshAuthState: () => void; + provider: string; + setAgentModalUsage: Dispatch>; + setLoginModal: Dispatch>; + handlePasteImage: () => void; + onStartWorkflow: ((name: string) => string) | undefined; + sendMessage: (message: OutboundUserMessage) => void; +}; + +export type CommandDispatchController = { + handleCommand: (result: CommandResult) => void; + refreshPermissions: () => void; + handleRevokePermission: (entry: ScopedApproval) => void; +}; + +/** Dispatches slash-command results (`CommandResult`) to the relevant piece + * of app state — overlays, the agent modal, workflows, tier switching — and + * owns the permissions-admin refresh/revoke round trip used by those overlays. */ +export function useCommandDispatch({ + handleSend, + setCommandMessage, + providerCatalog, + tiers, + applySelection, + reasoningEffort, + setTasksExpanded, + permissionsAdmin, + setPermissionEntries, + setPermissionsOpen, + setSettingsOpen, + pluginsAdmin, + setPluginsOpen, + setHelpOpen, + setAgentModalOpen, + refreshAuthState, + provider, + setAgentModalUsage, + setLoginModal, + handlePasteImage, + onStartWorkflow, + sendMessage, +}: UseCommandDispatchArgs): CommandDispatchController { + const refreshPermissions = (): void => { + if (permissionsAdmin === undefined) return; + void permissionsAdmin.list().then(setPermissionEntries); + }; + + const handleRevokePermission = (entry: ScopedApproval): void => { + if (permissionsAdmin === undefined) return; + void permissionsAdmin.revoke(entry).then(refreshPermissions); + }; + + const handleCommand = (result: CommandResult): void => { + if (result.type === "send") { + handleSend(result.text); + return; + } + if (result.type === "message") { + setCommandMessage(result.text); + return; + } + if (result.type === "tier") { + // Resolve strictly against the named tier (no fast→standard→clever + // fallback walk) so /fast means "the fast tier's model", not "whatever + // resolves." Provider names come from the live catalog so a tier assigned + // this session is recognised without a restart. + const settings: Settings = { + providers: Object.fromEntries(providerCatalog.map((p) => [p.name, p])), + tiers, + }; + const leg = tierDefinitionAt(result.tier, settings)?.order[0]; + if (leg === undefined) { + setCommandMessage(`The ${result.tier} tier is not configured. Assign it in /model.`); + return; + } + applySelection(leg.provider, leg.model, reasoningEffort); + setCommandMessage(`Switched to ${result.tier} tier (${leg.model}).`); + return; + } + if (result.type === "view") { + setTasksExpanded(true); + return; + } + if (result.type === "overlay") { + if (result.overlay === "permissions") { + refreshPermissions(); + setPermissionsOpen(true); + } else if (result.overlay === "settings") { + refreshPermissions(); + setSettingsOpen(true); + } else if (result.overlay === "plugins") { + if (pluginsAdmin === undefined) { + setCommandMessage("Plugins are not available in this context."); + } else { + setPluginsOpen(true); + } + } else { + setHelpOpen(true); + } + return; + } + if (result.type === "modal" && result.modal === "agent") { + setAgentModalOpen(true); + refreshAuthState(); + const codexName = codexProfileFromProviderName(provider); + const xaiName = xaiProfileFromProviderName(provider); + setAgentModalUsage(null); + if (codexName !== undefined) { + void fetchCodexUsage(codexName).then( + (usage) => { + setAgentModalUsage(formatCodexUsage(usage)); + }, + () => setAgentModalUsage(null), + ); + } else if (xaiName !== undefined) { + const entry = providerCatalog.find((e) => e.name === provider); + void fetchXaiUsage(xaiName, entry?.baseURL).then( + (usage) => { + setAgentModalUsage(formatXaiUsage(usage)); + }, + () => setAgentModalUsage(null), + ); + } else { + setAgentModalUsage(null); + } + } + if (result.type === "modal" && (result.modal === "codex-login" || result.modal === "xai-login" || result.modal === "login")) { + if (result.modal === "login") setLoginModal("choose"); + else setLoginModal(result.modal === "xai-login" ? "xai" : "codex"); + } + if (result.type === "paste-image") { + handlePasteImage(); + return; + } + if (result.type === "workflow") { + if (onStartWorkflow === undefined) { + setCommandMessage("Workflows are not available in this context."); + } else { + const msg = onStartWorkflow(result.name); + if (msg.startsWith("Started")) { + sendMessage({ text: workflowKickoffUserMessage(result.args), attachments: [] }); + } else { + setCommandMessage(msg); + } + } + } + }; + + return { handleCommand, refreshPermissions, handleRevokePermission }; +} diff --git a/src/tui/hooks/use-drain-logic.ts b/src/tui/hooks/use-drain-logic.ts new file mode 100644 index 000000000..0d26593c9 --- /dev/null +++ b/src/tui/hooks/use-drain-logic.ts @@ -0,0 +1,74 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import type { EventEmitter } from "node:events"; +import type { AgentStreamView } from "../use-stream.js"; +import type { SubAgentSessionStore } from "../../subagent/index.js"; +import type { Task } from "../../agent/tasks.js"; +import type { OutboundUserMessage } from "../message-types.js"; + +export type UseDrainLogicArgs = { + eventEmitter: EventEmitter; + subAgentSessions: SubAgentSessionStore | undefined; + setSessionsTick: Dispatch>; + stateRef: { current: AgentStreamView }; + activeSubAgentsRef: { current: Task[] }; + sendMessageRef: { current: (message: OutboundUserMessage) => void }; + pendingQueueRef: { current: OutboundUserMessage[] }; + tryDrainQueuedMessageRef: { current: () => void }; +}; + +export type DrainLogicController = { + queuedCount: number; + setQueuedCount: Dispatch>; + hasRunningSubAgentSessions: () => boolean; +}; + +// Messages queued while the agent is processing. Drained one-at-a-time when +// isProcessing goes false (connector.reply fires). Lives in React state so +// the drain path goes through sendMessage(), which correctly sets isProcessing. +export function useDrainLogic({ + eventEmitter, + subAgentSessions, + setSessionsTick, + stateRef, + activeSubAgentsRef, + sendMessageRef, + pendingQueueRef, + tryDrainQueuedMessageRef, +}: UseDrainLogicArgs): DrainLogicController { + const [queuedCount, setQueuedCount] = useState(0); + + const hasRunningSubAgentSessions = (): boolean => + subAgentSessions?.list().some((session) => session.status === "running") ?? false; + + tryDrainQueuedMessageRef.current = () => { + if (stateRef.current.status === "blocked") return; + if (stateRef.current.isProcessing) return; + if (activeSubAgentsRef.current.length > 0) return; + if (hasRunningSubAgentSessions()) return; + if (pendingQueueRef.current.length === 0) return; + const next = pendingQueueRef.current.shift()!; + setQueuedCount((c) => Math.max(0, c - 1)); + sendMessageRef.current(next); + }; + + useEffect(() => { + if (subAgentSessions === undefined) return; + return subAgentSessions.subscribe(() => { + setSessionsTick((n) => n + 1); + tryDrainQueuedMessageRef.current(); + }); + }, [subAgentSessions]); + + // Drain one queued message when the orchestrator is idle and no sub-agents run. + useEffect(() => { + const onEvent = (event: { type: string }) => { + if (event.type !== "connector.reply") return; + tryDrainQueuedMessageRef.current(); + }; + eventEmitter.on("event", onEvent); + return () => { eventEmitter.off("event", onEvent); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { queuedCount, setQueuedCount, hasRunningSubAgentSessions }; +} diff --git a/src/tui/hooks/use-image-attach.ts b/src/tui/hooks/use-image-attach.ts new file mode 100644 index 000000000..e870e55d0 --- /dev/null +++ b/src/tui/hooks/use-image-attach.ts @@ -0,0 +1,61 @@ +import { useState, type Dispatch, type SetStateAction } from "react"; +import { + extractPastedImagePaths, + imageAttachmentFromPath, + readClipboardImage, + type PendingImageAttachment, +} from "../image-attachments.js"; + +export type UseImageAttachArgs = { + cwd: string; + setCommandMessage: (message: string | null) => void; +}; + +export type ImageAttachController = { + pendingImages: PendingImageAttachment[]; + setPendingImages: Dispatch>; + addPendingImage: (attachment: PendingImageAttachment) => void; + handlePasteImage: () => void; + handlePasteText: (text: string) => boolean; +}; + +export function useImageAttach({ cwd, setCommandMessage }: UseImageAttachArgs): ImageAttachController { + const [pendingImages, setPendingImages] = useState([]); + + const addPendingImage = (attachment: PendingImageAttachment): void => { + setPendingImages((prev) => [...prev, attachment]); + setCommandMessage(`Attached image: ${attachment.name}`); + }; + + const handlePasteImage = (): void => { + setCommandMessage("Reading clipboard image..."); + void readClipboardImage().then((result) => { + if (!result.ok) { + setCommandMessage(`Image paste failed: ${result.reason}`); + return; + } + addPendingImage(result.attachment); + }); + }; + + const handlePasteText = (text: string): boolean => { + const paths = extractPastedImagePaths(text, cwd); + if (paths.length === 0) return false; + setCommandMessage(`Attaching ${paths.length} image${paths.length === 1 ? "" : "s"}...`); + void Promise.all(paths.map((path) => imageAttachmentFromPath(path))).then((results) => { + const attached = results.filter((result): result is { ok: true; attachment: PendingImageAttachment } => result.ok); + const failed = results.length - attached.length; + if (attached.length > 0) { + setPendingImages((prev) => [...prev, ...attached.map((result) => result.attachment)]); + } + setCommandMessage( + failed > 0 + ? `Attached ${attached.length} image${attached.length === 1 ? "" : "s"}; ${failed} failed.` + : `Attached ${attached.length} image${attached.length === 1 ? "" : "s"}.`, + ); + }); + return true; + }; + + return { pendingImages, setPendingImages, addPendingImage, handlePasteImage, handlePasteText }; +} diff --git a/src/tui/hooks/use-message-pipeline.ts b/src/tui/hooks/use-message-pipeline.ts new file mode 100644 index 000000000..558df209c --- /dev/null +++ b/src/tui/hooks/use-message-pipeline.ts @@ -0,0 +1,345 @@ +import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import type { Agent } from "@intx/agent"; +import type { InboundMessage } from "@intx/types/runtime"; +import type { OutboundUserMessage } from "../message-types.js"; +import { resolveAtMentions } from "../mention-resolution.js"; +import type { AgentStreamView } from "../use-stream.js"; +import { classifyAgentSendFailure, shouldSettleUiAfterSendFailure } from "../session-chrome.js"; +import { INFERENCE_ABORT_USER_STOP } from "../../inference-abort.js"; +import { appendSentMessage, loadSentMessages } from "../../session/sent-messages.js"; +import { createSentHistoryBrowse, resetSentHistoryBrowse, type SentHistoryBrowse } from "../sent-message-history.js"; +import { findImagePathMentions, imageAttachmentFromPath, type PendingImageAttachment } from "../image-attachments.js"; +import { isExitCommand } from "../exit-command.js"; +import { CodexAuthError } from "../../auth/codex/session.js"; +import { XaiAuthError } from "../../auth/xai/session.js"; +import type { ScrollController } from "./use-scroll.js"; +import type { GateController } from "./use-gates.js"; +import type { SubAgentSessionStore } from "../../subagent/index.js"; +import type { WorkflowStatus } from "../workflow-controller.js"; + +export type UseMessagePipelineArgs = { + cwd: string; + agent: Agent; + getSessionId: (() => string) | undefined; + exit: () => void; + onFirstUserMessage: (() => void) | undefined; + onInterrupt: (() => void) | undefined; + onNewSession: (() => void) | undefined; + onAgentError: ((err: unknown) => void) | undefined; + skipInitialTask: boolean; + initialTask: string; + state: AgentStreamView; + stateRef: { current: AgentStreamView }; + scroll: ScrollController; + gates: GateController; + subAgentSessions: SubAgentSessionStore | undefined; + activeSubAgentsRef: { current: readonly { status: string }[] }; + hasRunningSubAgentSessions: () => boolean; + pendingQueueRef: { current: OutboundUserMessage[] }; + setQueuedCount: Dispatch>; + pendingImages: PendingImageAttachment[]; + setPendingImages: Dispatch>; + setCommandMessage: Dispatch>; + setSentHistoryBrowse: Dispatch>; + promptCodexRelogin: (name: string) => void; + promptXaiRelogin: (name: string) => void; + setExpandedTools: Dispatch>>; + setWorkflowHistory: Dispatch>; + setInputValue: Dispatch>; + setSessionStartedAt: Dispatch>; + setEnteredSessionId: Dispatch>; + setAgentsNavOpen: Dispatch>; + setAgentsNavIndex: Dispatch>; + forceRender: Dispatch>; + sendMessageRef: { current: (message: OutboundUserMessage) => void }; + requestStopRef: { current: () => void }; +}; + +export type MessagePipelineController = { + sendMessage: (message: OutboundUserMessage) => void; + requestStop: () => void; + startNewSession: () => void; + startNewSessionRef: { current: () => void }; + prepareOutboundMessage: ( + message: string, + baseAttachments: PendingImageAttachment[], + ) => Promise; + handleSend: (message: string) => void; + handleInterrupt: (message: string) => void; + sendAbortRef: { current: AbortController | null }; + sendCounterRef: { current: number }; + lastSentMessageRef: { current: string }; + quotaAutoRetryFiredRef: { current: boolean }; +}; + +export function useMessagePipeline({ + cwd, + agent, + getSessionId, + exit, + onFirstUserMessage, + onInterrupt, + onNewSession, + onAgentError, + skipInitialTask, + initialTask, + state, + stateRef, + scroll, + gates, + subAgentSessions, + activeSubAgentsRef, + hasRunningSubAgentSessions, + pendingQueueRef, + setQueuedCount, + pendingImages, + setPendingImages, + setCommandMessage, + setSentHistoryBrowse, + promptCodexRelogin, + promptXaiRelogin, + setExpandedTools, + setWorkflowHistory, + setInputValue, + setSessionStartedAt, + setEnteredSessionId, + setAgentsNavOpen, + setAgentsNavIndex, + forceRender, + sendMessageRef, + requestStopRef, +}: UseMessagePipelineArgs): MessagePipelineController { + // One controller per in-flight send so Ctrl+C / double-Esc can abort the + // active run. Aborting rejects the send promise; the reactor's current cycle + // finishes but no new cycle starts, which is the "Stopping" → "Stopped" path. + const sendAbortRef = useRef(null); + const didSendInitial = useRef(false); + const firstUserMessageFired = useRef(false); + // Incremented on every send so useSpinner can reset its elapsed clock per turn. + const sendCounterRef = useRef(0); + const lastSentMessageRef = useRef(""); + const quotaAutoRetryFiredRef = useRef(false); + + sendMessageRef.current = (message: OutboundUserMessage) => { + lastSentMessageRef.current = message.text; + const trimmed = message.text.trim(); + if (trimmed.length > 0 && getSessionId !== undefined) { + const sid = getSessionId(); + void appendSentMessage(cwd, sid, trimmed).then(() => { + setSentHistoryBrowse((prev) => resetSentHistoryBrowse([...prev.sent, trimmed])); + }); + } + quotaAutoRetryFiredRef.current = false; + sendCounterRef.current += 1; + state.markRunning(); + scroll.scrollToBottom(); + + // Append the user message to the transcript immediately (optimistic echo). + // This ensures the input is visible even when send() is delayed by pre-send + // work such as Codex/XAI token refresh. The subsequent message.received will + // no-op the duplicate push. + const displayContent = message.text.length > 0 ? message.text : "Please inspect the attached image."; + const attachmentText = message.attachments.length > 0 + ? `\n[Attached ${message.attachments.length} image${message.attachments.length === 1 ? "" : "s"}: ${message.attachments.map((att) => att.name).join(", ")}]` + : ""; + state.appendUserMessage(`${displayContent}${attachmentText}`); + + // Nudge a re-render so the in-flight indicator and interval timer activate + // immediately rather than waiting for the first event from the new run. + forceRender((n) => n + 1); + const controller = new AbortController(); + sendAbortRef.current = controller; + const inbound: InboundMessage = { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: new Date().toISOString(), + messageId: `<${crypto.randomUUID()}@local>`, + interchangeType: "conversation.message", + }, + flags: [], + signatureStatus: "missing", + content: message.text.length > 0 ? message.text : "Please inspect the attached image.", + ...(message.attachments.length > 0 ? { attachments: message.attachments } : {}), + }; + agent.send(inbound, { signal: controller.signal }).catch((err: unknown) => { + const kind = classifyAgentSendFailure( + err, + controller.signal.aborted, + (e): e is CodexAuthError => e instanceof CodexAuthError, + (e): e is XaiAuthError => e instanceof XaiAuthError, + ); + if (kind === "abort") return; + if (shouldSettleUiAfterSendFailure(kind)) { + state.requestStop(); + gates.resetGates(); + forceRender((n) => n + 1); + } + if (kind === "codex_auth") { + promptCodexRelogin((err as CodexAuthError).profile); + return; + } + if (kind === "xai_auth") { + promptXaiRelogin((err as XaiAuthError).profile); + return; + } + onAgentError?.(err); + }); + }; + const sendMessage = (message: OutboundUserMessage) => sendMessageRef.current(message); + + const requestStop = () => { + quotaAutoRetryFiredRef.current = true; + sendAbortRef.current?.abort(INFERENCE_ABORT_USER_STOP); + // Parent stop must cancel live children too: aborting the parent send signal + // is linked into each task's child controller, and cancelAll flips session + // status + fires registerCancel hooks that close child agents. + subAgentSessions?.cancelAll("Parent stop"); + onInterrupt?.(); + state.requestStop(); + gates.resetGates(); + // Discard queued messages — a stopped run should not silently replay them + // into the next session's first turn when connector.reply eventually fires. + pendingQueueRef.current.length = 0; + setQueuedCount(0); + // Clear the last-sent prompt so the quota auto-retry loop cannot resubmit + // the interrupted turn once its retry-after window elapses; the agent is + // rebuilt from the persisted store on interrupt, and replaying the prompt + // on top of that would duplicate the turn's tool executions. + lastSentMessageRef.current = ""; + forceRender((n) => n + 1); + }; + + requestStopRef.current = requestStop; + + const startNewSessionRef = useRef<() => void>(() => undefined); + startNewSessionRef.current = () => { + sendAbortRef.current?.abort(); + // Cancel live workers before clearing the strip so child reactors close + // instead of continuing after /clear. + subAgentSessions?.cancelAll("New session"); + state.clear(); + gates.resetGates(); + setExpandedTools(new Set()); + pendingQueueRef.current.length = 0; + setQueuedCount(0); + // Same guard as requestStop: a cleared session must not auto-resubmit a + // prior prompt when the quota retry interval is still polling. + lastSentMessageRef.current = ""; + quotaAutoRetryFiredRef.current = true; + setWorkflowHistory([]); + setInputValue(""); + setSessionStartedAt(Date.now()); + setEnteredSessionId(null); + setAgentsNavOpen(false); + setAgentsNavIndex(0); + subAgentSessions?.clear(); + onNewSession?.(); + if (getSessionId !== undefined) { + void loadSentMessages(cwd, getSessionId()).then((sent) => { + setSentHistoryBrowse(createSentHistoryBrowse(sent)); + }); + } else { + setSentHistoryBrowse(createSentHistoryBrowse([])); + } + scroll.scrollToBottom(); + forceRender((n) => n + 1); + }; + const startNewSession = () => startNewSessionRef.current(); + + // Send the initial task once the App (and its gate listeners) is mounted, so + // the run is driven through the same abortable path as interactive sends. + useEffect(() => { + if (getSessionId === undefined) return; + void loadSentMessages(cwd, getSessionId()).then((sent) => { + setSentHistoryBrowse(createSentHistoryBrowse(sent)); + }); + }, [cwd, getSessionId]); + + useEffect(() => { + if (didSendInitial.current) return; + didSendInitial.current = true; + if (skipInitialTask) return; + if (initialTask.length > 0) sendMessage({ text: initialTask, attachments: [] }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const prepareOutboundMessage = async ( + message: string, + baseAttachments: PendingImageAttachment[], + ): Promise => { + let text = message; + const mentions = findImagePathMentions(message, cwd); + const loaded = await Promise.all(mentions.map((mention) => imageAttachmentFromPath(mention.path))); + const attachments = [...baseAttachments]; + for (let i = 0; i < mentions.length; i++) { + const mention = mentions[i]; + const result = loaded[i]; + if (mention === undefined || result === undefined || !result.ok) continue; + attachments.push(result.attachment); + text = text.replace(mention.raw, `[Attached image: ${result.attachment.name}]`); + } + return { text: await resolveAtMentions(text, cwd), attachments }; + }; + + const handleSend = (message: string) => { + if (isExitCommand(message)) { + exit(); + return; + } + if (!firstUserMessageFired.current) { + firstUserMessageFired.current = true; + onFirstUserMessage?.(); + } + setCommandMessage(null); + const attachments = pendingImages; + setPendingImages([]); + void prepareOutboundMessage(message, attachments).then((outbound) => { + // Read live state from the ref — prepareOutboundMessage is async (it does + // @-mention resolution + disk I/O), so the closed-over state.isProcessing + // can be stale by the time this resolves. A previous turn can finish and + // drain the queue during the async window; reading the stale value would + // then queue a message nothing will ever drain, leaving the UI stuck. + const childWorkActive = + activeSubAgentsRef.current.length > 0 || hasRunningSubAgentSessions(); + if (stateRef.current.isProcessing || childWorkActive) { + pendingQueueRef.current.push(outbound); + setQueuedCount((c) => c + 1); + return; + } + sendMessage(outbound); + }); + }; + + const handleInterrupt = (message: string) => { + if (isExitCommand(message)) { + requestStop(); + exit(); + return; + } + setCommandMessage(null); + // requestStop must fire synchronously before any async work so the abort + // signal reaches the in-flight HTTP request before at-mention resolution + // has a chance to yield, preventing a stale connector.reply from racing + // the new turn's state. + requestStop(); + const attachments = pendingImages; + setPendingImages([]); + void prepareOutboundMessage(message, attachments).then(sendMessage); + }; + + return { + sendMessage, + requestStop, + startNewSession, + startNewSessionRef, + prepareOutboundMessage, + handleSend, + handleInterrupt, + sendAbortRef, + sendCounterRef, + lastSentMessageRef, + quotaAutoRetryFiredRef, + }; +} diff --git a/src/tui/hooks/use-provider-auth.ts b/src/tui/hooks/use-provider-auth.ts new file mode 100644 index 000000000..de31c2bac --- /dev/null +++ b/src/tui/hooks/use-provider-auth.ts @@ -0,0 +1,231 @@ +import { useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { getValidCodexToken, CodexAuthError } from "../../auth/codex/session.js"; +import { refreshCodexInstructions } from "../../auth/codex/instructions.js"; +import { removeCodexProfile } from "../../auth/codex/store.js"; +import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "../../auth/codex/constants.js"; +import { getValidXaiToken, XaiAuthError } from "../../auth/xai/session.js"; +import { removeXaiProfile } from "../../auth/xai/store.js"; +import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../../auth/xai/constants.js"; +import { codexProviderName, codexProfileFromProviderName } from "../../config/codex-providers.js"; +import { xaiProviderName, xaiProfileFromProviderName } from "../../config/xai-providers.js"; +import { fetchCodexModels } from "../../auth/codex/usage.js"; +import type { ProviderCatalogEntry } from "../../config/index.js"; + +export type LoginModal = "codex" | "xai" | "choose" | null; + +export type UseProviderAuthArgs = { + provider: string; + providerCatalog: ProviderCatalogEntry[]; + registerCodexProvider: (entry: ProviderCatalogEntry) => void; + registerXaiProvider: (entry: ProviderCatalogEntry) => void; + removeCodexProvider: (name: string) => void; + removeXaiProvider: (name: string) => void; + setCommandMessage: Dispatch>; + onCredentialFailureRef: { current: () => void }; +}; + +export type ProviderAuthController = { + unauthedProviders: ReadonlySet; + setUnauthedProviders: Dispatch>>; + loginModal: LoginModal; + setLoginModal: Dispatch>; + autoLoginProfile: string | undefined; + setAutoLoginProfile: Dispatch>; + codexProfileNames: string[]; + xaiProfileNames: string[]; + refreshAuthState: () => void; + promptCodexRelogin: (name: string) => void; + promptXaiRelogin: (name: string) => void; + switchToCodexProfile: (name: string) => void; + switchToXaiProfile: (name: string) => void; + removeCodexProfileEverywhere: (name: string) => void; + removeXaiProfileEverywhere: (name: string) => void; +}; + +export function useProviderAuth({ + provider, + providerCatalog, + registerCodexProvider, + registerXaiProvider, + removeCodexProvider, + removeXaiProvider, + setCommandMessage, + onCredentialFailureRef, +}: UseProviderAuthArgs): ProviderAuthController { + const [unauthedProviders, setUnauthedProviders] = useState>(() => new Set()); + const [loginModal, setLoginModal] = useState(null); + const [autoLoginProfile, setAutoLoginProfile] = useState(undefined); + + // Updated every render so the stream callback always sees the current provider. + onCredentialFailureRef.current = () => { + if (loginModal !== null) return; + const xaiName = xaiProfileFromProviderName(provider); + const codexName = codexProfileFromProviderName(provider); + if (xaiName !== undefined) { + void getValidXaiToken(xaiName).then( + () => { + // Token is locally valid but the proxy returned 403 — subscription or + // account-level access issue, not a bad token. Re-authing won't help. + setCommandMessage( + `Grok 403: "${xaiName}" has a valid token but the proxy rejected the request. ` + + `Check your SuperGrok or X Premium+ subscription at grok.com.`, + ); + }, + (err: unknown) => { + if (err instanceof XaiAuthError) { + setAutoLoginProfile(xaiName); + setLoginModal("xai"); + } + }, + ); + } else if (codexName !== undefined) { + // Access token rejected by the provider (or refresh already dead): open + // the browser re-auth flow instead of leaving the user on a 401 banner. + setAutoLoginProfile(codexName); + setLoginModal("codex"); + } else { + setAutoLoginProfile(undefined); + setLoginModal("choose"); + } + }; + + const codexProfileNames = useMemo( + () => + providerCatalog + .map((p) => p.codexProfile) + .filter((name): name is string => name !== undefined), + [providerCatalog], + ); + const xaiProfileNames = useMemo( + () => + providerCatalog + .map((p) => p.xaiProfile) + .filter((name): name is string => name !== undefined), + [providerCatalog], + ); + + // Check which OAuth providers currently have valid tokens and update the + // unauthedProviders set. Called after login/logout and when the agent modal opens. + const refreshAuthState = (): void => { + const checks = providerCatalog.flatMap((p) => { + if (p.xaiProfile !== undefined) { + const profile = p.xaiProfile; + const providerName = p.name; + return [getValidXaiToken(profile).then( + () => ({ providerName, ok: true }), + () => ({ providerName, ok: false }), + )]; + } + return []; + }); + void Promise.all(checks).then((results) => { + const unauthed = new Set(results.filter((r) => !r.ok).map((r) => r.providerName)); + setUnauthedProviders(unauthed); + }); + }; + + // Open the OAuth re-login modal for a dead/missing profile instead of dumping + // the raw 401. The modal's autoLoginProfile path starts the browser flow + // immediately so the user does not have to dig through a profile list first. + const promptCodexRelogin = (name: string): void => { + setAutoLoginProfile(name); + setLoginModal("codex"); + }; + const promptXaiRelogin = (name: string): void => { + setAutoLoginProfile(name); + setLoginModal("xai"); + }; + + const switchToCodexProfile = (name: string): void => { + void refreshCodexInstructions().catch(() => {}); + void Promise.all([getValidCodexToken(name), fetchCodexModels(name).catch(() => [])]).then( + ([token, liveModels]) => { + const accountId = token.accountId; + // Prefer the account's live model catalog; fall back to the current + // default set when empty (e.g. while rate-limited the catalog is empty). + const models = liveModels.length > 0 ? liveModels : [...CODEX_DEFAULT_MODELS]; + const defaultModel = models[0] ?? CODEX_DEFAULT_MODELS[0]; + registerCodexProvider({ + name: codexProviderName(name), + baseURL: CODEX_BASE_URL, + apiKey: token.access, + models, + defaultModel, + codexProfile: name, + ...(accountId !== undefined ? { codexAccountId: accountId } : {}), + }); + }, + (err: unknown) => { + // Refresh/token missing: drop the user into the browser re-auth flow + // rather than surfacing the provider's 401 JSON as a status line. + if (err instanceof CodexAuthError) { + promptCodexRelogin(name); + return; + } + setCommandMessage( + `Could not use Codex profile "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + }, + ); + }; + + const removeCodexProfileEverywhere = (name: string): void => { + removeCodexProvider(codexProviderName(name)); + void removeCodexProfile(name).then( + () => setCommandMessage(`Removed Codex profile "${name}".`), + (err: unknown) => setCommandMessage(`Failed to remove Codex profile "${name}": ${err instanceof Error ? err.message : String(err)}`), + ); + }; + + const switchToXaiProfile = (name: string): void => { + void getValidXaiToken(name).then( + (token) => { + const defaultModel = XAI_DEFAULT_MODELS[0]; + registerXaiProvider({ + name: xaiProviderName(name), + baseURL: XAI_BASE_URL, + apiKey: token.access, + models: [...XAI_DEFAULT_MODELS], + defaultModel, + xaiProfile: name, + }); + refreshAuthState(); + }, + (err: unknown) => { + if (err instanceof XaiAuthError) { + promptXaiRelogin(name); + return; + } + setCommandMessage( + `Could not use xAI profile "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + }, + ); + }; + + const removeXaiProfileEverywhere = (name: string): void => { + removeXaiProvider(xaiProviderName(name)); + void removeXaiProfile(name).then( + () => setCommandMessage(`Removed xAI profile "${name}".`), + (err: unknown) => setCommandMessage(`Failed to remove xAI profile "${name}": ${err instanceof Error ? err.message : String(err)}`), + ); + }; + + return { + unauthedProviders, + setUnauthedProviders, + loginModal, + setLoginModal, + autoLoginProfile, + setAutoLoginProfile, + codexProfileNames, + xaiProfileNames, + refreshAuthState, + promptCodexRelogin, + promptXaiRelogin, + switchToCodexProfile, + switchToXaiProfile, + removeCodexProfileEverywhere, + removeXaiProfileEverywhere, + }; +} diff --git a/src/tui/hooks/use-quota-retry.ts b/src/tui/hooks/use-quota-retry.ts new file mode 100644 index 000000000..fd7ace321 --- /dev/null +++ b/src/tui/hooks/use-quota-retry.ts @@ -0,0 +1,42 @@ +import { useEffect, type RefObject } from "react"; +import type { AgentStreamView } from "../use-stream.js"; +import { shouldAutoRetryQuota } from "../quota-retry.js"; + +export type UseQuotaRetryArgs = { + state: AgentStreamView; + stateRef: RefObject; + lastSentMessageRef: RefObject; + quotaAutoRetryFiredRef: RefObject; + sendMessageRef: RefObject<(message: { text: string; attachments: [] }) => void>; +}; + +// When a quota error is active, poll once per second and auto-resubmit the +// last prompt as soon as the provider's retry-after window expires. +export function useQuotaRetry({ + state, + stateRef, + lastSentMessageRef, + quotaAutoRetryFiredRef, + sendMessageRef, +}: UseQuotaRetryArgs): void { + useEffect(() => { + if (state.quotaError === null) return; + const interval = setInterval(() => { + if ( + !shouldAutoRetryQuota({ + quotaError: stateRef.current.quotaError, + alreadyFired: quotaAutoRetryFiredRef.current, + nowMs: Date.now(), + lastSentMessage: lastSentMessageRef.current, + }) + ) { + return; + } + quotaAutoRetryFiredRef.current = true; + sendMessageRef.current({ text: lastSentMessageRef.current, attachments: [] }); + }, 1000); + return () => clearInterval(interval); + // `state` is a stable mutable object — only `quotaError` drives re-subscription. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.quotaError]); +} diff --git a/src/tui/hooks/use-transcript-layout.ts b/src/tui/hooks/use-transcript-layout.ts new file mode 100644 index 000000000..bbb3fde54 --- /dev/null +++ b/src/tui/hooks/use-transcript-layout.ts @@ -0,0 +1,289 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + buildLinesIncremental, + buildResourceBanner, + clearMarkdownLineCache, + DEFAULT_MAX_RENDERED_LOG_LINES, + maxLineOffset, + resolveViewportExpandIds, + type IncrementalLinesState, + type RenderableBlock, +} from "../components/event-log.js"; +import type { StyledLine } from "../view/index.js"; +import { subAgentScrollWindow, subAgentTranscriptWidth, renderTranscriptLines } from "../components/subagent-session-view.js"; +import { useScroll, type ScrollController } from "./use-scroll.js"; +import type { AgentStreamView } from "../use-stream.js"; +import type { SubAgentSession } from "../../subagent/index.js"; + +function sortedSetKey(ids: ReadonlySet): string { + const values: string[] = []; + ids.forEach((id) => { + values.push(id); + }); + return values.sort().join("\x1f"); +} + +function sameStringSet(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + return sortedSetKey(a) === sortedSetKey(b); +} + +export type UseTranscriptLayoutArgs = { + state: AgentStreamView; + contentWidth: number; + thinkingExpanded: boolean; + expandedTools: ReadonlySet; + verbose: boolean; + visibleRows: number; + loadedSkills: readonly { name: string }[] | undefined; + activePlugins: readonly string[] | undefined; + cwd: string; + telemetryNotice: string | undefined; + enteredSession: SubAgentSession | undefined; +}; + +export type TranscriptLayoutController = { + eventLogLines: StyledLine[]; + scrollMaxOffset: number; + scroll: ScrollController; + enteredScroll: ScrollController; + activeScroll: ScrollController; + lastToolId: string | null; + viewportExpandedIds: Set; + setViewportExpandedIds: (ids: Set) => void; + prefixLineCount: number; + incrementalLinesRef: { current: IncrementalLinesState | undefined }; + baseLinesRef: { current: IncrementalLinesState | undefined }; +}; + +export function useTranscriptLayout({ + state, + contentWidth, + thinkingExpanded, + expandedTools, + verbose, + visibleRows, + loadedSkills, + activePlugins, + cwd, + telemetryNotice, + enteredSession, +}: UseTranscriptLayoutArgs): TranscriptLayoutController { + // Cleared when layout width or thinking expand change — those affect all blocks. + // Verbose no longer invalidates the cache: each block already keys collapsed vs + // expanded layouts separately, and Ctrl+O only expands a viewport-local subset. + const lineCacheRef = useRef(new Map()); + const baseLinesRef = useRef(undefined); + const incrementalLinesRef = useRef(undefined); + const lineCacheKeysRef = useRef({ contentWidth, thinkingExpanded }); + if ( + lineCacheKeysRef.current.contentWidth !== contentWidth || + lineCacheKeysRef.current.thinkingExpanded !== thinkingExpanded + ) { + lineCacheRef.current.clear(); + clearMarkdownLineCache(); + baseLinesRef.current = undefined; + incrementalLinesRef.current = undefined; + lineCacheKeysRef.current = { contentWidth, thinkingExpanded }; + } + + // Tools Ctrl+O expands for the current viewport (± buffer). Refreshed after + // scroll in a layout effect so line layout can depend on a stable Set. + const [viewportExpandedIds, setViewportExpandedIds] = useState>(() => new Set()); + + const explicitExpandKey = useMemo( + () => sortedSetKey(expandedTools), + [expandedTools], + ); + + const baseLayoutKey = useMemo( + () => [ + contentWidth, + thinkingExpanded ? "1" : "0", + explicitExpandKey, + String(state.currentPlanStep), + state.planDeviated ? "1" : "0", + ].join("|"), + [contentWidth, thinkingExpanded, explicitExpandKey, state.currentPlanStep, state.planDeviated], + ); + + const isExplicitlyExpanded = useMemo( + () => (block: RenderableBlock) => expandedTools.has(block.id), + [expandedTools], + ); + + // Collapsed layout (explicit Ctrl+R expands only). Reused as the display when + // verbose is off so toggling Ctrl+O does not throw away the warm incremental state. + const membershipBase = useMemo( + () => { + const next = buildLinesIncremental( + baseLinesRef.current, + state.contentBlocks, + contentWidth, + thinkingExpanded, + isExplicitlyExpanded, + lineCacheRef.current, + { currentStep: state.currentPlanStep, deviated: state.planDeviated }, + baseLayoutKey, + DEFAULT_MAX_RENDERED_LOG_LINES, + ); + baseLinesRef.current = next; + return next; + }, + // lineCacheRef is a stable ref — intentionally not in the dep array. + // eslint-disable-next-line react-hooks/exhaustive-deps + [state.displayRevision, baseLayoutKey, contentWidth, thinkingExpanded, isExplicitlyExpanded, state.currentPlanStep, state.planDeviated], + ); + + const resourceBanner = useMemo( + () => buildResourceBanner(loadedSkills ?? [], activePlugins ?? [], contentWidth, cwd, telemetryNotice), + [loadedSkills, activePlugins, contentWidth, cwd, telemetryNotice], + ); + + const prefixLineCount = + resourceBanner.length + (state.trimmedBlockCount > 0 ? 2 : 0); + + const viewportExpandKey = useMemo( + () => { + if (!verbose || viewportExpandedIds.size === 0) return ""; + return sortedSetKey(viewportExpandedIds); + }, + [verbose, viewportExpandedIds], + ); + + const linesLayoutKey = useMemo( + () => [ + baseLayoutKey, + verbose ? "1" : "0", + viewportExpandKey, + ].join("|"), + [baseLayoutKey, verbose, viewportExpandKey], + ); + + const isViewportExpanded = useMemo( + () => { + if (!verbose || viewportExpandedIds.size === 0) return isExplicitlyExpanded; + return (block: RenderableBlock) => + expandedTools.has(block.id) || viewportExpandedIds.has(block.id); + }, + [verbose, viewportExpandedIds, expandedTools, isExplicitlyExpanded], + ); + + const eventLogLines = useMemo( + () => { + let next: IncrementalLinesState; + if (!verbose) { + next = membershipBase; + incrementalLinesRef.current = next; + } else { + next = buildLinesIncremental( + incrementalLinesRef.current, + state.contentBlocks, + contentWidth, + thinkingExpanded, + isViewportExpanded, + lineCacheRef.current, + { currentStep: state.currentPlanStep, deviated: state.planDeviated }, + linesLayoutKey, + DEFAULT_MAX_RENDERED_LOG_LINES, + ); + incrementalLinesRef.current = next; + } + return state.trimmedBlockCount > 0 + ? [ + ...resourceBanner, + [ + { text: `↑ ${state.trimmedBlockCount} earlier message${state.trimmedBlockCount === 1 ? "" : "s"} trimmed to keep the session responsive`, dim: true }, + ] satisfies StyledLine, + [], + ...next.lines, + ] + : [...resourceBanner, ...next.lines]; + }, + // lineCacheRef is a stable ref — intentionally not in the dep array. + // eslint-disable-next-line react-hooks/exhaustive-deps + [state.displayRevision, state.trimmedBlockCount, membershipBase, linesLayoutKey, contentWidth, thinkingExpanded, verbose, isViewportExpanded, state.currentPlanStep, state.planDeviated, resourceBanner], + ); + const scrollMaxOffset = maxLineOffset(eventLogLines, visibleRows); + + const lastToolId = useMemo(() => { + const blocks = state.contentBlocks; + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i]?.type === "tool_call") return blocks[i]!.id; + } + return null; + }, [state.contentBlocks]); + + const scroll = useScroll({ maxOffset: scrollMaxOffset }); + + // The entered child view owns its own scroll: the parent transcript and the + // child transcript have unrelated line counts, so one shared offset would + // scroll the hidden parent while the child stayed pinned to its newest rows. + const enteredTranscriptLineCount = useMemo(() => { + if (enteredSession === undefined) return 0; + return renderTranscriptLines( + enteredSession.entries, + subAgentTranscriptWidth(contentWidth), + ).length; + }, [enteredSession, contentWidth]); + const enteredScrollMaxOffset = subAgentScrollWindow( + enteredTranscriptLineCount, + visibleRows, + 0, + ).maxOffset; + const enteredScroll = useScroll({ maxOffset: enteredScrollMaxOffset }); + const activeScroll = enteredSession !== undefined ? enteredScroll : scroll; + + // Ctrl+O expands tools intersecting the visible window. Membership uses the + // *display* layout (same line space as scrollOffset) so mid-scroll tracking + // stays correct after tools grow. Sticky hold + tool-count cap keep the set + // from thrashing or exploding under dense tool rows. Toggle seeds the set + // synchronously so the first verbose paint is already expanded. + useLayoutEffect(() => { + if (!verbose) { + if (viewportExpandedIds.size > 0) setViewportExpandedIds(new Set()); + return; + } + + const layout = incrementalLinesRef.current; + if (layout === undefined) return; + + const nextIds = resolveViewportExpandIds({ + blocks: layout.blocks, + blockLineStarts: layout.blockLineStarts, + lineCount: layout.lines.length, + prefixLineCount, + visibleRows, + scrollOffset: scroll.scrollOffset, + atBottom: scroll.atBottom, + previousIds: viewportExpandedIds, + }); + + if (sameStringSet(nextIds, viewportExpandedIds)) return; + setViewportExpandedIds(nextIds); + }, [ + verbose, + scroll.scrollOffset, + scroll.atBottom, + visibleRows, + // Recompute when either layout changes (content, expand set, prefix). + membershipBase, + eventLogLines, + prefixLineCount, + viewportExpandedIds, + ]); + + return { + eventLogLines, + scrollMaxOffset, + scroll, + enteredScroll, + activeScroll, + lastToolId, + viewportExpandedIds, + setViewportExpandedIds, + prefixLineCount, + incrementalLinesRef, + baseLinesRef, + }; +} diff --git a/src/tui/mention-resolution.ts b/src/tui/mention-resolution.ts new file mode 100644 index 000000000..52538b9de --- /dev/null +++ b/src/tui/mention-resolution.ts @@ -0,0 +1,126 @@ +import { readFile, opendir, realpath, stat } from "node:fs/promises"; +import { resolve, isAbsolute } from "node:path"; +import { isSensitivePath } from "../plugins/secret-guard-plugin.js"; +import { createPathRestriction, type PathRestriction } from "../permission/path-restriction.js"; +import { createWorktreeRootsProvider } from "../permission/worktrees.js"; + +const MAX_MENTION_FILE_BYTES = 200_000; +const MAX_MENTION_TOTAL_BYTES = 400_000; +const MAX_MENTION_COUNT = 5; +const MAX_DIRECTORY_SUMMARY_ENTRIES = 200; +const MAX_DIRECTORY_NAMES = 20; + +async function resolveMentionPath( + cwd: string, + path: string, + pathRestriction: PathRestriction, +): Promise<{ ok: true; abs: string } | { ok: false; reason: string }> { + if (path === "~" || path.startsWith("~/")) { + return { ok: false, reason: "home-relative paths are not supported" }; + } + + let abs: string; + try { + abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path)); + } catch { + return { ok: false, reason: "not found" }; + } + + if (pathRestriction.isRestricted(abs, false)) { + return { ok: false, reason: "outside workspace" }; + } + + return { ok: true, abs }; +} + +async function summarizeDir(abs: string): Promise { + let scanned = 0; + let files = 0; + let dirs = 0; + const dirNames: string[] = []; + const directory = await opendir(abs).catch(() => null); + if (directory === null) return "unreadable directory"; + + for await (const entry of directory) { + if (scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES) break; + scanned++; + if (entry.isFile()) files++; + if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { + dirs++; + if (dirNames.length < MAX_DIRECTORY_NAMES) dirNames.push(`${entry.name}/`); + } + } + + const dirList = dirNames.join(", "); + const parts: string[] = []; + if (files > 0) parts.push(`${files}${scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES ? "+" : ""} file${files === 1 ? "" : "s"}`); + if (dirs > 0) parts.push(`${dirs}${scanned >= MAX_DIRECTORY_SUMMARY_ENTRIES ? "+" : ""} subdirector${dirs === 1 ? "y" : "ies"}${dirList ? ` (${dirList})` : ""}`); + return parts.length > 0 ? parts.join(", ") : "empty directory"; +} + +export async function resolveAtMentions(message: string, cwd: string): Promise { + const pattern = /@("([^"]+)"|(\S+))/g; + const mentions: Array<{ full: string; path: string }> = []; + let m: RegExpExecArray | null; + while ((m = pattern.exec(message)) !== null) { + const path = m[2] ?? m[3] ?? ""; + if (path.length > 0) mentions.push({ full: m[0], path }); + } + if (mentions.length === 0) return message; + + // Mirrors the permission gate's own containment check (see gate.ts): the + // gate resolves paths against cwd plus every registered git worktree of + // this session, so an @mention into a sibling worktree must resolve the + // same way rather than being wrongly rejected as an escape. + const pathRestriction = createPathRestriction(cwd, createWorktreeRootsProvider(cwd)); + const replacements: Array<{ full: string; replacement: string }> = []; + let totalBytes = 0; + + for (const [index, { full, path }] of mentions.entries()) { + if (index >= MAX_MENTION_COUNT) { + replacements.push({ full, replacement: `${full} (blocked: too many @mentions; max ${MAX_MENTION_COUNT})` }); + continue; + } + if (isSensitivePath(path)) { + replacements.push({ full, replacement: `${full} (blocked: sensitive path)` }); + continue; + } + const resolved = await resolveMentionPath(cwd, path, pathRestriction); + if (!resolved.ok) { + replacements.push({ full, replacement: `${full} (blocked: ${resolved.reason})` }); + continue; + } + if (isSensitivePath(resolved.abs)) { + replacements.push({ full, replacement: `${full} (blocked: sensitive path)` }); + continue; + } + try { + const info = await stat(resolved.abs); + if (info.isDirectory()) { + const summary = await summarizeDir(resolved.abs); + replacements.push({ full, replacement: `\`${path}\` (directory - ${summary})` }); + continue; + } + if (info.size > MAX_MENTION_FILE_BYTES) { + replacements.push({ full, replacement: `${full} (blocked: file is too large; max ${MAX_MENTION_FILE_BYTES} bytes)` }); + continue; + } + if (totalBytes + info.size > MAX_MENTION_TOTAL_BYTES) { + replacements.push({ full, replacement: `${full} (blocked: total @mention content is too large; max ${MAX_MENTION_TOTAL_BYTES} bytes)` }); + continue; + } + const content = await readFile(resolved.abs, "utf-8"); + totalBytes += info.size; + const ext = resolved.abs.split(".").pop() ?? ""; + replacements.push({ full, replacement: `\`${path}\`:\n\`\`\`${ext}\n${content}\n\`\`\`` }); + } catch { + replacements.push({ full, replacement: `${full} (not found)` }); + } + } + + let result = message; + for (const { full, replacement } of replacements) { + result = result.replace(full, () => replacement); + } + return result; +} diff --git a/src/tui/message-types.ts b/src/tui/message-types.ts new file mode 100644 index 000000000..4c965a8d7 --- /dev/null +++ b/src/tui/message-types.ts @@ -0,0 +1,6 @@ +import type { PendingImageAttachment } from "./image-attachments.js"; + +export type OutboundUserMessage = { + text: string; + attachments: PendingImageAttachment[]; +}; diff --git a/src/tui/stall-watchdog.ts b/src/tui/stall-watchdog.ts new file mode 100644 index 000000000..795feb590 --- /dev/null +++ b/src/tui/stall-watchdog.ts @@ -0,0 +1,57 @@ +import type { AgentStatus } from "./use-stream.js"; +import { + INFERENCE_ABORT_INTERNAL_RECOVERY, + type InferenceAbortReason, +} from "../inference-abort.js"; + +// How long the run can be continuously awaiting a response with no new content +// before the watchdog fires and aborts the in-flight request. +export const STALL_TIMEOUT_MS = 900_000; + +export type ShouldAbortForStallArgs = { + status: AgentStatus; + awaitingResponse: boolean; + lastActivityAt: number; + nowMs: number; + stallTimeoutMs: number; + isProcessing: boolean; + streamingType: "text" | "thinking" | "tool" | null; +}; + +// Pure decision helper: returns true when the run is genuinely stuck and should +// be aborted. Extracted so the timeout logic is unit-testable without a React harness. +export function shouldAbortForStall({ + status, + awaitingResponse, + lastActivityAt, + nowMs, + stallTimeoutMs, + isProcessing, + streamingType, +}: ShouldAbortForStallArgs): boolean { + if (status !== "running") return false; + const stalled = nowMs - lastActivityAt >= stallTimeoutMs; + if (!stalled) return false; + if (awaitingResponse) return true; + // Mid-stream hang: model stream stalled after first token. Long + // in-flight tool runs do not emit parent stream events; do not abort those. + if ( + isProcessing && + streamingType !== null && + streamingType !== "tool" + ) { + return true; + } + return false; +} + +export type ApplyStallRecoveryDeps = { + abortInFlight: (reason: InferenceAbortReason) => void; + setCommandMessage: (message: string) => void; +}; + +/** Abort the in-flight send; ChatDirector continues via infer() on internal-recovery. */ +export function applyStallRecovery(deps: ApplyStallRecoveryDeps): void { + deps.abortInFlight(INFERENCE_ABORT_INTERNAL_RECOVERY); + deps.setCommandMessage("Recovering after an internal stall..."); +} diff --git a/tests/unit/tui/at-mention-resolution.test.ts b/tests/unit/tui/at-mention-resolution.test.ts index bcc33262f..60579000c 100644 --- a/tests/unit/tui/at-mention-resolution.test.ts +++ b/tests/unit/tui/at-mention-resolution.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; -import { resolveAtMentions } from "../../../src/tui/app.js"; +import { resolveAtMentions } from "../../../src/tui/mention-resolution.js"; const execFileAsync = promisify(execFile); diff --git a/tests/unit/tui/stall-watchdog.test.ts b/tests/unit/tui/stall-watchdog.test.ts index 2a7e3d791..be69398a6 100644 --- a/tests/unit/tui/stall-watchdog.test.ts +++ b/tests/unit/tui/stall-watchdog.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { applyStallRecovery, shouldAbortForStall } from "../../../src/tui/app.js"; +import { applyStallRecovery, shouldAbortForStall } from "../../../src/tui/stall-watchdog.js"; import { INFERENCE_ABORT_INTERNAL_RECOVERY } from "../../../src/inference-abort.js"; const base = {