Use Cmd/Ctrl+Enter to send prompts and update docs

This commit is contained in:
Shantur Rathore
2025-11-03 10:59:32 +00:00
parent 40832ec1b6
commit 618729e1e3
12 changed files with 127 additions and 73 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -7,6 +7,25 @@ interface KbdProps {
class?: string
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
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<KbdProps> = (props) => {
const parts = () => {
if (props.children) return [{ text: props.children, isModifier: false }]
@@ -16,19 +35,27 @@ const Kbd: Component<KbdProps> = (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 })
}
}
})

View File

@@ -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(" ")
}

View File

@@ -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<number>())
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={
<>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files/agents {" "}
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>

View File

@@ -216,17 +216,17 @@ function setupRenderer(isDark: boolean) {
const escapedLang = escapeHtml(resolvedLang)
const header = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
`
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
`.trim()
// Skip highlighting for "text" language or when highlighter is not available
if (resolvedLang === "text" || !highlighter) {

View File

@@ -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;

View File

@@ -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 {