Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
Size: Mime:
/**
 * Optimized main application component
 * Uses atomic state management and optimized components
 */

import React, {
  useState,
  useCallback,
  useEffect,
  useRef,
  useLayoutEffect,
} from "react";
import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
import { useRPC } from "../hooks/useRPC.js";
import { useMessagesOptimized } from "../hooks/useMessagesNew.js";
import {
  useMessages,
  useThinking,
  useApproval,
  useUsage,
  useSessionModel,
  useSessionId,
  useCurrentRunId,
  usePreamble,
  useActions,
} from "../store/appStore.js";
import type { ApprovalInfo } from "../store/appStore.js";
import { Header } from "./Header.js";
import { Logo } from "./Logo.js";
import { MessageListOptimized } from "./MessageListOptimized.js";
import { StatusIndicator } from "./StatusIndicator.js";
import { MultiLineInputOptimized } from "./MultiLineInputOptimized.js";
import { ApprovalDialog } from "./ApprovalDialog.js";
import { Preamble } from "./Preamble.js";
import { Help } from "./Help.js";
import { SessionList } from "./SessionList.js";
import { Scrollable } from "./Scrollable.js";
import { MouseProvider } from "../contexts/MouseContext.js";
import { ScrollProvider } from "../contexts/ScrollProvider.js";
import { TokyoNightTheme } from "../theme.js";

/**
 * Wrapper to keep ApprovalDialog mounted to prevent flicker
 */
const ApprovalDialogWrapper = React.memo<{
  approval: ApprovalInfo | null;
  onApprove: (value: "yes" | "always" | "no") => void;
}>(
  ({ approval, onApprove }) => {
    if (!approval) {
      // Render empty placeholder to keep component mounted
      return null;
    }

    return (
      <ApprovalDialog
        tool={approval.tool}
        arguments={approval.arguments}
        metadata={approval.metadata}
        onApprove={onApprove}
      />
    );
  },
  (prev, next) => {
    // Only re-render if approval actually changed
    if (prev.approval === next.approval) return true;
    if (!prev.approval || !next.approval) return false;
    return prev.approval.request_id === next.approval.request_id;
  },
);

/**
 * Scrollable message list with keyboard and mouse support
 */
const ScrollableMessageList = React.memo<{
  availableHeight: number;
  onApprove: (value: "yes" | "always" | "no") => void;
}>(
  ({ availableHeight, onApprove }) => {
    const messages = useMessages();
    const approval = useApproval();
    const preamble = usePreamble();
    const contentVersion = messages.length;
    return (
      <Scrollable
        hasFocus={true}
        scrollToBottom={true}
        maxHeight={availableHeight}
        flexGrow={1}
        contentVersion={contentVersion}
      >
        <Box flexDirection="column">
          <MessageListOptimized />

          {approval && (
            <Box marginTop={1}>
              <ApprovalDialogWrapper approval={approval} onApprove={onApprove} />
            </Box>
          )}

          {preamble && (
            <Preamble content={preamble} />
          )}
        </Box>
      </Scrollable>
    );
  },
  (prev, next) => prev.availableHeight === next.availableHeight,
);

/**
 * Isolate StatusIndicator subscription to prevent parent re-renders
 */
const StatusIndicatorWrapper = React.memo<{
  approval: ApprovalInfo | null;
  thinking: boolean;
}>(
  ({ approval, thinking }) => {
    return (
      <Box
        paddingX={1}
        marginTop={1}
        display={!approval ? "flex" : "none"}
      >
        <StatusIndicator />
      </Box>
    );
  },
  (prev, next) => {
    return prev.approval === next.approval && prev.thinking === next.thinking;
  },
);

/**
 * Isolate Help subscription to prevent parent re-renders
 */
const HelpWrapper = React.memo<{
  connected: boolean;
  thinking: boolean;
  approval: ApprovalInfo | null;
  usage: ReturnType<typeof useUsage>;
  sessionModel: string | null;
  waitingForSecondCtrlC: boolean;
}>(
  ({ connected, thinking, approval, usage, sessionModel, waitingForSecondCtrlC }) => {
    return (
      <Box paddingX={1} paddingBottom={1}>
        <Help
          isThinking={thinking}
          isPendingApproval={!!approval}
          connected={connected}
          usage={usage}
          sessionModel={sessionModel}
          waitingForSecondCtrlC={waitingForSecondCtrlC}
        />
      </Box>
    );
  },
  (prev, next) => {
    return (
      prev.connected === next.connected &&
      prev.thinking === next.thinking &&
      prev.approval === next.approval &&
      prev.usage === next.usage &&
      prev.sessionModel === next.sessionModel &&
      prev.waitingForSecondCtrlC === next.waitingForSecondCtrlC
    );
  },
);

export interface AppProps {
  serverUrl: string;
  agentName: string;
  token?: string;
  debug?: boolean;
  sessionId?: string;
  resumeMode?: boolean;
  welcomeText?: string;
  initialMessage?: string;
  enableMouse?: boolean;
}

// UI state machine for proper flow control
type UIState = "connecting" | "session_select" | "chat" | "error";

export const App: React.FC<AppProps> = ({
  serverUrl,
  agentName,
  token,
  debug,
  sessionId: initialSessionId,
  resumeMode = false,
  welcomeText,
  initialMessage,
  enableMouse = true,
}) => {
  const [messageHistory, setMessageHistory] = useState<string[]>([]);
  const [uiState, setUIState] = useState<UIState>("connecting");
  const [availableSessions, setAvailableSessions] = useState<
    Array<{
      id: string;
      created_at: string;
      archived: boolean;
      message_count: number;
      first_message?: unknown;
      last_message?: unknown;
    }>
  >([]);
  const [selectedSessionIndex, setSelectedSessionIndex] = useState(0);

  // Get terminal dimensions for scrolling calculations
  const { stdout } = useStdout();
  const terminalHeight = stdout?.rows || 24;

  // RPC connection
  const { client, connected, rpcReady, error, sendMessage } = useRPC({
    url: serverUrl,
    token,
    debug,
  });

  // Get state from atomic store
  const messages = useMessages();
  const thinking = useThinking();
  const approval = useApproval();
  const usage = useUsage();
  const sessionModel = useSessionModel();
  const sessionId = useSessionId();
  const currentRunId = useCurrentRunId();
  const actions = useActions();

  // Measure controls/footer height dynamically (like gemini-cli)
  const controlsRef = useRef<any>(null);
  const [controlsHeight, setControlsHeight] = useState(0);

  useLayoutEffect(() => {
    if (controlsRef.current) {
      const measurement = measureElement(controlsRef.current);
      if (measurement.height > 0) {
        setControlsHeight(measurement.height);
      }
    }
  }, [terminalHeight, approval, thinking]);

  // Calculate available height for messages (gemini-cli approach)
  const availableTerminalHeight = Math.max(
    5,
    terminalHeight - controlsHeight - 2,
  );

  // Ctrl+C handling - require double tap to quit
  const { exit } = useApp();
  const ctrlCCount = useRef(0);
  const ctrlCTimer = useRef<NodeJS.Timeout | null>(null);
  const [waitingForSecondCtrlC, setWaitingForSecondCtrlC] = useState(false);

  // Setup message handlers with batching
  const { approveToolCall, cancelRun } = useMessagesOptimized({
    client,
    sessionId: sessionId ?? initialSessionId,
    rpcReady,
  });

  // Set initial session ID
  useEffect(() => {
    if (initialSessionId) {
      actions.setSessionId(initialSessionId);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [initialSessionId]);

  // Load global user history from server when RPC is ready
  useEffect(() => {
    if (!client || !rpcReady) {
      return;
    }

    // Fetch global history (last 200 user messages across all sessions)
    const loadHistory = async () => {
      try {
        const items = await client.getUserHistory(200, true);

        // Extract content and deduplicate
        const historyItems: string[] = [];
        const seen = new Set<string>();

        for (const item of items) {
          const content = item.content.trim();
          if (content && !seen.has(content)) {
            seen.add(content);
            historyItems.push(content);
          }
        }

        // Set as initial history
        setMessageHistory(historyItems);
      } catch (err) {
        if (debug) {
          console.error("Failed to load global history:", err);
        }
        // Don't fail - just continue without global history
      }
    };

    loadHistory();
  }, [client, rpcReady, debug]);

  // Load sessions for resume mode (wait for RPC ready to avoid race condition)
  useEffect(() => {
    if (!client || !rpcReady) {
      return;
    }

    // If resume mode and no specific session ID, show session list
    if (resumeMode && !initialSessionId) {
      const loadSessions = async () => {
        try {
          if (debug) {
            console.error("[DEBUG] Fetching sessions for resume mode...");
          }
          const sessions = await client.listSessions();
          if (debug) {
            console.error(`[DEBUG] Fetched ${sessions?.length || 0} sessions`);
          }
          if (sessions && sessions.length > 0) {
            setAvailableSessions(sessions);
            setUIState("session_select");
            if (debug) {
              console.error("[DEBUG] UI state: session_select");
            }
          } else {
            // No sessions - go directly to chat
            setUIState("chat");
            if (debug) {
              console.error("[DEBUG] No sessions found, UI state: chat");
            }
          }
        } catch (err) {
          console.error("Failed to load sessions:", err);
          // On error, just start fresh chat
          setUIState("chat");
        }
      };

      loadSessions();
    } else if (rpcReady && !resumeMode) {
      // Not in resume mode, go straight to chat
      setUIState("chat");
      if (debug) {
        console.error("[DEBUG] Not resume mode, UI state: chat");
      }
    } else if (rpcReady && initialSessionId) {
      // Have a specific session ID, go straight to chat
      setUIState("chat");
      if (debug) {
        console.error("[DEBUG] Have session ID, UI state: chat");
      }
    }
  }, [client, rpcReady, resumeMode, initialSessionId, debug]);

  // Reflect connection status into status indicator like bubbletea
  useEffect(() => {
    if (uiState !== "chat") return;
    if (!connected) {
      actions.setStatus("Disconnected from server", "error", false);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [connected, uiState]);

  // Handle session list navigation
  const handleSessionListInput = useCallback(
    (input: string, key: any) => {
      if (debug) {
        console.error("[DEBUG] Session list input:", {
          input,
          key: Object.keys(key).filter((k) => key[k]),
        });
      }

      if (key.upArrow) {
        setSelectedSessionIndex((prev) => Math.max(0, prev - 1));
      } else if (key.downArrow) {
        setSelectedSessionIndex((prev) =>
          Math.min(availableSessions.length - 1, prev + 1),
        );
      } else if (key.return) {
        // Select session and transition to chat
        const selected = availableSessions[selectedSessionIndex];
        if (selected) {
          if (debug) {
            console.error("[DEBUG] Selected session:", selected.id);
          }
          actions.setSessionId(selected.id);
          setUIState("chat");
          if (debug) {
            console.error("[DEBUG] UI state: chat (session selected)");
          }
        }
      } else if (key.tab) {
        // Create new session and transition to chat
        if (debug) {
          console.error("[DEBUG] Creating new session (Tab pressed)");
        }
        setUIState("chat");
        if (debug) {
          console.error("[DEBUG] UI state: chat (new session)");
        }
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [availableSessions, selectedSessionIndex, debug],
  );

  // Handle approval
  const handleApproval = useCallback(
    (value: "yes" | "always" | "no") => {
      const approved = value !== "no";
      const alwaysApprove = value === "always";
      approveToolCall(approved, alwaysApprove);
    },
    [approveToolCall],
  );

  

  // Handle slash commands
  const handleSlashCommand = useCallback(
    async (cmd: string): Promise<{ sendToLLM: boolean; result?: string }> => {
      const parts = cmd.trim().split(/\s+/);
      if (parts.length === 0) {
        return { sendToLLM: false };
      }

      const name = parts[0].toLowerCase();
      const argText = cmd.slice(parts[0].length).trim();

      // Built-in: /exit
      if (name === "/exit") {
        exit();
        return { sendToLLM: false };
      }

      // Built-in /help removed; allow server function 'help' or fallback

      // Server function invocation
      try {
        const fn = name.slice(1); // Remove leading /

        // Check if this server function exists
        const funcs = await client!.listServerFunctions();
        const found = funcs.find(
          (f) => f.name.toLowerCase() === fn.toLowerCase(),
        );

        if (!found) {
          // Not a server function - fall back to LLM
          return { sendToLLM: true };
        }

        // Parse arguments
        let args: Record<string, unknown> = {};
        if (argText) {
          try {
            const parsed = JSON.parse(argText);
            if (typeof parsed === "object" && !Array.isArray(parsed)) {
              args = parsed;
            } else if (Array.isArray(parsed)) {
              args = { args: parsed };
            } else if (typeof parsed === "string") {
              args = { text: parsed };
            } else {
              args = { value: parsed };
            }
          } catch {
            // Not JSON - treat as text argument
            args = { text: argText };
          }
        }

        // Call server function
        const result = await client!.serverCall(
          fn,
          args,
          sessionId || undefined,
        );

        // Format result
        if (result.message && typeof result.message === "string") {
          return { sendToLLM: false, result: result.message };
        }

        const formatted = JSON.stringify(result, null, 2);
        return {
          sendToLLM: false,
          result:
            formatted === "null" || !formatted.trim() ? "Done." : formatted,
        };
      } catch (err) {
        return { sendToLLM: false, result: `Error: ${err}` };
      }
    },
    [client, sessionId, exit],
  );

  // Handle user input
  const handleSubmit = useCallback(
    async (message: string) => {
      if (!client || !connected) {
        return;
      }

      // Check for slash command
      if (message.startsWith("/")) {
        const { sendToLLM, result } = await handleSlashCommand(message);

        if (!sendToLLM) {
          // Show result as assistant message (if any)
          if (result) {
            actions.addMessage({
              role: "assistant",
              content: result,
              timestamp: new Date(),
            });
          }
          return;
        }
        // Fall through to send to LLM if not handled
      }

      // Add user message to UI immediately
      actions.addMessage({
        role: "user",
        content: message,
        timestamp: new Date(),
      });

      // Add to history (avoid duplicates)
      setMessageHistory((prev) => {
        if (prev.length === 0 || prev[prev.length - 1] !== message) {
          return [...prev, message];
        }
        return prev;
      });

      try {
        // Send to server and get session ID back
        const response = await sendMessage(message, sessionId);
        if (!sessionId) {
          actions.setSessionId(response.session_id);
        }
      } catch (err) {
        // Show error in status indicator instead of just logging
        const errorMessage =
          err instanceof Error ? err.message : "Failed to send message";
        actions.setThinking(false, `Error: ${errorMessage}`);
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [client, connected, sendMessage, sessionId, handleSlashCommand],
  );

  // Handle session list input (only when in session_select state)
  useInput(
    (input, key) => {
      if (debug) {
        console.error("[DEBUG] useInput triggered:", {
          input,
          key: Object.keys(key).filter((k) => key[k]),
          uiState,
          isActive: uiState === "session_select",
        });
      }
      if (uiState === "session_select") {
        handleSessionListInput(input, key);
      }
    },
    { isActive: uiState === "session_select" },
  );

  // Handle Ctrl+C - double tap to quit (like bubbletea)
  // Only active in chat mode to avoid conflicts with session selection
  useInput(
    useCallback(
      (input, key) => {
        if (key.escape) {
          if (approval) {
            handleApproval("no");
          } else if (thinking && currentRunId) {
            cancelRun(currentRunId);
          }
          return;
        }
        if (key.ctrl && input === "c") {
          ctrlCCount.current++;

          // Clear existing timer
          if (ctrlCTimer.current) {
            clearTimeout(ctrlCTimer.current);
          }

          if (ctrlCCount.current === 1) {
            // First Ctrl+C: Cancel current run if active
            if (thinking && currentRunId) {
              cancelRun(currentRunId);
            } else if (approval) {
              handleApproval("no");
            }

            // Show warning message
            setWaitingForSecondCtrlC(true);

            // Reset counter and hide warning after 1 second
            ctrlCTimer.current = setTimeout(() => {
              ctrlCCount.current = 0;
              setWaitingForSecondCtrlC(false);
            }, 1000);
          } else if (ctrlCCount.current >= 2) {
            // Second Ctrl+C within 1 second: Exit
            setWaitingForSecondCtrlC(false);
            exit();
          }
        }
      },
      [thinking, currentRunId, approval, cancelRun, handleApproval, exit],
    ),
    { isActive: uiState === "chat" || !!approval },
  );

  // Cleanup timer on unmount
  useEffect(() => {
    return () => {
      if (ctrlCTimer.current) {
        clearTimeout(ctrlCTimer.current);
      }
    };
  }, []);

  // Send initial message if provided (only in chat state)
  useEffect(() => {
    if (initialMessage && uiState === "chat" && messages.length === 0) {
      handleSubmit(initialMessage);
    }
  }, [initialMessage, uiState, messages.length, handleSubmit]);

  // Render based on UI state
  if (uiState === "connecting") {
    return (
      <Box flexDirection="column">
        <Text color={TokyoNightTheme.defaultText}>
          Connecting to {agentName}...
        </Text>
      </Box>
    );
  }

  if (uiState === "error" || (error && !connected)) {
    return (
      <Box flexDirection="column">
        <Text color={TokyoNightTheme.error}>Failed to connect to server:</Text>
        <Text color={TokyoNightTheme.defaultText}>
          {error?.message || "Unknown error"}
        </Text>
      </Box>
    );
  }

  if (uiState === "session_select") {
    if (debug) {
      console.error(
        `[DEBUG] Rendering session_select state with ${availableSessions.length} sessions, selectedIndex=${selectedSessionIndex}`,
      );
    }
    return (
      <Box flexDirection="column">
        <Box paddingX={1}>
          <Logo agentName={agentName} />
        </Box>
        <Box marginTop={1}>
          <SessionList
            sessions={availableSessions}
            selectedIndex={selectedSessionIndex}
          />
        </Box>
        {debug && (
          <Box paddingX={1} marginTop={1}>
            <Text color={TokyoNightTheme.defaultText}>
              DEBUG: State={uiState}, Selected={selectedSessionIndex}/
              {availableSessions.length}
            </Text>
          </Box>
        )}
      </Box>
    );
  }

  // Chat UI (uiState === 'chat')
  return (
    <MouseProvider mouseEventsEnabled={!!enableMouse}>
      <ScrollProvider>
        <Box flexDirection="column">
          {/* Header with logo and welcome message - visible only before first message */}
          <Header
            agentName={agentName}
            welcomeText={welcomeText}
            visible={messages.length === 0}
          />

          {/* Message history - scrollable with keyboard/mouse support */}
          <Box paddingX={1} marginTop={1} flexGrow={1}>
            <ScrollableMessageList
              availableHeight={availableTerminalHeight}
              onApprove={handleApproval}
            />
          </Box>

          {/* Controls/footer section - measured for dynamic height calculation */}
          <Box ref={controlsRef} flexDirection="column">
            {/* Status indicator - ALWAYS in DOM, just hidden - isolated subscription */}
            <StatusIndicatorWrapper approval={approval} thinking={thinking} />


            {/* Input prompt - always rendered, disabled during approval/thinking */}
          <Box marginTop={1}>
            <MultiLineInputOptimized
              onSubmit={handleSubmit}
              disabled={!connected || !!approval}
              disableSubmit={thinking}
              history={messageHistory}
            />
          </Box>

            {/* Help text below input - isolated subscription */}
            <HelpWrapper
              connected={connected}
              thinking={thinking}
              approval={approval}
              usage={usage}
              sessionModel={sessionModel}
              waitingForSecondCtrlC={waitingForSecondCtrlC}
            />
          </Box>
        </Box>
      </ScrollProvider>
    </MouseProvider>
  );
};