| name | opencode-tui-safety |
| description | OpenCode TUI safety guidelines - avoid console.log pollution, use proper logging, interact with TUI events, understand 60 FPS rendering constraints, and prevent performance degradation. Use when creating or modifying OpenCode plugins/tools that interact with the Terminal UI. |
| license | MIT |
| compatibility | opencode |
| metadata | {"audience":"ai-agents","workflow":"plugin-development"} |
OpenCode TUI Safety
Guidelines for developing OpenCode plugins that integrate safely with the Terminal UI without causing visual pollution, performance degradation, or rendering issues.
TUI Architecture Overview
Rendering System
OpenCode's TUI is built on @opentui/solid (SolidJS-based terminal rendering) configured for 60 FPS rendering.
render(
() => <App />,
{
targetFps: 60,
useKittyKeyboard: {},
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}
)
Provider Architecture
The TUI uses a nested provider architecture (outer to inner):
ArgsProvider → ExitProvider → KVProvider → ToastProvider → RouteProvider
→ SDKProvider → SyncProvider → ThemeProvider → LocalProvider
→ KeybindProvider → PromptStashProvider → DialogProvider
→ CommandProvider → FrecencyProvider → PromptHistoryProvider
→ PromptRefProvider → App
Key implication: Plugins do NOT have direct access to these providers. All interaction must go through events or hooks.
Critical Rule: NO console.log
The Problem
Using console.log in plugins causes TUI background text pollution. The text appears behind the TUI rendering, creating visual artifacts that persist until the terminal is cleared.
export const MyPlugin = async ({ client }) => {
console.log("Plugin initialized")
console.error("Error occurred")
}
The Solution
Use client.app.log() for structured logging that integrates with OpenCode's logging system:
export const MyPlugin: Plugin = async ({ client }) => {
await client.app.log({
service: "my-plugin",
level: "info",
message: "Plugin initialized",
extra: { version: "1.0.0" },
})
}
Log Levels
await client.app.log({
service: "my-plugin",
level: "info",
message: "Descriptive message",
extra: {
key: "value",
error: errorObj,
},
})
TUI Event Interaction
Plugins interact with the TUI through events, NOT direct component manipulation.
Available TUI Events
| Event | Purpose | Properties |
|---|
tui.prompt.append | Append text to prompt input | { text: string } |
tui.command.execute | Execute TUI command | { command: string } |
tui.toast.show | Show toast notification | { variant, title, message, duration } |
tui.session.select | Navigate to session | { sessionID: string } |
Using TUI Events
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
await Bus.publish(TuiEvent.ToastShow, {
variant: "info",
message: "Tool completed successfully",
})
Examples
Show Toast Notification:
"tool.execute.after": async (input, output) => {
await Bus.publish(TuiEvent.ToastShow, {
variant: "success",
title: "Done",
message: `Processed ${input.tool} successfully`,
duration: 3000,
})
}
Append to Prompt:
await Bus.publish(TuiEvent.PromptAppend, {
text: " --additional-flag"
})
Execute TUI Command:
await Bus.publish(TuiEvent.CommandExecute, {
command: "status_view"
})
Performance Guidelines
60 FPS Constraint
The TUI renders at 60 FPS. Plugins must not block the render loop.
Do's and Don'ts
"tool.execute.before": async (input, output) => {
const result = await reallyLongOperation()
}
"tool.execute.before": async (input, output) => {
const result = await Promise.race([
doWork(input),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 5000)
),
])
}
Message History Management
The TUI automatically truncates message history to the most recent 100 messages to prevent memory issues. Plugins should not rely on unlimited message history.
Plugin Loading Order
Understanding load priority helps avoid conflicts:
- Internal plugins (built-in)
- Built-in npm plugins (opencode-anthropic-auth, etc.)
- Global config (
~/.config/opencode/opencode.json)
- Project config (
opencode.json)
- Global plugin directory (
~/.config/opencode/plugins/)
- Project plugin directory (
.opencode/plugins/)
Key implication: Local plugins override npm packages with the same name.
Common Anti-Patterns
❌ Anti-Pattern 1: Direct Console Output
console.log("Debug info")
console.warn("Warning")
console.error("Error")
Fix: Use client.app.log()
❌ Anti-Pattern 2: Blocking Hooks
event: async (input) => {
await fetch("https://slow-api.com/data")
}
Fix: Use caching or background processing
❌ Anti-Pattern 3: Direct UI Access
import { SomeComponent } from "@/component"
SomeComponent.forceUpdate()
Fix: Use TUI events to trigger UI changes
❌ Anti-Pattern 4: Heavy Computation in Hooks
"tool.execute.after": async (input, output) => {
const result = processHugeDataset(output.output)
}
Fix: Offload to tools or use incremental processing
Best Practices
✅ Pattern 1: Structured Logging
try {
await riskyOperation()
} catch (error) {
await client.app.log({
service: "my-plugin",
level: "error",
message: "Operation failed",
extra: {
error: error.message,
stack: error.stack,
context: { sessionId: input.sessionID },
},
})
}
✅ Pattern 2: Non-Blocking Hooks
event: async (input) => {
if (input.event.type !== "tool.execute.after") return
await handleEvent(input)
}
✅ Pattern 3: TUI Event Feedback
async execute(args, context) {
try {
const result = await process(args)
await Bus.publish(TuiEvent.ToastShow, {
variant: "success",
message: "Completed successfully",
})
return result
} catch (error) {
await Bus.publish(TuiEvent.ToastShow, {
variant: "error",
message: `Error: ${error.message}`,
duration: 5000,
})
throw error
}
}
✅ Pattern 4: Graceful Degradation
"tool.execute.before": async (input, output) => {
try {
} catch (error) {
await client.app.log({
service: "my-plugin",
level: "warn",
message: "Hook processing failed, continuing",
extra: { error: String(error) },
})
}
}
Troubleshooting
TUI Pollution Symptoms
If you see text appearing behind the OpenCode interface:
- Check your plugin code for
console.log calls
- Check dependencies for console output
- Use
client.app.log() instead
Performance Issues
If the TUI feels laggy:
- Profile your hooks for blocking operations
- Check if you're processing too many events
- Consider caching expensive operations
Events Not Working
If TUI events don't trigger:
- Verify event name matches exactly (
tui.toast.show, etc.)
- Check event properties match the schema
- Ensure
Bus is imported from correct path
Resources
See opencode-plugin-compliance for hook system details.
See opencode-tool-compliance for tool development patterns.
See references/tui-architecture.md for detailed TUI architecture documentation.