Files
opencode-rails/lib/opencode/tool_display.rb
Ajay Krishnan 9b0c4cd3cd
Some checks failed
Test / test (3.2) (push) Failing after 9m43s
Test / test (3.3) (push) Failing after 10m0s
Test / test (3.4) (push) Failing after 10m0s
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.
2026-05-25 06:49:09 -07:00

424 lines
18 KiB
Ruby

# 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