feat: implement QoL features (toast notifications, command palette, auto-save)
Add three major quality-of-life features to improve user experience: Toast Notification System: - Non-blocking notifications for all major actions - Four variants: success, error, warning, info - Auto-dismiss with progress bar - Bottom-right positioning with smooth animations - Replaced old status messages throughout the app Command Palette (Ctrl+P): - Keyboard-driven quick access to all actions - 14 built-in commands across 5 categories - Real-time fuzzy search with arrow key navigation - Shows keyboard shortcuts for each command - Grouped by category with visual feedback Auto-save & Recovery: - Automatic draft saving (1s debounce) - Per-character draft storage in localStorage - Auto-recovery on app restart or character switch - Draft age display (e.g., "2 hours ago") - Auto-cleanup of drafts older than 7 days - Clears draft when message is sent Updated README with new Ctrl+P keyboard shortcut Updated ROADMAP with Phase 8: Quality of Life & Polish section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,7 @@ Config stored in `~/.config/claudia/config.json`
|
|||||||
- **Left/Right Arrow** - Navigate between response alternatives
|
- **Left/Right Arrow** - Navigate between response alternatives
|
||||||
- **Escape** - Close panels/modals, cancel editing
|
- **Escape** - Close panels/modals, cancel editing
|
||||||
- **Ctrl+K** - Focus message input
|
- **Ctrl+K** - Focus message input
|
||||||
|
- **Ctrl+P** - Open command palette (quick access to all actions)
|
||||||
- **Ctrl+/** - Toggle Roleplay Tools panel
|
- **Ctrl+/** - Toggle Roleplay Tools panel
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
167
ROADMAP.md
167
ROADMAP.md
@@ -19,11 +19,12 @@
|
|||||||
- Enhanced Message Controls (delete, pin, hide, continue, regenerate any message)
|
- Enhanced Message Controls (delete, pin, hide, continue, regenerate any message)
|
||||||
- Token Counter (real-time display with per-section breakdown)
|
- Token Counter (real-time display with per-section breakdown)
|
||||||
- Message Examples (character card examples injected into context)
|
- Message Examples (character card examples injected into context)
|
||||||
|
- Chat Branching/Checkpoints (create, switch, delete, rename branches from any message)
|
||||||
|
|
||||||
### 🎯 Current Focus: Advanced Chat Management
|
### 🎯 Current Focus: Quality of Life & Polish
|
||||||
**Next Up:** Implementing Chat Branching/Checkpoints to enable non-linear conversation exploration with the ability to save conversation states, create branches from any point, and switch between different conversation paths.
|
**Next Up:** Implementing high-impact QoL features to reduce friction and improve user experience - starting with Toast Notifications, Command Palette, Auto-save, Drag & Drop, and Chat Search.
|
||||||
|
|
||||||
**Recent Completion:** Message Examples - character card message examples are now parsed, processed with template variable replacement, and injected into context at configurable positions to teach the AI the character's voice and writing style.
|
**Recent Completion:** Chat Branching/Checkpoints - Full conversation branching system allowing users to create and explore alternate conversation paths from any message point. Each branch maintains its own complete message history with a branch manager modal for easy navigation.
|
||||||
|
|
||||||
## Phase 1: Core Roleplay Infrastructure (High Priority)
|
## Phase 1: Core Roleplay Infrastructure (High Priority)
|
||||||
**Goal: Enable basic roleplay-focused prompt engineering**
|
**Goal: Enable basic roleplay-focused prompt engineering**
|
||||||
@@ -87,13 +88,14 @@
|
|||||||
## Phase 3: Advanced Chat Management (Medium Priority)
|
## Phase 3: Advanced Chat Management (Medium Priority)
|
||||||
**Goal: Non-linear conversation control**
|
**Goal: Non-linear conversation control**
|
||||||
|
|
||||||
### 1. Chat Branching/Checkpoints
|
### 1. Chat Branching/Checkpoints ✅
|
||||||
- [ ] Save conversation state at any message
|
- [x] Save conversation state at any message
|
||||||
- [ ] Create branches from any point
|
- [x] Create branches from any point
|
||||||
- [ ] Switch between branches
|
- [x] Switch between branches
|
||||||
- [ ] Visual branch indicator in UI
|
- [x] Visual branch indicator in UI
|
||||||
- [ ] Branch naming and organization
|
- [x] Branch naming and organization
|
||||||
- [ ] Delete/merge branches
|
- [x] Delete branches
|
||||||
|
- [ ] Merge branches (deferred - nice to have)
|
||||||
|
|
||||||
**Why Important:** Roleplay often involves exploring "what if" scenarios. Branching lets you explore different conversation paths without losing previous progress.
|
**Why Important:** Roleplay often involves exploring "what if" scenarios. Branching lets you explore different conversation paths without losing previous progress.
|
||||||
|
|
||||||
@@ -252,6 +254,151 @@
|
|||||||
|
|
||||||
**Why Important:** Better UI means less friction and more immersion in roleplay.
|
**Why Important:** Better UI means less friction and more immersion in roleplay.
|
||||||
|
|
||||||
|
## Phase 8: Quality of Life & Polish (High Priority)
|
||||||
|
**Goal: Reduce friction, improve feedback, and enhance overall user experience**
|
||||||
|
|
||||||
|
### 1. Toast Notification System
|
||||||
|
- [ ] Create toast component (bottom-right positioning)
|
||||||
|
- [ ] Success/error/info/warning variants
|
||||||
|
- [ ] Auto-dismiss with configurable timeout
|
||||||
|
- [ ] Queue multiple toasts
|
||||||
|
- [ ] Hook into all major actions (save, delete, import, export, etc.)
|
||||||
|
|
||||||
|
**Why Important:** Users currently have no immediate feedback when actions succeed or fail. Toasts provide instant visual confirmation without blocking workflow.
|
||||||
|
|
||||||
|
### 2. Command Palette
|
||||||
|
- [ ] Ctrl+P to open command palette modal
|
||||||
|
- [ ] Fuzzy search for all actions
|
||||||
|
- [ ] Keyboard navigation (arrow keys, enter, escape)
|
||||||
|
- [ ] Recent/frequent actions at top
|
||||||
|
- [ ] Show keyboard shortcuts in results
|
||||||
|
- [ ] Categories (Chat, Character, Settings, etc.)
|
||||||
|
|
||||||
|
**Why Important:** Power users want keyboard-first workflow. Command palette dramatically speeds up common actions without memorizing shortcuts.
|
||||||
|
|
||||||
|
### 3. Auto-save & Recovery
|
||||||
|
- [ ] Auto-save unsent message in input field
|
||||||
|
- [ ] Restore unsent message after app restart
|
||||||
|
- [ ] Draft system for in-progress edits
|
||||||
|
- [ ] Session recovery (restore scroll position, open panels)
|
||||||
|
- [ ] Crash recovery with last known state
|
||||||
|
|
||||||
|
**Why Important:** Losing work due to crashes or accidental closes is extremely frustrating. Auto-save provides a safety net for all user work.
|
||||||
|
|
||||||
|
### 4. Drag & Drop Support
|
||||||
|
- [ ] Drag character card PNGs to import
|
||||||
|
- [ ] Drag lorebook JSON files to import
|
||||||
|
- [ ] Drag chat history JSON to import
|
||||||
|
- [ ] Drag images to set as character avatar
|
||||||
|
- [ ] Drop zone overlay with visual feedback
|
||||||
|
- [ ] Support for multiple file drops
|
||||||
|
|
||||||
|
**Why Important:** Drag & drop feels natural and is much faster than navigate-click-select workflow. Modern desktop apps are expected to support this.
|
||||||
|
|
||||||
|
### 5. Search in Chat History
|
||||||
|
- [ ] Ctrl+F to open search bar
|
||||||
|
- [ ] Highlight all matches in messages
|
||||||
|
- [ ] Navigate between results (prev/next buttons)
|
||||||
|
- [ ] Case-insensitive search
|
||||||
|
- [ ] Search counter (e.g., "3 of 42 matches")
|
||||||
|
- [ ] Clear search and restore view
|
||||||
|
|
||||||
|
**Why Important:** Long roleplay sessions can span hundreds of messages. Finding specific content without search is tedious and time-consuming.
|
||||||
|
|
||||||
|
### 6. Context Menus (Right-Click)
|
||||||
|
- [ ] Right-click messages for actions (edit, delete, regenerate, branch, copy)
|
||||||
|
- [ ] Right-click character dropdown for quick actions
|
||||||
|
- [ ] Right-click World Info entries for edit/delete
|
||||||
|
- [ ] Right-click in message input for paste/clear/templates
|
||||||
|
- [ ] Context-aware menu items
|
||||||
|
|
||||||
|
**Why Important:** Right-click is muscle memory for desktop users. Faster than hovering to reveal action buttons.
|
||||||
|
|
||||||
|
### 7. Better Feedback & Confirmations
|
||||||
|
- [ ] Confirmation dialogs for destructive actions (delete character, clear chat)
|
||||||
|
- [ ] Loading spinners for API calls
|
||||||
|
- [ ] Progress bars for file imports
|
||||||
|
- [ ] "Saving..." / "Saved" indicators
|
||||||
|
- [ ] Success messages for completed actions
|
||||||
|
|
||||||
|
**Why Important:** Users should never wonder if an action succeeded or is still processing. Clear feedback prevents confusion and repeated clicks.
|
||||||
|
|
||||||
|
### 8. Undo/Redo System
|
||||||
|
- [ ] Undo message edit (Ctrl+Z)
|
||||||
|
- [ ] Undo message delete
|
||||||
|
- [ ] Undo character field changes
|
||||||
|
- [ ] Undo World Info changes
|
||||||
|
- [ ] Action history panel (optional)
|
||||||
|
- [ ] Redo support (Ctrl+Shift+Z)
|
||||||
|
|
||||||
|
**Why Important:** Mistakes happen. An undo system provides a safety net and encourages experimentation without fear of losing work.
|
||||||
|
|
||||||
|
### 9. Settings Search
|
||||||
|
- [ ] Search bar at top of settings panel
|
||||||
|
- [ ] Fuzzy search across all setting names and descriptions
|
||||||
|
- [ ] Highlight matching settings
|
||||||
|
- [ ] Collapse/expand sections based on matches
|
||||||
|
- [ ] "Recently changed" section
|
||||||
|
|
||||||
|
**Why Important:** With 22+ features, finding specific settings is tedious. Search makes configuration much faster.
|
||||||
|
|
||||||
|
### 10. Character Management Enhancements
|
||||||
|
- [ ] Recent characters quick-switch dropdown
|
||||||
|
- [ ] Character search/filter by name or tags
|
||||||
|
- [ ] Character folders/categories
|
||||||
|
- [ ] Duplicate character (as template)
|
||||||
|
- [ ] Favorite/star characters
|
||||||
|
- [ ] Sort options (name, date created, last used)
|
||||||
|
|
||||||
|
**Why Important:** Managing 10+ characters becomes messy. Better organization tools scale with user's character collection.
|
||||||
|
|
||||||
|
### 11. Enhanced Keyboard Support
|
||||||
|
- [ ] Full keyboard navigation in all modals (Tab, Arrow keys, Enter)
|
||||||
|
- [ ] Escape to close any open panel/modal
|
||||||
|
- [ ] Vim-style navigation mode (optional, j/k for scroll)
|
||||||
|
- [ ] Keyboard shortcut hints on hover
|
||||||
|
- [ ] Focus indicators for keyboard navigation
|
||||||
|
|
||||||
|
**Why Important:** Keyboard navigation should work everywhere. Current implementation is inconsistent across different UI sections.
|
||||||
|
|
||||||
|
### 12. Export/Share Enhancements
|
||||||
|
- [ ] Export conversation as formatted HTML
|
||||||
|
- [ ] Export conversation as formatted PDF
|
||||||
|
- [ ] Export as markdown with proper formatting
|
||||||
|
- [ ] Copy conversation to clipboard (formatted)
|
||||||
|
- [ ] Export individual messages
|
||||||
|
|
||||||
|
**Why Important:** Users want to share and archive conversations in readable formats, not just JSON.
|
||||||
|
|
||||||
|
### 13. Accessibility Improvements
|
||||||
|
- [ ] ARIA labels for all interactive elements
|
||||||
|
- [ ] Screen reader support
|
||||||
|
- [ ] High contrast mode option
|
||||||
|
- [ ] Larger click targets option (accessibility mode)
|
||||||
|
- [ ] Reduced motion mode (respect prefers-reduced-motion)
|
||||||
|
- [ ] Focus indicators for keyboard navigation
|
||||||
|
|
||||||
|
**Why Important:** Accessibility makes the app usable for everyone, including users with disabilities. It's also often legally required.
|
||||||
|
|
||||||
|
### 14. Better Visual Feedback
|
||||||
|
- [ ] Smooth transitions for panel open/close
|
||||||
|
- [ ] Hover states for all interactive elements
|
||||||
|
- [ ] Active state indicators (focused panel)
|
||||||
|
- [ ] Better empty states with helpful text
|
||||||
|
- [ ] Skeleton loaders for content loading
|
||||||
|
- [ ] Micro-animations for actions (delete, save, etc.)
|
||||||
|
|
||||||
|
**Why Important:** Visual polish makes the app feel responsive and professional. Small animations provide context for state changes.
|
||||||
|
|
||||||
|
### 15. Smart Defaults & Templates
|
||||||
|
- [ ] Scenario templates (fantasy RPG, sci-fi, modern, etc.)
|
||||||
|
- [ ] Pre-filled World Info templates
|
||||||
|
- [ ] Character card templates
|
||||||
|
- [ ] Quick-start wizard for new users
|
||||||
|
- [ ] Import from popular character repositories
|
||||||
|
|
||||||
|
**Why Important:** Reduces friction for new users and speeds up common tasks. Templates provide starting points for customization.
|
||||||
|
|
||||||
## Implementation Priority Ranking
|
## Implementation Priority Ranking
|
||||||
|
|
||||||
### Must-Have for Basic Roleplay:
|
### Must-Have for Basic Roleplay:
|
||||||
|
|||||||
@@ -733,6 +733,33 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Command Palette -->
|
||||||
|
<div id="command-palette-modal" class="command-palette-modal" style="display: none;">
|
||||||
|
<div class="command-palette-overlay"></div>
|
||||||
|
<div class="command-palette-content">
|
||||||
|
<div class="command-palette-search">
|
||||||
|
<svg class="command-palette-search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="command-palette-input"
|
||||||
|
class="command-palette-input"
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<kbd class="command-palette-hint">Esc to close</kbd>
|
||||||
|
</div>
|
||||||
|
<div id="command-palette-results" class="command-palette-results">
|
||||||
|
<!-- Command results will be dynamically populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Avatar zoom modal -->
|
<!-- Avatar zoom modal -->
|
||||||
<div id="avatar-modal" class="avatar-modal" style="display: none;">
|
<div id="avatar-modal" class="avatar-modal" style="display: none;">
|
||||||
<div class="avatar-modal-overlay"></div>
|
<div class="avatar-modal-overlay"></div>
|
||||||
|
|||||||
613
src/main.js
613
src/main.js
@@ -146,6 +146,511 @@ function loadSavedTheme() {
|
|||||||
applyTheme(savedTheme);
|
applyTheme(savedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast Notification System
|
||||||
|
const toastQueue = [];
|
||||||
|
let toastContainer;
|
||||||
|
|
||||||
|
function showToast(options) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration = 3000,
|
||||||
|
dismissible = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!toastContainer) {
|
||||||
|
toastContainer = document.getElementById('toast-container');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
|
||||||
|
// Icon based on type
|
||||||
|
const icons = {
|
||||||
|
success: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M16.667 5L7.5 14.167L3.333 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>`,
|
||||||
|
error: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M10 6v4M10 13h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>`,
|
||||||
|
warning: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 3.333L17.5 16.667H2.5L10 3.333z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 8.333v3.334M10 14.167h.008" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>`,
|
||||||
|
info: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M10 13.333V10M10 6.667h.008" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="toast-icon">${icons[type]}</div>
|
||||||
|
<div class="toast-content">
|
||||||
|
${title ? `<div class="toast-title">${title}</div>` : ''}
|
||||||
|
${message ? `<div class="toast-message">${message}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${dismissible ? `<button class="toast-close" aria-label="Close">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M1 1l12 12M13 1L1 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>` : ''}
|
||||||
|
${duration > 0 ? `<div class="toast-progress"><div class="toast-progress-bar" style="--duration: ${duration}ms;"></div></div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Dismiss on close button click
|
||||||
|
if (dismissible) {
|
||||||
|
const closeBtn = toast.querySelector('.toast-close');
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
dismissToast(toast);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
dismissToast(toast);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click to dismiss (anywhere on toast)
|
||||||
|
toast.addEventListener('click', (e) => {
|
||||||
|
if (e.target !== toast.querySelector('.toast-close') && e.target.closest('.toast-close') === null) {
|
||||||
|
dismissToast(toast);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return toast;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissToast(toast) {
|
||||||
|
if (!toast || toast.classList.contains('removing')) return;
|
||||||
|
|
||||||
|
toast.classList.add('removing');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
|
toast.parentNode.removeChild(toast);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience functions
|
||||||
|
function showSuccess(title, message, duration) {
|
||||||
|
return showToast({ type: 'success', title, message, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(title, message, duration) {
|
||||||
|
return showToast({ type: 'error', title, message, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarning(title, message, duration) {
|
||||||
|
return showToast({ type: 'warning', title, message, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfo(title, message, duration) {
|
||||||
|
return showToast({ type: 'info', title, message, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command Palette System
|
||||||
|
let commandPaletteModal;
|
||||||
|
let commandPaletteInput;
|
||||||
|
let commandPaletteResults;
|
||||||
|
let selectedCommandIndex = 0;
|
||||||
|
let filteredCommands = [];
|
||||||
|
|
||||||
|
// Define all available commands
|
||||||
|
const commands = [
|
||||||
|
// Chat actions
|
||||||
|
{
|
||||||
|
id: 'clear-chat',
|
||||||
|
title: 'Clear Conversation',
|
||||||
|
description: 'Clear all messages in the current conversation',
|
||||||
|
category: 'Chat',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 4h14M6 4V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v1M5 4v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => clearHistory(),
|
||||||
|
keywords: ['delete', 'remove', 'reset']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'export-chat',
|
||||||
|
title: 'Export Conversation',
|
||||||
|
description: 'Save conversation to a JSON file',
|
||||||
|
category: 'Chat',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3v10M7 10l3 3 3-3" stroke="currentColor" stroke-width="1.5"/><path d="M3 16h14" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => exportChatHistory(),
|
||||||
|
keywords: ['save', 'download', 'backup']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'import-chat',
|
||||||
|
title: 'Import Conversation',
|
||||||
|
description: 'Load conversation from a JSON file',
|
||||||
|
category: 'Chat',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 13V3M7 6l3-3 3 3" stroke="currentColor" stroke-width="1.5"/><path d="M3 16h14" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => importChatHistory(),
|
||||||
|
keywords: ['load', 'restore', 'open']
|
||||||
|
},
|
||||||
|
// Character actions
|
||||||
|
{
|
||||||
|
id: 'new-character',
|
||||||
|
title: 'New Character',
|
||||||
|
description: 'Create a new character',
|
||||||
|
category: 'Characters',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => handleNewCharacter(),
|
||||||
|
shortcut: ['Ctrl', 'N'],
|
||||||
|
keywords: ['create', 'add']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'import-character',
|
||||||
|
title: 'Import Character Card',
|
||||||
|
description: 'Import a character from PNG card',
|
||||||
|
category: 'Characters',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 13V3M7 6l3-3 3 3" stroke="currentColor" stroke-width="1.5"/><path d="M3 16h14" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => handleImportCharacter(),
|
||||||
|
keywords: ['load', 'v2', 'card', 'png']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'export-character',
|
||||||
|
title: 'Export Character Card',
|
||||||
|
description: 'Export current character as PNG card',
|
||||||
|
category: 'Characters',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3v10M7 10l3 3 3-3" stroke="currentColor" stroke-width="1.5"/><path d="M3 16h14" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => handleExportCharacter(),
|
||||||
|
keywords: ['save', 'v2', 'card', 'png']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete-character',
|
||||||
|
title: 'Delete Character',
|
||||||
|
description: 'Delete the current character',
|
||||||
|
category: 'Characters',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 4h14M6 4V3a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v1M5 4v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => handleDeleteCharacter(),
|
||||||
|
keywords: ['remove']
|
||||||
|
},
|
||||||
|
// Settings actions
|
||||||
|
{
|
||||||
|
id: 'open-settings',
|
||||||
|
title: 'Open Settings',
|
||||||
|
description: 'Open the settings panel',
|
||||||
|
category: 'Settings',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="2" stroke="currentColor" stroke-width="1.5"/><path d="M17 10c0-.5-.1-1-.3-1.4l1.2-.7-1-1.7-1.2.7c-.6-.6-1.3-1-2.1-1.2V4h-2v1.7c-.8.2-1.5.6-2.1 1.2L8.3 6.2l-1 1.7 1.2.7C8.1 9 8 9.5 8 10s.1 1 .3 1.4l-1.2.7 1 1.7 1.2-.7c.6.6 1.3 1 2.1 1.2V16h2v-1.7c.8-.2 1.5-.6 2.1-1.2l1.2.7 1-1.7-1.2-.7c.2-.4.3-.9.3-1.4z" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => showSettings(),
|
||||||
|
keywords: ['preferences', 'config', 'api']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-roleplay-tools',
|
||||||
|
title: 'Open Roleplay Tools',
|
||||||
|
description: 'Open the roleplay tools panel',
|
||||||
|
category: 'Settings',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => showRoleplayPanel(),
|
||||||
|
shortcut: ['Ctrl', '/'],
|
||||||
|
keywords: ['world info', 'lorebook', 'persona', 'preset']
|
||||||
|
},
|
||||||
|
// Focus actions
|
||||||
|
{
|
||||||
|
id: 'focus-input',
|
||||||
|
title: 'Focus Message Input',
|
||||||
|
description: 'Move cursor to the message input',
|
||||||
|
category: 'Navigation',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 10h14M13 6l4 4-4 4" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => {
|
||||||
|
if (messageInput) messageInput.focus();
|
||||||
|
closeCommandPalette();
|
||||||
|
},
|
||||||
|
shortcut: ['Ctrl', 'K'],
|
||||||
|
keywords: ['cursor', 'type']
|
||||||
|
},
|
||||||
|
// Theme actions
|
||||||
|
{
|
||||||
|
id: 'theme-dark',
|
||||||
|
title: 'Switch to Dark Theme',
|
||||||
|
description: 'Change theme to dark mode',
|
||||||
|
category: 'Appearance',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M17 10.5A7 7 0 1 1 9.5 3a6 6 0 0 0 7.5 7.5z" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
document.getElementById('theme-select').value = 'dark';
|
||||||
|
closeCommandPalette();
|
||||||
|
showSuccess('Theme Changed', 'Switched to Dark theme');
|
||||||
|
},
|
||||||
|
keywords: ['color', 'style']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme-light',
|
||||||
|
title: 'Switch to Light Theme',
|
||||||
|
description: 'Change theme to light mode',
|
||||||
|
category: 'Appearance',
|
||||||
|
icon: `<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v2M10 16v2M18 10h-2M4 10H2M15.66 4.34l-1.41 1.41M5.75 14.25l-1.41 1.41M15.66 15.66l-1.41-1.41M5.75 5.75L4.34 4.34" stroke="currentColor" stroke-width="1.5"/></svg>`,
|
||||||
|
action: () => {
|
||||||
|
applyTheme('light');
|
||||||
|
document.getElementById('theme-select').value = 'light';
|
||||||
|
closeCommandPalette();
|
||||||
|
showSuccess('Theme Changed', 'Switched to Light theme');
|
||||||
|
},
|
||||||
|
keywords: ['color', 'style']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function openCommandPalette() {
|
||||||
|
if (!commandPaletteModal) {
|
||||||
|
commandPaletteModal = document.getElementById('command-palette-modal');
|
||||||
|
commandPaletteInput = document.getElementById('command-palette-input');
|
||||||
|
commandPaletteResults = document.getElementById('command-palette-results');
|
||||||
|
|
||||||
|
// Overlay click to close
|
||||||
|
commandPaletteModal.querySelector('.command-palette-overlay').addEventListener('click', closeCommandPalette);
|
||||||
|
|
||||||
|
// Input event listener for filtering
|
||||||
|
commandPaletteInput.addEventListener('input', (e) => {
|
||||||
|
filterCommands(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commandPaletteModal.style.display = 'flex';
|
||||||
|
commandPaletteInput.value = '';
|
||||||
|
selectedCommandIndex = 0;
|
||||||
|
|
||||||
|
// Show all commands initially
|
||||||
|
filterCommands('');
|
||||||
|
|
||||||
|
// Focus the input after a brief delay to ensure modal is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
commandPaletteInput.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCommandPalette() {
|
||||||
|
if (commandPaletteModal) {
|
||||||
|
commandPaletteModal.style.display = 'none';
|
||||||
|
commandPaletteInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCommands(searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (term === '') {
|
||||||
|
filteredCommands = [...commands];
|
||||||
|
} else {
|
||||||
|
filteredCommands = commands.filter(cmd => {
|
||||||
|
const titleMatch = cmd.title.toLowerCase().includes(term);
|
||||||
|
const descMatch = cmd.description.toLowerCase().includes(term);
|
||||||
|
const categoryMatch = cmd.category.toLowerCase().includes(term);
|
||||||
|
const keywordMatch = cmd.keywords && cmd.keywords.some(k => k.includes(term));
|
||||||
|
|
||||||
|
return titleMatch || descMatch || categoryMatch || keywordMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCommandIndex = 0;
|
||||||
|
renderCommandResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommandResults() {
|
||||||
|
if (filteredCommands.length === 0) {
|
||||||
|
commandPaletteResults.innerHTML = `
|
||||||
|
<div class="command-palette-empty">
|
||||||
|
<svg class="command-palette-empty-icon" viewBox="0 0 48 48" fill="none">
|
||||||
|
<circle cx="20" cy="20" r="16" stroke="currentColor" stroke-width="3"/>
|
||||||
|
<path d="M32 32l10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M20 14v12M20 30h.02" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<p class="command-palette-empty-text">No commands found</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group commands by category
|
||||||
|
const grouped = {};
|
||||||
|
filteredCommands.forEach(cmd => {
|
||||||
|
if (!grouped[cmd.category]) {
|
||||||
|
grouped[cmd.category] = [];
|
||||||
|
}
|
||||||
|
grouped[cmd.category].push(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
Object.keys(grouped).forEach((category, catIndex) => {
|
||||||
|
html += `<div class="command-palette-section">${category}</div>`;
|
||||||
|
|
||||||
|
grouped[category].forEach((cmd, cmdIndex) => {
|
||||||
|
const globalIndex = filteredCommands.indexOf(cmd);
|
||||||
|
const isSelected = globalIndex === selectedCommandIndex;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="command-item ${isSelected ? 'selected' : ''}" data-index="${globalIndex}">
|
||||||
|
<div class="command-item-icon">${cmd.icon}</div>
|
||||||
|
<div class="command-item-content">
|
||||||
|
<div class="command-item-title">${cmd.title}</div>
|
||||||
|
<div class="command-item-description">${cmd.description}</div>
|
||||||
|
</div>
|
||||||
|
${cmd.shortcut ? `
|
||||||
|
<div class="command-item-shortcut">
|
||||||
|
${cmd.shortcut.map(key => `<kbd>${key}</kbd>`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
commandPaletteResults.innerHTML = html;
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
commandPaletteResults.querySelectorAll('.command-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const index = parseInt(item.dataset.index);
|
||||||
|
executeCommand(filteredCommands[index]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
const selectedItem = commandPaletteResults.querySelector('.command-item.selected');
|
||||||
|
if (selectedItem) {
|
||||||
|
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeCommand(command) {
|
||||||
|
if (command && command.action) {
|
||||||
|
closeCommandPalette();
|
||||||
|
command.action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommandPaletteKeydown(e) {
|
||||||
|
if (!commandPaletteModal || commandPaletteModal.style.display === 'none') return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeCommandPalette();
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCommandIndex = Math.min(selectedCommandIndex + 1, filteredCommands.length - 1);
|
||||||
|
renderCommandResults();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedCommandIndex = Math.max(selectedCommandIndex - 1, 0);
|
||||||
|
renderCommandResults();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredCommands.length > 0) {
|
||||||
|
executeCommand(filteredCommands[selectedCommandIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save & Recovery System
|
||||||
|
let autoSaveTimeout;
|
||||||
|
const AUTO_SAVE_DELAY = 1000; // Save after 1 second of inactivity
|
||||||
|
|
||||||
|
function getAutoSaveKey() {
|
||||||
|
if (!currentCharacter) return null;
|
||||||
|
return `claudia-draft-${currentCharacter.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoSaveMessageInput() {
|
||||||
|
const key = getAutoSaveKey();
|
||||||
|
if (!key || !messageInput) return;
|
||||||
|
|
||||||
|
const content = messageInput.value.trim();
|
||||||
|
|
||||||
|
if (content === '') {
|
||||||
|
// Clear the draft if input is empty
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
// Save the draft
|
||||||
|
const draft = {
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
characterId: currentCharacter.id,
|
||||||
|
characterName: currentCharacter.name
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify(draft));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAutoSavedDraft() {
|
||||||
|
const key = getAutoSaveKey();
|
||||||
|
if (!key || !messageInput) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedDraft = localStorage.getItem(key);
|
||||||
|
if (!savedDraft) return;
|
||||||
|
|
||||||
|
const draft = JSON.parse(savedDraft);
|
||||||
|
|
||||||
|
// Check if draft is not too old (older than 7 days)
|
||||||
|
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
|
||||||
|
const age = Date.now() - draft.timestamp;
|
||||||
|
|
||||||
|
if (age > maxAge) {
|
||||||
|
// Draft is too old, remove it
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the draft
|
||||||
|
if (draft.content && draft.content.trim() !== '') {
|
||||||
|
messageInput.value = draft.content;
|
||||||
|
|
||||||
|
// Auto-resize the textarea
|
||||||
|
messageInput.style.height = 'auto';
|
||||||
|
messageInput.style.height = messageInput.scrollHeight + 'px';
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
const draftAge = formatDraftAge(age);
|
||||||
|
showInfo('Draft Recovered', `Unsent message from ${draftAge} ago has been restored.`, 4000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load auto-saved draft:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoSavedDraft() {
|
||||||
|
const key = getAutoSaveKey();
|
||||||
|
if (!key) return;
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDraftAge(milliseconds) {
|
||||||
|
const seconds = Math.floor(milliseconds / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return `${days} day${days > 1 ? 's' : ''}`;
|
||||||
|
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
|
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||||
|
return 'a few seconds';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAutoSave() {
|
||||||
|
if (!messageInput) return;
|
||||||
|
|
||||||
|
// Auto-save on input with debouncing
|
||||||
|
messageInput.addEventListener('input', () => {
|
||||||
|
// Clear existing timeout
|
||||||
|
if (autoSaveTimeout) {
|
||||||
|
clearTimeout(autoSaveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout to save after delay
|
||||||
|
autoSaveTimeout = setTimeout(() => {
|
||||||
|
autoSaveMessageInput();
|
||||||
|
}, AUTO_SAVE_DELAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also save on blur (when user clicks away)
|
||||||
|
messageInput.addEventListener('blur', () => {
|
||||||
|
autoSaveMessageInput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply view mode
|
// Apply view mode
|
||||||
function applyViewMode(mode) {
|
function applyViewMode(mode) {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
@@ -206,14 +711,14 @@ async function exportChatHistory() {
|
|||||||
try {
|
try {
|
||||||
setStatus('Exporting chat...', 'default');
|
setStatus('Exporting chat...', 'default');
|
||||||
const filePath = await invoke('export_chat_history');
|
const filePath = await invoke('export_chat_history');
|
||||||
setStatus('Chat exported successfully!', 'success');
|
setStatus('Ready');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
showSuccess('Chat Exported', `Successfully exported to ${filePath}`, 4000);
|
||||||
console.log('Chat exported to:', filePath);
|
console.log('Chat exported to:', filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export failed:', error);
|
console.error('Export failed:', error);
|
||||||
if (error && !error.toString().includes('cancelled')) {
|
if (error && !error.toString().includes('cancelled')) {
|
||||||
setStatus(`Export failed: ${error}`, 'error');
|
setStatus('Ready');
|
||||||
setTimeout(() => setStatus('Ready'), 3000);
|
showError('Export Failed', `Failed to export chat: ${error}`);
|
||||||
} else {
|
} else {
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
}
|
}
|
||||||
@@ -229,15 +734,15 @@ async function importChatHistory() {
|
|||||||
// Reload the chat history
|
// Reload the chat history
|
||||||
await loadChatHistory();
|
await loadChatHistory();
|
||||||
|
|
||||||
setStatus(`Imported ${messageCount} messages successfully!`, 'success');
|
setStatus('Ready');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
showSuccess('Chat Imported', `Successfully imported ${messageCount} messages!`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import failed:', error);
|
console.error('Import failed:', error);
|
||||||
if (error === 'No file selected' || error.toString().includes('cancelled')) {
|
if (error === 'No file selected' || error.toString().includes('cancelled')) {
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} else {
|
} else {
|
||||||
setStatus(`Import failed: ${error}`, 'error');
|
setStatus('Ready');
|
||||||
setTimeout(() => setStatus('Ready'), 3000);
|
showError('Import Failed', `Failed to import chat: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,11 +1529,10 @@ async function handleDeleteMessage(messageDiv) {
|
|||||||
await invoke('delete_message_at_index', { messageIndex });
|
await invoke('delete_message_at_index', { messageIndex });
|
||||||
messageDiv.remove();
|
messageDiv.remove();
|
||||||
await updateTokenCount();
|
await updateTokenCount();
|
||||||
setStatus('Message deleted', 'success');
|
showSuccess('Message Deleted', 'The message has been deleted successfully.');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete message:', error);
|
console.error('Failed to delete message:', error);
|
||||||
setStatus(`Delete failed: ${error}`, 'error');
|
showError('Delete Failed', `Failed to delete message: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,13 +1559,13 @@ async function handleCreateBranch(messageDiv) {
|
|||||||
branchName: branchName.trim()
|
branchName: branchName.trim()
|
||||||
});
|
});
|
||||||
|
|
||||||
setStatus(`Created branch: ${branch.name}`, 'success');
|
showSuccess('Branch Created', `Created new branch: ${branch.name}`);
|
||||||
|
|
||||||
// Switch to the new branch
|
// Switch to the new branch
|
||||||
await handleSwitchBranch(branch.id);
|
await handleSwitchBranch(branch.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create branch:', error);
|
console.error('Failed to create branch:', error);
|
||||||
setStatus(`Branch creation failed: ${error}`, 'error');
|
showError('Branch Creation Failed', `Failed to create branch: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,6 +2006,9 @@ async function handleSubmit(e) {
|
|||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
autoResize(messageInput);
|
autoResize(messageInput);
|
||||||
|
|
||||||
|
// Clear the auto-saved draft since message is being sent
|
||||||
|
clearAutoSavedDraft();
|
||||||
|
|
||||||
await sendMessage(message);
|
await sendMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1986,6 +2493,9 @@ async function loadCharacters() {
|
|||||||
|
|
||||||
await loadChatHistory();
|
await loadChatHistory();
|
||||||
await updateBranchIndicator();
|
await updateBranchIndicator();
|
||||||
|
|
||||||
|
// Load auto-saved draft for this character
|
||||||
|
loadAutoSavedDraft();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load characters:', error);
|
console.error('Failed to load characters:', error);
|
||||||
addMessage(`Failed to load characters: ${error}`, false);
|
addMessage(`Failed to load characters: ${error}`, false);
|
||||||
@@ -2076,63 +2586,51 @@ async function handleNewCharacter() {
|
|||||||
// Handle character deletion
|
// Handle character deletion
|
||||||
async function handleDeleteCharacter() {
|
async function handleDeleteCharacter() {
|
||||||
if (!currentCharacter || currentCharacter.id === 'default') {
|
if (!currentCharacter || currentCharacter.id === 'default') {
|
||||||
addMessage('Cannot delete the default character.', false);
|
showWarning('Cannot Delete', 'The default character cannot be deleted.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to delete ${currentCharacter.name}? This cannot be undone.`)) {
|
if (confirm(`Are you sure you want to delete ${currentCharacter.name}? This cannot be undone.`)) {
|
||||||
try {
|
try {
|
||||||
|
const characterName = currentCharacter.name;
|
||||||
await invoke('delete_character', { characterId: currentCharacter.id });
|
await invoke('delete_character', { characterId: currentCharacter.id });
|
||||||
await loadCharacters();
|
await loadCharacters();
|
||||||
hideSettings();
|
hideSettings();
|
||||||
|
showSuccess('Character Deleted', `${characterName} has been deleted successfully.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete character:', error);
|
console.error('Failed to delete character:', error);
|
||||||
addMessage(`Failed to delete character: ${error}`, false);
|
showError('Delete Failed', `Failed to delete character: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle character card import
|
// Handle character card import
|
||||||
async function handleImportCharacter() {
|
async function handleImportCharacter() {
|
||||||
const characterMsg = document.getElementById('character-message');
|
|
||||||
try {
|
try {
|
||||||
const importedCharacter = await invoke('import_character_card');
|
const importedCharacter = await invoke('import_character_card');
|
||||||
characterMsg.textContent = `Successfully imported ${importedCharacter.name}!`;
|
showSuccess('Character Imported', `${importedCharacter.name} has been imported successfully!`);
|
||||||
characterMsg.className = 'validation-message success';
|
|
||||||
|
|
||||||
// Reload characters and switch to the imported one
|
// Reload characters and switch to the imported one
|
||||||
await loadCharacters();
|
await loadCharacters();
|
||||||
await loadCharacterSettings();
|
await loadCharacterSettings();
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
characterMsg.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to import character:', error);
|
console.error('Failed to import character:', error);
|
||||||
if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) {
|
if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) {
|
||||||
characterMsg.textContent = `Failed to import: ${error}`;
|
showError('Import Failed', `Failed to import character: ${error}`);
|
||||||
characterMsg.className = 'validation-message error';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle character card export
|
// Handle character card export
|
||||||
async function handleExportCharacter() {
|
async function handleExportCharacter() {
|
||||||
const characterMsg = document.getElementById('character-message');
|
|
||||||
try {
|
try {
|
||||||
const characterId = document.getElementById('character-settings-select').value;
|
const characterId = document.getElementById('character-settings-select').value;
|
||||||
const outputPath = await invoke('export_character_card', { characterId });
|
const outputPath = await invoke('export_character_card', { characterId });
|
||||||
characterMsg.textContent = `Successfully exported to ${outputPath}`;
|
showSuccess('Character Exported', `Successfully exported to ${outputPath}`, 4000);
|
||||||
characterMsg.className = 'validation-message success';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
characterMsg.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to export character:', error);
|
console.error('Failed to export character:', error);
|
||||||
if (error && !error.toString().includes('cancelled')) {
|
if (error && !error.toString().includes('cancelled')) {
|
||||||
characterMsg.textContent = `Failed to export: ${error}`;
|
showError('Export Failed', `Failed to export character: ${error}`);
|
||||||
characterMsg.className = 'validation-message error';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2207,11 +2705,11 @@ async function clearHistory() {
|
|||||||
} else {
|
} else {
|
||||||
addMessage('Conversation cleared. Ready to chat.', false, true);
|
addMessage('Conversation cleared. Ready to chat.', false, true);
|
||||||
}
|
}
|
||||||
setStatus('History cleared', 'success');
|
setStatus('Ready');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
showSuccess('History Cleared', 'Conversation history has been cleared.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('Failed to clear history', 'error');
|
setStatus('Ready');
|
||||||
addMessage(`Failed to clear history: ${error}`, false);
|
showError('Clear Failed', `Failed to clear history: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2289,8 +2787,7 @@ async function handleSaveCharacter(e) {
|
|||||||
const characterMsg = document.getElementById('character-message');
|
const characterMsg = document.getElementById('character-message');
|
||||||
|
|
||||||
if (!name || !systemPrompt) {
|
if (!name || !systemPrompt) {
|
||||||
characterMsg.textContent = 'Name and System Prompt are required';
|
showWarning('Missing Fields', 'Name and System Prompt are required.');
|
||||||
characterMsg.className = 'validation-message error';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2315,17 +2812,11 @@ async function handleSaveCharacter(e) {
|
|||||||
creatorNotes,
|
creatorNotes,
|
||||||
avatarPath: pendingAvatarPath
|
avatarPath: pendingAvatarPath
|
||||||
});
|
});
|
||||||
characterMsg.textContent = 'Character saved successfully';
|
|
||||||
characterMsg.className = 'validation-message success';
|
|
||||||
|
|
||||||
await loadCharacters();
|
await loadCharacters();
|
||||||
|
showSuccess('Character Saved', `${name} has been saved successfully.`);
|
||||||
setTimeout(() => {
|
|
||||||
characterMsg.style.display = 'none';
|
|
||||||
}, 3000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
characterMsg.textContent = `Failed to save: ${error}`;
|
showError('Save Failed', `Failed to save character: ${error}`);
|
||||||
characterMsg.className = 'validation-message error';
|
|
||||||
} finally {
|
} finally {
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
saveBtn.classList.remove('loading');
|
saveBtn.classList.remove('loading');
|
||||||
@@ -2759,11 +3250,10 @@ async function handleSaveAuthorsNote() {
|
|||||||
updateFeatureBadges();
|
updateFeatureBadges();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setStatus('Author\'s Note saved', 'success');
|
showSuccess('Author\'s Note Saved', 'Your author\'s note has been saved successfully.');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save Author\'s Note:', error);
|
console.error('Failed to save Author\'s Note:', error);
|
||||||
setStatus('Failed to save Author\'s Note', 'error');
|
showError('Save Failed', `Failed to save author's note: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2794,11 +3284,10 @@ async function handleSavePersona() {
|
|||||||
updateFeatureBadges();
|
updateFeatureBadges();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setStatus('Persona saved', 'success');
|
showSuccess('Persona Saved', 'Your persona has been saved successfully.');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save Persona:', error);
|
console.error('Failed to save Persona:', error);
|
||||||
setStatus('Failed to save Persona', 'error');
|
showError('Save Failed', `Failed to save persona: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2826,11 +3315,10 @@ async function handleSaveExamples() {
|
|||||||
updateFeatureBadges();
|
updateFeatureBadges();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
setStatus('Message Examples settings saved', 'success');
|
showSuccess('Examples Saved', 'Message examples settings have been saved.');
|
||||||
setTimeout(() => setStatus('Ready'), 2000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save Message Examples settings:', error);
|
console.error('Failed to save Message Examples settings:', error);
|
||||||
setStatus('Failed to save Message Examples settings', 'error');
|
showError('Save Failed', `Failed to save message examples settings: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3810,6 +4298,9 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
// ESC key to close modal
|
// ESC key to close modal
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Handle command palette keydown (for arrow keys, enter, escape)
|
||||||
|
handleCommandPaletteKeydown(e);
|
||||||
|
|
||||||
// Escape key handling
|
// Escape key handling
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Close new character modal
|
// Close new character modal
|
||||||
@@ -3895,6 +4386,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + P - Open command palette
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||||
|
e.preventDefault();
|
||||||
|
openCommandPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd + / - Toggle roleplay panel
|
// Ctrl/Cmd + / - Toggle roleplay panel
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3918,5 +4416,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadSavedViewMode();
|
loadSavedViewMode();
|
||||||
loadSavedFontSize();
|
loadSavedFontSize();
|
||||||
|
|
||||||
|
// Setup auto-save for message input
|
||||||
|
setupAutoSave();
|
||||||
|
|
||||||
loadExistingConfig();
|
loadExistingConfig();
|
||||||
});
|
});
|
||||||
|
|||||||
432
src/styles.css
432
src/styles.css
@@ -2181,3 +2181,435 @@ body.view-comfortable .message-content pre {
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast Notification System */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 10003;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.removing {
|
||||||
|
animation: slideOutRight 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Variants */
|
||||||
|
.toast.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success .toast-icon {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success .toast-title {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error .toast-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error .toast-title {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning {
|
||||||
|
border-color: rgba(249, 115, 22, 0.5);
|
||||||
|
background: rgba(249, 115, 22, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning .toast-icon {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.warning .toast-title {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
border-color: rgba(99, 102, 241, 0.5);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info .toast-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info .toast-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar for auto-dismiss */
|
||||||
|
.toast-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
|
animation: progressShrink var(--duration) linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressShrink {
|
||||||
|
from {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive toast positioning */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.toast-container {
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command Palette */
|
||||||
|
.command-palette-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10004;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 60vh;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideInDown 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-search-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-hint {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-results {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 50vh;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-results::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-results::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-results::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-shortcut {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-shortcut kbd {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty-text {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-section {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-section:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive command palette */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.command-palette-modal {
|
||||||
|
padding-top: 10vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-content {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-shortcut {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user