generated from nhcarrigan/template
feat: naomi did too much at once (#53)
- feat: add slash commands - feat: toggle window always on top - fix: save settings button closes settings panel - feat: input history (both text and commands) - feat: add keyboard shortcuts - feat: add confirmation modal when closing connected tabs - fix: better text colours in light mode - fix: handle multiple tabs requesting permission Closes #6 Closes #13 Closes #21 Closes #28 Reviewed-on: #53 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #53.
This commit is contained in:
@@ -13,13 +13,64 @@
|
||||
clearHistoryRestore,
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
import {
|
||||
parseSlashCommand,
|
||||
getMatchingCommands,
|
||||
isSlashCommand,
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
|
||||
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);
|
||||
|
||||
// Input history state
|
||||
let inputHistory = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let tempInput = $state("");
|
||||
|
||||
// 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";
|
||||
@@ -29,11 +80,70 @@
|
||||
isProcessing = processing;
|
||||
});
|
||||
|
||||
function handleInputChange() {
|
||||
// Reset history navigation when user types
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
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();
|
||||
if (!message || isSubmitting || !isConnected) return;
|
||||
if (!message || isSubmitting) return;
|
||||
|
||||
// Check for slash commands first (these work even when disconnected)
|
||||
if (isSlashCommand(message)) {
|
||||
// Add slash commands to history too
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
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
|
||||
addToHistory(message);
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
@@ -139,6 +249,60 @@ User: ${formattedMessage}`;
|
||||
}
|
||||
|
||||
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)
|
||||
if (event.key === "ArrowUp" && inputHistory.length > 0) {
|
||||
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) {
|
||||
event.preventDefault();
|
||||
historyIndex--;
|
||||
if (historyIndex === -1) {
|
||||
// Restore the temp input when going back to current
|
||||
inputValue = tempInput;
|
||||
} else {
|
||||
inputValue = inputHistory[historyIndex];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event);
|
||||
}
|
||||
@@ -152,11 +316,19 @@ User: ${formattedMessage}`;
|
||||
|
||||
<div class="input-row flex gap-3 items-end">
|
||||
<div class="flex-1 relative">
|
||||
<SlashCommandMenu
|
||||
commands={matchingCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={selectCommand}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeyDown}
|
||||
placeholder={isConnected ? "Ask Hikari anything..." : "Connect to Claude first..."}
|
||||
disabled={!isConnected || isSubmitting}
|
||||
oninput={handleInputChange}
|
||||
placeholder={isConnected
|
||||
? "Ask Hikari anything... (type / for commands)"
|
||||
: "Connect to Claude first..."}
|
||||
disabled={isSubmitting}
|
||||
rows={1}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user