diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5e1d69e..df247d3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -35,7 +35,7 @@ code-editor/ │ │ ├── device-carousel # Multi-device preview │ │ ├── pip-window # Picture-in-Picture floating preview │ │ ├── agent-annotations # AI change highlights -│ │ └── component-isolator # Component isolation (⌘⇧I) +│ │ └── component-isolator # Component isolation (Cmd/Ctrl+Shift+I) │ ├── workspace-sidebar # Chat list + navigation │ ├── agent-panel # Chat/agent interface │ ├── settings-panel # Settings (themes, GitHub, editor) @@ -101,9 +101,9 @@ Launches a native macOS window with the web app inside. Both the web frontend an Copy `.env.example` → `.env` and fill in: -| Variable | Required | Description | -| ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` | Optional | Spotify PKCE OAuth Client ID for the music plugin. Create at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard). | +| Variable | Required | Description | +| ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_SPOTIFY_CLIENT_ID` | Optional | Spotify PKCE OAuth Client ID for the music plugin. Create at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard). | > **Note:** `NEXT_PUBLIC_` variables are embedded in the client bundle. Only put public client IDs here, never secrets. @@ -198,19 +198,19 @@ The `release.yml` workflow: ## Keyboard Shortcuts -| Shortcut | Action | -| -------- | --------------------------- | -| `⌘B` | Toggle file explorer | -| `⌘J` | Toggle terminal | -| `⌘\` | Toggle sidebar | -| `⌘P` | Quick open file | -| `⌘⇧I` | Isolate component (preview) | -| `⌘K` | Inline edit | -| `⌘L` | Send selection to chat | -| `⌘S` | Save file | -| `⌘⇧F` | Global search | -| `⌘⇧P` | Command palette | -| `Esc` | Close overlays | +| Shortcut | Action | +| ------------------ | --------------------------- | +| `Cmd/Ctrl+B` | Toggle file explorer | +| `Cmd/Ctrl+J` | Toggle terminal | +| `Cmd/Ctrl+\` | Toggle sidebar | +| `Cmd/Ctrl+P` | Quick open file | +| `Cmd/Ctrl+Shift+I` | Isolate component (preview) | +| `Cmd/Ctrl+K` | Inline edit | +| `Cmd/Ctrl+L` | Send selection to chat | +| `Cmd/Ctrl+S` | Save file | +| `Cmd/Ctrl+Shift+F` | Global search | +| `Cmd/Ctrl+Shift+P` | Command palette | +| `Esc` | Close overlays | --- @@ -236,11 +236,11 @@ To add a new theme: ## Preview System -The preview panel (`⌘3` or click Preview tab) connects to any local dev server: +The preview panel (`Cmd/Ctrl+3` or click Preview tab) connects to any local dev server: - **URL bar** — type `localhost:5173` or any dev server URL - **Device Carousel** — see your app on iPhone, Pixel, iPad, MacBook, Desktop simultaneously -- **Component Isolation** (`⌘⇧I`) — isolate a React component from the active file +- **Component Isolation** (`Cmd/Ctrl+Shift+I`) — isolate a React component from the active file - **Picture-in-Picture** — float the preview over your code while editing - **Agent Annotations** — when the AI makes changes, glowing highlights show what changed diff --git a/README.md b/README.md index 7f12591..6858d69 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ A lightweight, AI-native code editor powered by [OpenClaw](https://github.com/op ┌──────────────┬──────────────────────────┬─────────────────┐ │ File Tree │ Monaco Editor │ Agent Panel │ │ │ (multi-tab, vim mode) │ (chat + diff) │ -│ ⌘B toggle │ ⌘K inline edit │ ⌘J toggle │ -│ │ ⌘P quick open │ │ +│ Cmd/Ctrl+B │ Cmd/Ctrl+K │ Cmd/Ctrl+J │ +│ toggle │ inline edit │ toggle │ +│ │ Cmd/Ctrl+P quick open │ │ ├──────────────┴──────────────────────────┴─────────────────┤ │ Terminal (xterm.js) │ └───────────────────────────────────────────────────────────┘ @@ -81,7 +82,7 @@ Copy `.env.example` to `.env` and configure. All variables are optional — the - **Agent Builder** — Choose a persona, customize your system prompt, configure behaviors - **Inline Edits** — Agent proposes changes, you review diffs and accept/reject per-hunk - **7 Themes** — Obsidian, Bone, Neon, Catppuccin, VooDoo, CyberNord, PrettyPink -- **Monaco Editor** — Multi-tab, Vim mode, syntax highlighting, ⌘P quick open +- **Monaco Editor** — Multi-tab, Vim mode, syntax highlighting, Cmd/Ctrl+P quick open - **GitHub Integration** — Token-based auth, commit, push, branch switching - **Terminal** — Integrated xterm.js with gateway slash commands - **Spotify + YouTube** — Built-in music and video plugins @@ -108,15 +109,15 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the technical architecture, ## Keyboard Shortcuts -| Shortcut | Action | -| -------- | ------------------------------ | -| `⌘P` | Quick file open (fuzzy search) | -| `⌘K` | Inline edit at selection | -| `⌘B` | Toggle file explorer | -| `⌘I` | Toggle agent panel | -| `⌘J` | Toggle terminal | -| `Enter` | Send message / Start chat | -| `Esc` | Close overlays | +| Shortcut | Action | +| ------------ | ------------------------------ | +| `Cmd/Ctrl+P` | Quick file open (fuzzy search) | +| `Cmd/Ctrl+K` | Inline edit at selection | +| `Cmd/Ctrl+B` | Toggle file explorer | +| `Cmd/Ctrl+I` | Toggle agent panel | +| `Cmd/Ctrl+J` | Toggle terminal | +| `Enter` | Send message / Start chat | +| `Esc` | Close overlays | ## Tech Stack diff --git a/__tests__/platform.test.ts b/__tests__/platform.test.ts new file mode 100644 index 0000000..4141d58 --- /dev/null +++ b/__tests__/platform.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { formatShortcut, formatShortcutKeys } from '@/lib/platform' + +describe('shortcut labels', () => { + it('renders generic modifiers for display text', () => { + expect(formatShortcut('meta+shift+p')).toBe('Cmd/Ctrl+Shift+P') + }) + + it('renders generic modifier keys for kbd chips', () => { + expect(formatShortcutKeys('meta+alt+1')).toEqual(['Cmd/Ctrl', 'Alt/Option', '1']) + }) + + it('handles special keys', () => { + expect(formatShortcut('meta+enter')).toBe('Cmd/Ctrl+Enter') + expect(formatShortcut('escape')).toBe('Esc') + }) +}) diff --git a/app/page.tsx b/app/page.tsx index 44ecea3..0270026 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,6 +14,7 @@ import { useAppMode } from '@/context/app-mode-context' import { WorkspaceSidebar } from '@/components/workspace-sidebar' import { FloatingPanel } from '@/components/floating-panel' import { EditorTabs } from '@/components/editor-tabs' +import { formatShortcut } from '@/lib/platform' import { isTauri } from '@/lib/tauri' import { fetchFileContentsByName as fetchFileContents, @@ -668,7 +669,7 @@ export default function EditorLayout() { key={m.id} onClick={() => setMode(m.id)} className={`shell-mode-button ${mode === m.id ? 'shell-mode-button--active' : ''}`} - title={`${m.label} mode (⌘⇧${index + 1})`} + title={`${m.label} mode (${formatShortcut(`meta+shift+${index + 1}`)})`} > diff --git a/components/agent-panel.tsx b/components/agent-panel.tsx index a71546d..71449ff 100644 --- a/components/agent-panel.tsx +++ b/components/agent-panel.tsx @@ -38,6 +38,7 @@ import { MessageList } from '@/components/chat/message-list' import { ChatInputBar } from '@/components/chat/chat-input-bar' import { emit, on } from '@/lib/events' import { copyToClipboard } from '@/lib/clipboard' +import { formatShortcut } from '@/lib/platform' import type { PlanStep } from '@/components/plan-view' import { navigateToLine } from '@/lib/line-links' import { useChatAppearance, FONT_OPTIONS } from '@/context/chat-appearance-context' @@ -106,9 +107,7 @@ function AgentConnectPrompt() { {isConnecting ? ( <> -

- Connecting… -

+

Connecting…

Looking for your gateway

) : ( @@ -387,7 +386,9 @@ export function AgentPanel() { const [sending, setSending] = useState(false) const [isStreaming, setIsStreaming] = useState(false) const [thinkingTrail, setThinkingTrail] = useState([]) - const [agentActivities, setAgentActivities] = useState([]) + const [agentActivities, setAgentActivities] = useState< + import('@/lib/agent-activity').AgentActivity[] + >([]) const [turnStartTime, setTurnStartTime] = useState(null) const [turnElapsedMs, setTurnElapsedMs] = useState(0) const [activeDiff, setActiveDiff] = useState<{ @@ -557,7 +558,9 @@ export function AgentPanel() { }, [isStreaming, sending, turnStartTime]) // ─── Load chat input history ───────────────────────────────── - useEffect(() => { historyNav.current.load() }, []) + useEffect(() => { + historyNav.current.load() + }, []) // ─── Listen for chat events (streaming replies) ─────────────── useEffect(() => { @@ -1214,479 +1217,500 @@ export function AgentPanel() { }, [appendErrorMessage, appendStatusMessage]) // ─── Send message ───────────────────────────────────────────── - const sendMessage = useCallback(async (overrideText?: string) => { - const text = (overrideText ?? input).trim() - if (!text || sending) return - - // Handle slash commands locally - if (text === '/ask') { setAgentMode('ask'); setInput(''); return } - if (text === '/agent') { setAgentMode('agent'); setInput(''); return } - if (text === '/plan') { setAgentMode('plan'); setInput(''); return } - if (text === '/clear') { - setMessages([]) - setAgentActivities([]) - setInput('') - return - } - - // Save to cross-session history - addChatHistory(text) - historyNav.current.reset() - - logChatDebug('send attempt', { - textLength: text.length, - mode: agentMode, - connected: isConnected, - gatewayStatus: status, - sessionKey, - attachmentCount: contextAttachments.length, - imageCount: imageAttachments.length, - }) - setInput('') - - // ─── Slash command interception ─────────────────────────── - if (text.startsWith('/commit')) { - const commitMsg = text.replace(/^\/commit\s*/, '').trim() - appendSlashCommand(text) - - if (commitMsg) { - emit('agent-commit', { message: commitMsg }) - appendStatusMessage('Committing...') + const sendMessage = useCallback( + async (overrideText?: string) => { + const text = (overrideText ?? input).trim() + if (!text || sending) return + + // Handle slash commands locally + if (text === '/ask') { + setAgentMode('ask') + setInput('') return } - - if (!isConnected) { - appendErrorMessage('Gateway disconnected — cannot generate commit message.') + if (text === '/agent') { + setAgentMode('agent') + setInput('') + return + } + if (text === '/plan') { + setAgentMode('plan') + setInput('') + return + } + if (text === '/clear') { + setMessages([]) + setAgentActivities([]) + setInput('') return } - appendStatusMessage('Generating commit message with gateway AI...') + // Save to cross-session history + addChatHistory(text) + historyNav.current.reset() - try { - await ensureSessionInit() - const changes = await collectCommitChangesForGeneration() - if (changes.length === 0) { - appendStatusMessage('No changes detected to commit.') + logChatDebug('send attempt', { + textLength: text.length, + mode: agentMode, + connected: isConnected, + gatewayStatus: status, + sessionKey, + attachmentCount: contextAttachments.length, + imageCount: imageAttachments.length, + }) + setInput('') + + // ─── Slash command interception ─────────────────────────── + if (text.startsWith('/commit')) { + const commitMsg = text.replace(/^\/commit\s*/, '').trim() + appendSlashCommand(text) + + if (commitMsg) { + emit('agent-commit', { message: commitMsg }) + appendStatusMessage('Committing...') return } - const generatedCommitMsg = await generateCommitMessageWithGateway({ - sendRequest, - onEvent, - sessionKey, - repoFullName: repo?.fullName ?? local.remoteRepo ?? undefined, - branch: repo?.branch ?? local.gitInfo?.branch ?? undefined, - changes, - }) + if (!isConnected) { + appendErrorMessage('Gateway disconnected — cannot generate commit message.') + return + } - appendStatusMessage(`Generated commit message: ${generatedCommitMsg}`) - emit('agent-commit', { message: generatedCommitMsg }) - appendStatusMessage('Committing...') - } catch (err) { - appendErrorMessage( - `Generate commit message failed: ${err instanceof Error ? err.message : String(err)}`, - ) + appendStatusMessage('Generating commit message with gateway AI...') + + try { + await ensureSessionInit() + const changes = await collectCommitChangesForGeneration() + if (changes.length === 0) { + appendStatusMessage('No changes detected to commit.') + return + } + + const generatedCommitMsg = await generateCommitMessageWithGateway({ + sendRequest, + onEvent, + sessionKey, + repoFullName: repo?.fullName ?? local.remoteRepo ?? undefined, + branch: repo?.branch ?? local.gitInfo?.branch ?? undefined, + changes, + }) + + appendStatusMessage(`Generated commit message: ${generatedCommitMsg}`) + emit('agent-commit', { message: generatedCommitMsg }) + appendStatusMessage('Committing...') + } catch (err) { + appendErrorMessage( + `Generate commit message failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + return } - return - } - if (text === '/changes') { - appendSlashCommand(text) - emit('open-changes-panel') - appendStatusMessage('Opening pre-commit review...') - return - } - if (text === '/diff') { - appendSlashCommand(text) - const changes = diffEngine.getChanges() - if (changes.length > 0) { - const summary = diffEngine.getSummary() - appendStatusMessage( - `${summary.fileCount} file(s) changed: +${summary.additions} -${summary.deletions}\nFiles: ${changes.map((c) => c.path).join(', ')}`, - ) - } else { - const dirtyFiles = files.filter((f) => f.dirty) - if (dirtyFiles.length > 0) { + if (text === '/changes') { + appendSlashCommand(text) + emit('open-changes-panel') + appendStatusMessage('Opening pre-commit review...') + return + } + if (text === '/diff') { + appendSlashCommand(text) + const changes = diffEngine.getChanges() + if (changes.length > 0) { + const summary = diffEngine.getSummary() appendStatusMessage( - `${dirtyFiles.length} unsaved file(s): ${dirtyFiles.map((f) => f.path).join(', ')}`, + `${summary.fileCount} file(s) changed: +${summary.additions} -${summary.deletions}\nFiles: ${changes.map((c) => c.path).join(', ')}`, ) } else { - appendStatusMessage('No changes detected.') + const dirtyFiles = files.filter((f) => f.dirty) + if (dirtyFiles.length > 0) { + appendStatusMessage( + `${dirtyFiles.length} unsaved file(s): ${dirtyFiles.map((f) => f.path).join(', ')}`, + ) + } else { + appendStatusMessage('No changes detected.') + } } - } - return - } - if (text === '/unstage') { - appendSlashCommand(text) - if (!local.localMode || !local.rootPath || !local.gitInfo?.is_repo) { - appendErrorMessage('Unstage requires a local git repository.') return } - const staged = local.gitInfo.status?.filter((s) => s.status !== '??').map((s) => s.path) ?? [] - if (staged.length === 0) { - appendStatusMessage('No staged files to unstage.') + if (text === '/unstage') { + appendSlashCommand(text) + if (!local.localMode || !local.rootPath || !local.gitInfo?.is_repo) { + appendErrorMessage('Unstage requires a local git repository.') + return + } + const staged = + local.gitInfo.status?.filter((s) => s.status !== '??').map((s) => s.path) ?? [] + if (staged.length === 0) { + appendStatusMessage('No staged files to unstage.') + return + } + try { + await local.unstageFiles(staged) + appendStatusMessage(`Unstaged ${staged.length} file(s).`) + } catch (err) { + appendErrorMessage(`Unstage failed: ${err instanceof Error ? err.message : String(err)}`) + } return } - try { - await local.unstageFiles(staged) - appendStatusMessage(`Unstaged ${staged.length} file(s).`) - } catch (err) { - appendErrorMessage(`Unstage failed: ${err instanceof Error ? err.message : String(err)}`) - } - return - } - if (text === '/undo') { - appendSlashCommand(text) - if (!local.localMode || !local.rootPath || !local.gitInfo?.is_repo) { - appendErrorMessage('Undo commit requires a local git repository.') + if (text === '/undo') { + appendSlashCommand(text) + if (!local.localMode || !local.rootPath || !local.gitInfo?.is_repo) { + appendErrorMessage('Undo commit requires a local git repository.') + return + } + try { + await local.undoLastCommit() + appendStatusMessage('Undid last commit. Changes are back in the working tree.') + } catch (err) { + appendErrorMessage(`Undo failed: ${err instanceof Error ? err.message : String(err)}`) + } return } - try { - await local.undoLastCommit() - appendStatusMessage('Undid last commit. Changes are back in the working tree.') - } catch (err) { - appendErrorMessage(`Undo failed: ${err instanceof Error ? err.message : String(err)}`) - } - return - } - if (text === '/push') { - appendSlashCommand(text) - const localRepo = requireLocalGitRepo('Push') - if (!localRepo) return - appendStatusMessage(`Pushing ${localRepo.branch} to origin...`) - try { - await local.push() - appendStatusMessage(`Pushed ${localRepo.branch} to origin.`) - } catch (err) { - appendErrorMessage(`Push failed: ${err instanceof Error ? err.message : String(err)}`) - } - return - } - if (text === '/pull') { - appendSlashCommand(text) - const localRepo = requireLocalGitRepo('Pull') - if (!localRepo) return - appendStatusMessage(`Pulling ${localRepo.branch} from origin with rebase...`) - try { - const result = await local.pull() - appendStatusMessage(result || `Pulled ${localRepo.branch} from origin.`) - } catch (err) { - appendErrorMessage(`Pull failed: ${err instanceof Error ? err.message : String(err)}`) + if (text === '/push') { + appendSlashCommand(text) + const localRepo = requireLocalGitRepo('Push') + if (!localRepo) return + appendStatusMessage(`Pushing ${localRepo.branch} to origin...`) + try { + await local.push() + appendStatusMessage(`Pushed ${localRepo.branch} to origin.`) + } catch (err) { + appendErrorMessage(`Push failed: ${err instanceof Error ? err.message : String(err)}`) + } + return } - return - } - if (text === '/sync') { - appendSlashCommand(text) - const localRepo = requireLocalGitRepo('Sync') - if (!localRepo) return - appendStatusMessage(`Syncing ${localRepo.branch} with origin...`) - try { - const result = await local.gitSync() - appendStatusMessage(result || `Synced ${localRepo.branch}.`) - } catch (err) { - appendErrorMessage(`Sync failed: ${err instanceof Error ? err.message : String(err)}`) + if (text === '/pull') { + appendSlashCommand(text) + const localRepo = requireLocalGitRepo('Pull') + if (!localRepo) return + appendStatusMessage(`Pulling ${localRepo.branch} from origin with rebase...`) + try { + const result = await local.pull() + appendStatusMessage(result || `Pulled ${localRepo.branch} from origin.`) + } catch (err) { + appendErrorMessage(`Pull failed: ${err instanceof Error ? err.message : String(err)}`) + } + return } - return - } - - const pullRequestCreateMatch = text.match(/^\/pr\s+create(?:\s+(.*))?$/i) - if (pullRequestCreateMatch) { - appendSlashCommand(text) - const githubRepo = requireGithubRepo('Create pull request', true) - if (!githubRepo) return - const currentBranch = githubRepo.branch - if (!currentBranch) { - appendErrorMessage('Create pull request requires an active branch.') + if (text === '/sync') { + appendSlashCommand(text) + const localRepo = requireLocalGitRepo('Sync') + if (!localRepo) return + appendStatusMessage(`Syncing ${localRepo.branch} with origin...`) + try { + const result = await local.gitSync() + appendStatusMessage(result || `Synced ${localRepo.branch}.`) + } catch (err) { + appendErrorMessage(`Sync failed: ${err instanceof Error ? err.message : String(err)}`) + } return } - try { - const { title, body, base } = parsePullRequestCreateArgs(pullRequestCreateMatch[1] ?? '') - const repoInfo = await fetchRepoByName(githubRepo.repoFullName) - const baseBranch = base || repoInfo.default_branch - - if (currentBranch === baseBranch) { - appendErrorMessage( - `Current branch \`${currentBranch}\` matches the base branch. Switch to a feature branch first.`, - ) + const pullRequestCreateMatch = text.match(/^\/pr\s+create(?:\s+(.*))?$/i) + if (pullRequestCreateMatch) { + appendSlashCommand(text) + const githubRepo = requireGithubRepo('Create pull request', true) + if (!githubRepo) return + const currentBranch = githubRepo.branch + if (!currentBranch) { + appendErrorMessage('Create pull request requires an active branch.') return } - if (local.localMode && local.rootPath && local.gitInfo?.is_repo) { - appendStatusMessage(`Pushing ${currentBranch} to origin before creating the PR...`) - await local.push(currentBranch) - } - - appendStatusMessage(`Creating pull request from ${currentBranch} to ${baseBranch}...`) - const created = await createPullRequest( - githubRepo.repoFullName, - title || titleFromBranchName(currentBranch), - body || buildDefaultPullRequestBody(currentBranch, baseBranch), - currentBranch, - baseBranch, - ) - appendStatusMessage(`Created PR #${created.number}: ${created.title}\n${created.url}`) - } catch (err) { - appendErrorMessage( - `Create pull request failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } - return - } + try { + const { title, body, base } = parsePullRequestCreateArgs(pullRequestCreateMatch[1] ?? '') + const repoInfo = await fetchRepoByName(githubRepo.repoFullName) + const baseBranch = base || repoInfo.default_branch + + if (currentBranch === baseBranch) { + appendErrorMessage( + `Current branch \`${currentBranch}\` matches the base branch. Switch to a feature branch first.`, + ) + return + } - const pullRequestDetailMatch = text.match(/^\/pr\s+(\d+)$/i) - if (text === '/pr' || pullRequestDetailMatch) { - appendSlashCommand(text) - const githubRepo = requireGithubRepo('Pull request lookup') - if (!githubRepo) return + if (local.localMode && local.rootPath && local.gitInfo?.is_repo) { + appendStatusMessage(`Pushing ${currentBranch} to origin before creating the PR...`) + await local.push(currentBranch) + } - try { - if (pullRequestDetailMatch) { - const number = Number.parseInt(pullRequestDetailMatch[1], 10) - appendStatusMessage(`Loading PR #${number}...`) - const pr = await fetchPullRequest(githubRepo.repoFullName, number) - const [reviews, checks] = await Promise.all([ - fetchPRReviews(githubRepo.repoFullName, number), - fetchPRChecks(githubRepo.repoFullName, pr.headSha), - ]) - appendStatusMessage(formatPullRequestDetails(pr, reviews.length, summarizeChecks(checks))) - } else { - const prs = await fetchPullRequests(githubRepo.repoFullName, 'open', 20) - appendStatusMessage( - formatPullRequestList(githubRepo.repoFullName, prs, githubRepo.branch), + appendStatusMessage(`Creating pull request from ${currentBranch} to ${baseBranch}...`) + const created = await createPullRequest( + githubRepo.repoFullName, + title || titleFromBranchName(currentBranch), + body || buildDefaultPullRequestBody(currentBranch, baseBranch), + currentBranch, + baseBranch, + ) + appendStatusMessage(`Created PR #${created.number}: ${created.title}\n${created.url}`) + } catch (err) { + appendErrorMessage( + `Create pull request failed: ${err instanceof Error ? err.message : String(err)}`, ) } - } catch (err) { - appendErrorMessage( - `Pull request lookup failed: ${err instanceof Error ? err.message : String(err)}`, - ) + return } - return - } - - const mergeMatch = text.match(/^\/merge(?:\s+(\d+))(?:\s+(merge|squash|rebase))?$/i) - if (mergeMatch) { - appendSlashCommand(text) - const githubRepo = requireGithubRepo('Merge pull request', true) - if (!githubRepo) return - const prNumber = Number.parseInt(mergeMatch[1], 10) - const mergeMethod = (mergeMatch[2]?.toLowerCase() ?? 'merge') as 'merge' | 'squash' | 'rebase' + const pullRequestDetailMatch = text.match(/^\/pr\s+(\d+)$/i) + if (text === '/pr' || pullRequestDetailMatch) { + appendSlashCommand(text) + const githubRepo = requireGithubRepo('Pull request lookup') + if (!githubRepo) return - try { - appendStatusMessage(`Merging PR #${prNumber} with ${mergeMethod}...`) - const result = await mergePullRequest(githubRepo.repoFullName, prNumber, mergeMethod) - const shaLine = result.sha ? `\nCommit: ${result.sha.slice(0, 7)}` : '' - appendStatusMessage(`Merged PR #${prNumber}. ${result.message}${shaLine}`) - } catch (err) { - appendErrorMessage(`Merge failed: ${err instanceof Error ? err.message : String(err)}`) + try { + if (pullRequestDetailMatch) { + const number = Number.parseInt(pullRequestDetailMatch[1], 10) + appendStatusMessage(`Loading PR #${number}...`) + const pr = await fetchPullRequest(githubRepo.repoFullName, number) + const [reviews, checks] = await Promise.all([ + fetchPRReviews(githubRepo.repoFullName, number), + fetchPRChecks(githubRepo.repoFullName, pr.headSha), + ]) + appendStatusMessage( + formatPullRequestDetails(pr, reviews.length, summarizeChecks(checks)), + ) + } else { + const prs = await fetchPullRequests(githubRepo.repoFullName, 'open', 20) + appendStatusMessage( + formatPullRequestList(githubRepo.repoFullName, prs, githubRepo.branch), + ) + } + } catch (err) { + appendErrorMessage( + `Pull request lookup failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + return } - return - } - const parsedSkillCommand = parseSkillSlashCommand(text) - if (parsedSkillCommand) { - appendMessage({ - id: crypto.randomUUID(), - role: 'user', - type: 'text', - content: text, - timestamp: Date.now(), - }) + const mergeMatch = text.match(/^\/merge(?:\s+(\d+))(?:\s+(merge|squash|rebase))?$/i) + if (mergeMatch) { + appendSlashCommand(text) + const githubRepo = requireGithubRepo('Merge pull request', true) + if (!githubRepo) return - if (parsedSkillCommand.kind === 'help') { - appendMessage({ - id: crypto.randomUUID(), - role: 'system', - type: 'status', - content: buildSkillCommandHelp(), - timestamp: Date.now(), - }) + const prNumber = Number.parseInt(mergeMatch[1], 10) + const mergeMethod = (mergeMatch[2]?.toLowerCase() ?? 'merge') as + | 'merge' + | 'squash' + | 'rebase' + + try { + appendStatusMessage(`Merging PR #${prNumber} with ${mergeMethod}...`) + const result = await mergePullRequest(githubRepo.repoFullName, prNumber, mergeMethod) + const shaLine = result.sha ? `\nCommit: ${result.sha.slice(0, 7)}` : '' + appendStatusMessage(`Merged PR #${prNumber}. ${result.message}${shaLine}`) + } catch (err) { + appendErrorMessage(`Merge failed: ${err instanceof Error ? err.message : String(err)}`) + } return } - if (parsedSkillCommand.kind === 'list') { + const parsedSkillCommand = parseSkillSlashCommand(text) + if (parsedSkillCommand) { appendMessage({ id: crypto.randomUUID(), - role: 'system', - type: 'status', - content: buildCatalogSummary(SKILLS_CATALOG), + role: 'user', + type: 'text', + content: text, timestamp: Date.now(), }) - return - } - if (parsedSkillCommand.kind === 'use') { - const skill = parsedSkillCommand.skillSlug - ? getSkillBySlug(parsedSkillCommand.skillSlug) - : undefined - if (!skill) { + if (parsedSkillCommand.kind === 'help') { appendMessage({ id: crypto.randomUUID(), role: 'system', - type: 'error', - content: `Unknown skill: ${parsedSkillCommand.skillSlug ?? 'unknown'}`, + type: 'status', + content: buildSkillCommandHelp(), timestamp: Date.now(), }) return } - const attachLabels = buildAttachmentLabels() - const request = parsedSkillCommand.request?.trim() || skill.starterPrompt - const envelope = buildSkillUseEnvelope({ - skill, - request, - modelName: modelInfo.current, - }) - const displayText = - attachLabels.length > 0 - ? `[${attachLabels.join(' · ')}]\n/skill use ${skill.slug} ${request}` - : `/skill use ${skill.slug} ${request}` - const outboundMessage = buildGatewayMessage(envelope.prompt, buildSilentContext()) - const messageImages = - imageAttachments.length > 0 - ? imageAttachments.map((img) => ({ name: img.name, dataUrl: img.dataUrl })) + if (parsedSkillCommand.kind === 'list') { + appendMessage({ + id: crypto.randomUUID(), + role: 'system', + type: 'status', + content: buildCatalogSummary(SKILLS_CATALOG), + timestamp: Date.now(), + }) + return + } + + if (parsedSkillCommand.kind === 'use') { + const skill = parsedSkillCommand.skillSlug + ? getSkillBySlug(parsedSkillCommand.skillSlug) : undefined + if (!skill) { + appendMessage({ + id: crypto.randomUUID(), + role: 'system', + type: 'error', + content: `Unknown skill: ${parsedSkillCommand.skillSlug ?? 'unknown'}`, + timestamp: Date.now(), + }) + return + } - setSending(true) - streamStateRef.current.isSending = true - try { - await sendStructuredGatewayMessage({ - displayText, - outboundMessage, - images: messageImages, + const attachLabels = buildAttachmentLabels() + const request = parsedSkillCommand.request?.trim() || skill.starterPrompt + const envelope = buildSkillUseEnvelope({ + skill, + request, + modelName: modelInfo.current, }) - } catch (err) { + const displayText = + attachLabels.length > 0 + ? `[${attachLabels.join(' · ')}]\n/skill use ${skill.slug} ${request}` + : `/skill use ${skill.slug} ${request}` + const outboundMessage = buildGatewayMessage(envelope.prompt, buildSilentContext()) + const messageImages = + imageAttachments.length > 0 + ? imageAttachments.map((img) => ({ name: img.name, dataUrl: img.dataUrl })) + : undefined + + setSending(true) + streamStateRef.current.isSending = true + try { + await sendStructuredGatewayMessage({ + displayText, + outboundMessage, + images: messageImages, + }) + } catch (err) { + appendMessage({ + id: crypto.randomUUID(), + role: 'system', + type: 'error', + content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + timestamp: Date.now(), + }) + setIsStreaming(false) + setSending(false) + streamStateRef.current.isSending = false + } + return + } + + const plan = buildExecutionPlan(parsedSkillCommand, { preferTerminal: isTauri() }) + if (!plan) { appendMessage({ id: crypto.randomUUID(), role: 'system', type: 'error', - content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + content: 'Could not build a skill workflow for that request.', timestamp: Date.now(), }) - setIsStreaming(false) - setSending(false) - streamStateRef.current.isSending = false + return + } + + if (plan.target === 'terminal' && plan.command) { + emit('show-terminal') + emit('run-command-in-terminal', { command: plan.command }) + appendMessage({ + id: crypto.randomUUID(), + role: 'system', + type: 'status', + content: `${plan.label} started in the desktop terminal.`, + timestamp: Date.now(), + }) + return + } + + if (plan.message) { + setSending(true) + streamStateRef.current.isSending = true + try { + await sendStructuredGatewayMessage({ + displayText: text, + outboundMessage: plan.message, + preserveAttachments: true, + }) + } catch (err) { + appendMessage({ + id: crypto.randomUUID(), + role: 'system', + type: 'error', + content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + timestamp: Date.now(), + }) + setIsStreaming(false) + setSending(false) + streamStateRef.current.isSending = false + } } return } - const plan = buildExecutionPlan(parsedSkillCommand, { preferTerminal: isTauri() }) - if (!plan) { - appendMessage({ - id: crypto.randomUUID(), - role: 'system', - type: 'error', - content: 'Could not build a skill workflow for that request.', - timestamp: Date.now(), - }) + if (!enforceSkillFirstPolicy(text)) { return } - if (plan.target === 'terminal' && plan.command) { - emit('show-terminal') - emit('run-command-in-terminal', { command: plan.command }) + setSending(true) + streamStateRef.current.isSending = true + setAgentActivities([]) + setThinkingTrail([]) + + // Build visual label for attachments + const attachLabels = buildAttachmentLabels() + const displayText = attachLabels.length > 0 ? `[${attachLabels.join(' · ')}]\n${text}` : text + const messageImages = + imageAttachments.length > 0 + ? imageAttachments.map((img) => ({ name: img.name, dataUrl: img.dataUrl })) + : undefined + try { + const outboundMessage = buildGatewayMessage(text, buildSilentContext()) + await sendStructuredGatewayMessage({ + displayText, + outboundMessage, + images: messageImages, + }) + } catch (err) { + logChatDebug('chat.send failed', { + error: err instanceof Error ? err.message : String(err), + sessionKey, + }) appendMessage({ id: crypto.randomUUID(), role: 'system', - type: 'status', - content: `${plan.label} started in the desktop terminal.`, + type: 'error', + content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, timestamp: Date.now(), }) - return - } - - if (plan.message) { - setSending(true) - streamStateRef.current.isSending = true - try { - await sendStructuredGatewayMessage({ - displayText: text, - outboundMessage: plan.message, - preserveAttachments: true, - }) - } catch (err) { - appendMessage({ - id: crypto.randomUUID(), - role: 'system', - type: 'error', - content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, - timestamp: Date.now(), - }) - setIsStreaming(false) - setSending(false) - streamStateRef.current.isSending = false - } + setIsStreaming(false) + setSending(false) + streamStateRef.current.isSending = false } - return - } - - if (!enforceSkillFirstPolicy(text)) { - return - } - - setSending(true) - streamStateRef.current.isSending = true - setAgentActivities([]) - setThinkingTrail([]) - - // Build visual label for attachments - const attachLabels = buildAttachmentLabels() - const displayText = attachLabels.length > 0 ? `[${attachLabels.join(' · ')}]\n${text}` : text - const messageImages = - imageAttachments.length > 0 - ? imageAttachments.map((img) => ({ name: img.name, dataUrl: img.dataUrl })) - : undefined - try { - const outboundMessage = buildGatewayMessage(text, buildSilentContext()) - await sendStructuredGatewayMessage({ - displayText, - outboundMessage, - images: messageImages, - }) - } catch (err) { - logChatDebug('chat.send failed', { - error: err instanceof Error ? err.message : String(err), - sessionKey, - }) - appendMessage({ - id: crypto.randomUUID(), - role: 'system', - type: 'error', - content: `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, - timestamp: Date.now(), - }) - setIsStreaming(false) - setSending(false) - streamStateRef.current.isSending = false - } - }, [ - input, - sending, - agentMode, - isConnected, - status, - sessionKey, - local, - repo, - files, - sendRequest, - onEvent, - buildContext, - buildSilentContext, - buildAttachmentLabels, - appendMessage, - appendErrorMessage, - appendSlashCommand, - appendStatusMessage, - ensureSessionInit, - collectCommitChangesForGeneration, - logChatDebug, - enforceSkillFirstPolicy, - modelInfo.current, - requireGithubRepo, - requireLocalGitRepo, - sendStructuredGatewayMessage, - ]) + }, + [ + input, + sending, + agentMode, + isConnected, + status, + sessionKey, + local, + repo, + files, + sendRequest, + onEvent, + buildContext, + buildSilentContext, + buildAttachmentLabels, + appendMessage, + appendErrorMessage, + appendSlashCommand, + appendStatusMessage, + ensureSessionInit, + collectCommitChangesForGeneration, + logChatDebug, + enforceSkillFirstPolicy, + modelInfo.current, + requireGithubRepo, + requireLocalGitRepo, + sendStructuredGatewayMessage, + ], + ) // ─── Handle ⌘K inline edit requests ──────────────────────────── useEffect(() => { @@ -1729,7 +1753,7 @@ export function AgentPanel() { id: crypto.randomUUID(), role: 'user', type: 'text', - content: `⌘K: ${instruction}`, + content: `${formatShortcut('meta+K')}: ${instruction}`, timestamp: Date.now(), }) @@ -1870,7 +1894,11 @@ export function AgentPanel() { // ─── Auto-apply when full access or approval tier allows ────── const autoAppliedRef = useRef(new Set()) const currentApprovalTier = useMemo(() => { - try { return getAgentConfig()?.approvalTier ?? 'ask-all' } catch { return 'ask-all' as const } + try { + return getAgentConfig()?.approvalTier ?? 'ask-all' + } catch { + return 'ask-all' as const + } }, [messages.length]) // re-check when messages change useEffect(() => { const tierAllows = currentApprovalTier === 'auto-edits' || currentApprovalTier === 'auto-all' @@ -1920,7 +1948,11 @@ export function AgentPanel() { { cmd: '/push', desc: 'Push to origin', icon: 'lucide:arrow-up-circle' }, { cmd: '/sync', desc: 'Pull and push current branch', icon: 'lucide:refresh-cw' }, { cmd: '/pr', desc: 'View pull requests', icon: 'lucide:git-pull-request' }, - { cmd: '/pr create', desc: 'Create pull request', icon: 'lucide:git-pull-request-create-arrow' }, + { + cmd: '/pr create', + desc: 'Create pull request', + icon: 'lucide:git-pull-request-create-arrow', + }, { cmd: '/merge', desc: 'Merge pull request', icon: 'lucide:git-merge' }, // Modes { cmd: '/ask', desc: 'Switch to Ask mode', icon: 'lucide:message-circle' }, @@ -2140,7 +2172,14 @@ export function AgentPanel() { modelName={modelInfo.current || undefined} contextTokens={contextTokens} activityCount={agentActivities.length} - filesChanged={agentActivities.filter(a => a.type === 'edit' || a.type === 'write' || a.type === 'create').reduce((acc, a) => { if (a.file) acc.add(a.file); return acc }, new Set()).size} + filesChanged={ + agentActivities + .filter((a) => a.type === 'edit' || a.type === 'write' || a.type === 'create') + .reduce((acc, a) => { + if (a.file) acc.add(a.file) + return acc + }, new Set()).size + } /> {messages.length > 0 && (
@@ -2150,7 +2189,7 @@ export function AgentPanel() { @@ -2160,7 +2199,7 @@ export function AgentPanel() {
) } diff --git a/components/chat/chat-input-bar.tsx b/components/chat/chat-input-bar.tsx index 5648836..017f268 100644 --- a/components/chat/chat-input-bar.tsx +++ b/components/chat/chat-input-bar.tsx @@ -5,6 +5,7 @@ import { useState, useCallback, useEffect, + useMemo, type KeyboardEvent, type DragEvent, type ClipboardEvent, @@ -12,6 +13,7 @@ import { import { Icon } from '@iconify/react' import { ModeSelector } from '@/components/mode-selector' import type { AgentMode } from '@/components/mode-selector' +import { formatShortcut } from '@/lib/platform' export interface Suggestion { cmd: string @@ -113,12 +115,14 @@ function formatFileSize(dataUrl: string): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } -const PLACEHOLDER_HINTS = [ - 'Ask anything...', - 'Ask anything... \u2318L to add selection', - 'Ask anything... @ to mention a file', - 'Ask anything... /commit to save changes', -] +function getPlaceholderHints(): string[] { + return [ + 'Ask anything...', + `Ask anything... ${formatShortcut('meta+L')} to add selection`, + 'Ask anything... @ to mention a file', + 'Ask anything... /commit to save changes', + ] +} export function ChatInputBar({ input, @@ -159,18 +163,19 @@ export function ChatInputBar({ const [lightboxSrc, setLightboxSrc] = useState(null) const [inputDragOver, setInputDragOver] = useState(false) const inputDragCounter = useRef(0) + const placeholderHints = useMemo(() => getPlaceholderHints(), []) useEffect(() => { if (input) return const interval = setInterval(() => { - setPlaceholderIdx((i) => (i + 1) % PLACEHOLDER_HINTS.length) + setPlaceholderIdx((i) => (i + 1) % placeholderHints.length) }, 4000) return () => clearInterval(interval) - }, [input]) + }, [input, placeholderHints.length]) const currentPlaceholder = activeFile ? `Ask about ${activeFile.split('/').pop()}...` - : PLACEHOLDER_HINTS[placeholderIdx] + : placeholderHints[placeholderIdx] const handleInputDragEnter = useCallback((e: DragEvent) => { e.preventDefault() diff --git a/components/code-editor.tsx b/components/code-editor.tsx index a835feb..09ed6db 100644 --- a/components/code-editor.tsx +++ b/components/code-editor.tsx @@ -16,6 +16,7 @@ import { registerEditorTheme } from '@/lib/monaco-theme' import { useGateway } from '@/context/gateway-context' import { createInlineCompletionsProvider } from '@/lib/inline-completions' import { showInlineDiff } from '@/lib/inline-diff' +import { formatShortcut } from '@/lib/platform' import { InlineEdit } from '@/components/inline-edit' import { MarkdownPreview } from '@/components/markdown-preview' import { MarkdownModeToggle, type MarkdownViewMode } from '@/components/markdown-mode-toggle' @@ -84,19 +85,19 @@ function WelcomeView() { { icon: 'lucide:folder-open', label: 'Open Folder', - hint: '⌘O', + hint: formatShortcut('meta+O'), action: () => emit('open-folder'), }, { icon: 'lucide:file-plus', label: 'New File', - hint: '⌘N', + hint: formatShortcut('meta+N'), action: () => emit('file-select', { path: 'untitled', sha: '' }), }, { icon: 'lucide:search', label: 'Quick Open', - hint: '⌘P', + hint: formatShortcut('meta+P'), action: () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })), }, ...(isDesktop @@ -104,7 +105,7 @@ function WelcomeView() { { icon: 'lucide:terminal', label: 'Open Terminal', - hint: '⌘\`', + hint: formatShortcut('meta+`'), action: () => emit('toggle-terminal'), }, ] @@ -208,7 +209,7 @@ function WelcomeView() { /> Open Agent Panel - ⌘J + {formatShortcut('meta+J')}
@@ -230,18 +231,18 @@ function WelcomeView() {
{[ - ['⌘P', 'Quick Open'], - ['⌘B', 'Toggle Explorer'], - ['⌘J', 'Toggle Agent'], - ['⌘K', 'Inline Edit'], - ['⌘S', 'Save'], - ['⌘⇧F', 'Search Files'], - ['⌘\`', 'Terminal'], + ['meta+P', 'Quick Open'], + ['meta+B', 'Toggle Explorer'], + ['meta+J', 'Toggle Agent'], + ['meta+K', 'Inline Edit'], + ['meta+S', 'Save'], + ['meta+shift+F', 'Search Files'], + ['meta+`', 'Terminal'], ['?', 'All Shortcuts'], - ].map(([key, label]) => ( -
+ ].map(([combo, label]) => ( +
- {key} + {formatShortcut(combo)} {label}
@@ -1149,7 +1150,7 @@ export function CodeEditor() { Reject - ⌘⏎ accept · Esc reject + {formatShortcut('meta+Enter')} accept · Esc reject
)} @@ -1164,7 +1165,7 @@ export function CodeEditor() { {[ { icon: 'lucide:message-square', - tip: 'Add to Chat (⌘L)', + tip: `Add to Chat (${formatShortcut('meta+L')})`, ev: 'add-to-chat', detail: { path: activeFile || 'untitled', @@ -1175,7 +1176,7 @@ export function CodeEditor() { }, { icon: 'lucide:pencil', - tip: 'Edit (⌘K)', + tip: `Edit (${formatShortcut('meta+K')})`, ev: 'inline-edit-request', detail: { text: selToolbar.text }, }, diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 366abfd..8391daa 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Icon } from '@iconify/react' import { cn } from '@/lib/utils' import { useView, type ViewId } from '@/context/view-context' +import { formatShortcut } from '@/lib/platform' import { isTauri } from '@/lib/tauri' type CommandId = @@ -55,7 +56,7 @@ interface CommandItem { hint: string keywords: string[] icon: string - shortcut?: string + combo?: string group: 'search' | 'layout' | 'preset' | 'navigate' | 'git' | 'pr' | 'preview' } @@ -67,7 +68,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Open quick file search', keywords: ['file', 'quick', 'open'], icon: 'lucide:file-search', - shortcut: '\u2318P', + combo: 'meta+P', group: 'search', }, { @@ -76,7 +77,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Save the active file', keywords: ['save', 'write', 'file'], icon: 'lucide:save', - shortcut: '\u2318S', + combo: 'meta+S', group: 'search', }, { @@ -93,7 +94,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Open editor search', keywords: ['find', 'search', 'match'], icon: 'lucide:search', - shortcut: '\u2318F', + combo: 'meta+F', group: 'search', }, { @@ -102,7 +103,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Open replace widget', keywords: ['replace', 'search', 'find'], icon: 'lucide:replace', - shortcut: '\u2318H', + combo: 'meta+H', group: 'search', }, { @@ -137,7 +138,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Show or hide the file tree', keywords: ['files', 'tree', 'explorer', 'sidebar'], icon: 'lucide:folder', - shortcut: '\u2318B', + combo: 'meta+B', group: 'layout', }, { @@ -146,7 +147,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Show or hide the terminal panel', keywords: ['terminal', 'shell', 'console'], icon: 'lucide:terminal', - shortcut: '\u2318J', + combo: 'meta+J', group: 'layout', }, { @@ -155,7 +156,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Show or hide the AI agent panel', keywords: ['chat', 'agent', 'ai', 'assistant'], icon: 'lucide:message-square', - shortcut: '\u2318I', + combo: 'meta+I', group: 'layout', }, { @@ -172,7 +173,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Minimize editor to icon rail', keywords: ['collapse', 'minimize', 'hide', 'editor'], icon: 'lucide:minimize-2', - shortcut: '\u2318E', + combo: 'meta+E', group: 'layout', }, @@ -235,7 +236,7 @@ const COMMANDS: CommandItem[] = [ hint: 'Launch another editor instance', keywords: ['new', 'window', 'instance', 'editor', 'desktop'], icon: 'lucide:square-plus', - shortcut: '\u2318\u21e7N', + combo: 'meta+shift+N', group: 'navigate', }, { @@ -596,9 +597,9 @@ export function CommandPalette({ open, onClose, onRun }: CommandPaletteProps) { {command.hint}

- {command.shortcut && ( + {command.combo && ( - {command.shortcut} + {formatShortcut(command.combo)} )} diff --git a/components/editor-tabs.tsx b/components/editor-tabs.tsx index e0b1b1f..df069e1 100644 --- a/components/editor-tabs.tsx +++ b/components/editor-tabs.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useRef } from 'react' import { Icon } from '@iconify/react' import { useEditor } from '@/context/editor-context' +import { formatShortcut } from '@/lib/platform' const EXT_ICONS: Record = { ts: { icon: 'lucide:file-code', color: '#3178c6' }, @@ -154,7 +155,7 @@ export function EditorTabs({ onTabSelect }: { onTabSelect?: (path: string) => vo closeFile(file.path) }} className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-[color-mix(in_srgb,var(--text-primary)_10%,transparent)] transition-all cursor-pointer ml-1 hover:scale-110" - title="Close (⌘W)" + title={`Close (${formatShortcut('meta+W')})`} > diff --git a/components/file-explorer.tsx b/components/file-explorer.tsx index 76b37d8..3de046f 100644 --- a/components/file-explorer.tsx +++ b/components/file-explorer.tsx @@ -635,7 +635,8 @@ export function FileExplorer() { className="text-[var(--brand)] shrink-0 group-hover:scale-110 transition-transform" /> - {local.rootPath?.split('/').pop() || (repo ? repo.repo.split('/').pop() : 'Open Folder')} + {local.rootPath?.split('/').pop() || + (repo ? repo.repo.split('/').pop() : 'Open Folder')} {fileCount > 0 && ( @@ -715,7 +716,7 @@ export function FileExplorer() {
)} {treeError && !local.localMode && ( -
{treeError}
+
{treeError}
)} {filteredTree.map((node) => node.type === 'dir' ? ( diff --git a/components/gateway-terminal.tsx b/components/gateway-terminal.tsx index a7acd8b..e4818f5 100644 --- a/components/gateway-terminal.tsx +++ b/components/gateway-terminal.tsx @@ -323,7 +323,7 @@ function EntryView({ entry, hasBg }: { entry: TerminalEntry; hasBg: boolean }) { : 'rgba(239, 68, 68, 0.06)', }} > - {entry.text} + {entry.text} ) } diff --git a/components/onboarding-tour.tsx b/components/onboarding-tour.tsx index 16f8397..6156721 100644 --- a/components/onboarding-tour.tsx +++ b/components/onboarding-tour.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { Icon } from '@iconify/react' +import { formatShortcut } from '@/lib/platform' export const ONBOARDING_KEY = 'ce:onboarding:v1' @@ -29,12 +30,12 @@ export function OnboardingTour({ open, onClose }: { open: boolean; onClose: () = }, { title: 'Keyboard-first navigation', - body: 'Use ⌘P to open files, ⌘⇧P for the command palette, and ⌘⌥1–4 to jump focus (Files / Editor / Chat / Terminal).', + body: `Use ${formatShortcut('meta+P')} to open files, ${formatShortcut('meta+shift+P')} for the command palette, and ${formatShortcut('meta+alt+1')}–4 to jump focus (Files / Editor / Chat / Terminal).`, icon: 'lucide:keyboard', }, { title: 'Panels & layout', - body: 'Toggle Explorer with ⌘B, Chat with ⌘I, Terminal with ⌘J (or ⌘`). On smaller screens, panels open as drawers to avoid clipping.', + body: `Toggle Explorer with ${formatShortcut('meta+B')}, Chat with ${formatShortcut('meta+I')}, Terminal with ${formatShortcut('meta+J')} (or ${formatShortcut('meta+`')}). On smaller screens, panels open as drawers to avoid clipping.`, icon: 'lucide:layout-panel-left', }, { diff --git a/components/plugins/spotify/spotify-player.tsx b/components/plugins/spotify/spotify-player.tsx index 2e764f5..5561d17 100644 --- a/components/plugins/spotify/spotify-player.tsx +++ b/components/plugins/spotify/spotify-player.tsx @@ -398,7 +398,7 @@ export function SpotifyPlayer() { > {loggingIn ? 'Connecting…' : 'Connect'} - {error &&

{error}

} + {error &&

{error}

} ) : ( <> diff --git a/components/plugins/spotify/spotify-settings.tsx b/components/plugins/spotify/spotify-settings.tsx index 3affe64..d14b227 100644 --- a/components/plugins/spotify/spotify-settings.tsx +++ b/components/plugins/spotify/spotify-settings.tsx @@ -45,11 +45,20 @@ export function SpotifySettings() { Spotify

- Set NEXT_PUBLIC_SPOTIFY_CLIENT_ID in your environment to enable Spotify integration. + Set{' '} + + NEXT_PUBLIC_SPOTIFY_CLIENT_ID + {' '} + in your environment to enable Spotify integration.

Create an app at{' '} - + developer.spotify.com . No client secret needed — uses PKCE flow. @@ -68,8 +77,15 @@ export function SpotifySettings() { {authenticated ? (

- - Connected to Spotify + + + Connected to Spotify +
)}
diff --git a/components/plugins/youtube/youtube-player.tsx b/components/plugins/youtube/youtube-player.tsx index 961ba1b..466f05e 100644 --- a/components/plugins/youtube/youtube-player.tsx +++ b/components/plugins/youtube/youtube-player.tsx @@ -400,7 +400,7 @@ export function YouTubePlayer() { )} - {error &&

{error}

} + {error &&

{error}

} {/* Curated playlists */} {!current && ( diff --git a/components/preview/preview-panel.tsx b/components/preview/preview-panel.tsx index cd3e387..cd3183d 100644 --- a/components/preview/preview-panel.tsx +++ b/components/preview/preview-panel.tsx @@ -13,6 +13,7 @@ import { import { useEditor } from '@/context/editor-context' import { useView } from '@/context/view-context' import { useLocal } from '@/context/local-context' +import { formatShortcut } from '@/lib/platform' import { isTauri, tauriInvoke } from '@/lib/tauri' import { emit } from '@/lib/events' @@ -705,7 +706,7 @@ function SingleDeviceZoomBar({ @@ -717,7 +718,7 @@ function SingleDeviceZoomBar({ }} className="p-0.5 rounded hover:bg-[var(--bg-subtle)] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" disabled={zoom <= ZOOM_MIN} - title="Zoom out (⌘−)" + title={`Zoom out (${formatShortcut('meta+-')})`} > @@ -753,7 +754,7 @@ function SingleDeviceZoomBar({ }} className="p-0.5 rounded hover:bg-[var(--bg-subtle)] text-[var(--text-disabled)] hover:text-[var(--text-secondary)] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" disabled={zoom >= ZOOM_MAX} - title="Zoom in (⌘+)" + title={`Zoom in (${formatShortcut('meta+=')})`} > @@ -804,7 +805,7 @@ function SingleDeviceZoomBar({ className="w-full flex items-center gap-2 px-3 py-1.5 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-subtle)] transition-colors cursor-pointer" > - Reset (⌘0) + Reset ({formatShortcut('meta+0')}) diff --git a/components/shortcuts-overlay.tsx b/components/shortcuts-overlay.tsx index 2e8da6b..a184a00 100644 --- a/components/shortcuts-overlay.tsx +++ b/components/shortcuts-overlay.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react' import { Icon } from '@iconify/react' +import { formatShortcutKeys } from '@/lib/platform' import { isTauri } from '@/lib/tauri' interface ShortcutsOverlayProps { @@ -10,31 +11,31 @@ interface ShortcutsOverlayProps { } const NAV_SHORTCUTS = [ - { keys: ['⌘', 'K'], desc: 'Command palette' }, - { keys: ['⌘', 'P'], desc: 'Quick file open' }, - { keys: ['⌘', 'B'], desc: 'Toggle file explorer' }, - { keys: ['⌘', 'J'], desc: 'Toggle agent panel' }, - { keys: ['⌘', '⇧', 'E'], desc: 'Toggle Gateway Engine' }, - { keys: ['⌘', '⌥', '1'], desc: 'Focus Explorer' }, - { keys: ['⌘', '⌥', '2'], desc: 'Focus Editor' }, - { keys: ['⌘', '⌥', '3'], desc: 'Focus Chat' }, - { keys: ['?'], desc: 'This shortcuts overlay' }, + { combo: 'meta+K', desc: 'Command palette' }, + { combo: 'meta+P', desc: 'Quick file open' }, + { combo: 'meta+B', desc: 'Toggle file explorer' }, + { combo: 'meta+J', desc: 'Toggle agent panel' }, + { combo: 'meta+shift+E', desc: 'Toggle Gateway Engine' }, + { combo: 'meta+alt+1', desc: 'Focus Explorer' }, + { combo: 'meta+alt+2', desc: 'Focus Editor' }, + { combo: 'meta+alt+3', desc: 'Focus Chat' }, + { combo: '?', desc: 'This shortcuts overlay' }, ] const NAV_TERMINAL_SHORTCUTS = [ - { keys: ['⌘', '`'], desc: 'Toggle terminal' }, - { keys: ['⌘', '⌥', '4'], desc: 'Focus Terminal' }, + { combo: 'meta+`', desc: 'Toggle terminal' }, + { combo: 'meta+alt+4', desc: 'Focus Terminal' }, ] const STATIC_SECTIONS = [ { title: 'Editing', shortcuts: [ - { keys: ['⌘', '⇧', 'K'], desc: 'Inline edit at selection' }, - { keys: ['⌘', '⇧', 'V'], desc: 'Cycle markdown edit/preview/split' }, - { keys: ['⌘', 'S'], desc: 'Save (commit) file' }, - { keys: ['⌘', 'Z'], desc: 'Undo' }, - { keys: ['⌘', '⇧', 'Z'], desc: 'Redo' }, + { combo: 'meta+shift+K', desc: 'Inline edit at selection' }, + { combo: 'meta+shift+V', desc: 'Cycle markdown edit/preview/split' }, + { combo: 'meta+S', desc: 'Save (commit) file' }, + { combo: 'meta+Z', desc: 'Undo' }, + { combo: 'meta+shift+Z', desc: 'Redo' }, ], }, { @@ -119,30 +120,33 @@ export function ShortcutsOverlay({ open, onClose }: ShortcutsOverlayProps) {
- {section.shortcuts.map((s) => ( -
- - {s.desc} - -
- {s.keys.map((key, i) => ( - - {key} - - ))} + {section.shortcuts.map((s) => { + const keys = 'combo' in s ? formatShortcutKeys(s.combo) : s.keys + return ( +
+ + {s.desc} + +
+ {keys.map((key, i) => ( + + {key} + + ))} +
-
- ))} + ) + })}
))} diff --git a/components/source-switcher.tsx b/components/source-switcher.tsx index dea53e0..ae9b85d 100644 --- a/components/source-switcher.tsx +++ b/components/source-switcher.tsx @@ -4,7 +4,11 @@ import { useState, useRef, useEffect } from 'react' import { Icon } from '@iconify/react' import { useLocal, getRecentFolders } from '@/context/local-context' -function BranchDropdown({ current, branches, onSwitch }: { +function BranchDropdown({ + current, + branches, + onSwitch, +}: { current: string branches: string[] onSwitch: (branch: string) => Promise @@ -30,7 +34,7 @@ function BranchDropdown({ current, branches, onSwitch }: { return (
{dropdownOpen && (
@@ -155,13 +190,16 @@ export function SourceSwitcher() {
Recent
- {recentFolders.map(path => { + {recentFolders.map((path) => { const name = path.split('/').pop() ?? path const isActive = path === local.rootPath return ( {menuOpen && menuPos && ( @@ -245,10 +288,18 @@ export function FolderIndicator() { style={{ left: menuPos.left, bottom: menuPos.bottom }} > @@ -257,13 +308,16 @@ export function FolderIndicator() {
Recent
- {recentFolders.map(path => { + {recentFolders.map((path) => { const name = path.split('/').pop() ?? path const isActive = path === local.rootPath return ( diff --git a/components/views/editor-view.tsx b/components/views/editor-view.tsx index 5743d61..a1f081c 100644 --- a/components/views/editor-view.tsx +++ b/components/views/editor-view.tsx @@ -10,6 +10,7 @@ import { useRepo } from '@/context/repo-context' import { useLayout, usePanelResize } from '@/context/layout-context' import { EditorTabs } from '@/components/editor-tabs' import { FloatingPanel } from '@/components/floating-panel' +import { formatShortcut } from '@/lib/platform' import { isTauri } from '@/lib/tauri' import { emit } from '@/lib/events' @@ -17,14 +18,12 @@ const FileExplorer = dynamic( () => import('@/components/file-explorer').then((m) => m.FileExplorer), { ssr: false }, ) -const CodeEditor = dynamic( - () => import('@/components/code-editor').then((m) => m.CodeEditor), - { ssr: false }, -) -const AgentPanel = dynamic( - () => import('@/components/agent-panel').then((m) => m.AgentPanel), - { ssr: false }, -) +const CodeEditor = dynamic(() => import('@/components/code-editor').then((m) => m.CodeEditor), { + ssr: false, +}) +const AgentPanel = dynamic(() => import('@/components/agent-panel').then((m) => m.AgentPanel), { + ssr: false, +}) const PANEL_SPRING = { type: 'spring' as const, stiffness: 500, damping: 35 } @@ -51,7 +50,8 @@ function NoCodebasePane({ No codebase selected

- Open a folder{!isDesktop ? ' or connect a GitHub repo' : ''} to start editing, browsing files, and using the agent. + Open a folder{!isDesktop ? ' or connect a GitHub repo' : ''} to start editing, browsing + files, and using the agent.

@@ -65,8 +65,8 @@ function NoCodebasePane({
- - P for commands + P for + commands
@@ -114,7 +114,7 @@ function MainEditorPane({ @@ -252,7 +253,7 @@ export function EditorView() { @@ -261,7 +262,7 @@ export function EditorView() { @@ -315,7 +316,7 @@ export function EditorView() { @@ -354,7 +355,8 @@ export function EditorView() { className="text-[var(--brand)] shrink-0 group-hover:scale-110 transition-transform" /> - {local.rootPath?.split('/').pop() || (repo ? repo.repo.split('/').pop() : 'Explorer')} + {local.rootPath?.split('/').pop() || + (repo ? repo.repo.split('/').pop() : 'Explorer')} @@ -209,7 +210,7 @@ export function WorkspaceSidebar({ collapsed, onToggle, repoName }: Props) { type="button" onClick={onToggle} className="codex-sidebar-hero__action" - title="Collapse sidebar (⌘\\)" + title={`Collapse sidebar (${formatShortcut('meta+\\')})`} > diff --git a/docs/AGENT.md b/docs/AGENT.md index a3d9d5d..6b32a00 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -95,11 +95,11 @@ User: "/edit add error handling to the fetch call" ``` -### Via Inline Edit (⌘K) +### Via Inline Edit (Cmd/Ctrl+K) ``` -User selects code → ⌘K → types "add null check" +User selects code → Cmd/Ctrl+K → types "add null check" → InlineEdit component appears at cursor position → Submit dispatches CustomEvent('inline-edit-request') → AgentPanel handles event: - Includes selected text + line range - Sends to gateway with full context diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 745e0a3..985b370 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,9 +75,9 @@ code-editor/ │ ├── editor-tabs.tsx # Multi-file tab bar │ ├── file-explorer.tsx # Tree view with search │ ├── glass-card.tsx # Glassmorphic card primitive -│ ├── inline-edit.tsx # ⌘K inline edit prompt +│ ├── inline-edit.tsx # Cmd/Ctrl+K inline edit prompt │ ├── markdown-preview.tsx # Agent response rendering -│ ├── quick-open.tsx # ⌘P fuzzy file search +│ ├── quick-open.tsx # Cmd/Ctrl+P fuzzy file search │ ├── repo-selector.tsx # Repo + branch switcher │ ├── resize-handle.tsx # Draggable panel resizer │ ├── shortcuts-overlay.tsx # ? keyboard shortcuts modal @@ -145,10 +145,10 @@ User types message + Enter → If [EDIT path] markers found → "Review diff" button shown ``` -### Edit Flow (⌘K or /edit) +### Edit Flow (Cmd/Ctrl+K or /edit) ``` -User selects code → ⌘K → types instruction +User selects code → Cmd/Ctrl+K → types instruction → CustomEvent('inline-edit-request', { filePath, instruction, selectedText }) → AgentPanel handles event → sends to gateway with selection context → Agent responds with [EDIT path/to/file] + fenced code block diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c2a4ab4..ad692fa 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -25,13 +25,13 @@ pnpm dev ## Scripts -| Script | Purpose | -|--------|---------| -| `pnpm dev` | Next.js dev server (port 3080) | -| `pnpm build` | Production build (Vercel) | -| `pnpm build --webpack` | Production build with webpack bundler | -| `pnpm tauri:dev` | Desktop dev (Next.js + native window) | -| `pnpm tauri:build` | Desktop production build (.app + .dmg) | +| Script | Purpose | +| ---------------------- | -------------------------------------- | +| `pnpm dev` | Next.js dev server (port 3080) | +| `pnpm build` | Production build (Vercel) | +| `pnpm build --webpack` | Production build with webpack bundler | +| `pnpm tauri:dev` | Desktop dev (Next.js + native window) | +| `pnpm tauri:build` | Desktop production build (.app + .dmg) | ## Code Style @@ -76,9 +76,11 @@ Components communicate via `CustomEvent` on `window`: ```typescript // Dispatch -window.dispatchEvent(new CustomEvent('file-select', { - detail: { path: 'src/app.tsx', sha: 'abc123' } -})) +window.dispatchEvent( + new CustomEvent('file-select', { + detail: { path: 'src/app.tsx', sha: 'abc123' }, + }), +) // Listen useEffect(() => { @@ -96,14 +98,15 @@ Events in use: |-------|---------|---------| | `file-select` | `{ path, sha }` | Open a file in editor | | `editor-navigate` | `{ startLine, endLine? }` | Scroll to line | -| `inline-edit-request` | `{ filePath, instruction, selectedText, startLine, endLine }` | ⌘K edit | -| `quick-open-prefill` | `{ query }` | Prefill ⌘P search | +| `inline-edit-request` | `{ filePath, instruction, selectedText, startLine, endLine }` | Cmd/Ctrl+K edit | +| `quick-open-prefill` | `{ query }` | Prefill Cmd/Ctrl+P search | ## Adding a Theme 1. Add CSS variables in `app/globals.css`: + ```css - .dark[data-theme="my-theme"] { + .dark[data-theme='my-theme'] { --bg: #0a0a0a; --brand: #ff6b6b; /* ... all variables ... */ @@ -111,6 +114,7 @@ Events in use: ``` 2. Add to `THEMES` array in `components/theme-switcher.tsx`: + ```typescript { id: 'my-theme', label: 'My Theme', color: '#ff6b6b' }, ``` @@ -120,6 +124,7 @@ Events in use: ## Adding a Slash Command 1. Add to the `cmds` array in `components/agent-panel.tsx`: + ```typescript { cmd: '/mycommand', desc: 'Description', icon: 'lucide:star' }, ``` @@ -135,6 +140,7 @@ pnpm build --webpack ``` Check for: + - TypeScript errors - Missing imports - CSS syntax errors (Tailwind) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7476fd4..1dab128 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -42,10 +42,10 @@ ### Navigation -- [x] **⌘P Quick File Open** — fuzzy search across entire repo, keyboard-driven -- [x] **⌘K Inline Edit** — select code → type instruction → agent proposes diff -- [x] **⌘B Toggle Explorer** — show/hide file tree -- [x] **⌘J Toggle Agent** — show/hide agent panel +- [x] **Cmd/Ctrl+P Quick File Open** — fuzzy search across entire repo, keyboard-driven +- [x] **Cmd/Ctrl+K Inline Edit** — select code → type instruction → agent proposes diff +- [x] **Cmd/Ctrl+B Toggle Explorer** — show/hide file tree +- [x] **Cmd/Ctrl+J Toggle Agent** — show/hide agent panel - [x] **? Shortcuts Overlay** — modal showing all keyboard shortcuts - [x] **Chat bubble** — floating button when agent panel is hidden @@ -103,7 +103,7 @@ - [ ] **Git status in file tree** — green (new), orange (modified), red (deleted) indicators - [ ] **Modified files diff** — view all changes before committing -- [ ] **⌘S Save shortcut** — quick commit current file +- [ ] **Cmd/Ctrl+S Save shortcut** — quick commit current file - [ ] **Agent-initiated file navigation** — agent says "open file X" → editor opens it - [ ] **Selection-aware /explain** — explain selected code, not whole file - [ ] **Recent files** — quick access to recently opened files @@ -118,8 +118,8 @@ - [ ] **File deletion** — delete files with confirmation - [ ] **Rename/move** — rename files via context menu - [ ] **Go to definition** — click symbols to jump to definition -- [ ] **Search and replace** — ⌘H across current file -- [ ] **Global search** — ⌘⇧F across all repo files +- [ ] **Search and replace** — Cmd/Ctrl+H across current file +- [ ] **Global search** — Cmd/Ctrl+Shift+F across all repo files ### Long Term diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 912401f..f179675 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -7,6 +7,7 @@ **Symptom:** Build fails with various webpack-related errors. **Fix:** Always use the webpack flag: + ```bash pnpm build --webpack ``` @@ -20,11 +21,13 @@ The default Turbopack bundler can crash with port binding errors. Webpack is the **Symptom:** Missing dependency during build. **Fix:** Install the missing package: + ```bash pnpm add ``` Common ones that may need manual installation: + - `@workos-inc/authkit-nextjs` - `create-markdown` - `tw-animate-css` @@ -39,6 +42,7 @@ Common ones that may need manual installation: **Context:** Monaco's TypeScript types are deprecated in newer versions. **Fix:** Use `beforeMount` callback with optional chaining: + ```typescript const handleBeforeMount: BeforeMount = (monaco) => { monaco.languages.typescript?.typescriptDefaults?.setDiagnosticsOptions({ @@ -68,17 +72,20 @@ const handleBeforeMount: BeforeMount = (monaco) => { **Fix:** 1. Ensure Rust is installed: + ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y ``` 2. Add to your shell profile (`~/.zshrc` for macOS): + ```bash echo '. "$HOME/.cargo/env"' >> ~/.zshrc source ~/.zshrc ``` 3. Verify: + ```bash cargo --version # should print cargo 1.x.x rustc --version # should print rustc 1.x.x @@ -101,11 +108,13 @@ Subsequent builds only recompile changed code and are much faster (<30 seconds). **Symptom:** Compilation errors mentioning Xcode, SDK, or system frameworks. **Fix:** Ensure Xcode Command Line Tools are installed: + ```bash xcode-select --install ``` If already installed, try resetting: + ```bash sudo xcode-select --reset ``` @@ -117,6 +126,7 @@ sudo xcode-select --reset **Possible causes:** 1. **Dev server not running:** `pnpm tauri:dev` should start Next.js dev server automatically. Check that port 3080 is free: + ```bash lsof -i :3080 ``` @@ -140,6 +150,7 @@ sudo xcode-select --reset **Cause:** The code-editor's origin is not in the gateway's allowed origins list. **Fix:** Add the origin to `~/.openclaw/openclaw.json`: + ```json { "gateway": { @@ -155,6 +166,7 @@ sudo xcode-select --reset ``` Then restart the gateway: + ```bash openclaw gateway restart ``` @@ -168,6 +180,7 @@ openclaw gateway restart **Possible causes:** 1. **Gateway not running:** + ```bash openclaw gateway status # If stopped: @@ -190,6 +203,7 @@ openclaw gateway restart **Symptom:** Login shows pairing instructions. **Fix:** On the gateway host machine: + ```bash openclaw devices list # find the pending request openclaw devices approve @@ -208,7 +222,8 @@ Then click Connect again in the editor. **Cause:** Monaco tries to type-check TypeScript/JavaScript files but has no `tsconfig` or type definitions. **Fix:** This is already handled — `beforeMount` disables semantic validation. If you still see red lines: -1. Hard refresh the page (⌘⇧R) + +1. Hard refresh the page (Cmd/Ctrl+Shift+R) 2. Check that the `handleBeforeMount` callback is being called --- @@ -264,7 +279,7 @@ Then click Connect again in the editor. **Symptom:** Page keeps redirecting to WorkOS login and back. **Fix:** Check that `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and `WORKOS_REDIRECT_URI` are set correctly in environment variables. - + For local development, the redirect URI should be `http://localhost:3080/callback`. --- @@ -276,6 +291,7 @@ For local development, the redirect URI should be `http://localhost:3080/callbac **Cause:** `ALLOWED_USER_EMAIL` or `ALLOWED_USER_ID` is set and your account doesn't match. **Fix:** Either: + - Update the env var to match your email/user ID - Remove the env var to allow all authenticated users @@ -288,6 +304,7 @@ For local development, the redirect URI should be `http://localhost:3080/callbac **Cause:** Large repos (10,000+ files) create a heavy tree in memory. **Workarounds:** + - Use the search to filter before browsing - Tree rendering uses virtual-ish approach (dirs collapse by default) - Consider filtering tree API response server-side for very large repos @@ -297,6 +314,7 @@ For local development, the redirect URI should be `http://localhost:3080/callbac ### Agent responses are slow **Possible causes:** + 1. **Model choice:** Larger models (Opus) take longer than smaller ones (Haiku) 2. **Context size:** Files >8KB are truncated in context injection to prevent overload 3. **Gateway load:** Multiple sessions competing for the same gateway @@ -305,12 +323,12 @@ For local development, the redirect URI should be `http://localhost:3080/callbac ## Common Environment Variables -| Variable | Purpose | Required | -|----------|---------|----------| -| `GITHUB_TOKEN` | GitHub API access | Yes | -| `WORKOS_CLIENT_ID` | WorkOS OAuth | Yes (web) | -| `WORKOS_API_KEY` | WorkOS server auth | Yes (web) | -| `WORKOS_REDIRECT_URI` | OAuth callback URL | Yes (web) | -| `ALLOWED_USER_EMAIL` | Restrict to one user | No | -| `ALLOWED_USER_ID` | Restrict to one user | No | -| `ALLOWED_IPS` | IP allowlist (CIDR) | No (`*` = disabled) | +| Variable | Purpose | Required | +| --------------------- | -------------------- | ------------------- | +| `GITHUB_TOKEN` | GitHub API access | Yes | +| `WORKOS_CLIENT_ID` | WorkOS OAuth | Yes (web) | +| `WORKOS_API_KEY` | WorkOS server auth | Yes (web) | +| `WORKOS_REDIRECT_URI` | OAuth callback URL | Yes (web) | +| `ALLOWED_USER_EMAIL` | Restrict to one user | No | +| `ALLOWED_USER_ID` | Restrict to one user | No | +| `ALLOWED_IPS` | IP allowlist (CIDR) | No (`*` = disabled) | diff --git a/lib/platform.ts b/lib/platform.ts new file mode 100644 index 0000000..62965e4 --- /dev/null +++ b/lib/platform.ts @@ -0,0 +1,47 @@ +const MODIFIER_LABELS = { + meta: 'Cmd/Ctrl', + shift: 'Shift', + alt: 'Alt/Option', +} as const + +const MODIFIERS = new Set(['meta', 'shift', 'alt']) + +export function formatShortcut(combo: string): string { + return formatShortcutKeys(combo).join('+') +} + +export function formatShortcutKeys(combo: string): string[] { + const parts = combo + .toLowerCase() + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + + if (parts.length === 0) return [combo] + + const key = parts.find((part) => !MODIFIERS.has(part as keyof typeof MODIFIER_LABELS)) + if (!key && parts.length === 1) return [formatKey(parts[0]!)] + + const out: string[] = [] + if (parts.includes('meta')) out.push(MODIFIER_LABELS.meta) + if (parts.includes('shift')) out.push(MODIFIER_LABELS.shift) + if (parts.includes('alt')) out.push(MODIFIER_LABELS.alt) + if (key) out.push(formatKey(key)) + + return out.length > 0 ? out : [combo] +} + +function formatKey(key: string): string { + const special: Record = { + '`': '`', + '\\': '\\', + enter: 'Enter', + escape: 'Esc', + ' ': 'Space', + '-': '-', + '=': '+', + '?': '?', + } + + return special[key] ?? key.toUpperCase() +} diff --git a/package.json b/package.json index 64e8eaa..a9d3d63 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "desktop:dev": "pnpm exec tauri dev", "ios:dev": "node scripts/tauri-ios-dev.mjs", "desktop:check": "tsc --noEmit", - "desktop:build": "CI=true tauri build --target aarch64-apple-darwin", - "ios:build": "CI=true tauri ios build", - "desktop:build:debug": "tauri build --debug", - "desktop:release": "pnpm desktop:check && pnpm desktop:build", + "desktop:build": "cross-env CI=true pnpm exec tauri build --bundles app", + "desktop:package": "cross-env CI=true pnpm exec tauri build", + "ios:build": "cross-env CI=true pnpm exec tauri ios build", + "desktop:build:debug": "pnpm exec tauri build --debug", + "desktop:release": "pnpm desktop:check && pnpm desktop:package", "desktop:sign": "./scripts/sign-and-deploy.sh", "desktop:doctor": "pkill -f \"next dev|tauri dev\" || true; rm -f .next/lock", "bdev": "pnpm desktop:build && pnpm desktop:dev", @@ -24,9 +25,9 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "secrets:scan": "scripts/secrets-scan.sh history", - "secrets:scan:staged": "scripts/secrets-scan.sh staged", - "secrets:baseline": "scripts/secrets-scan.sh baseline", + "secrets:scan": "bash ./scripts/secrets-scan.sh history", + "secrets:scan:staged": "bash ./scripts/secrets-scan.sh staged", + "secrets:baseline": "bash ./scripts/secrets-scan.sh baseline", "policy:skill-first": "node scripts/preflight-skill-first.mjs staged", "policy:skill-first:ci": "node scripts/preflight-skill-first.mjs ci", "prepare": "husky" @@ -79,6 +80,7 @@ "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", + "cross-env": "^7.0.3", "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e708fd3..29fd868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(yaml@2.8.2)) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^10.0.2 version: 10.0.2(jiti@2.6.1) @@ -1374,6 +1377,11 @@ packages: shiki: optional: true + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3312,6 +3320,10 @@ snapshots: optionalDependencies: react: 19.2.4 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/scripts/secrets-scan.sh b/scripts/secrets-scan.sh index 00cb5ac..af15d1a 100755 --- a/scripts/secrets-scan.sh +++ b/scripts/secrets-scan.sh @@ -12,6 +12,14 @@ resolve_gitleaks() { echo "$ROOT/.tools/bin/gitleaks" return 0 fi + if [ -n "${LOCALAPPDATA:-}" ]; then + for candidate in "$LOCALAPPDATA"/Microsoft/WinGet/Packages/*/gitleaks.exe; do + if [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + fi if command -v gitleaks >/dev/null 2>&1; then command -v gitleaks return 0