generated from nhcarrigan/template
feba03155c
Messages now indicate how the interrupt was triggered (keyboard shortcut, stop button, or unexpected crash) so the cause is immediately clear.
1300 lines
38 KiB
Svelte
1300 lines
38 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { readImage } from "@tauri-apps/plugin-clipboard-manager";
|
|
import { get } from "svelte/store";
|
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
|
import { characterState } from "$lib/stores/character";
|
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
|
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
|
import { clipboardStore } from "$lib/stores/clipboard";
|
|
import {
|
|
setShouldRestoreHistory,
|
|
setSavedHistory,
|
|
getShouldRestoreHistory,
|
|
getSavedHistory,
|
|
clearHistoryRestore,
|
|
} from "$lib/stores/historyRestore";
|
|
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
|
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
|
import SystemClock from "$lib/components/SystemClock.svelte";
|
|
import CliVersion from "$lib/components/CliVersion.svelte";
|
|
import { getCurrentMode } from "$lib/stores/messageMode";
|
|
import { formatMessageWithMode } from "$lib/types/messageMode";
|
|
import {
|
|
parseSlashCommand,
|
|
getMatchingCommands,
|
|
isSlashCommand,
|
|
type SlashCommand,
|
|
} from "$lib/commands/slashCommands";
|
|
import { configStore, isStreamerMode } from "$lib/stores/config";
|
|
import { conversationsStore } from "$lib/stores/conversations";
|
|
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
|
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
|
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
|
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
|
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
|
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
|
import type { Attachment } from "$lib/types/messages";
|
|
|
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
|
const MAX_HISTORY_SIZE = 100;
|
|
|
|
let inputValue = $state("");
|
|
let isSubmitting = $state(false);
|
|
let isConnected = $state(false);
|
|
let isProcessing = $state(false);
|
|
let showCommandMenu = $state(false);
|
|
let matchingCommands = $state<SlashCommand[]>([]);
|
|
let selectedCommandIndex = $state(0);
|
|
let attachments = $state<Attachment[]>([]);
|
|
let isDragging = $state(false);
|
|
let showSnippetLibrary = $state(false);
|
|
let showQuickActions = $state(false);
|
|
let showClipboardHistory = $state(false);
|
|
let streamerModeActive = $state(false);
|
|
|
|
// Cost estimation for pre-submission display
|
|
let costEstimate = $derived(
|
|
inputValue.trim()
|
|
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
|
|
: null
|
|
);
|
|
|
|
// Context menu state
|
|
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
|
let contextMenuShow = $state(false);
|
|
let contextMenuX = $state(0);
|
|
let contextMenuY = $state(0);
|
|
|
|
function handleContextMenu(event: MouseEvent) {
|
|
event.preventDefault();
|
|
contextMenuShow = true;
|
|
contextMenuX = event.clientX;
|
|
contextMenuY = event.clientY;
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenuShow = false;
|
|
}
|
|
|
|
isStreamerMode.subscribe((value) => {
|
|
streamerModeActive = value;
|
|
});
|
|
|
|
// Input history state
|
|
let inputHistory = $state<string[]>([]);
|
|
let historyIndex = $state(-1);
|
|
let tempInput = $state("");
|
|
let userHasTyped = $state(false); // Track if user manually typed (vs history navigation)
|
|
|
|
// Textarea resize state
|
|
let textareaHeight = $state(48);
|
|
const MIN_HEIGHT = 48;
|
|
const MAX_HEIGHT = 200;
|
|
let isResizing = $state(false);
|
|
let startY = 0;
|
|
let startHeight = 0;
|
|
|
|
function handleResizeStart(event: MouseEvent) {
|
|
isResizing = true;
|
|
startY = event.clientY;
|
|
startHeight = textareaHeight;
|
|
document.addEventListener("mousemove", handleResizeMove);
|
|
document.addEventListener("mouseup", handleResizeEnd);
|
|
event.preventDefault();
|
|
}
|
|
|
|
function handleResizeMove(event: MouseEvent) {
|
|
if (!isResizing) return;
|
|
// Dragging up (negative deltaY) should increase height
|
|
const deltaY = startY - event.clientY;
|
|
const newHeight = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, startHeight + deltaY));
|
|
textareaHeight = newHeight;
|
|
}
|
|
|
|
function handleResizeEnd() {
|
|
isResizing = false;
|
|
document.removeEventListener("mousemove", handleResizeMove);
|
|
document.removeEventListener("mouseup", handleResizeEnd);
|
|
}
|
|
|
|
// Load history from localStorage on init
|
|
function loadHistory(): string[] {
|
|
try {
|
|
const stored = localStorage.getItem(INPUT_HISTORY_KEY);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveHistory(history: string[]) {
|
|
try {
|
|
localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(history));
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
function addToHistory(input: string) {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return;
|
|
|
|
// Don't add duplicates of the most recent entry
|
|
if (inputHistory.length > 0 && inputHistory[0] === trimmed) return;
|
|
|
|
// Add to front of history
|
|
inputHistory = [trimmed, ...inputHistory.slice(0, MAX_HISTORY_SIZE - 1)];
|
|
saveHistory(inputHistory);
|
|
}
|
|
|
|
// Initialize history on mount
|
|
inputHistory = loadHistory();
|
|
|
|
claudeStore.connectionStatus.subscribe((status) => {
|
|
isConnected = status === "connected";
|
|
});
|
|
|
|
isClaudeProcessing.subscribe((processing) => {
|
|
isProcessing = processing;
|
|
});
|
|
|
|
claudeStore.attachments.subscribe((storedAttachments) => {
|
|
attachments = storedAttachments;
|
|
});
|
|
|
|
// Per-tab draft persistence — restore the draft text whenever the active
|
|
// conversation changes, and save it back on every keystroke.
|
|
claudeStore.activeConversationId.subscribe((conversationId) => {
|
|
if (conversationId) {
|
|
const conv = get(claudeStore.conversations).get(conversationId);
|
|
inputValue = conv?.draftText ?? "";
|
|
} else {
|
|
inputValue = "";
|
|
}
|
|
});
|
|
|
|
function handleInputChange() {
|
|
// If input is empty, allow history navigation again
|
|
// Otherwise, mark that user has manually typed
|
|
if (inputValue === "") {
|
|
userHasTyped = false;
|
|
} else {
|
|
userHasTyped = true;
|
|
}
|
|
// Reset history navigation when user types
|
|
historyIndex = -1;
|
|
tempInput = "";
|
|
|
|
// Save the current draft so it persists if the user switches tabs.
|
|
const activeId = get(claudeStore.activeConversationId);
|
|
if (activeId) {
|
|
claudeStore.setDraftText(activeId, inputValue);
|
|
}
|
|
|
|
if (isSlashCommand(inputValue)) {
|
|
matchingCommands = getMatchingCommands(inputValue);
|
|
showCommandMenu = matchingCommands.length > 0;
|
|
selectedCommandIndex = 0;
|
|
} else {
|
|
showCommandMenu = false;
|
|
matchingCommands = [];
|
|
}
|
|
}
|
|
|
|
function selectCommand(command: SlashCommand) {
|
|
inputValue = `/${command.name} `;
|
|
showCommandMenu = false;
|
|
matchingCommands = [];
|
|
}
|
|
|
|
async function executeSlashCommand(): Promise<boolean> {
|
|
const { command, args } = parseSlashCommand(inputValue);
|
|
if (command) {
|
|
inputValue = "";
|
|
showCommandMenu = false;
|
|
matchingCommands = [];
|
|
await command.execute(args);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function handleSubmit(event: Event) {
|
|
event.preventDefault();
|
|
|
|
const message = inputValue.trim();
|
|
const hasAttachments = attachments.length > 0;
|
|
|
|
// Need either a message or attachments to submit
|
|
if ((!message && !hasAttachments) || isSubmitting) return;
|
|
|
|
// Check for slash commands first (these work even when disconnected)
|
|
if (message && isSlashCommand(message)) {
|
|
// Add slash commands to history too
|
|
addToHistory(message);
|
|
historyIndex = -1;
|
|
tempInput = "";
|
|
userHasTyped = false;
|
|
|
|
const wasCommand = await executeSlashCommand();
|
|
if (wasCommand) return;
|
|
// If it started with / but wasn't a valid command, show error
|
|
claudeStore.addLine(
|
|
"error",
|
|
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
|
);
|
|
inputValue = "";
|
|
return;
|
|
}
|
|
|
|
// Regular messages require connection
|
|
if (!isConnected) return;
|
|
|
|
// Add to history before clearing (only if there's text)
|
|
if (message) {
|
|
addToHistory(message);
|
|
}
|
|
historyIndex = -1;
|
|
tempInput = "";
|
|
userHasTyped = false;
|
|
|
|
isSubmitting = true;
|
|
inputValue = "";
|
|
|
|
// Capture attachments before clearing
|
|
const currentAttachments = [...attachments];
|
|
|
|
// Apply mode prefix if needed (only if there's a message)
|
|
const currentMode = getCurrentMode();
|
|
const formattedMessage = message ? formatMessageWithMode(message, currentMode) : "";
|
|
|
|
// Build message with attachments
|
|
let messageWithAttachments = formattedMessage;
|
|
if (currentAttachments.length > 0) {
|
|
const attachmentPaths = currentAttachments.map((a) => a.path).join("\n");
|
|
const attachmentPrefix = formattedMessage ? `${formattedMessage}\n\n` : "";
|
|
messageWithAttachments = `${attachmentPrefix}[Attached files - please read these files to see their contents:]\n${attachmentPaths}`;
|
|
|
|
// Log attached files to the output
|
|
for (const attachment of currentAttachments) {
|
|
claudeStore.addLine("system", `📎 Attached: ${attachment.filename} (${attachment.path})`);
|
|
}
|
|
}
|
|
|
|
// Clear attachments after capturing them
|
|
claudeStore.clearAttachments();
|
|
|
|
// Check if we need to restore conversation history
|
|
let messageToSend = messageWithAttachments;
|
|
if (getShouldRestoreHistory()) {
|
|
const savedHistory = getSavedHistory();
|
|
|
|
if (savedHistory) {
|
|
// Prepend the conversation history with a context message
|
|
messageToSend = `[Previous conversation context:]
|
|
${savedHistory}
|
|
|
|
[Continuing conversation after reconnection:]
|
|
User: ${formattedMessage}`;
|
|
|
|
// Clear the restoration flags
|
|
clearHistoryRestore();
|
|
}
|
|
}
|
|
|
|
// Reset notification state for new user message
|
|
handleNewUserMessage();
|
|
|
|
claudeStore.addLine("user", formattedMessage);
|
|
characterState.setState("thinking");
|
|
|
|
try {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
throw new Error("No active conversation");
|
|
}
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message: messageToSend,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to send prompt:", error);
|
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
} finally {
|
|
isSubmitting = false;
|
|
}
|
|
}
|
|
|
|
async function handleInterrupt() {
|
|
// Save the conversation history FIRST before anything else
|
|
const history = claudeStore.getConversationHistory();
|
|
|
|
if (history) {
|
|
setSavedHistory(history);
|
|
setShouldRestoreHistory(true);
|
|
}
|
|
|
|
try {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
throw new Error("No active conversation");
|
|
}
|
|
await invoke("interrupt_claude", { conversationId });
|
|
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
|
characterState.setState("idle");
|
|
|
|
// Show connecting status while we reconnect
|
|
claudeStore.setConnectionStatus("connecting");
|
|
|
|
// Auto-reconnect after a brief delay
|
|
setTimeout(async () => {
|
|
try {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
throw new Error("No active conversation");
|
|
}
|
|
|
|
// Get current working directory and granted tools before reconnecting
|
|
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
|
const activeConversation = get(conversationsStore.activeConversation);
|
|
const grantedTools = activeConversation
|
|
? Array.from(activeConversation.grantedTools)
|
|
: [];
|
|
const config = configStore.getConfig();
|
|
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
|
|
|
// Set the flag to skip greeting on next connection
|
|
setSkipNextGreeting(true);
|
|
|
|
// Reconnect to Claude with preserved permissions
|
|
await invoke("start_claude", {
|
|
conversationId,
|
|
options: {
|
|
working_dir: workingDir,
|
|
model: config.model || null,
|
|
api_key: config.api_key || null,
|
|
custom_instructions: config.custom_instructions || null,
|
|
mcp_servers_json: config.mcp_servers_json || null,
|
|
allowed_tools: allAllowedTools,
|
|
use_worktree: config.use_worktree ?? false,
|
|
disable_1m_context: config.disable_1m_context ?? false,
|
|
},
|
|
});
|
|
|
|
// Update Discord RPC when reconnecting
|
|
if (activeConversation) {
|
|
await updateDiscordRpc(
|
|
activeConversation.name,
|
|
config.model || "claude",
|
|
activeConversation.startedAt
|
|
);
|
|
}
|
|
} catch (reconnectError) {
|
|
console.error("Failed to auto-reconnect:", reconnectError);
|
|
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
|
claudeStore.addLine("system", "Please manually reconnect to continue");
|
|
}
|
|
}, 500); // Brief delay to ensure process is fully terminated
|
|
} catch (error) {
|
|
console.error("Failed to interrupt:", error);
|
|
claudeStore.addLine("error", `Failed to interrupt: ${error}`);
|
|
}
|
|
}
|
|
|
|
function handleRemoveAttachment(id: string) {
|
|
claudeStore.removeAttachment(id);
|
|
}
|
|
|
|
function getFileTypeFromExtension(extension: string): "image" | "document" | "other" {
|
|
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
|
const documentExtensions = ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"];
|
|
|
|
if (imageExtensions.includes(extension)) {
|
|
return "image";
|
|
} else if (documentExtensions.includes(extension)) {
|
|
return "document";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function handleDragEnter(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.dataTransfer?.types.includes("Files")) {
|
|
isDragging = true;
|
|
}
|
|
}
|
|
|
|
function handleDragOver(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.dropEffect = "copy";
|
|
}
|
|
}
|
|
|
|
function handleDragLeave(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
// Only set isDragging to false if we're leaving the form element entirely
|
|
const relatedTarget = event.relatedTarget as Node | null;
|
|
const currentTarget = event.currentTarget as HTMLElement;
|
|
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
|
isDragging = false;
|
|
}
|
|
}
|
|
|
|
async function handleDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragging = false;
|
|
|
|
const files = event.dataTransfer?.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
|
|
for (const file of files) {
|
|
const filename = file.name;
|
|
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
|
const fileType = getFileTypeFromExtension(extension);
|
|
|
|
// Create attachment from dropped file
|
|
// Note: For dropped files, we create a preview URL for images
|
|
let previewUrl: string | undefined;
|
|
if (fileType === "image") {
|
|
previewUrl = URL.createObjectURL(file);
|
|
}
|
|
|
|
// Check if the file has a native path (from Tauri's file drop)
|
|
// If not, we need to save it to a temp file
|
|
let savedPath = (file as File & { path?: string }).path;
|
|
|
|
if (!savedPath && conversationId) {
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
|
conversationId,
|
|
filename,
|
|
data: bytes,
|
|
});
|
|
savedPath = result.path;
|
|
} catch (error) {
|
|
console.error("Failed to save dropped file to temp:", error);
|
|
savedPath = file.name;
|
|
}
|
|
}
|
|
|
|
const attachment: Attachment = {
|
|
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
filename,
|
|
path: savedPath || file.name,
|
|
size: file.size,
|
|
type: fileType,
|
|
mimeType: file.type,
|
|
previewUrl,
|
|
};
|
|
|
|
claudeStore.addAttachment(attachment);
|
|
}
|
|
}
|
|
|
|
async function handleFilePicker() {
|
|
try {
|
|
const selected = await open({
|
|
multiple: true,
|
|
filters: [
|
|
{
|
|
name: "All Files",
|
|
extensions: ["*"],
|
|
},
|
|
{
|
|
name: "Images",
|
|
extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"],
|
|
},
|
|
{
|
|
name: "Documents",
|
|
extensions: ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"],
|
|
},
|
|
{
|
|
name: "Code",
|
|
extensions: [
|
|
"js",
|
|
"ts",
|
|
"jsx",
|
|
"tsx",
|
|
"py",
|
|
"rs",
|
|
"go",
|
|
"java",
|
|
"c",
|
|
"cpp",
|
|
"h",
|
|
"hpp",
|
|
"css",
|
|
"html",
|
|
"svelte",
|
|
"vue",
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!selected) return;
|
|
|
|
// Handle both single and multiple file selection
|
|
const files = Array.isArray(selected) ? selected : [selected];
|
|
|
|
for (const filePath of files) {
|
|
const filename = filePath.split(/[/\\]/).pop() || "unknown";
|
|
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
|
const fileType = getFileTypeFromExtension(extension);
|
|
|
|
// Get file size from Tauri
|
|
let fileSize = 0;
|
|
try {
|
|
fileSize = await invoke<number>("get_file_size", { filePath });
|
|
} catch (e) {
|
|
console.warn("Could not get file size:", e);
|
|
}
|
|
|
|
const attachment: Attachment = {
|
|
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
filename,
|
|
path: filePath,
|
|
size: fileSize,
|
|
type: fileType,
|
|
};
|
|
|
|
claudeStore.addAttachment(attachment);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to open file picker:", error);
|
|
}
|
|
}
|
|
|
|
async function handlePaste(event: ClipboardEvent) {
|
|
// First, try the web clipboard API for files
|
|
const items = event.clipboardData?.items;
|
|
let handledFile = false;
|
|
|
|
// Also capture text content to clipboard history
|
|
const textContent = event.clipboardData?.getData("text/plain");
|
|
if (textContent && textContent.trim().length > 0) {
|
|
// Only capture multi-line or longer text (likely code snippets)
|
|
if (textContent.includes("\n") || textContent.length > 50) {
|
|
clipboardStore.captureClipboard(textContent, null, "Pasted into chat");
|
|
}
|
|
}
|
|
|
|
if (items && items.length > 0) {
|
|
for (const item of items) {
|
|
if (item.kind === "file") {
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
|
|
event.preventDefault();
|
|
handledFile = true;
|
|
|
|
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
|
|
const extension = filename.split(".").pop()?.toLowerCase() || "";
|
|
const fileType = getFileTypeFromExtension(extension);
|
|
|
|
let previewUrl: string | undefined;
|
|
if (fileType === "image" || file.type.startsWith("image/")) {
|
|
previewUrl = URL.createObjectURL(file);
|
|
}
|
|
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
let savedPath = filename;
|
|
|
|
if (conversationId) {
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
|
conversationId,
|
|
filename,
|
|
data: bytes,
|
|
});
|
|
savedPath = result.path;
|
|
} catch (error) {
|
|
console.error("Failed to save pasted file to temp:", error);
|
|
}
|
|
}
|
|
|
|
const attachment: Attachment = {
|
|
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
filename,
|
|
path: savedPath,
|
|
size: file.size,
|
|
type: file.type.startsWith("image/") ? "image" : fileType,
|
|
mimeType: file.type,
|
|
previewUrl,
|
|
};
|
|
|
|
claudeStore.addAttachment(attachment);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If web clipboard didn't have files, try Tauri's native clipboard for images
|
|
if (!handledFile) {
|
|
try {
|
|
const image = await readImage();
|
|
const rgba = await image.rgba();
|
|
const size = await image.size();
|
|
|
|
if (rgba && rgba.length > 0) {
|
|
event.preventDefault();
|
|
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
const filename = `screenshot-${Date.now()}.png`;
|
|
|
|
// Convert RGBA to PNG using canvas
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = size.width;
|
|
canvas.height = size.height;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
if (ctx) {
|
|
const imgData = ctx.createImageData(size.width, size.height);
|
|
imgData.data.set(new Uint8ClampedArray(rgba));
|
|
ctx.putImageData(imgData, 0, 0);
|
|
|
|
// Create preview URL from canvas
|
|
const previewUrl = canvas.toDataURL("image/png");
|
|
|
|
// Convert to blob for saving
|
|
const blob = await new Promise<Blob | null>((resolve) =>
|
|
canvas.toBlob(resolve, "image/png")
|
|
);
|
|
|
|
let savedPath = filename;
|
|
if (blob && conversationId) {
|
|
try {
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
|
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
|
conversationId,
|
|
filename,
|
|
data: bytes,
|
|
});
|
|
savedPath = result.path;
|
|
} catch (error) {
|
|
console.error("Failed to save clipboard image to temp:", error);
|
|
}
|
|
}
|
|
|
|
const attachment: Attachment = {
|
|
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
filename,
|
|
path: savedPath,
|
|
size: blob?.size || 0,
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
previewUrl,
|
|
};
|
|
|
|
claudeStore.addAttachment(attachment);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// No image in clipboard or clipboard read failed - that's fine, just ignore
|
|
console.log("No image in native clipboard:", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleSnippetInsert(content: string): void {
|
|
// Insert snippet at cursor position or append to input
|
|
if (inputValue.trim()) {
|
|
inputValue = inputValue + "\n\n" + content;
|
|
} else {
|
|
inputValue = content;
|
|
}
|
|
userHasTyped = true;
|
|
}
|
|
|
|
function handleClipboardInsert(content: string): void {
|
|
// Insert clipboard content at cursor position or append to input
|
|
if (inputValue.trim()) {
|
|
inputValue = inputValue + "\n\n" + content;
|
|
} else {
|
|
inputValue = content;
|
|
}
|
|
userHasTyped = true;
|
|
}
|
|
|
|
async function handleQuickAction(prompt: string): Promise<void> {
|
|
// Quick actions send the prompt directly
|
|
if (!isConnected || isSubmitting) return;
|
|
|
|
// Add to history
|
|
addToHistory(prompt);
|
|
historyIndex = -1;
|
|
tempInput = "";
|
|
userHasTyped = false;
|
|
|
|
isSubmitting = true;
|
|
|
|
// Reset notification state for new user message
|
|
handleNewUserMessage();
|
|
|
|
claudeStore.addLine("user", prompt);
|
|
characterState.setState("thinking");
|
|
|
|
try {
|
|
const conversationId = get(claudeStore.activeConversationId);
|
|
if (!conversationId) {
|
|
throw new Error("No active conversation");
|
|
}
|
|
await invoke("send_prompt", {
|
|
conversationId,
|
|
message: prompt,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to send quick action:", error);
|
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
|
characterState.setTemporaryState("error", 3000);
|
|
} finally {
|
|
isSubmitting = false;
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
// Handle command menu navigation
|
|
if (showCommandMenu && matchingCommands.length > 0) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
selectedCommandIndex = (selectedCommandIndex + 1) % matchingCommands.length;
|
|
return;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
selectedCommandIndex =
|
|
(selectedCommandIndex - 1 + matchingCommands.length) % matchingCommands.length;
|
|
return;
|
|
}
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
const selected = matchingCommands[selectedCommandIndex];
|
|
if (selected) {
|
|
selectCommand(selected);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
showCommandMenu = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Handle input history navigation (when command menu is closed AND user hasn't typed)
|
|
// If user has typed something, let arrow keys navigate the cursor instead
|
|
if (event.key === "ArrowUp" && inputHistory.length > 0 && !userHasTyped) {
|
|
event.preventDefault();
|
|
if (historyIndex === -1) {
|
|
// Save current input before navigating history
|
|
tempInput = inputValue;
|
|
}
|
|
if (historyIndex < inputHistory.length - 1) {
|
|
historyIndex++;
|
|
inputValue = inputHistory[historyIndex];
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowDown" && historyIndex >= 0 && !userHasTyped) {
|
|
event.preventDefault();
|
|
historyIndex--;
|
|
if (historyIndex === -1) {
|
|
// Restore the temp input when going back to current
|
|
inputValue = tempInput;
|
|
userHasTyped = false; // Reset since we're back to empty/temp state
|
|
} else {
|
|
inputValue = inputHistory[historyIndex];
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
handleSubmit(event);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<form
|
|
onsubmit={handleSubmit}
|
|
ondragenter={handleDragEnter}
|
|
ondragover={handleDragOver}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={handleDrop}
|
|
class="input-bar"
|
|
class:is-dragging={isDragging}
|
|
>
|
|
<AttachmentPreview {attachments} onRemove={handleRemoveAttachment} />
|
|
|
|
<div class="input-controls flex gap-2 mb-2">
|
|
<MessageModeSelector />
|
|
<button
|
|
type="button"
|
|
onclick={() => configStore.toggleStreamerMode()}
|
|
class="control-button streamer-toggle"
|
|
class:streamer-active={streamerModeActive}
|
|
title="Toggle Streamer Mode (Ctrl+Shift+S)"
|
|
>
|
|
{#if streamerModeActive}
|
|
<div class="live-indicator"></div>
|
|
<span>LIVE</span>
|
|
{:else}
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
<line x1="1" y1="1" x2="23" y2="23" />
|
|
</svg>
|
|
<span>Stream</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => (showQuickActions = true)}
|
|
class="control-button"
|
|
title="Quick Actions"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
<span>Actions</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => (showSnippetLibrary = true)}
|
|
class="control-button"
|
|
title="Snippet Library"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" />
|
|
<line x1="16" y1="17" x2="8" y2="17" />
|
|
<line x1="10" y1="9" x2="8" y2="9" />
|
|
</svg>
|
|
<span>Snippets</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => (showClipboardHistory = true)}
|
|
class="control-button"
|
|
title="Clipboard History"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
|
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
|
</svg>
|
|
<span>Clipboard</span>
|
|
</button>
|
|
|
|
<CliVersion />
|
|
<SystemClock />
|
|
</div>
|
|
|
|
<div class="input-row">
|
|
<div class="textarea-wrapper">
|
|
<SlashCommandMenu
|
|
commands={matchingCommands}
|
|
selectedIndex={selectedCommandIndex}
|
|
onSelect={selectCommand}
|
|
/>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
|
|
<textarea
|
|
bind:this={textareaElement}
|
|
bind:value={inputValue}
|
|
onkeydown={handleKeyDown}
|
|
oninput={handleInputChange}
|
|
onpaste={handlePaste}
|
|
oncontextmenu={handleContextMenu}
|
|
placeholder={isConnected
|
|
? "Ask Hikari anything... (type / for commands)"
|
|
: "Connect to Claude first..."}
|
|
disabled={isSubmitting}
|
|
rows={1}
|
|
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="button-wrapper">
|
|
{#if costEstimate && isConnected && !isProcessing}
|
|
<div class="cost-estimate" title="Estimated input cost for this message">
|
|
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
|
|
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{#if isProcessing}
|
|
<button
|
|
type="button"
|
|
onclick={handleInterrupt}
|
|
class="send-button btn-trans-gradient"
|
|
title="Interrupt the current response (Ctrl+C)"
|
|
>
|
|
<span class="font-bold">■</span> Stop
|
|
</button>
|
|
{:else}
|
|
<button
|
|
type="submit"
|
|
disabled={!isConnected ||
|
|
isSubmitting ||
|
|
(!inputValue.trim() && attachments.length === 0)}
|
|
class="send-button trans-gradient-button
|
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{#if isSubmitting}
|
|
<span class="inline-block animate-spin">⏳</span>
|
|
{:else}
|
|
Send
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{#if showSnippetLibrary}
|
|
<SnippetLibraryPanel
|
|
onClose={() => (showSnippetLibrary = false)}
|
|
onInsert={handleSnippetInsert}
|
|
/>
|
|
{/if}
|
|
|
|
{#if showQuickActions}
|
|
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
|
|
{/if}
|
|
|
|
{#if showClipboardHistory}
|
|
<ClipboardHistoryPanel
|
|
isOpen={showClipboardHistory}
|
|
onClose={() => (showClipboardHistory = false)}
|
|
onInsert={handleClipboardInsert}
|
|
/>
|
|
{/if}
|
|
|
|
{#if contextMenuShow && textareaElement}
|
|
<TextInputContextMenu
|
|
x={contextMenuX}
|
|
y={contextMenuY}
|
|
inputElement={textareaElement}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.input-bar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
position: relative;
|
|
transition:
|
|
border-color 0.2s,
|
|
background 0.2s;
|
|
}
|
|
|
|
.input-bar.is-dragging {
|
|
background: var(--bg-secondary);
|
|
border: 2px dashed var(--accent-primary);
|
|
border-radius: 12px;
|
|
padding: 8px;
|
|
}
|
|
|
|
.input-bar.is-dragging::before {
|
|
content: "Drop files here";
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--accent-primary);
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
.input-bar.is-dragging > * {
|
|
opacity: 0.3;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.input-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.control-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 10px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Hide button text on smaller screens, show icons only */
|
|
@media (max-width: 640px) {
|
|
.control-button span {
|
|
display: none;
|
|
}
|
|
.control-button {
|
|
padding: 10px;
|
|
min-width: 40px;
|
|
}
|
|
}
|
|
|
|
.control-button:hover {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--accent-primary);
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.control-button:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.streamer-toggle.streamer-active {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
border-color: rgb(239, 68, 68);
|
|
color: rgb(248, 113, 113);
|
|
animation: pulse-red 2s ease-in-out infinite;
|
|
}
|
|
|
|
.streamer-toggle.streamer-active:hover {
|
|
background: rgba(239, 68, 68, 0.3);
|
|
border-color: rgb(248, 113, 113);
|
|
}
|
|
|
|
.live-indicator {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: rgb(239, 68, 68);
|
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse-red {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse-dot {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.textarea-wrapper {
|
|
flex: 1;
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.resize-handle {
|
|
height: 6px;
|
|
cursor: ns-resize;
|
|
background: transparent;
|
|
border-radius: 3px;
|
|
margin-bottom: 2px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.resize-handle::before {
|
|
content: "";
|
|
width: 40px;
|
|
height: 3px;
|
|
background: var(--border-color);
|
|
border-radius: 2px;
|
|
opacity: 0.5;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.resize-handle:hover::before {
|
|
opacity: 1;
|
|
background: var(--accent-primary);
|
|
}
|
|
|
|
.button-wrapper {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
height: 100%;
|
|
gap: 8px;
|
|
}
|
|
|
|
.cost-estimate {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
justify-content: center;
|
|
padding: 0 8px;
|
|
font-size: 0.7rem;
|
|
color: var(--text-secondary);
|
|
min-width: 60px;
|
|
height: 48px;
|
|
}
|
|
|
|
.cost-tokens {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.cost-value {
|
|
font-family: var(--font-mono, monospace);
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.attach-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 48px;
|
|
height: 48px;
|
|
padding: 0;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.attach-button:hover {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--accent-primary);
|
|
color: var(--accent-primary);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.attach-button:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.send-button {
|
|
padding: 0 24px;
|
|
height: 48px;
|
|
color: white;
|
|
font-weight: 500;
|
|
border-radius: 8px;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.send-button:hover:not(:disabled) {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.send-button:active:not(:disabled) {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.trans-gradient-button {
|
|
background: var(--trans-gradient-vibrant);
|
|
border: none;
|
|
color: #1a1a2e;
|
|
font-weight: 600;
|
|
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
.trans-gradient-button:hover:not(:disabled) {
|
|
filter: brightness(1.1);
|
|
box-shadow:
|
|
0 0 20px rgba(91, 206, 250, 0.4),
|
|
0 0 30px rgba(245, 169, 184, 0.3);
|
|
}
|
|
</style>
|