Initial public release v0.0.1.alpha2
opencode-rails — production-grade Rails integration for OpenCode. Rails companion to opencode-ruby. ActiveRecord-aware session lifecycle (idempotent ensure!/recreate!/abort! with row-level locks), a Turn orchestrator driving the Reply state machine and recovering from session-not-found, an artifact pipeline backed by ActiveStorage, sandbox seeding, and tool-display value objects for Turbo Stream broadcasts. Drop into any Rails 7.1+ app that wants production-grade OpenCode streaming without rolling boilerplate. What this version ships: - Opencode::Session (AR-coupled lifecycle, row-level locks) - Opencode::Turn (Reply state machine, session-not-found recovery) - Opencode::Exchange (one turn = one request/response unit) - Opencode::Impostor (deterministic mock for tests) - Opencode::Sandbox / SandboxFile (per-session FS scratch space) - Opencode::Transform (host-rendered artifact pipeline) - Opencode::Artifact / MessageArtifacts (ActiveStorage-backed) - Opencode::UploadedFilesPrompt (system-prompt builder) - Opencode::ToolDisplay (Turbo Stream value objects) - Opencode::ErrorReporter (pluggable adapter — Honeybadger/Sentry/etc.) - examples/rails_integration.rb — canonical wiring blueprint 53 smoke tests. CI on Ruby 3.2/3.3/3.4. Ruby >= 3.2. Runtime deps: opencode-ruby = 0.0.1.alpha2, activerecord/activestorage/activesupport >= 7.1, < 9.0. See CHANGELOG.md for the alpha1 -> alpha2 delta.
This commit is contained in:
423
lib/opencode/tool_display.rb
Normal file
423
lib/opencode/tool_display.rb
Normal file
@@ -0,0 +1,423 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Opencode
|
||||
# A value object that wraps an OpenCode tool part (the shape produced by
|
||||
# Opencode::Reply from `message.part.updated` events) and exposes the
|
||||
# information a renderer needs — canonical tool name, human labels,
|
||||
# target (filepath / pattern / url / command), semantic accessors for
|
||||
# rich content (unified diffs, todo lists, bash output, etc.), and an
|
||||
# icon identifier.
|
||||
#
|
||||
# Pure Ruby over ActiveSupport. Lives in the shared Opencode namespace
|
||||
# so any view that renders OpenCode tool calls — across whatever
|
||||
# products the host runs — can do so consistently.
|
||||
#
|
||||
# ## Data shape (Opencode::Reply writes this into `parts_json`)
|
||||
#
|
||||
# {
|
||||
# "type" => "tool",
|
||||
# "tool" => "read" | "edit" | "exa_web_search_exa" | ...,
|
||||
# "status" => "pending" | "running" | "completed" | "error",
|
||||
# "input" => { "filePath" => ..., "content" => ..., ... },
|
||||
# "title" => "..." (optional, from tool-result)
|
||||
# "error" => "..." (only when status == "error")
|
||||
# "metadata" => { (optional, from tool-result)
|
||||
# "diff" => "...unified diff text...",
|
||||
# "diagnostics" => { filePath => [LSP diagnostics] },
|
||||
# "preview" => "...file preview...",
|
||||
# "matches" => Integer,
|
||||
# "count" => Integer,
|
||||
# "output" => "...bash stdout...",
|
||||
# "stdout" => "...bash stdout (legacy key)...",
|
||||
# "description" => "...bash description...",
|
||||
# "error" => truthy when the tool ran but returned an error,
|
||||
# },
|
||||
# "output" => "...raw tool output string..."
|
||||
# }
|
||||
#
|
||||
# ## MCP prefix handling
|
||||
#
|
||||
# OpenCode's MCP adapter prefixes tools with the server name: Exa's
|
||||
# `web_search_exa` becomes `exa_web_search_exa` on the wire (double
|
||||
# suffix because Exa also names the tool with an `_exa` suffix).
|
||||
# `#canonical_tool` strips known MCP prefixes so switching logic can
|
||||
# treat `exa_web_search_exa` and `web_search_exa` as the same tool.
|
||||
#
|
||||
# ## Adding a new tool
|
||||
#
|
||||
# Add one row to the `TOOLS` table below — every derived concern (kind,
|
||||
# gerund, icon, past-tense verb, KNOWN membership) is computed from it.
|
||||
#
|
||||
# ## Example
|
||||
#
|
||||
# display = Opencode::ToolDisplay.new(part)
|
||||
# display.canonical_tool # => "edit"
|
||||
# display.kind # => "Edit"
|
||||
# display.gerund # => "Editing"
|
||||
# display.target # => "app/models/user.rb"
|
||||
# display.diff # => "@@ -1,3 +1,4 @@\n..."
|
||||
# display.icon # => :pencil_square
|
||||
#
|
||||
class ToolDisplay
|
||||
# The single source of truth for every tool we render with dedicated
|
||||
# affordances. One row per tool, five columns:
|
||||
#
|
||||
# :kind — noun label ("Read", "Web search")
|
||||
# :gerund — present-progressive phrase ("Reading", "Searching the web")
|
||||
# :icon — abstract icon name the view layer maps to an SVG
|
||||
# :past — past-tense verb ("Read", "Searched", "Wrote")
|
||||
#
|
||||
# Anything not in this table falls back to generic rendering (humanize
|
||||
# the canonical name + OpenCode's title).
|
||||
TOOLS = {
|
||||
"read" => { kind: "Read", gerund: "Reading", icon: :document, past: "Read" },
|
||||
"write" => { kind: "Write", gerund: "Writing", icon: :document_plus, past: "Wrote" },
|
||||
"edit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
|
||||
"multiedit" => { kind: "Edit", gerund: "Editing", icon: :pencil_square, past: "Edited" },
|
||||
"apply_patch" => { kind: "Patch", gerund: "Applying changes", icon: :pencil_square, past: "Applied changes to" },
|
||||
"bash" => { kind: "Bash", gerund: "Running command", icon: :command_line, past: "Ran" },
|
||||
"grep" => { kind: "Grep", gerund: "Searching files", icon: :document_magnifying_glass, past: "Searched for" },
|
||||
"glob" => { kind: "Glob", gerund: "Searching files", icon: :magnifying_glass, past: "Searched for" },
|
||||
"list" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
|
||||
"ls" => { kind: "LS", gerund: "Listing files", icon: :rectangle_stack, past: "Listed" },
|
||||
"webfetch" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
|
||||
"websearch" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
||||
"codesearch" => { kind: "Code search", gerund: "Searching code", icon: :magnifying_glass, past: "Searched code for" },
|
||||
"web_search_exa" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
||||
# @zhafron/mcp-web-search exposes two tools: search_web (SearXNG meta-
|
||||
# search) and fetch_url (Mozilla Readability page fetch). OpenCode
|
||||
# prefixes them with the MCP server name from config.json
|
||||
# (`local-web-search_`), which `canonical_tool` strips before lookup.
|
||||
"search_web" => { kind: "Web search", gerund: "Searching the web", icon: :globe, past: "Searched" },
|
||||
"fetch_url" => { kind: "Fetch", gerund: "Reading web page", icon: :globe, past: "Fetched" },
|
||||
"get_code_context_exa" => { kind: "Code lookup", gerund: "Looking up code", icon: :document_magnifying_glass, past: "Looked up" },
|
||||
"company_research_exa" => { kind: "Company research", gerund: "Researching company", icon: :globe, past: "Researched" },
|
||||
"todowrite" => { kind: "Plan", gerund: "Planning", icon: :queue_list, past: "Updated plan" },
|
||||
"todoread" => { kind: "Plan", gerund: "Reading plan", icon: :queue_list, past: "Read plan" },
|
||||
"task" => { kind: "Task", gerund: "Researching", icon: :robot, past: "Ran subtask" },
|
||||
"skill" => { kind: "Skill", gerund: "Loading skill", icon: :sparkles, past: "Loaded skill" }
|
||||
}.freeze
|
||||
|
||||
KNOWN = TOOLS.keys.freeze
|
||||
DEFAULT_ICON = :sparkles
|
||||
|
||||
# MCP server prefixes to strip, paired with the tools they canonicalize
|
||||
# to (for `#provider` classification). Prefixes sorted by length
|
||||
# descending in case future additions overlap.
|
||||
PROVIDERS = {
|
||||
"exa" => { prefix: "exa_", canonical: %w[web_search_exa get_code_context_exa company_research_exa].freeze },
|
||||
"brave" => { prefix: "brave_", canonical: [].freeze },
|
||||
"serper" => { prefix: "serper_", canonical: [].freeze },
|
||||
"tavily" => { prefix: "tavily_", canonical: [].freeze },
|
||||
# The local-web-search MCP server registered in
|
||||
# config/opencode/<product>/config.json. Hyphenated server name plus
|
||||
# underscore separator; canonical tools are search_web and fetch_url.
|
||||
"local-web-search" => { prefix: "local-web-search_", canonical: %w[search_web fetch_url].freeze }
|
||||
}.freeze
|
||||
|
||||
MCP_PREFIXES = PROVIDERS.values.map { |p| p[:prefix] }.freeze
|
||||
|
||||
attr_reader :part
|
||||
|
||||
def initialize(part)
|
||||
@part = part || {}
|
||||
end
|
||||
|
||||
# Convenience constructor for raw OpenCode API parts (symbol keys,
|
||||
# nested under `state`). Flattens into the canonical `parts_json`
|
||||
# shape Reply persists.
|
||||
#
|
||||
# raw = { type: "tool", tool: "bash", callID: ...,
|
||||
# state: { status: "running", input: {...}, title: "..." } }
|
||||
# Opencode::ToolDisplay.from_raw(raw)
|
||||
def self.from_raw(raw)
|
||||
raw = (raw || {}).deep_stringify_keys
|
||||
state = raw["state"] || {}
|
||||
new(
|
||||
"type" => "tool",
|
||||
"tool" => raw["tool"],
|
||||
"status" => state["status"],
|
||||
"input" => state["input"] || {},
|
||||
"output" => state["output"],
|
||||
"title" => state["title"],
|
||||
"error" => state["error"],
|
||||
"metadata" => state["metadata"] || {}
|
||||
)
|
||||
end
|
||||
|
||||
# ----- Identity -----------------------------------------------------
|
||||
|
||||
def tool_name
|
||||
@part["tool"].to_s
|
||||
end
|
||||
|
||||
# Strips MCP prefixes — and, when present, the matching MCP suffix —
|
||||
# so tools render cleanly regardless of how the server namespaces them.
|
||||
#
|
||||
# Examples:
|
||||
# exa_web_search_exa → web_search_exa (KNOWN tool, preserved)
|
||||
# exa_web_fetch_exa → web_fetch (not KNOWN; cleaned for display)
|
||||
# exa_nonexistent → nonexistent (prefix-stripped fallback)
|
||||
# brave_read → read (KNOWN after prefix strip)
|
||||
#
|
||||
# The double-strip handles Exa's naming convention: the MCP server
|
||||
# exports tools like `web_fetch_exa`, and OpenCode prepends `exa_`,
|
||||
# producing `exa_web_fetch_exa`. Stripping both yields a readable
|
||||
# `web_fetch`.
|
||||
def canonical_tool
|
||||
name = tool_name
|
||||
return name if name.empty? || KNOWN.include?(name)
|
||||
|
||||
MCP_PREFIXES.each do |prefix|
|
||||
stripped = strip_mcp_decoration(name, prefix)
|
||||
return stripped if stripped
|
||||
end
|
||||
name
|
||||
end
|
||||
|
||||
def known?
|
||||
KNOWN.include?(canonical_tool)
|
||||
end
|
||||
|
||||
def icon
|
||||
TOOLS.dig(canonical_tool, :icon) || DEFAULT_ICON
|
||||
end
|
||||
|
||||
# "Read", "Edit", "Bash", or a humanized *canonical* name for unknowns.
|
||||
# Humanizes `canonical_tool`, not `tool_name` — otherwise an MCP tool
|
||||
# like `exa_web_fetch_exa` whose canonical form (`web_fetch`) isn't
|
||||
# in TOOLS would fall back to the raw, MCP-prefixed name and display
|
||||
# as "Exa web fetch exa". Using the canonical form gives the clean
|
||||
# "Web fetch" label.
|
||||
def kind
|
||||
TOOLS.dig(canonical_tool, :kind) || canonical_tool.humanize
|
||||
end
|
||||
|
||||
# "Reading", "Editing", "Running command", falls back to "<kind>...".
|
||||
def gerund
|
||||
TOOLS.dig(canonical_tool, :gerund) || "#{kind}..."
|
||||
end
|
||||
|
||||
# ----- Status -------------------------------------------------------
|
||||
|
||||
def status
|
||||
@part["status"].to_s
|
||||
end
|
||||
|
||||
def pending? = status == "pending"
|
||||
def running? = status == "running"
|
||||
def completed? = status == "completed"
|
||||
def errored? = status == "error"
|
||||
def terminal? = completed? || errored?
|
||||
def in_flight? = pending? || running?
|
||||
|
||||
# ----- Raw payloads -------------------------------------------------
|
||||
|
||||
def input
|
||||
@part["input"].is_a?(Hash) ? @part["input"] : {}
|
||||
end
|
||||
|
||||
def metadata
|
||||
@part["metadata"].is_a?(Hash) ? @part["metadata"] : {}
|
||||
end
|
||||
|
||||
def output
|
||||
@part["output"].to_s
|
||||
end
|
||||
|
||||
def error_text
|
||||
@part["error"].to_s
|
||||
end
|
||||
|
||||
# OpenCode-supplied title (from tool-result). Used as a fallback for
|
||||
# unknown MCP tools that don't match KNOWN.
|
||||
def opencode_title
|
||||
@part["title"].to_s
|
||||
end
|
||||
|
||||
# ----- Target (what the tool is operating on) ----------------------
|
||||
|
||||
# Returns a single-string representation of the tool's primary target,
|
||||
# or nil when the tool has no meaningful single target. Callers can
|
||||
# substitute display-friendly names (e.g., sandbox filenames).
|
||||
def target
|
||||
raw = case canonical_tool
|
||||
when "read", "write", "edit", "multiedit", "apply_patch"
|
||||
input["filePath"] || input["path"]
|
||||
when "bash"
|
||||
input["command"]
|
||||
when "grep", "glob"
|
||||
input["pattern"]
|
||||
when "list", "ls"
|
||||
input["path"]
|
||||
when "webfetch", "fetch_url"
|
||||
input["url"]
|
||||
when "websearch", "codesearch",
|
||||
"web_search_exa", "get_code_context_exa", "company_research_exa"
|
||||
input["query"]
|
||||
when "search_web"
|
||||
# @zhafron/mcp-web-search names the argument `q`, not `query`.
|
||||
input["q"]
|
||||
when "task"
|
||||
input["description"]
|
||||
when "skill"
|
||||
input["skill_name"] || input["name"]
|
||||
end
|
||||
raw.to_s.presence
|
||||
end
|
||||
|
||||
# A short label combining kind + target, suitable for a one-line
|
||||
# summary. Falls back to the OpenCode-supplied title for unknown MCP
|
||||
# tools, then to just kind.
|
||||
def title
|
||||
if known?
|
||||
target.present? ? "#{kind}: #{target}" : kind
|
||||
else
|
||||
opencode_title.presence || kind
|
||||
end
|
||||
end
|
||||
|
||||
# Past-tense "done" variant: "Read foo.rb", "Wrote contract.pdf",
|
||||
# "Edited user.rb", "Ran `ls -la`". Used after completion.
|
||||
def past_tense_title
|
||||
if known?
|
||||
verb = TOOLS.dig(canonical_tool, :past)
|
||||
return kind unless verb
|
||||
target.present? ? "#{verb} #{target}" : verb
|
||||
else
|
||||
opencode_title.presence || kind
|
||||
end
|
||||
end
|
||||
|
||||
# ----- Semantic accessors (rich content) ---------------------------
|
||||
|
||||
# Unified-diff text produced by the edit tool (OpenCode attaches this
|
||||
# under metadata.diff). Present only after completion.
|
||||
def diff
|
||||
metadata["diff"].presence
|
||||
end
|
||||
|
||||
# Sorted list of todo hashes { "content", "status", "priority", "id" }.
|
||||
# Available during running (input is populated) and completed states.
|
||||
# Order: in_progress first, pending next, completed last.
|
||||
# Canonicalization (string keys + status hyphen→underscore) is
|
||||
# delegated to Opencode::Todo so Reply and ToolDisplay can't drift.
|
||||
def todos
|
||||
return [] unless %w[todowrite todoread].include?(canonical_tool)
|
||||
items = input["todos"]
|
||||
return [] unless items.is_a?(Array)
|
||||
order = { "in_progress" => 0, "pending" => 1, "completed" => 2 }
|
||||
items
|
||||
.select { |t| t.is_a?(Hash) }
|
||||
.map { |todo| Opencode::Todo.canonicalize(todo) }
|
||||
.sort_by { |todo| order[todo["status"]] || 99 }
|
||||
end
|
||||
|
||||
# File content that the write tool is creating (lives in input.content).
|
||||
def file_content
|
||||
return nil unless canonical_tool == "write"
|
||||
input["content"].presence
|
||||
end
|
||||
|
||||
# Syntax-highlighting language hint based on the target filename.
|
||||
# Falls back to the raw extension so unknown file types still get a
|
||||
# hint their syntax-highlighter may recognize heuristically.
|
||||
def file_lang
|
||||
name = File.basename(target.to_s)
|
||||
return nil if name.empty?
|
||||
ext = File.extname(name).delete_prefix(".").downcase
|
||||
LANG_BY_EXT[ext] || ext.presence
|
||||
end
|
||||
|
||||
LANG_BY_EXT = {
|
||||
"md" => "markdown", "markdown" => "markdown",
|
||||
"rb" => "ruby", "rake" => "ruby",
|
||||
"py" => "python",
|
||||
"js" => "javascript", "mjs" => "javascript",
|
||||
"ts" => "typescript", "tsx" => "typescript",
|
||||
"jsx" => "jsx",
|
||||
"json" => "json", "yml" => "yaml", "yaml" => "yaml",
|
||||
"html" => "html", "erb" => "erb",
|
||||
"css" => "css", "scss" => "scss",
|
||||
"sh" => "shell", "bash" => "shell", "zsh" => "shell",
|
||||
"sql" => "sql",
|
||||
"go" => "go", "rs" => "rust",
|
||||
"c" => "c", "h" => "c",
|
||||
"cpp" => "cpp", "hpp" => "cpp",
|
||||
"java" => "java", "kt" => "kotlin",
|
||||
"swift" => "swift", "php" => "php",
|
||||
"lua" => "lua", "toml" => "toml",
|
||||
"xml" => "xml", "conf" => "shell"
|
||||
}.freeze
|
||||
|
||||
# Bash-specific accessors.
|
||||
def bash_command
|
||||
return nil unless canonical_tool == "bash"
|
||||
input["command"].presence
|
||||
end
|
||||
|
||||
def bash_output
|
||||
return nil unless canonical_tool == "bash"
|
||||
(metadata["output"] || metadata["stdout"] || output).to_s.presence
|
||||
end
|
||||
|
||||
def bash_description
|
||||
return nil unless canonical_tool == "bash"
|
||||
metadata["description"].presence
|
||||
end
|
||||
|
||||
# Read preview (OpenCode populates metadata.preview after the read).
|
||||
def read_preview
|
||||
return nil unless canonical_tool == "read"
|
||||
metadata["preview"].presence
|
||||
end
|
||||
|
||||
# Grep/Glob match counts.
|
||||
def match_count
|
||||
case canonical_tool
|
||||
when "grep" then metadata["matches"].to_i
|
||||
when "glob" then metadata["count"].to_i
|
||||
end
|
||||
end
|
||||
|
||||
# ----- Provider identification for log tagging ---------------------
|
||||
|
||||
# Groups tools by which MCP server / built-in provides them, for
|
||||
# operational logs and metrics. Adding a new provider = one row in
|
||||
# PROVIDERS.
|
||||
def provider
|
||||
name = tool_name
|
||||
canonical = canonical_tool
|
||||
PROVIDERS.each do |provider_name, config|
|
||||
return provider_name if name.start_with?(config[:prefix])
|
||||
return provider_name if config[:canonical].include?(canonical)
|
||||
end
|
||||
KNOWN.include?(canonical) ? "opencode-builtin" : "unknown"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns `name` with `prefix` (and the matching MCP suffix, where
|
||||
# Exa double-encodes) removed, or nil when `name` doesn't carry that
|
||||
# prefix. Precedence:
|
||||
#
|
||||
# 1. Prefer the single-stripped form when it's KNOWN
|
||||
# (exa_web_search_exa → web_search_exa, which IS a TOOLS key).
|
||||
# 2. Otherwise prefer the clean double-stripped form when both
|
||||
# prefix and suffix are present
|
||||
# (exa_web_fetch_exa → web_fetch, for humanization).
|
||||
# 3. Fall back to single-stripped when double-stripped is empty.
|
||||
def strip_mcp_decoration(name, prefix)
|
||||
return nil unless name.start_with?(prefix)
|
||||
|
||||
stripped = name.delete_prefix(prefix)
|
||||
return stripped if KNOWN.include?(stripped)
|
||||
|
||||
suffix = "_#{prefix.chomp('_')}"
|
||||
return stripped unless stripped.end_with?(suffix)
|
||||
|
||||
double = stripped.delete_suffix(suffix)
|
||||
double.empty? ? stripped : double
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user