diff --git a/PROGRESS.md b/PROGRESS.md index 275d38be..95ddfc90 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -110,7 +110,7 @@ packages/opencode-client/ - Text input with multi-line support - Send button - File attachment support -- Keyboard shortcuts (Enter to send, Shift+Enter for newline) +- Keyboard shortcuts (Enter for new line; Cmd+Enter/Ctrl+Enter to send) ### Task 008: Instance Tabs - Tab bar for multiple instances diff --git a/README.md b/README.md index d575a77b..9cb87069 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ bun run preview # Preview production build ### Sending Messages - Type in the input box at bottom -- Press Enter to send (Shift+Enter for new line) +- Press Enter for new line (Cmd+Enter on macOS, Ctrl+Enter on Windows/Linux) - Use `/` for commands - Use `@` to mention files diff --git a/docs/INDEX.md b/docs/INDEX.md index 460e6132..290f7f79 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -162,6 +162,8 @@ A: See [build-roadmap.md](build-roadmap.md) for phases | tasks/README.md | ✅ Complete | 2024-10-22 | | Task 001-005 | ✅ Complete | 2024-10-22 | +**Project phase:** Post-MVP (Phases 1-3 complete; Phase 4 work underway). + --- ## Contributing to Documentation diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5c820123..f592b53e 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,5 +1,9 @@ # OpenCode Client - Project Summary +## Current Status + +We have completed the MVP milestones (Phases 1-3) and are now operating in post-MVP mode. Future work prioritizes multi-instance support, advanced input polish, and system integrations outlined in later phases. + ## What We've Created A comprehensive specification and task breakdown for building the OpenCode Client desktop application. diff --git a/docs/build-roadmap.md b/docs/build-roadmap.md index 386a3d2f..00829dfb 100644 --- a/docs/build-roadmap.md +++ b/docs/build-roadmap.md @@ -4,6 +4,8 @@ This document outlines the phased approach to building the OpenCode Client desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones. +**Status:** MVP (Phases 1-3) is complete. Focus now shifts to post-MVP phases starting with multi-instance support and advanced input refinements. + ## MVP Scope (Phases 1-3) The minimum viable product includes: diff --git a/docs/user-interface.md b/docs/user-interface.md index 58a748ec..285c3a04 100644 --- a/docs/user-interface.md +++ b/docs/user-interface.md @@ -266,8 +266,8 @@ Click to expand: Show diff inline **Keyboard Shortcuts:** -- Enter: Send message -- Shift+Enter: New line +- Enter: New line +- Cmd+Enter (macOS) / Ctrl+Enter (Windows/Linux): Send message - Cmd/Ctrl+K: Clear input - Cmd/Ctrl+V: Paste (handles files) - Cmd/Ctrl+L: Focus input diff --git a/src/components/kbd.tsx b/src/components/kbd.tsx index 1f77eb0e..d5b9c977 100644 --- a/src/components/kbd.tsx +++ b/src/components/kbd.tsx @@ -7,6 +7,25 @@ interface KbdProps { class?: string } +const SPECIAL_KEY_LABELS: Record = { + enter: "Enter", + return: "Enter", + esc: "Esc", + escape: "Esc", + tab: "Tab", + space: "Space", + backspace: "Backspace", + delete: "Delete", + pageup: "Page Up", + pagedown: "Page Down", + home: "Home", + end: "End", + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", +} + const Kbd: Component = (props) => { const parts = () => { if (props.children) return [{ text: props.children, isModifier: false }] @@ -16,19 +35,27 @@ const Kbd: Component = (props) => { const shortcut = props.shortcut.toLowerCase() const tokens = shortcut.split("+") - tokens.forEach((token, i) => { + tokens.forEach((token) => { const trimmed = token.trim() + const lower = trimmed.toLowerCase() - if (trimmed === "cmd" || trimmed === "command") { + if (lower === "cmd" || lower === "command") { result.push({ text: isMac() ? "Cmd" : "Ctrl", isModifier: false }) - } else if (trimmed === "shift") { + } else if (lower === "shift") { result.push({ text: "Shift", isModifier: false }) - } else if (trimmed === "alt" || trimmed === "option") { + } else if (lower === "alt" || lower === "option") { result.push({ text: isMac() ? "Option" : "Alt", isModifier: false }) - } else if (trimmed === "ctrl") { + } else if (lower === "ctrl" || lower === "control") { result.push({ text: "Ctrl", isModifier: false }) } else { - result.push({ text: trimmed.toUpperCase(), isModifier: false }) + const label = SPECIAL_KEY_LABELS[lower] + if (label) { + result.push({ text: label, isModifier: false }) + } else if (trimmed.length === 1) { + result.push({ text: trimmed.toUpperCase(), isModifier: false }) + } else { + result.push({ text: trimmed.charAt(0).toUpperCase() + trimmed.slice(1), isModifier: false }) + } } }) diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index 1727ab5a..4e54db99 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -99,13 +99,13 @@ export function Markdown(props: MarkdownProps) { }) const proseClass = () => { - const classes = ["prose", "dark:prose-invert", "max-w-none"] + const classes = ["dark:prose-invert", "max-w-none", "prose-tight", "prose"] - if (props.size === "tight") { - classes.push("prose-sm", "prose-tight") - } else if (props.size === "sm") { - classes.push("prose-sm") - } + // if (props.size === "tight") { + // classes.push("prose-sm", "prose-tight") + // } else if (props.size === "sm") { + // classes.push("prose-sm") + // } return classes.join(" ") } diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index e4125a09..192648dd 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -34,6 +34,24 @@ export default function PromptInput(props: PromptInputProps) { let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined + const MAX_TEXTAREA_HEIGHT = 200 + const MIN_TEXTAREA_LINES = 4 + + function adjustTextareaHeight(textarea: HTMLTextAreaElement | undefined) { + if (!textarea) return + + const computedStyle = window.getComputedStyle(textarea) + const fontSizeValue = parseFloat(computedStyle.fontSize) + const fallbackFontSize = Number.isFinite(fontSizeValue) && fontSizeValue > 0 ? fontSizeValue : 16 + const lineHeightValue = parseFloat(computedStyle.lineHeight) + const lineHeight = Number.isFinite(lineHeightValue) && lineHeightValue > 0 ? lineHeightValue : fallbackFontSize * 1.5 + const minHeight = lineHeight * MIN_TEXTAREA_LINES + + textarea.style.height = "auto" + const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), MAX_TEXTAREA_HEIGHT) + textarea.style.height = newHeight + "px" + } + const attachments = () => getAttachments(props.instanceId, props.sessionId) const instanceAgents = () => agents().get(props.instanceId) || [] @@ -94,10 +112,18 @@ export default function PromptInput(props: PromptInputProps) { on( () => `${props.instanceId}:${props.sessionId}`, () => { - const storedPrompt = getPromptValue(props.instanceId, props.sessionId) - const currentAttachments = untrack(() => getAttachments(props.instanceId, props.sessionId)) + const instanceId = props.instanceId + const sessionId = props.sessionId - setPrompt(storedPrompt) + onCleanup(() => { + setPromptValue(instanceId, sessionId, prompt()) + }) + + const storedPrompt = getPromptValue(instanceId, sessionId) + const currentAttachments = untrack(() => getAttachments(instanceId, sessionId)) + + setPromptInternal(storedPrompt) + setPromptValue(instanceId, sessionId, storedPrompt) setHistoryIndex(-1) setIgnoredAtPositions(new Set()) setShowPicker(false) @@ -106,10 +132,7 @@ export default function PromptInput(props: PromptInputProps) { syncAttachmentCounters(storedPrompt, currentAttachments) queueMicrotask(() => { - if (textareaRef) { - textareaRef.style.height = "auto" - textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" - } + adjustTextareaHeight(textareaRef) }) }, { defer: true }, @@ -150,10 +173,7 @@ export default function PromptInput(props: PromptInputProps) { setPrompt(newPrompt) - if (textareaRef) { - textareaRef.style.height = "auto" - textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" - } + adjustTextareaHeight(textareaRef) } } @@ -202,8 +222,7 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { const newCursorPos = start + placeholder.length textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) textarea.focus() }, 0) } @@ -247,18 +266,14 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { const newCursorPos = start + placeholder.length textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) textarea.focus() }, 0) } } } - onMount(async () => { - const loaded = await getHistory(props.instanceFolder) - setHistory(loaded) - + onMount(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement @@ -287,6 +302,11 @@ export default function PromptInput(props: PromptInputProps) { onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) + + void (async () => { + const loaded = await getHistory(props.instanceFolder) + setHistory(loaded) + })() }) function handleKeyDown(e: KeyboardEvent) { @@ -329,8 +349,7 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { textarea.setSelectionRange(placeholderStart, placeholderStart) - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) }, 0) return @@ -372,8 +391,7 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { textarea.setSelectionRange(placeholderStart, placeholderStart) - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) }, 0) return @@ -417,8 +435,7 @@ export default function PromptInput(props: PromptInputProps) { setTimeout(() => { textarea.setSelectionRange(mentionStart, mentionStart) - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) }, 0) return @@ -427,8 +444,11 @@ export default function PromptInput(props: PromptInputProps) { } } - if (e.key === "Enter" && !e.shiftKey && !showPicker()) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault() + if (showPicker()) { + handlePickerClose() + } handleSend() return } @@ -442,8 +462,7 @@ export default function PromptInput(props: PromptInputProps) { setHistoryIndex(newIndex) setPrompt(currentHistory[newIndex]) setTimeout(() => { - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) }, 0) return } @@ -459,8 +478,7 @@ export default function PromptInput(props: PromptInputProps) { setPrompt("") } setTimeout(() => { - textarea.style.height = "auto" - textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + adjustTextareaHeight(textarea) }, 0) return } @@ -477,9 +495,7 @@ export default function PromptInput(props: PromptInputProps) { setPasteCount(0) setImageCount(0) - if (textareaRef) { - textareaRef.style.height = "auto" - } + adjustTextareaHeight(textareaRef) try { await addToHistory(props.instanceFolder, text) @@ -503,8 +519,7 @@ export default function PromptInput(props: PromptInputProps) { setPrompt(value) setHistoryIndex(-1) - target.style.height = "auto" - target.style.height = Math.min(target.scrollHeight, 200) + "px" + adjustTextareaHeight(target) const cursorPos = target.selectionStart const textBeforeCursor = value.substring(0, cursorPos) @@ -568,8 +583,7 @@ export default function PromptInput(props: PromptInputProps) { if (textareaRef) { const newCursorPos = pos + attachmentText.length + 1 textareaRef.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.style.height = "auto" - textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" + adjustTextareaHeight(textareaRef) } }, 0) } @@ -594,8 +608,7 @@ export default function PromptInput(props: PromptInputProps) { if (textareaRef) { const newCursorPos = pos + 1 + path.length textareaRef.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.style.height = "auto" - textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" + adjustTextareaHeight(textareaRef) textareaRef.focus() } }, 0) @@ -627,8 +640,7 @@ export default function PromptInput(props: PromptInputProps) { if (textareaRef) { const newCursorPos = pos + attachmentText.length + 1 textareaRef.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.style.height = "auto" - textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" + adjustTextareaHeight(textareaRef) } }, 0) } @@ -811,7 +823,7 @@ export default function PromptInput(props: PromptInputProps) { when={props.escapeInDebounce} fallback={ <> - Enter to send • Shift+Enter for new line • @ for files/agents •{" "} + Enter for new line • to send • @ for files/agents •{" "} ↑↓ for history 0}> • {attachments().length} file(s) attached diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index a30ed0d0..8f1a48f8 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -216,17 +216,17 @@ function setupRenderer(isDark: boolean) { const escapedLang = escapeHtml(resolvedLang) const header = ` -
- ${escapedLang} - -
- ` +
+ ${escapedLang} + +
+`.trim() // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { diff --git a/src/styles/components.css b/src/styles/components.css index a30f8118..a2ba9fed 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -183,7 +183,7 @@ button.button-primary { } .prompt-input { - @apply flex-1 min-h-[40px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors; + @apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors; font-family: inherit; background-color: var(--surface-base); color: inherit; diff --git a/src/styles/markdown.css b/src/styles/markdown.css index 653e0c73..6b778b05 100644 --- a/src/styles/markdown.css +++ b/src/styles/markdown.css @@ -3,6 +3,7 @@ /* Prose styles for markdown content */ .prose { color: var(--text-primary); + line-height: var(--line-height-tight) !important; } .prose code { @@ -47,20 +48,25 @@ .prose ul, .prose ol { - margin: 8px 0; + margin: 0; padding-left: 24px; } .prose ul { + margin: 0 !important; list-style-type: disc; + line-height: var(--line-height-tight) !important; } .prose ol { + margin: 0 !important; list-style-type: decimal; + line-height: var(--line-height-tight) !important; } .prose li { - margin: 4px 0; + margin: 0 !important; + line-height: var(--line-height-tight) !important; } .prose h1 { @@ -105,7 +111,8 @@ } .prose p { - margin: 8px 0; + margin: 0 !important; + line-height: var(--line-height-normal) !important; } .prose hr {