+
{recentChats.map((chat) => (
switchToChat(chat.id)}
- className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-hover"
+ className="flex w-full items-center gap-2 rounded-xl border border-transparent px-2 py-2 text-left transition-colors hover:border-border hover:bg-hover"
>
{chat.title}
@@ -112,8 +114,12 @@ export const ChatMessages = memo(
const isPlanMessage = message.role === "assistant" && hasPlanBlock(message.content);
const messageClassName = cn(
- isToolOnlyMessage ? (previousMessageIsToolOnly ? "px-3" : "px-3 pt-1") : "p-3",
- isPlanMessage && "border-l-2 border-accent/40",
+ isToolOnlyMessage
+ ? previousMessageIsToolOnly
+ ? "px-4 py-1"
+ : "px-4 pt-2 pb-1"
+ : "px-4 py-2",
+ isPlanMessage && "pt-2",
);
return (
@@ -126,6 +132,17 @@ export const ChatMessages = memo(
);
})}
+ {acpEvents && acpEvents.length > 0 && (
+
+
+ {acpEvents.map((event) => (
+
+ {event.text}
+
+ ))}
+
+
+ )}
>
);
diff --git a/src/features/ai/components/github-copilot-settings.tsx b/src/features/ai/components/github-copilot-settings.tsx
deleted file mode 100644
index 6d0e8a3d..00000000
--- a/src/features/ai/components/github-copilot-settings.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { AlertCircle, Zap } from "lucide-react";
-import { useUIState } from "@/stores/ui-state-store";
-import Button from "@/ui/button";
-
-const GitHubCopilotSettings = () => {
- // Get data from stores
- const { isGitHubCopilotSettingsVisible, setIsGitHubCopilotSettingsVisible } = useUIState();
-
- const isVisible = isGitHubCopilotSettingsVisible;
- const onClose = () => setIsGitHubCopilotSettingsVisible(false);
-
- if (!isVisible) {
- return null;
- }
-
- return (
-
-
- {/* Header */}
-
-
-
GitHub Copilot Integration
-
-
- ×
-
-
-
- {/* Content */}
-
-
- GitHub Copilot integration uses official GitHub authentication through the code Editor.
-
-
- {/* Coming Soon Notice */}
-
-
-
- GitHub Copilot integration is currently in development. GitHub Copilot does not
- support API key authentication. It requires OAuth-based authentication through
- official GitHub channels.
-
-
-
- {/* Information */}
-
-
- How GitHub Copilot authentication works:
-
-
- Requires a GitHub Copilot subscription
- Uses OAuth authentication with GitHub
- Integrates through official IDE extensions
- Does not support standalone API keys
-
-
-
- {/* Actions */}
-
-
- Got it
-
-
-
-
-
- );
-};
-
-export default GitHubCopilotSettings;
diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx
index bf34b361..509f36b7 100644
--- a/src/features/ai/components/input/chat-input-bar.tsx
+++ b/src/features/ai/components/input/chat-input-bar.tsx
@@ -1,12 +1,10 @@
-import { Send, Slash, Square } from "lucide-react";
+import { Send, Slash, Square, X } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useAIChatStore } from "@/features/ai/store/store";
import type { SlashCommand } from "@/features/ai/types/acp";
import type { AIChatInputBarProps } from "@/features/ai/types/ai-chat";
-import { getModelById } from "@/features/ai/types/providers";
import { useEditorSettingsStore } from "@/features/editor/stores/settings-store";
import { useSettingsStore } from "@/features/settings/store";
-import { useUIState } from "@/stores/ui-state-store";
import Button from "@/ui/button";
import Dropdown from "@/ui/dropdown";
import { cn } from "@/utils/cn";
@@ -21,8 +19,6 @@ import { FileMentionDropdown } from "../mentions/file-mention-dropdown";
import { SlashCommandDropdown } from "../mentions/slash-command-dropdown";
import { ChatModeSelector } from "../selectors/chat-mode-selector";
import { ContextSelector } from "../selectors/context-selector";
-import { ModelSelectorDropdown } from "../selectors/model-selector-dropdown";
-import { SessionModeSelector } from "../selectors/session-mode-selector";
const KAIRO_GPT_REASONING_OPTIONS = [
{ value: "0", label: "None" },
@@ -71,9 +67,9 @@ const AIChatInputBar = memo(function AIChatInputBar({
const [isLoadingKairoModels, setIsLoadingKairoModels] = useState(false);
// Get state from stores with optimized selectors
- const { settings, updateSetting } = useSettingsStore();
- const { openSettingsDialog } = useUIState();
const { fontSize, fontFamily } = useEditorSettingsStore();
+ const settings = useSettingsStore((state) => state.settings);
+ const updateSetting = useSettingsStore((state) => state.updateSetting);
// Get state from store - DO NOT subscribe to 'input' to avoid re-renders on every keystroke
const isTyping = useAIChatStore((state) => state.isTyping);
@@ -94,6 +90,7 @@ const AIChatInputBar = memo(function AIChatInputBar({
// ACP agents don't need API key (they handle their own auth)
const isInputEnabled = isCustomAgent ? hasApiKey : true;
+ const isStreaming = isTyping && !!streamingMessageId;
useEffect(() => {
if (!isKairoAgent) {
@@ -149,6 +146,16 @@ const AIChatInputBar = memo(function AIChatInputBar({
const selectPreviousSlashCommand = useAIChatStore((state) => state.selectPreviousSlashCommand);
const getFilteredSlashCommands = useAIChatStore((state) => state.getFilteredSlashCommands);
+ // Pasted images state and actions
+ const pastedImages = useAIChatStore((state) => state.pastedImages);
+ const addPastedImage = useAIChatStore((state) => state.addPastedImage);
+ const removePastedImage = useAIChatStore((state) => state.removePastedImage);
+ const clearPastedImages = useAIChatStore((state) => state.clearPastedImages);
+
+ // Computed state for send button
+ const hasImages = pastedImages.length > 0;
+ const isSendDisabled = isStreaming ? false : (!hasInputText && !hasImages) || !isInputEnabled;
+
// Highly optimized function to get plain text from contentEditable div
const getPlainTextFromDiv = useCallback(() => {
if (!inputRef.current) return "";
@@ -490,6 +497,72 @@ const AIChatInputBar = memo(function AIChatInputBar({
slashCommandState.active,
]);
+ // Handle paste - strip HTML formatting, keep only plain text. Images are added to preview.
+ const handlePaste = useCallback(
+ (e: React.ClipboardEvent
) => {
+ const clipboardData = e.clipboardData;
+ if (!clipboardData) return;
+
+ // Check for images first
+ const items = clipboardData.items;
+ let hasImage = false;
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.startsWith("image/")) {
+ hasImage = true;
+ e.preventDefault();
+
+ const file = items[i].getAsFile();
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const dataUrl = event.target?.result as string;
+ if (dataUrl) {
+ addPastedImage({
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ dataUrl,
+ name: file.name || `image-${Date.now()}.png`,
+ size: file.size,
+ });
+ }
+ };
+ reader.readAsDataURL(file);
+ }
+ }
+ }
+
+ // If there was an image, don't process text
+ if (hasImage) return;
+
+ // For text content, prevent default and insert plain text only
+ e.preventDefault();
+
+ // Get plain text from clipboard
+ const plainText = clipboardData.getData("text/plain");
+ if (!plainText) return;
+
+ // Insert plain text at cursor position
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return;
+
+ const range = selection.getRangeAt(0);
+ range.deleteContents();
+
+ const textNode = document.createTextNode(plainText);
+ range.insertNode(textNode);
+
+ // Move cursor to end of inserted text
+ range.setStartAfter(textNode);
+ range.setEndAfter(textNode);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Trigger input change handler to update state
+ handleInputChange();
+ },
+ [handleInputChange, addPastedImage],
+ );
+
// Handle file mention selection
const handleFileMentionSelect = useCallback(
(file: any) => {
@@ -612,7 +685,9 @@ const AIChatInputBar = memo(function AIChatInputBar({
const handleSendMessage = async () => {
const currentInput = useAIChatStore.getState().input;
- if (!currentInput.trim() || !isInputEnabled) return;
+ const currentImages = useAIChatStore.getState().pastedImages;
+ const hasContent = currentInput.trim() || currentImages.length > 0;
+ if (!hasContent || !isInputEnabled) return;
// Trigger send animation
setIsSendAnimating(true);
@@ -620,14 +695,15 @@ const AIChatInputBar = memo(function AIChatInputBar({
// Reset animation after the flying animation completes
setTimeout(() => setIsSendAnimating(false), 800);
- // Clear input immediately after send is triggered
+ // Clear input and images immediately after send is triggered
setInput("");
setHasInputText(false);
+ clearPastedImages();
if (inputRef.current) {
inputRef.current.innerHTML = "";
}
- // Send the captured message
+ // Send the captured message (TODO: include images in message)
await onSendMessage(currentInput);
};
@@ -715,15 +791,41 @@ const AIChatInputBar = memo(function AIChatInputBar({
return (
-
+
+ {/* Pasted images preview */}
+ {pastedImages.length > 0 && (
+
+ {pastedImages.map((image) => (
+
+
+
removePastedImage(image.id)}
+ className="absolute top-0.5 right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
+ aria-label="Remove image"
+ >
+
+
+
+ ))}
+
+ )}
+
{/* Input area */}
{/* Bottom row: Context + Mode + Style + Model/Agent + Send */}
-
-
+
+
0 && (
-
-
+
{/* Chat mode selector */}
@@ -867,65 +969,37 @@ const AIChatInputBar = memo(function AIChatInputBar({
showSlashCommands(position, "");
}
}}
- className="flex h-7 items-center gap-1 rounded px-1.5 text-text-lighter text-xs hover:bg-hover hover:text-text"
+ className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-secondary-bg/80 text-text-lighter transition-colors hover:bg-hover hover:text-text"
title="Show slash commands"
>
)}
- {/* Model selector dropdown - only shown for custom agent */}
- {isCustomAgent ? (
-
{
- const { dynamicModels } = useAIChatStore.getState();
- const providerModels = dynamicModels[settings.aiProviderId];
- const dynamicModel = providerModels?.find((m) => m.id === settings.aiModelId);
- if (dynamicModel) return dynamicModel.name;
-
- return (
- getModelById(settings.aiProviderId, settings.aiModelId)?.name || "Select Model"
- );
- })()}
- onSelect={(providerId, modelId) => {
- updateSetting("aiProviderId", providerId);
- updateSetting("aiModelId", modelId);
- }}
- onOpenSettings={() => openSettingsDialog("ai")}
- hasApiKey={(providerId) => useAIChatStore.getState().hasProviderApiKey(providerId)}
- />
- ) : (
-
- )}
-
0
? "Add to queue (Enter)"
: "Send message (Enter)"
}
- aria-label={isTyping && streamingMessageId ? "Stop generation" : "Send message"}
+ aria-label={isStreaming ? "Stop generation" : "Send message"}
tabIndex={0}
>
- {isTyping && streamingMessageId && !isSendAnimating ? (
+ {isStreaming && !isSendAnimating ? (
) : (
)}
-
-
+
+
Plan ({plan.steps.length} {plan.steps.length === 1 ? "step" : "steps"})
-
+
{plan.steps.map((step) => (
))}
{!isStreaming && onExecuteStep && (
-
+
diff --git a/src/features/ai/components/messages/plan-step-display.tsx b/src/features/ai/components/messages/plan-step-display.tsx
index dca0b922..159f09db 100644
--- a/src/features/ai/components/messages/plan-step-display.tsx
+++ b/src/features/ai/components/messages/plan-step-display.tsx
@@ -27,14 +27,14 @@ export const PlanStepDisplay = memo(function PlanStepDisplay({
return (
step.description && setIsExpanded(!isExpanded)}
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-hover"
+ className="flex w-full items-center gap-2 px-2.5 py-2 text-left text-xs transition-colors hover:bg-hover"
>
@@ -51,7 +51,7 @@ export const PlanStepDisplay = memo(function PlanStepDisplay({
)}
{isExpanded && step.description && (
-
+
)}
diff --git a/src/features/ai/components/selectors/chat-mode-selector.tsx b/src/features/ai/components/selectors/chat-mode-selector.tsx
index af06cd55..803b05c8 100644
--- a/src/features/ai/components/selectors/chat-mode-selector.tsx
+++ b/src/features/ai/components/selectors/chat-mode-selector.tsx
@@ -32,7 +32,7 @@ export const ChatModeSelector = memo(function ChatModeSelector({
return (
@@ -42,16 +42,18 @@ export const ChatModeSelector = memo(function ChatModeSelector({
return (
setMode(m.id)}
+ aria-pressed={isActive}
className={cn(
- "flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
+ "inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 font-medium text-[11px] transition-all duration-200",
isActive
- ? "bg-accent/15 font-medium text-accent"
- : "text-text-lighter hover:text-text-light",
+ ? "border-border bg-primary-bg text-text"
+ : "border-transparent text-text-lighter hover:border-border/70 hover:bg-hover/70 hover:text-text",
)}
title={m.description}
>
-
+
{m.label}
);
diff --git a/src/features/ai/components/selectors/context-selector.tsx b/src/features/ai/components/selectors/context-selector.tsx
index d4d453c0..51653cc3 100644
--- a/src/features/ai/components/selectors/context-selector.tsx
+++ b/src/features/ai/components/selectors/context-selector.tsx
@@ -249,12 +249,12 @@ export function ContextSelector({
}, [handleKeyDown]);
return (
-
+
{/* Search input */}
-
+
setSearchTerm(e.target.value)}
- className="w-full bg-transparent py-1 pr-2 pl-6 text-text text-xs placeholder-text-lighter focus:outline-none"
+ className="w-full bg-transparent py-1.5 pr-2 pl-6 text-text text-xs placeholder-text-lighter focus:outline-none"
aria-label="Search files"
/>
@@ -319,7 +319,7 @@ export function ContextSelector({
}
}}
className={cn(
- "group ui-font flex w-full cursor-pointer items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-hover focus:outline-none focus:ring-1 focus:ring-accent/50",
+ "group ui-font mx-1 flex w-[calc(100%-8px)] cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors hover:bg-hover focus:outline-none focus:ring-1 focus:ring-accent/50",
item.isSelected && "bg-selected",
)}
aria-label={`${item.isSelected ? "Remove" : "Add"} ${item.name} ${item.isSelected ? "from" : "to"} context`}
@@ -384,11 +384,11 @@ export function ContextSelector({
{/* Selected items as compact badges with horizontal scrolling */}
-
+
{selectedItems.map((item) => (
{item.type === "buffer" ? (
item.isSQLite ? (
@@ -420,7 +420,7 @@ export function ContextSelector({
onToggleFile(item.id);
}
}}
- className="rounded text-text-lighter opacity-0 transition-opacity hover:text-red-400 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-red-400/50 group-hover:opacity-100"
+ className="rounded-full p-0.5 text-text-lighter opacity-0 transition-all hover:bg-red-500/20 hover:text-red-400 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-red-400/50 group-hover:opacity-100"
aria-label={`Remove ${item.name} from context`}
tabIndex={0}
>
diff --git a/src/features/ai/components/selectors/model-selector-dropdown.tsx b/src/features/ai/components/selectors/model-selector-dropdown.tsx
index 1aa6465e..4644ba5d 100644
--- a/src/features/ai/components/selectors/model-selector-dropdown.tsx
+++ b/src/features/ai/components/selectors/model-selector-dropdown.tsx
@@ -180,9 +180,9 @@ export function ModelSelectorDropdown({
setIsOpen(!isOpen)}
- className="ui-font flex items-center gap-1 rounded bg-transparent px-2 py-1 text-xs transition-colors hover:bg-hover"
+ className="ui-font flex h-8 items-center gap-1.5 rounded-full border border-border bg-secondary-bg/80 px-3 text-xs transition-colors hover:bg-hover"
>
- {currentModelName}
+ {currentModelName}
- setIsOpen(false)} />
+
setIsOpen(false)} />
-
+
{item.providerName}
@@ -262,7 +262,7 @@ export function ModelSelectorDropdown({
}}
onMouseEnter={() => setSelectedIndex(selectableIndex)}
className={cn(
- "flex w-full items-center gap-2 rounded px-3 py-1.5 text-left transition-colors",
+ "mx-1 flex w-[calc(100%-8px)] items-center gap-2 rounded-lg px-3 py-1.5 text-left transition-colors",
isSelected ? "bg-hover" : "bg-transparent",
isCurrent && "bg-accent/10",
)}
diff --git a/src/features/ai/components/selectors/session-mode-selector.tsx b/src/features/ai/components/selectors/session-mode-selector.tsx
index 8ee88c09..8f54eba1 100644
--- a/src/features/ai/components/selectors/session-mode-selector.tsx
+++ b/src/features/ai/components/selectors/session-mode-selector.tsx
@@ -1,3 +1,4 @@
+import { ChevronsUpDown } from "lucide-react";
import { memo, useMemo } from "react";
import { useAIChatStore } from "@/features/ai/store/store";
import Dropdown from "@/ui/dropdown";
@@ -19,6 +20,10 @@ export const SessionModeSelector = memo(function SessionModeSelector({
label: mode.name,
}));
}, [sessionModeState.availableModes]);
+ const currentModeLabel = useMemo(
+ () => modeOptions.find((option) => option.value === sessionModeState.currentModeId)?.label,
+ [modeOptions, sessionModeState.currentModeId],
+ );
const handleModeChange = (modeId: string) => {
changeSessionMode(modeId);
@@ -37,8 +42,19 @@ export const SessionModeSelector = memo(function SessionModeSelector({
onChange={handleModeChange}
size="xs"
openDirection="up"
- className="min-w-20"
+ className="min-w-[96px]"
placeholder="Mode"
+ CustomTrigger={({ ref, onClick }) => (
+
+ {currentModeLabel || "Mode"}
+
+
+ )}
/>
);
diff --git a/src/features/ai/components/selectors/unified-agent-selector.tsx b/src/features/ai/components/selectors/unified-agent-selector.tsx
new file mode 100644
index 00000000..fe5467fc
--- /dev/null
+++ b/src/features/ai/components/selectors/unified-agent-selector.tsx
@@ -0,0 +1,486 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Check, ChevronDown, Key, Plus, Search, Terminal } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useAIChatStore } from "@/features/ai/store/store";
+import type { AgentConfig } from "@/features/ai/types/acp";
+import { AGENT_OPTIONS, type AgentType } from "@/features/ai/types/ai-chat";
+import { getAvailableProviders } from "@/features/ai/types/providers";
+import { useSettingsStore } from "@/features/settings/store";
+import { cn } from "@/utils/cn";
+import { getProvider } from "@/utils/providers";
+
+interface UnifiedAgentSelectorProps {
+ variant?: "header" | "input";
+ onOpenSettings?: () => void;
+}
+
+export function UnifiedAgentSelector({
+ variant = "header",
+ onOpenSettings,
+}: UnifiedAgentSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [installedAgents, setInstalledAgents] = useState
>(new Set(["custom"]));
+ const [activeSection, setActiveSection] = useState<"agents" | "models">("agents");
+
+ const { settings, updateSetting } = useSettingsStore();
+ const { dynamicModels, setDynamicModels } = useAIChatStore();
+ const getCurrentAgentId = useAIChatStore((state) => state.getCurrentAgentId);
+ const setSelectedAgentId = useAIChatStore((state) => state.setSelectedAgentId);
+ const createNewChat = useAIChatStore((state) => state.createNewChat);
+ const changeCurrentChatAgent = useAIChatStore((state) => state.changeCurrentChatAgent);
+ const hasProviderApiKey = useAIChatStore((state) => state.hasProviderApiKey);
+
+ const triggerRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const currentAgentId = getCurrentAgentId();
+ const currentAgent = AGENT_OPTIONS.find((a) => a.id === currentAgentId);
+ const isCustomAgent = currentAgentId === "custom";
+ const providers = getAvailableProviders();
+
+ // Get current model name for custom agent
+ const currentModelName = useMemo(() => {
+ if (!isCustomAgent) return null;
+ const providerModels = dynamicModels[settings.aiProviderId];
+ const dynamicModel = providerModels?.find((m) => m.id === settings.aiModelId);
+ if (dynamicModel) return dynamicModel.name;
+ const provider = providers.find((p) => p.id === settings.aiProviderId);
+ const staticModel = provider?.models.find((m) => m.id === settings.aiModelId);
+ return staticModel?.name || settings.aiModelId;
+ }, [isCustomAgent, dynamicModels, settings.aiProviderId, settings.aiModelId, providers]);
+
+ // Detect installed agents
+ useEffect(() => {
+ const detectAgents = async () => {
+ try {
+ const availableAgents = await invoke("get_available_agents");
+ const installed = new Set(["custom"]);
+ for (const agent of availableAgents) {
+ if (agent.installed) {
+ installed.add(agent.id);
+ }
+ }
+ setInstalledAgents(installed);
+ } catch {
+ // Silent fail
+ }
+ };
+ detectAgents();
+ }, []);
+
+ // Fetch dynamic models for providers
+ useEffect(() => {
+ const fetchModels = async () => {
+ for (const provider of providers) {
+ if (dynamicModels[provider.id]?.length > 0) continue;
+ if (provider.requiresApiKey) continue;
+ const providerInstance = getProvider(provider.id);
+ if (providerInstance?.getModels) {
+ try {
+ const models = await providerInstance.getModels();
+ if (models.length > 0) {
+ setDynamicModels(provider.id, models);
+ }
+ } catch {
+ // Silent fail
+ }
+ }
+ }
+ };
+ fetchModels();
+ }, [providers, dynamicModels, setDynamicModels]);
+
+ // Build filtered items list
+ const filteredItems = useMemo(() => {
+ const items: Array<{
+ type: "section" | "agent" | "provider" | "model";
+ id: string;
+ name: string;
+ providerId?: string;
+ isInstalled?: boolean;
+ isCurrent?: boolean;
+ requiresApiKey?: boolean;
+ hasKey?: boolean;
+ }> = [];
+
+ const searchLower = search.toLowerCase();
+
+ // Add agents section
+ if (activeSection === "agents" || !search) {
+ const matchingAgents = AGENT_OPTIONS.filter(
+ (agent) =>
+ !search ||
+ agent.name.toLowerCase().includes(searchLower) ||
+ agent.description.toLowerCase().includes(searchLower),
+ );
+
+ if (matchingAgents.length > 0) {
+ items.push({ type: "section", id: "agents-section", name: "Agents" });
+ for (const agent of matchingAgents) {
+ items.push({
+ type: "agent",
+ id: agent.id,
+ name: agent.name,
+ isInstalled: installedAgents.has(agent.id),
+ isCurrent: agent.id === currentAgentId,
+ });
+ }
+ }
+ }
+
+ // Add models section (only for custom agent view or when searching)
+ if ((activeSection === "models" || search) && isCustomAgent) {
+ for (const provider of providers) {
+ const providerHasKey = !provider.requiresApiKey || hasProviderApiKey(provider.id);
+ const models = dynamicModels[provider.id] || provider.models;
+
+ const matchingModels = models.filter(
+ (model) =>
+ !search ||
+ provider.name.toLowerCase().includes(searchLower) ||
+ model.name.toLowerCase().includes(searchLower) ||
+ model.id.toLowerCase().includes(searchLower),
+ );
+
+ if (
+ matchingModels.length > 0 ||
+ (!search && provider.name.toLowerCase().includes(searchLower))
+ ) {
+ items.push({
+ type: "provider",
+ id: `provider-${provider.id}`,
+ name: provider.name,
+ providerId: provider.id,
+ requiresApiKey: provider.requiresApiKey,
+ hasKey: providerHasKey,
+ });
+
+ if (providerHasKey) {
+ for (const model of matchingModels) {
+ items.push({
+ type: "model",
+ id: model.id,
+ name: model.name,
+ providerId: provider.id,
+ isCurrent: settings.aiProviderId === provider.id && settings.aiModelId === model.id,
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return items;
+ }, [
+ search,
+ activeSection,
+ installedAgents,
+ currentAgentId,
+ isCustomAgent,
+ providers,
+ dynamicModels,
+ hasProviderApiKey,
+ settings.aiProviderId,
+ settings.aiModelId,
+ ]);
+
+ const selectableItems = useMemo(
+ () => filteredItems.filter((item) => item.type === "agent" || item.type === "model"),
+ [filteredItems],
+ );
+
+ useEffect(() => {
+ if (isOpen && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isOpen]);
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [search, activeSection]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setSearch("");
+ setSelectedIndex(0);
+ setActiveSection("agents");
+ }
+ }, [isOpen]);
+
+ const handleAgentChange = useCallback(
+ async (agentId: AgentType) => {
+ if (variant !== "header" && agentId === currentAgentId) {
+ setIsOpen(false);
+ return;
+ }
+
+ // In header variant, selecting Custom API should show models tab
+ if (variant === "header" && agentId === "custom") {
+ setSelectedAgentId(agentId);
+ setActiveSection("models");
+ return;
+ }
+
+ setIsOpen(false);
+ setSelectedAgentId(agentId);
+
+ const currentAgentInfo = AGENT_OPTIONS.find((a) => a.id === currentAgentId);
+ if (currentAgentInfo?.isAcp) {
+ try {
+ await invoke("stop_acp_agent");
+ } catch {
+ // Silent fail
+ }
+ }
+
+ if (variant === "header") {
+ createNewChat(agentId);
+ } else {
+ changeCurrentChatAgent(agentId);
+ }
+ },
+ [variant, currentAgentId, setSelectedAgentId, changeCurrentChatAgent, createNewChat],
+ );
+
+ const handleModelSelect = useCallback(
+ (providerId: string, modelId: string) => {
+ updateSetting("aiProviderId", providerId);
+ updateSetting("aiModelId", modelId);
+ setIsOpen(false);
+ if (variant === "header") {
+ createNewChat();
+ }
+ },
+ [updateSetting, variant, createNewChat],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!isOpen) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setSelectedIndex((prev) => Math.min(prev + 1, selectableItems.length - 1));
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (selectableItems[selectedIndex]) {
+ const item = selectableItems[selectedIndex];
+ if (item.type === "agent") {
+ handleAgentChange(item.id as AgentType);
+ } else if (item.type === "model" && item.providerId) {
+ handleModelSelect(item.providerId, item.id);
+ }
+ }
+ break;
+ case "Escape":
+ e.preventDefault();
+ setIsOpen(false);
+ break;
+ case "Tab":
+ e.preventDefault();
+ if (isCustomAgent) {
+ setActiveSection((prev) => (prev === "agents" ? "models" : "agents"));
+ }
+ break;
+ }
+ },
+ [isOpen, selectableItems, selectedIndex, handleAgentChange, handleModelSelect, isCustomAgent],
+ );
+
+ let selectableIndex = -1;
+
+ return (
+
+ {variant === "header" ? (
+
setIsOpen(!isOpen)}
+ className="flex h-8 items-center gap-1 rounded-full border border-border bg-primary-bg/80 pl-2 pr-1.5 text-text-lighter transition-colors hover:bg-hover hover:text-text"
+ aria-label="New chat"
+ >
+
+
+
+ ) : (
+
setIsOpen(!isOpen)}
+ className="ui-font flex h-8 items-center gap-1.5 rounded-full border border-border bg-secondary-bg/80 px-3 text-xs transition-colors hover:bg-hover"
+ >
+
+
+ {currentAgent?.name || "Custom"}
+ {isCustomAgent && currentModelName && (
+ / {currentModelName}
+ )}
+
+
+
+ )}
+
+ {isOpen && (
+ <>
+
setIsOpen(false)} />
+
+ {/* Search */}
+
+
+
+ setSearch(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Search agents or models..."
+ className="flex-1 bg-transparent text-text text-xs outline-none placeholder:text-text-lighter"
+ />
+
+
+
+ {/* Section tabs (only for custom agent) */}
+ {isCustomAgent && !search && (
+
+ setActiveSection("agents")}
+ className={cn(
+ "flex-1 rounded-lg px-2 py-1 text-xs transition-colors",
+ activeSection === "agents"
+ ? "bg-hover text-text"
+ : "text-text-lighter hover:text-text",
+ )}
+ >
+ Agents
+
+ setActiveSection("models")}
+ className={cn(
+ "flex-1 rounded-lg px-2 py-1 text-xs transition-colors",
+ activeSection === "models"
+ ? "bg-hover text-text"
+ : "text-text-lighter hover:text-text",
+ )}
+ >
+ Models
+
+
+ )}
+
+ {/* Items */}
+
+ {filteredItems.length === 0 ? (
+
No results found
+ ) : (
+ filteredItems.map((item) => {
+ if (item.type === "section") {
+ return (
+
+ {item.name}
+
+ );
+ }
+
+ if (item.type === "provider") {
+ return (
+
+ {item.name}
+ {item.requiresApiKey && !item.hasKey && (
+ {
+ e.stopPropagation();
+ onOpenSettings?.();
+ setIsOpen(false);
+ }}
+ className="flex items-center gap-1 rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30"
+ >
+
+ Set Key
+
+ )}
+
+ );
+ }
+
+ if (item.type === "agent") {
+ selectableIndex++;
+ const itemIndex = selectableIndex;
+ const isSelected = itemIndex === selectedIndex;
+
+ return (
+
handleAgentChange(item.id as AgentType)}
+ onMouseEnter={() => setSelectedIndex(itemIndex)}
+ className={cn(
+ "mx-1 flex w-[calc(100%-8px)] items-center gap-2 rounded-xl px-3 py-1.5 text-left transition-colors",
+ isSelected ? "bg-hover" : "bg-transparent",
+ item.isCurrent && "bg-accent/10",
+ )}
+ >
+
+ {item.name}
+ {item.isCurrent && }
+ {!item.isCurrent && item.isInstalled && item.id !== "custom" && (
+
+ )}
+
+ );
+ }
+
+ if (item.type === "model") {
+ selectableIndex++;
+ const itemIndex = selectableIndex;
+ const isSelected = itemIndex === selectedIndex;
+
+ return (
+
handleModelSelect(item.providerId!, item.id)}
+ onMouseEnter={() => setSelectedIndex(itemIndex)}
+ className={cn(
+ "mx-1 flex w-[calc(100%-8px)] items-center gap-2 rounded-lg px-3 py-1.5 text-left transition-colors",
+ isSelected ? "bg-hover" : "bg-transparent",
+ item.isCurrent && "bg-accent/10",
+ )}
+ >
+ {item.name}
+ {item.isCurrent && }
+
+ );
+ }
+
+ return null;
+ })
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/features/ai/store/store.ts b/src/features/ai/store/store.ts
index 1cd65808..32eb7cb2 100644
--- a/src/features/ai/store/store.ts
+++ b/src/features/ai/store/store.ts
@@ -29,6 +29,7 @@ export const useAIChatStore = create
()(
currentChatId: null,
selectedAgentId: "custom" as AgentType, // Default to custom (API-based)
input: "",
+ pastedImages: [],
isTyping: false,
streamingMessageId: null,
selectedBufferIds: new Set(),
@@ -140,6 +141,18 @@ export const useAIChatStore = create()(
set((state) => {
state.input = input;
}),
+ addPastedImage: (image) =>
+ set((state) => {
+ state.pastedImages = [...state.pastedImages, image];
+ }),
+ removePastedImage: (imageId) =>
+ set((state) => {
+ state.pastedImages = state.pastedImages.filter((img) => img.id !== imageId);
+ }),
+ clearPastedImages: () =>
+ set((state) => {
+ state.pastedImages = [];
+ }),
setIsTyping: (isTyping) =>
set((state) => {
state.isTyping = isTyping;
@@ -768,19 +781,7 @@ export const useAIChatStore = create()(
},
applyDefaultSettings: () => {
- // Import settings store dynamically to avoid circular dependency
- import("@/features/settings/store").then(({ useSettingsStore }) => {
- const settings = useSettingsStore.getState().settings;
- set((state) => {
- // Apply default output style if not already set or different
- if (
- settings.aiDefaultOutputStyle &&
- settings.aiDefaultOutputStyle !== state.outputStyle
- ) {
- state.outputStyle = settings.aiDefaultOutputStyle;
- }
- });
- });
+ // No-op: settings that were applied here have been removed
},
// Helper getters
@@ -796,21 +797,26 @@ export const useAIChatStore = create()(
},
}),
{
- name: "athas-ai-chat-settings-v6",
- version: 2,
+ name: "athas-ai-chat-settings-v7",
+ version: 3,
partialize: (state) => ({
mode: state.mode,
outputStyle: state.outputStyle,
selectedAgentId: state.selectedAgentId,
+ sessionModeState: state.sessionModeState,
}),
merge: (persistedState, currentState) =>
produce(currentState, (draft) => {
- // Only merge mode, outputStyle, and selectedAgentId from localStorage
+ // Only merge mode, outputStyle, selectedAgentId, and sessionModeState from localStorage
// Chats are loaded from SQLite separately
if (persistedState) {
draft.mode = (persistedState as any).mode || "chat";
draft.outputStyle = (persistedState as any).outputStyle || "default";
draft.selectedAgentId = (persistedState as any).selectedAgentId || "custom";
+ draft.sessionModeState = (persistedState as any).sessionModeState || {
+ currentModeId: null,
+ availableModes: [],
+ };
}
}),
},
diff --git a/src/features/ai/store/types.ts b/src/features/ai/store/types.ts
index 494fbaf1..c1767a76 100644
--- a/src/features/ai/store/types.ts
+++ b/src/features/ai/store/types.ts
@@ -12,12 +12,20 @@ export interface QueuedMessage {
timestamp: Date;
}
+export interface PastedImage {
+ id: string;
+ dataUrl: string;
+ name: string;
+ size: number;
+}
+
export interface AIChatState {
// Single session state
chats: Chat[];
currentChatId: string | null;
selectedAgentId: AgentType; // Current agent selection for new chats
input: string;
+ pastedImages: PastedImage[];
isTyping: boolean;
streamingMessageId: string | null;
selectedBufferIds: Set;
@@ -82,6 +90,9 @@ export interface AIChatActions {
// Input actions
setInput: (input: string) => void;
+ addPastedImage: (image: PastedImage) => void;
+ removePastedImage: (imageId: string) => void;
+ clearPastedImages: () => void;
setIsTyping: (isTyping: boolean) => void;
setStreamingMessageId: (streamingMessageId: string | null) => void;
toggleBufferSelection: (bufferId: string) => void;
diff --git a/src/features/ai/types/acp.ts b/src/features/ai/types/acp.ts
index b8ea3ab5..88f33bfb 100644
--- a/src/features/ai/types/acp.ts
+++ b/src/features/ai/types/acp.ts
@@ -52,6 +52,11 @@ export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refu
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "failed";
+// UI action types that agents can request
+export type UiAction =
+ | { action: "open_web_viewer"; url: string }
+ | { action: "open_terminal"; command: string | null };
+
export type AcpEvent =
| {
type: "content_chunk";
@@ -123,4 +128,9 @@ export type AcpEvent =
type: "prompt_complete";
sessionId: string;
stopReason: StopReason;
+ }
+ | {
+ type: "ui_action";
+ sessionId: string;
+ action: UiAction;
};
diff --git a/src/features/ai/types/ai-chat.ts b/src/features/ai/types/ai-chat.ts
index bfca62af..63b1ddb9 100644
--- a/src/features/ai/types/ai-chat.ts
+++ b/src/features/ai/types/ai-chat.ts
@@ -16,6 +16,16 @@ export interface ToolCall {
isComplete?: boolean;
}
+export interface ImageContent {
+ data: string;
+ mediaType: string;
+}
+
+export interface ResourceContent {
+ uri: string;
+ name: string | null;
+}
+
export interface Message {
id: string;
content: string;
@@ -25,6 +35,8 @@ export interface Message {
isToolUse?: boolean;
toolName?: string;
toolCalls?: ToolCall[];
+ images?: ImageContent[];
+ resources?: ResourceContent[];
}
// Agent types for AI chat
@@ -112,6 +124,7 @@ export interface ContextInfo {
projectRoot?: string;
language?: string;
providerId?: string;
+ agentId?: AgentType;
}
export interface AIChatProps {
diff --git a/src/features/command-bar/components/file-list-item.tsx b/src/features/command-bar/components/file-list-item.tsx
index 0940f214..f86f0f37 100644
--- a/src/features/command-bar/components/file-list-item.tsx
+++ b/src/features/command-bar/components/file-list-item.tsx
@@ -1,4 +1,5 @@
-import { ClockIcon, File } from "lucide-react";
+import { ClockIcon } from "lucide-react";
+import { FileIcon } from "@/features/file-explorer/components/file-icon";
import { CommandItem } from "@/ui/command";
import { getDirectoryPath } from "@/utils/path-helpers";
import type { FileCategory, FileItem } from "../types/command-bar";
@@ -30,10 +31,7 @@ export const FileListItem = ({
isSelected={isSelected}
className="ui-font"
>
-
+
{file.name}
diff --git a/src/features/command-bar/hooks/use-file-loader.ts b/src/features/command-bar/hooks/use-file-loader.ts
index e3b43338..c3796944 100644
--- a/src/features/command-bar/hooks/use-file-loader.ts
+++ b/src/features/command-bar/hooks/use-file-loader.ts
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { useFileSystemStore } from "@/features/file-system/controllers/store";
-import { useSettingsStore } from "@/features/settings/store";
import type { FileItem } from "../types/command-bar";
import { shouldIgnoreFile } from "../utils/file-filtering";
export const useFileLoader = (isVisible: boolean) => {
const getAllProjectFiles = useFileSystemStore((state) => state.getAllProjectFiles);
const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath);
- const commandBarFileLimit = useSettingsStore((state) => state.settings.commandBarFileLimit);
const [files, setFiles] = useState
([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [isIndexing, setIsIndexing] = useState(false);
@@ -43,7 +41,7 @@ export const useFileLoader = (isVisible: boolean) => {
};
loadFiles();
- }, [getAllProjectFiles, isVisible, commandBarFileLimit]);
+ }, [getAllProjectFiles, isVisible]);
return { files, isLoadingFiles, isIndexing, rootFolderPath };
};
diff --git a/src/features/database/providers/sqlite/components/context-menus.tsx b/src/features/database/providers/sqlite/components/context-menus.tsx
index 66f69485..0d6d1911 100644
--- a/src/features/database/providers/sqlite/components/context-menus.tsx
+++ b/src/features/database/providers/sqlite/components/context-menus.tsx
@@ -25,7 +25,7 @@ export const SqliteTableMenu = ({
return (
Add New Row
-
+
{
@@ -57,8 +57,8 @@ export const SqliteTableMenu = ({
onCloseMenu();
}}
className={cn(
- "flex w-full items-center gap-2 px-3 py-1.5",
- "ui-font text-left text-red-400 text-xs hover:bg-hover",
+ "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5",
+ "ui-font text-left text-red-400 text-xs transition-colors hover:bg-hover",
)}
>
@@ -89,7 +89,7 @@ export const SqliteRowMenu = ({
return (
@@ -119,8 +119,8 @@ export const SqliteRowMenu = ({
onCloseMenu();
}}
className={cn(
- "flex w-full items-center gap-2 px-3 py-1.5",
- "ui-font text-left text-red-400 text-xs hover:bg-hover",
+ "flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5",
+ "ui-font text-left text-red-400 text-xs transition-colors hover:bg-hover",
)}
>
diff --git a/src/features/database/providers/sqlite/components/crud-modals.tsx b/src/features/database/providers/sqlite/components/crud-modals.tsx
index f48aa32c..06ea78ec 100644
--- a/src/features/database/providers/sqlite/components/crud-modals.tsx
+++ b/src/features/database/providers/sqlite/components/crud-modals.tsx
@@ -77,14 +77,17 @@ export const CreateRowModal = ({
if (!isOpen) return null;
return (
-
+
Add Row to {tableName}
-
+
@@ -217,14 +220,17 @@ export const EditRowModal = ({
if (!isOpen) return null;
return (
-
+
Edit Row in {tableName}
-
+
@@ -339,14 +345,17 @@ export const CreateTableModal = ({ isOpen, onClose, onSubmit }: CreateTableModal
if (!isOpen) return null;
return (
-
+
Create New Table
-
+
@@ -381,7 +390,7 @@ export const CreateTableModal = ({ isOpen, onClose, onSubmit }: CreateTableModal
updateColumn(index, "type", e.target.value)}
- className="ui-font rounded-md border border-border bg-input px-2 py-1 text-sm text-text"
+ className="ui-font rounded-lg border border-border bg-input px-2 py-1 text-sm text-text"
>
TEXT
INTEGER
@@ -401,7 +410,7 @@ export const CreateTableModal = ({ isOpen, onClose, onSubmit }: CreateTableModal
removeColumn(index)}
- className="rounded-md p-1 text-red-400 hover:bg-hover"
+ className="rounded-full border border-transparent p-1 text-red-400 hover:border-border/70 hover:bg-hover"
>
@@ -412,7 +421,7 @@ export const CreateTableModal = ({ isOpen, onClose, onSubmit }: CreateTableModal
type="button"
onClick={addColumn}
className={cn(
- "flex items-center gap-1 rounded-md px-2 py-1",
+ "flex items-center gap-1 rounded-full border border-transparent px-2 py-1",
"ui-font text-sm text-text hover:bg-hover",
)}
>
diff --git a/src/features/database/providers/sqlite/sqlite-viewer.tsx b/src/features/database/providers/sqlite/sqlite-viewer.tsx
index 986edbfa..3e21fb53 100644
--- a/src/features/database/providers/sqlite/sqlite-viewer.tsx
+++ b/src/features/database/providers/sqlite/sqlite-viewer.tsx
@@ -1,5 +1,6 @@
-import { invoke } from "@tauri-apps/api/core";
import {
+ ArrowDown,
+ ArrowUp,
Calendar,
Code,
Copy,
@@ -10,7 +11,7 @@ import {
Hash,
Info,
Key,
- PlusIcon,
+ Plus,
RefreshCw,
Search,
Settings,
@@ -18,581 +19,119 @@ import {
Type,
X,
} from "lucide-react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useUIState } from "@/stores/ui-state-store";
import Button from "@/ui/button";
import Dropdown from "@/ui/dropdown";
-import DataViewComponent from "../../components/data-view";
-import type {
- ColumnFilter,
- ColumnInfo,
- DatabaseInfo,
- QueryResult,
- TableInfo,
- ViewMode,
-} from "../../models/common.types";
+import Input from "@/ui/input";
+import Textarea from "@/ui/textarea";
+import { cn } from "@/utils/cn";
+import type { ColumnFilter, ColumnInfo, ViewMode } from "../../models/common.types";
import { SqliteRowMenu, SqliteTableMenu } from "./components/context-menus";
import { CreateRowModal, CreateTableModal, EditRowModal } from "./components/crud-modals";
+import { useSqliteStore } from "./stores/sqlite-store";
export interface SQLiteViewerProps {
databasePath: string;
}
-const SQLiteViewer = ({ databasePath }: SQLiteViewerProps) => {
- const [tables, setTables] = useState([]);
- const [selectedTable, setSelectedTable] = useState(null);
- const [queryResult, setQueryResult] = useState(null);
- const [customQuery, setCustomQuery] = useState("");
- const [error, setError] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [currentPage, setCurrentPage] = useState(1);
- const [totalPages, setTotalPages] = useState(1);
- const [pageSize, setPageSize] = useState(50);
- const [tableMeta, setTableMeta] = useState([]);
- const [searchTerm, setSearchTerm] = useState("");
- const [isCustomQuery, setIsCustomQuery] = useState(false);
- const [sqlHistory, setSqlHistory] = useState([]);
- const [viewMode, setViewMode] = useState("data");
- const [columnFilters, setColumnFilters] = useState([]);
- const [sortColumn, setSortColumn] = useState