Restored from origin/main (b4663fb): - .github/ workflows and issue templates - .gitignore (proper exclusions) - .opencode/agent/web_developer.md - AGENTS.md, BUILD.md, PROGRESS.md - dev-docs/ (9 architecture/implementation docs) - docs/screenshots/ (4 UI screenshots) - images/ (CodeNomad icons) - package-lock.json (dependency lockfile) - tasks/ (25+ project task files) Also restored original source files that were modified: - packages/ui/src/App.tsx - packages/ui/src/lib/logger.ts - packages/ui/src/stores/instances.ts - packages/server/src/server/routes/workspaces.ts - packages/server/src/workspaces/manager.ts - packages/server/src/workspaces/runtime.ts - packages/server/package.json Kept new additions: - Install-*.bat/.sh (enhanced installers) - Launch-*.bat/.sh (new launchers) - README.md (SEO optimized with GLM 4.7)
13 KiB
Task 009: Prompt Input Basic - Text Input with Send Functionality
Status: TODO
Objective
Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop.
Prerequisites
- Task 007 (Message display) complete
- Task 008 (SSE integration) complete
- Active session available
- SDK client configured
Context
The prompt input is the primary way users interact with OpenCode. For the MVP, we need:
- Simple text input (multi-line textarea)
- Send button
- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line)
- Loading state while assistant is responding
- Basic validation (empty message prevention)
Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024.
Implementation Steps
Step 1: Create Prompt Input Component
Create src/components/prompt-input.tsx:
import { createSignal, Show } from "solid-js"
interface PromptInputProps {
instanceId: string
sessionId: string
onSend: (prompt: string) => Promise<void>
disabled?: boolean
}
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPrompt] = createSignal("")
const [sending, setSending] = createSignal(false)
let textareaRef: HTMLTextAreaElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
async function handleSend() {
const text = prompt().trim()
if (!text || sending() || props.disabled) return
setSending(true)
try {
await props.onSend(text)
setPrompt("")
// Auto-resize textarea back to initial size
if (textareaRef) {
textareaRef.style.height = "auto"
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
} finally {
setSending(false)
textareaRef?.focus()
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
setPrompt(target.value)
// Auto-resize textarea
target.style.height = "auto"
target.style.height = Math.min(target.scrollHeight, 200) + "px"
}
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
return (
<div class="prompt-input-container">
<div class="prompt-input-wrapper">
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message or /command..."
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
disabled={sending() || props.disabled}
rows={1}
/>
<button
class="send-button"
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
>
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
<span class="spinner-small" />
</Show>
</button>
</div>
<div class="prompt-input-hints">
<span class="hint">
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
</span>
</div>
</div>
)
}
Step 2: Add Send Message Function to Sessions Store
Update src/stores/sessions.ts to add message sending:
async function sendMessage(
instanceId: string,
sessionId: string,
prompt: string,
attachments: string[] = [],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
// Add user message optimistically
const userMessage: Message = {
id: `temp-${Date.now()}`,
sessionId,
type: "user",
parts: [{ type: "text", text: prompt }],
timestamp: Date.now(),
status: "sending",
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const newMessages = [...updatedSession.messages, userMessage]
instanceSessions.set(sessionId, { ...updatedSession, messages: newMessages })
}
next.set(instanceId, instanceSessions)
return next
})
try {
// Send to server using session.prompt (not session.message)
await instance.client.session.prompt({
path: { id: sessionId },
body: {
messageID: userMessage.id,
parts: [
{
type: "text",
text: prompt,
},
],
},
})
// Update user message status
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "sent" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
} catch (error) {
// Update user message with error
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const updatedSession = instanceSessions.get(sessionId)
if (updatedSession) {
const messages = updatedSession.messages.map((m) =>
m.id === userMessage.id ? { ...m, status: "error" as const } : m,
)
instanceSessions.set(sessionId, { ...updatedSession, messages })
}
next.set(instanceId, instanceSessions)
return next
})
throw error
}
}
// Export it
export { sendMessage }
Step 3: Integrate Prompt Input into App
Update src/App.tsx to add the prompt input:
import PromptInput from "./components/prompt-input"
import { sendMessage } from "./stores/sessions"
// In the SessionMessages component or create a new wrapper component
const SessionView: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
}
})
async function handleSendMessage(prompt: string) {
await sendMessage(props.instanceId, props.sessionId, prompt)
}
return (
<Show
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
</div>
}
>
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
/>
<PromptInput
instanceId={props.instanceId}
sessionId={s().id}
onSend={handleSendMessage}
/>
</div>
)}
</Show>
)
}
// Replace SessionMessages usage with SessionView
Step 4: Add Styling
Add to src/index.css:
.prompt-input-container {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-color);
background-color: var(--background);
}
.prompt-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
}
.prompt-input {
flex: 1;
min-height: 40px;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
resize: none;
background-color: var(--background);
color: inherit;
outline: none;
transition: border-color 150ms ease;
}
.prompt-input:focus {
border-color: var(--accent-color);
}
.prompt-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prompt-input::placeholder {
color: var(--text-muted);
}
.send-button {
width: 40px;
height: 40px;
border-radius: 6px;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
opacity 150ms ease,
transform 150ms ease;
flex-shrink: 0;
}
.send-button:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.05);
}
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-icon {
font-size: 16px;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.prompt-input-hints {
padding: 0 16px 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.hint kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
background-color: var(--secondary-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
margin: 0 2px;
}
.session-view {
display: flex;
flex-direction: column;
height: 100%;
}
Step 5: Update Message Display for User Messages
Make sure user messages display correctly in src/components/message-item.tsx:
// User messages should show with user styling
// Message status should be visible (sending, sent, error)
<Show when={props.message.status === "error"}>
<div class="message-error">Failed to send message</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner">●</span> Sending...
</div>
</Show>
Step 6: Handle Real-time Response
The SSE integration from Task 008 should automatically:
- Receive message_updated events
- Create assistant message in the session
- Stream message parts as they arrive
- Update the UI in real-time
No additional code needed - this should "just work" if SSE is connected.
Testing Checklist
Basic Functionality
- Prompt input renders at bottom of session view
- Can type text in the textarea
- Textarea auto-expands as you type (up to max height)
- Send button is disabled when input is empty
- Send button is enabled when text is present
Sending Messages
- Click send button - message appears in stream
- Press Enter - message sends
- Press Shift+Enter - adds new line (doesn't send)
- Input clears after sending
- Focus returns to input after sending
User Message Display
- User message appears immediately (optimistic update)
- User message shows "Sending..." state briefly
- User message updates to "sent" after API confirms
- Error state shows if send fails
Assistant Response
- After sending, SSE receives message updates
- Assistant message appears in stream
- Message parts stream in real-time
- Tool calls appear as they execute
- Connection status indicator shows "Connected"
Edge Cases
- Can't send while previous message is processing
- Empty/whitespace-only messages don't send
- Very long messages work correctly
- Multiple rapid sends are queued properly
- Network error shows helpful message
Acceptance Criteria
- Can type and send text messages
- Enter key sends message
- Shift+Enter creates new line
- Send button works correctly
- User messages appear immediately
- Assistant responses stream in real-time via SSE
- Input auto-expands up to max height
- Loading states are clear
- Error handling works
- No console errors during normal operation
Performance Considerations
Per MVP principles - keep it simple:
- Direct API calls - no batching
- Optimistic updates for user messages
- SSE handles streaming automatically
- No debouncing or throttling needed
Future Enhancements (Post-MVP)
- Slash command autocomplete (Task 021)
- File attachment support (Task 022)
- Drag & drop files (Task 023)
- Attachment chips (Task 024)
- Message history navigation (Task 025)
- Multi-line paste handling
- Rich text formatting
- Message drafts persistence
References
- User Interface - Prompt Input
- Technical Implementation - Message Rendering
- Task 008 - SSE Integration
Estimated Time
2-3 hours
Notes
- Focus on core functionality - no fancy features yet
- Test thoroughly with SSE to ensure real-time streaming works
- This completes the basic chat loop - users can now interact with OpenCode
- Keep error messages user-friendly and actionable
- Ensure keyboard shortcuts work as expected