Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ One origin means no CORS, no double auth, and SSE streaming included. **It works
| --- | --- |
| 🗂️ **Project picker** | Start page with known projects and recents. Open any folder by path or browse the server filesystem. Everything is scoped per project via opencode's `?directory=` API. |
| 💬 **Live chat** | SSE streaming with markdown, collapsible thinking, tool calls with input/output, per-step tokens & cost, timestamps, abort, a prompt queue, and permission and question prompts that survive reloads. Message actions: copy, read aloud, edit & resend, fork. |
| ⌨️ **Autocomplete like opencode** | `@` mentions files, folders and subagents (attached the way opencode does), `/` runs commands, `!` switches to shell mode. On phones the options row hides while the keyboard is open. |
| 🧠 **Model + think level** | Model picker across all providers with pricing & context details, think-level selector for reasoning models, agent picker. Persisted per project. |
| ⌨️ **Input like opencode's TUI** | `@` mentions files, folders and subagents (attached the way opencode does), `/` runs commands, `!` switches to shell mode, **Tab** cycles agents, **↑/↓** recall earlier prompts, **Esc Esc** stops a reply. **Paste screenshots or drop files** anywhere on the chat. Every option has a **?** that explains it and links to its settings. On phones the options row hides while the keyboard is open. |
| 🧠 **Model + think level** | Model picker across all providers with pricing & context details (long lists open a roomy searchable grid), think-level selector for reasoning models, agent picker. Persisted per project. Chat width: comfortable, wide or full. |
| 🧑‍🔧 **Agents** | View, create and edit agents: description, mode, model, prompt (drafted by AI if you like), built-in tools and **which MCP servers each agent may use**. [More →](docs/mcp.md#agents) |
| 🎓 **Skills** | Every skill opencode can load, with what it says, where it comes from, **quality checks**, and auto / ask / off per skill. Write new skills or **improve** any of them with AI; a **skill library** served by the web app overrides a skill of the same name. Also from the API and agent control. [More →](docs/mcp.md#skills) |
| 📊 **Usage & cost** | Group usage by session, day, hour, weekday, model, provider or agent; **real cost** next to the **list-price estimate**, so promotions and free tiers show what they save. |
| 🔌 **MCP manager** | Per-project and global MCP status, off / ask / auto modes per server and per tool, presets, and add remote or local servers from a catalog, a URL, a command line, pasted JSON or a form. **Per-conversation MCP selection** in the prompt box. Tool lists are discovered from the servers themselves over **Streamable HTTP, legacy SSE or stdio**. Every server has a **details view**: why it fails and how to fix it, its tools with full descriptions and parameters, an **editor** that never exposes stored secrets, and a step-by-step **connection test**. [More →](docs/mcp.md) |
| 🤖 **Agent control** | One click registers this app's MCP server with opencode, so **the chat can manage opencode itself**: test, add and reconnect MCP servers, reload the config (after running replies finish), read and prompt other chats, see task lists, and manage schedules. State-changing tools ask first. [More →](docs/mcp.md#agent-control) |
| 🤖 **Agent control** | On by default: this app's MCP server is registered with opencode, so **the chat can manage opencode itself**: test, add, switch and reconnect MCP servers (applied at once), reload the config and **continue the chat afterwards**, run any agent in a **sub-chat**, read and prompt other chats, see task lists, and manage schedules. State-changing tools ask first. [More →](docs/mcp.md#agent-control) |
| ⏰ **Scheduled prompts** | Cron-timed prompts per project (every weekday at 08:00, every 30 minutes…), in a new session each run or always the same one, with run history and links to the chats. [More →](docs/mcp.md#scheduled-prompts) |
| 🧩 **MCP UI apps** | Tool results render live in sandboxed iframes: **MCP Apps** (SEP-1865 JSON-RPC host, theme & display-mode aware), mcp-ui **raw HTML**, **external URLs** and **remote-dom** components. Apps can move to a side panel or fullscreen, with shareable `?app=` links. Tools aren't re-run silently: only read-only ones are, and the rest get a **Render UI** button. A built-in demo server (`/mcp-demo`) showcases every flavour. [More →](docs/mcp.md#mcp-ui-apps) |
| 🔑 **Provider config from the UI** | "Configure providers…" right inside the model dropdown adds or updates API keys without touching the server. Keys are never sent back to the browser. |
| 📱 **Mobile first** | The desktop sidebar (resizable, collapsible to an icon rail, optional projects panel) becomes a slideover on phones. There are touch-sized controls, safe-area aware layouts and a prompt box that stays above the keyboard, so you can continue any session from your phone. Installable as a PWA. |
| 📱 **Mobile first** | The desktop sidebar (resizable, collapsible to an icon rail, optional projects panel that lists each project's starred, recent, unread and running chats, all configurable) becomes a slideover on phones. There are touch-sized controls, safe-area aware layouts and a prompt box that stays above the keyboard, so you can continue any session from your phone. Installable as a PWA. |
| 🔄 **Any opencode version** | Works with every opencode 1.x server (1.0 → 1.19) and with **opencode 2.x** (`@opencode/cli`), which only speaks the new **v2 API** (`/api/*`): the web app detects which one it talks to and translates, so the UI behaves the same, MCP, config and subagents included. Features a server lacks are hidden instead of failing. [More →](docs/compatibility.md) |
| 🕸️ **Subagent per MCP** | One switch creates a specialist subagent per MCP server (only that server's tools) and an `@orchestrator` that knows what to send each one, keeping its own context free of MCP tools. Summaries are editable and stay in sync as servers change. [More →](docs/mcp.md#subagent-per-mcp) |
| 🤖 **Subagents** | When the agent hands work to a subagent, the chat shows a live card (agent, steps, result) with a link into the subagent's own conversation and back; the sidebar nests subagent sessions under their parent. |
Expand Down Expand Up @@ -196,8 +199,9 @@ Tools:
| area | tools |
| --- | --- |
| overview | `server_info` (version, protocol, features, busy sessions, schedules, last reload) |
| chats | `list_projects`, `create_project`, `list_sessions`, `session_status`, `read_session`, `get_messages`, `create_session`, `send_prompt` (waits for the reply, or `wait: false`), `abort_session`, `fork_session`, `list_tasks` |
| opencode | `list_models`, `list_agents`, `list_commands`, `list_skills`, `get_config` (redacted), `reload_opencode` |
| chats | `list_projects`, `create_project`, `list_sessions`, `session_status`, `current_session`, `read_session`, `get_messages`, `create_session`, `send_prompt` (waits for the reply, or `wait: false`), `run_subagent` (any agent in a sub-chat), `abort_session`, `fork_session`, `list_tasks` |
| opencode | `list_models`, `list_agents`, `list_commands`, `get_config` (redacted), `reload_opencode` |
| skills | `list_skills`, `get_skill` (content + checks), `save_skill` (library), `delete_skill`, `set_skill_permission`, `improve_skill` (AI) |
| MCP | `list_mcp` (status, errors with a fix, tools), `mcp_status`, `test_mcp`, `add_mcp`, `connect_mcp`, `disconnect_mcp`, `set_mcp_mode`, `set_tool_mode`, `mcp_subagents`, `configure_mcp_subagents` |
| schedules | `list_schedules`, `create_schedule`, `update_schedule`, `delete_schedule`, `run_schedule` |

Expand All @@ -220,6 +224,7 @@ Read-only tools carry `readOnlyHint`. Register the same server **with opencode i
| `NUXT_PUBLIC_DEMO_MCP_URL` | *(browser origin)* | URL at which opencode reaches the built-in `/mcp-demo` server (`http://web:3000/mcp-demo` in compose) |
| `NUXT_MCP_LOCAL_DISCOVERY` | `always` | `always` / `same-host` / `never`: whether the web app spawns local MCP servers to list their tools |
| `NUXT_SELF_URL` | *(origin of the demo URL, else the request)* | URL at which opencode reaches this app; agent control registers `<url>/mcp` (`http://web:3000` in compose) |
| `NUXT_AGENT_CONTROL` | `auto` | `auto` registers agent control in opencode's global config once (switching it off is respected); `off` never does |
| `NUXT_SCHEDULER` | `on` | `off` stops firing scheduled prompts (e.g. a second replica on the same data volume) |
| `TZ` | *(container default, UTC)* | time zone of scheduled prompts, e.g. `Europe/Madrid` |

Expand Down
86 changes: 71 additions & 15 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,29 @@ body {

/* ---- motion system ---- */

/* page transitions */
.oc-page-enter-active,
/* page transitions: a short fade only. The project shell (sidebar, header)
stays mounted while its content changes, so nothing around the content
moves; old content leaves fast so the new one isn't kept waiting */
.oc-page-enter-active {
transition: opacity 0.14s ease-out;
}
.oc-page-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
transition: opacity 0.08s ease-in;
}
.oc-page-enter-from {
.oc-page-enter-from,
.oc-page-leave-to {
opacity: 0;
transform: translateY(6px);
}
.oc-page-leave-to {

/* cross-fade of two contents stacked in the same box (sidebar <-> rail):
both are absolutely positioned by their parent, so no layout shift */
.oc-xfade-enter-active,
.oc-xfade-leave-active {
transition: opacity 0.16s ease;
}
.oc-xfade-enter-from,
.oc-xfade-leave-to {
opacity: 0;
transform: translateY(-4px);
}

/* vertical collapse (options panels, tool details, reasoning) */
Expand Down Expand Up @@ -222,9 +233,6 @@ body {
.oc-tile {
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
}
.oc-tile:hover {
transform: translateY(-1px);
}
.oc-tile:active {
transform: scale(0.96);
}
Expand Down Expand Up @@ -262,17 +270,17 @@ body {
animation: oc-send-up 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* interactive rows/buttons: smooth hover feedback */
/* interactive rows/buttons: smooth hover feedback (colour only: rows that
shift sideways under the pointer read as jitter in long lists) */
.oc-row {
transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.oc-row:hover {
transform: translateX(2px);
transition: background-color 0.15s ease, color 0.15s ease;
}

@media (prefers-reduced-motion: reduce) {
.oc-page-enter-active,
.oc-page-leave-active,
.oc-xfade-enter-active,
.oc-xfade-leave-active,
.oc-collapse-enter-active,
.oc-collapse-leave-active,
.oc-slide-enter-active,
Expand Down Expand Up @@ -406,3 +414,51 @@ body {
color: var(--ui-text-highlighted);
border-color: var(--ui-border-inverted);
}

/* filled variant of an outline (lucide) icon, e.g. a favourite star */
.oc-filled path,
.oc-filled polygon,
.oc-filled circle {
fill: currentColor;
}

/* long-press label on touch screens (plugins/longpress-tip.client.ts) */
.oc-touch-tip {
position: fixed;
z-index: 100;
max-width: min(18rem, calc(100vw - 1rem));
padding: 0.3rem 0.55rem;
border-radius: 0.25rem;
background: var(--ui-bg-inverted);
color: var(--ui-bg);
font-size: 0.75rem;
line-height: 1.3;
pointer-events: none;
animation: oc-fade-up 0.14s ease both;
}
@media (pointer: coarse) {
/* a long press shows the label above instead of iOS's link preview */
button,
a[aria-label] {
-webkit-touch-callout: none;
}
}

/* brief highlight when a value changes from the keyboard (Tab → agent) */
@keyframes oc-flash {
0% { background-color: var(--ui-bg-inverted); color: var(--ui-bg); }
100% { background-color: var(--ui-bg-elevated); }
}
.oc-flash {
animation: oc-flash 0.6s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.oc-flash { animation: none; }
}

/* MCP apps and data tables in the chat: when the column is narrower than the
scroller (@container), they grow into the free space on both sides, 1rem
from its edges; never narrower than the column */
.oc-wide-apps .oc-breakout {
margin-inline: min(0px, calc(50% - 50cqw + 1rem));
}
9 changes: 5 additions & 4 deletions app/components/AgentControlCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ async function install(enabled = true) {
}

const TOOLS = [
['MCP', 'list, test, add, reconnect, set modes'],
['Reload', 'one project or all of opencode, after replies finish'],
['MCP', 'list, test, add, switch on/off at once, set modes'],
['Reload', 'one project or all of opencode; the chat continues by itself afterwards'],
['Chats', 'list, read and prompt other sessions, abort'],
['Sub-chats', 'run any agent on a task in a sub-chat (run_subagent)'],
['Tasks', 'todo lists of busy and recent sessions'],
['Schedules', 'create, run and manage scheduled prompts'],
['Info', 'version, models, agents, commands, skills, config']
Expand All @@ -81,7 +82,7 @@ const TOOLS = [
</button>
<CollapseTransition>
<div v-if="expanded" class="px-3 pb-3 space-y-2.5">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
<div v-for="[title, text] in TOOLS" :key="title" class="rounded-sm bg-elevated/70 px-2 py-1.5">
<div class="text-[11px] font-medium">{{ title }}</div>
<div class="text-[10px] text-dimmed leading-tight">{{ text }}</div>
Expand Down Expand Up @@ -123,7 +124,7 @@ const TOOLS = [
label="Tools"
@click="emit('details', state.name)"
/>
<span class="oc-note text-[10px] text-dimmed">Saved in the global config; new chats get the tools.</span>
<span class="oc-note text-[10px] text-dimmed">On by default, saved in the global config: every chat lists it among its MCP servers. Switching it off here is remembered.</span>
</div>
</div>
</CollapseTransition>
Expand Down
9 changes: 9 additions & 0 deletions app/components/HScroll.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ function update() {
canRight.value = node.scrollLeft + node.clientWidth < node.scrollWidth - 4
}

const mask = computed(() => {
if (!canLeft.value && !canRight.value) return undefined
const left = canLeft.value ? 'transparent 0, #000 2.5rem' : '#000 0'
const right = canRight.value ? '#000 calc(100% - 2.5rem), transparent 100%' : '#000 100%'
return `linear-gradient(to right, ${left}, ${right})`
})

function scrollBy(direction: 1 | -1) {
el.value?.scrollBy({ left: direction * el.value.clientWidth * 0.8, behavior: 'smooth' })
}
Expand Down Expand Up @@ -56,9 +63,11 @@ onBeforeUnmount(() => observer?.disconnect())

<template>
<div class="relative group/scroll">
<!-- edges fade out where more cards hide, so the overflow is visible -->
<div
ref="el"
class="flex gap-2 overflow-x-auto select-none"
:style="{ maskImage: mask, WebkitMaskImage: mask }"
@scroll.passive="update"
@pointerdown="onPointerDown"
>
Expand Down
57 changes: 57 additions & 0 deletions app/components/HelpTip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
// A small "?" next to a setting: opens a short explanation with buttons to
// the place where the setting is managed (providers, agents, MCP servers…).
export interface HelpAction {
label: string
icon?: string
to?: string
onClick?: () => void
}

const props = defineProps<{
title: string
/** markdown */
text: string
actions?: HelpAction[]
}>()

const open = ref(false)

function run(action: HelpAction) {
open.value = false
action.onClick?.()
}
</script>

<template>
<UButton
icon="i-lucide-circle-help"
size="xs"
color="neutral"
variant="link"
class="p-0 align-middle text-dimmed hover:text-highlighted relative oc-tap-zone"
:aria-label="`About: ${props.title}`"
@click.stop.prevent="open = true"
/>
<UModal v-model:open="open" :title="title">
<template #body>
<Markdown :text="text" class="text-sm" />
</template>
<template v-if="actions?.length" #footer>
<div class="oc-actions">
<UButton variant="ghost" color="neutral" label="Close" @click="open = false" />
<span class="flex-1 oc-spacer" />
<UButton
v-for="action in actions"
:key="action.label"
color="primary"
variant="soft"
:icon="action.icon"
:label="action.label"
:to="action.to"
@click="run(action)"
/>
</div>
</template>
</UModal>
</template>
Loading
Loading