Use Cmd/Ctrl+Enter to send prompts and update docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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(" ")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user