Files
NomadArch/tasks/done/009-prompt-input-basic.md
Gemini AI 157449a9ad restore: recover deleted documentation, CI/CD, and infrastructure files
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)
2025-12-23 13:03:48 +04:00

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:

  1. Receive message_updated events
  2. Create assistant message in the session
  3. Stream message parts as they arrive
  4. 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

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