/**
* ChatApp — whale code CLI
*
* Uses Ink's <Static> for completed messages — written to stdout once,
* never re-rendered. Only the active area (streaming, tools, input)
* is managed by Ink's render loop. This prevents scroll bounce when
* content exceeds the terminal height.
*/
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { Box, Text, Static, useApp, useInput } from "ink";
import Spinner from "ink-spinner";
import type Anthropic from "@anthropic-ai/sdk";
import {
canUseAgent, getServerToolCount, getModelShortName,
setModel, getPermissionMode, mcpClientManager,
} from "../services/agent-loop.js";
import { type ChatMessage, type ToolCall } from "./MessageList.js";
import { CompletedMessage } from "./MessageList.js";
import { ToolIndicator } from "./ToolIndicator.js";
import { SubagentPanel, type SubagentActivityState, type CompletedSubagentInfo } from "./SubagentPanel.js";
import { TeamPanel } from "./TeamPanel.js";
import { StreamingText } from "./StreamingText.js";
import { ChatInput } from "./ChatInput.js";
import { StoreSelector } from "./StoreSelector.js";
import { ModelSelector, type ModelOption } from "./ModelSelector.js";
import { RewindViewer, RewindOutcome } from "./RewindViewer.js";
import { RewindManager } from "../services/rewind.js";
import { colors, symbols } from "../shared/Theme.js";
import { loadKeybindings, matchesBinding } from "../services/keybinding-manager.js";
import { loadConfig, updateConfig } from "../services/config-store.js";
import type { StoreInfo } from "../services/auth-service.js";
import { createRequire } from "module";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
// Extracted hooks
import { useSlashCommands } from "./hooks/useSlashCommands.js";
import { useAgentLoop } from "./hooks/useAgentLoop.js";
// Thinking verbs — rotate randomly each render (Claude Code parity)
const THINKING_VERBS = [
"thinking…",
"reasoning…",
"considering…",
"analyzing…",
"evaluating…",
"pondering…",
"processing…",
"reflecting…",
"examining…",
"working…",
];
function randomVerb(): string {
return THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
}
const PKG_NAME = "swagmanager-mcp";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PKG_VERSION: string = createRequire(import.meta.url)(join(__dirname, "..", "..", "..", "package.json")).version;
// ── Types for Static rendering ──
type StaticItem =
| { id: string; type: "header" }
| { id: string; type: "message"; msg: ChatMessage; index: number };
// ── Component ──
export function ChatApp() {
const { exit } = useApp();
// Core state
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [activeTools, setActiveTools] = useState<ToolCall[]>([]);
const [subagentActivity, setSubagentActivity] = useState<Map<string, SubagentActivityState>>(new Map());
const [completedSubagents, setCompletedSubagents] = useState<CompletedSubagentInfo[]>([]);
const [teamState, setTeamState] = useState<{
name: string;
tasksCompleted: number;
tasksTotal: number;
teammates: Map<string, { name: string; status: string }>;
} | null>(null);
// UI state
const [userLabel, setUserLabel] = useState("");
const [toolsExpanded, setToolsExpanded] = useState(false);
const [serverToolsAvailable, setServerToolsAvailable] = useState(0);
const [storeSelectMode, setStoreSelectMode] = useState(false);
const [storeList, setStoreList] = useState<StoreInfo[]>([]);
const [modelSelectMode, setModelSelectMode] = useState(false);
const [currentModel, setCurrentModel] = useState(getModelShortName());
const [sessionId, setSessionId] = useState<string | null>(null);
const [thinkingEnabled, setThinkingEnabled] = useState(() => {
try { return loadConfig().thinking_enabled ?? true; } catch { return true; }
});
// Rewind state
const [showRewind, setShowRewind] = useState(false);
const rewindManagerRef = useRef(new RewindManager());
const turnIndexRef = useRef(0);
// Refs
const conversationRef = useRef<Anthropic.MessageParam[]>([]);
const abortRef = useRef<AbortController | null>(null);
const accTextRef = useRef("");
const textTimerRef = useRef<NodeJS.Timeout | null>(null);
const teamTimerRef = useRef<NodeJS.Timeout | null>(null);
const toolOutputTimerRef = useRef<NodeJS.Timeout | null>(null);
const thinkingVerbRef = useRef(randomVerb());
useEffect(() => {
return () => {
if (textTimerRef.current) clearTimeout(textTimerRef.current);
if (teamTimerRef.current) clearTimeout(teamTimerRef.current);
if (toolOutputTimerRef.current) clearTimeout(toolOutputTimerRef.current);
};
}, []);
// ── Init ──
useEffect(() => {
const check = canUseAgent();
if (!check.ready) {
setError(check.reason || "Run 'whale login' to authenticate.");
} else {
const config = loadConfig();
if (config.email) setUserLabel(config.store_name || config.email);
setReady(true);
getServerToolCount().then((count) => setServerToolsAvailable(count));
mcpClientManager.connectAll().catch(() => {});
}
return () => {
mcpClientManager.disconnectAll().catch(() => {});
};
}, []);
// ── Keys (configurable via ~/.swagmanager/keybindings.json) ──
const kb = useMemo(() => loadKeybindings(), []);
useInput((input, key) => {
if (matchesBinding(kb.exit, input, key)) {
if (abortRef.current) abortRef.current.abort();
exit();
}
if (matchesBinding(kb.cancel_stream, input, key) && isStreaming && abortRef.current) {
abortRef.current.abort();
}
if (matchesBinding(kb.toggle_expand, input, key)) {
setToolsExpanded((prev) => !prev);
}
if (matchesBinding(kb.toggle_thinking, input, key)) {
setThinkingEnabled((prev) => {
const next = !prev;
try { updateConfig({ thinking_enabled: next }); } catch { /* best effort */ }
setMessages((msgs) => [...msgs, {
role: "assistant",
text: ` Thinking: ${next ? "on" : "off"}`,
}]);
return next;
});
}
});
// ── Extracted hooks ──
const { handleCommand, handleStoreSelect, handleStoreCancel } = useSlashCommands({
exit, toolsExpanded, serverToolsAvailable, sessionId, thinkingEnabled,
conversationRef,
setMessages, setStreamingText, setActiveTools, setTeamState,
setStoreList, setStoreSelectMode, setModelSelectMode, setCurrentModel,
setSessionId, setThinkingEnabled, setUserLabel, setServerToolsAvailable,
setShowRewind,
rewindCheckpointCount: rewindManagerRef.current.getCheckpointCount(),
PKG_NAME, PKG_VERSION,
});
const { handleSend } = useAgentLoop({
isStreaming, thinkingEnabled, conversationRef, abortRef,
accTextRef, textTimerRef, teamTimerRef, toolOutputTimerRef, thinkingVerbRef,
rewindManagerRef, turnIndexRef,
setMessages, setStreamingText, setIsStreaming, setActiveTools,
setSubagentActivity, setCompletedSubagents, setTeamState,
});
// ── Rewind handler ──
const handleRewind = useCallback((checkpointIndex: number, outcome: RewindOutcome) => {
const rm = rewindManagerRef.current;
if (outcome === RewindOutcome.Cancel) {
setShowRewind(false);
return;
}
if (outcome === RewindOutcome.RevertOnly) {
// Revert files only, keep conversation
const result = rm.revertFilesFrom(checkpointIndex);
setShowRewind(false);
const total = result.filesReverted.length + result.filesDeleted.length;
const parts: string[] = [];
if (result.filesReverted.length > 0) parts.push(`${result.filesReverted.length} reverted`);
if (result.filesDeleted.length > 0) parts.push(`${result.filesDeleted.length} deleted`);
if (result.errors.length > 0) parts.push(`${result.errors.length} errors`);
setMessages(prev => [...prev, {
role: "assistant" as const,
text: total > 0
? ` ${symbols.check} File changes reverted (${parts.join(", ")})`
: ` ${symbols.check} No file changes to revert.`,
}]);
return;
}
// RewindOnly or RewindAndRevert
const shouldRevertFiles = outcome === RewindOutcome.RewindAndRevert;
const result = rm.rewindTo(checkpointIndex);
// Truncate messages
setMessages(prev => prev.slice(0, result.messageCount));
// Truncate conversation history to match
// We need to figure out how many Anthropic messages correspond to the checkpoint.
// The checkpoint stores messageCount which is the UI messages count at that point.
// Rebuild conversation from remaining messages by keeping only the conversation
// entries up to the corresponding turn.
const targetTurnIndex = rm.getCheckpoints()[checkpointIndex]?.turnIndex ?? 0;
// Each user/assistant exchange is roughly 2 conversation entries (user + assistant).
// But the actual conversation is maintained independently.
// We slice it to 2*(turnIndex+1) entries (user+assistant pairs)
const conversationSlicePoint = Math.min(
(targetTurnIndex + 1) * 2,
conversationRef.current.length,
);
conversationRef.current = conversationRef.current.slice(0, conversationSlicePoint);
// Reset turn index to match
turnIndexRef.current = targetTurnIndex + 1;
setShowRewind(false);
const parts: string[] = [];
if (shouldRevertFiles) {
if (result.filesReverted.length > 0) parts.push(`${result.filesReverted.length} files reverted`);
if (result.filesDeleted.length > 0) parts.push(`${result.filesDeleted.length} files deleted`);
}
if (result.errors.length > 0) parts.push(`${result.errors.length} revert errors`);
const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
setMessages(prev => [...prev, {
role: "assistant" as const,
text: ` ${symbols.check} Rewound to turn ${targetTurnIndex + 1}${detail}`,
}]);
}, []);
// ── Render ──
const termWidth = process.stdout.columns || 80;
const contentWidth = Math.max(20, termWidth - 2);
const { staticItems, dynamicMessages } = useMemo(() => {
const items: StaticItem[] = [{ id: "header", type: "header" }];
const cutoff = Math.max(0, messages.length - 1);
for (let i = 0; i < cutoff; i++) {
items.push({ id: `msg-${i}`, type: "message", msg: messages[i], index: i });
}
const tail = messages.length > 0 ? [messages[messages.length - 1]] : [];
return { staticItems: items, dynamicMessages: tail };
}, [messages]);
if (error) {
return (
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
<Text>{" "}</Text>
<Text color={colors.brand} bold>◆ whale code</Text>
<Text>{" "}</Text>
<Text color={colors.error}>{error}</Text>
</Box>
);
}
if (!ready) {
return (
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
<Text color={colors.tertiary}>loading...</Text>
</Box>
);
}
return (
<Box flexDirection="column" paddingLeft={1} paddingRight={1}>
{/* Static area: header + all-but-last messages — committed to stdout, immune to re-renders */}
<Static items={staticItems}>
{(item: StaticItem) => {
if (item.type === "header") {
return (
<Box key={item.id} flexDirection="column">
<Text>{" "}</Text>
<Text>
<Text color={colors.brand} bold>◆ whale code</Text>
{userLabel ? <Text color={colors.dim}> {userLabel}</Text> : null}
<Text color={colors.dim}> {currentModel}</Text>
{thinkingEnabled ? <Text color={colors.warning}> thinking</Text> : null}
{getPermissionMode() !== "default" && (
<Text color={getPermissionMode() === "yolo" ? colors.error : colors.info}> {getPermissionMode()}</Text>
)}
{serverToolsAvailable > 0 ? (
<Text color={colors.tertiary}> {symbols.dot} {serverToolsAvailable} server tools</Text>
) : null}
</Text>
<Text color={colors.separator}>{"─".repeat(contentWidth)}</Text>
</Box>
);
}
return <CompletedMessage key={item.id} msg={item.msg} index={item.index} toolsExpanded={toolsExpanded} />;
}}
</Static>
{/* Last completed message — stays in dynamic area so content doesn't vanish from viewport */}
{dynamicMessages.map((msg) => (
<CompletedMessage key={`dynamic-${messages.length - 1}`} msg={msg} index={messages.length - 1} toolsExpanded={toolsExpanded} />
))}
{/* During team mode: tree-style teammate status */}
{teamState ? (
<TeamPanel team={teamState} />
) : (
<>
{/* Thinking */}
{isStreaming && !streamingText && activeTools.length === 0 && (
<Box marginLeft={2} marginY={1}>
<Text color={colors.brand}><Spinner type="dots" /></Text>
<Text color={colors.dim}> {thinkingVerbRef.current}</Text>
</Box>
)}
{/* Running tools (completed tools are already in the message feed) */}
{activeTools.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
{activeTools.map((tc, i) => (
<Box key={`live-${tc.name}-${i}`} flexDirection="column">
<ToolIndicator
id={`live-${tc.name}-${i}`}
name={tc.name}
status={tc.status}
input={tc.input}
expanded={toolsExpanded}
/>
{/* Show subagent activity below running task tools */}
{tc.name === "task" && tc.status === "running" && (subagentActivity.size > 0 || completedSubagents.length > 0) && (
<SubagentPanel running={subagentActivity} completed={completedSubagents} />
)}
</Box>
))}
</Box>
)}
{/* Streaming text */}
{streamingText && (
<Box marginLeft={2}>
<StreamingText text={streamingText} />
</Box>
)}
</>
)}
{/* Input */}
{showRewind ? (
<RewindViewer
checkpoints={rewindManagerRef.current.getCheckpoints()}
onRewind={handleRewind}
onCancel={() => setShowRewind(false)}
/>
) : storeSelectMode ? (
<StoreSelector
stores={storeList}
currentStoreId={loadConfig().store_id || ""}
onSelect={handleStoreSelect}
onCancel={handleStoreCancel}
/>
) : modelSelectMode ? (
<ModelSelector
currentModel={currentModel}
onSelect={(model: ModelOption) => {
setModelSelectMode(false);
setModel(model.value);
setCurrentModel(model.value);
setMessages((prev) => [...prev, {
role: "assistant" as const,
text: ` ${symbols.check} Model: ${model.label} (${model.modelId})`,
}]);
}}
onCancel={() => setModelSelectMode(false)}
/>
) : (
<ChatInput
onSubmit={handleSend}
onCommand={handleCommand}
disabled={isStreaming}
agentName="whale code"
/>
)}
</Box>
);
}