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
49 changes: 47 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
branches:
- main
- dev
types:
- opened
- synchronize
Expand Down Expand Up @@ -33,13 +34,20 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: npm

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: npm ci

- name: Cache Neovim reference binary
uses: actions/cache@v4
with:
path: node_modules/.cache/neovim
key: neovim-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/helpers/neovim.ts') }}

- name: Typecheck
run: bun run typecheck

Expand All @@ -50,10 +58,14 @@ jobs:
run: bun run test

e2e:
name: E2E tests
name: E2E shard ${{ matrix.shard }}
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]

steps:
- name: Checkout code
Expand All @@ -63,6 +75,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: npm

- name: Setup Bun
uses: oven-sh/setup-bun@v2
Expand All @@ -75,13 +88,45 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Cache E2E binaries
uses: actions/cache@v4
with:
path: |
node_modules/.cache/opencode
node_modules/.cache/neovim
key: e2e-binaries-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/e2e/support/opencode.ts', 'test/helpers/neovim.ts') }}-${{ github.sha }}
restore-keys: |
e2e-binaries-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/e2e/support/opencode.ts', 'test/helpers/neovim.ts') }}-

- name: Test source and npm package in OpenCode
env:
E2E_WORKERS: "2"
E2E_SHARD: ${{ matrix.shard }}/4
run: bun run test:e2e

- name: Upload timings
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-timings-${{ matrix.shard }}
path: test-results/e2e/*/result.json
retention-days: 7

- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-diagnostics
name: e2e-diagnostics-${{ matrix.shard }}
path: test-results/e2e/
retention-days: 7

# Branch protection requires one "E2E tests" check, whatever the shard count.
e2e-result:
name: E2E tests
if: ${{ !cancelled() && github.event.pull_request.draft == false }}
needs: e2e
runs-on: ubuntu-latest

steps:
- name: Check E2E shards
run: test "${{ needs.e2e.result }}" = success
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Press `Esc` to enter normal mode and `i` to type again in insert mode.
In a session, press `s` from normal mode to browse messages and tools. Use `j`/`k`
to navigate, `Enter` to open an item, and `s` to return to the prompt.

Subagent entries open the full saved response, with Vim navigation, selection,
and copying. Background completion entries use the same reader.

Use `/vim` to toggle the plugin on or off.

## Documentation
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ All options below belong inside `options.vim`.

| Option | Default | Purpose |
| --- | --- | --- |
| `defaultMode` | `"insert"` | Starting Vim mode; use `"normal"` to start in normal mode |
| `defaultMode` | `"insert"` | Vim mode on startup and when switching chats; use `"normal"` to start in normal mode |
| `sessionKey` | `"s"` | Single key to enter and leave session mode |
| `modeIndicator` | `"full"` | Mode label in the prompt footer: `"full"` (`INSERT`, `NORMAL`, ...) or `"none"` to hide it |
| `diffView` | `"after"` | Starting edit/patch view: `"after"`, `"before"`, or `"diff"`; added/deleted files use the available side |
| `keymapTimeout` | `500` | Milliseconds to wait for the rest of a custom mapping |
| `keymaps` | `{}` | Custom mappings, grouped by mode |
Expand Down
27 changes: 21 additions & 6 deletions src/plugin.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { Plugin } from "@opencode/plugin/tui"
import type { Context } from "@opencode/plugin/tui/context"
import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core"
import { KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core"
import { createEffect, createSignal, onCleanup, untrack } from "solid-js"
import type { EditorContext } from "./vim/editor"
import { createVimConfig } from "./vim/config"
Expand Down Expand Up @@ -68,6 +68,7 @@ function VimHost(props: { context: Context }) {
if (enabled() && terminal.focused()) return <terminal.Status />
if (footer.mode !== "normal") return null
if (session.active() && !dialogFocused()) return <session.Status />
if (config.modeIndicator === "none") return null
return (
<VimStatus
mode={() => (dialogFocused() ? dialogState.mode() : state.mode())}
Expand Down Expand Up @@ -224,11 +225,13 @@ function VimHost(props: { context: Context }) {
props.context.renderer.keyInput.prependListener("paste", onPaste)
props.context.renderer.on("focused_editor", onFocus)
createEffect(() => syncCursor())
let route = props.context.ui.router.current()
let route = routeKey(props.context.ui.router.current())
createEffect(() => {
const next = props.context.ui.router.current()
if (next !== route && pendingKeys) onFocus()
const next = routeKey(props.context.ui.router.current())
if (next === route) return
route = next
resetMode()
if (pendingKeys) onFocus()
})
onCleanup(() => {
disposed = true
Expand All @@ -246,6 +249,15 @@ function VimHost(props: { context: Context }) {

return null

function resetMode() {
untrack(() => {
if (state.mode() === config.defaultMode) return
vimee.suspend()
state.setMode(config.defaultMode)
syncCursor(true)
})
}

function syncCursor(force = false) {
const input = props.context.renderer.currentFocusedEditor
const kind = inputKind(props.context)
Expand Down Expand Up @@ -296,8 +308,7 @@ function createEditorContext(context: Context): EditorContext {
},
setText(text) {
const input = context.renderer.currentFocusedEditor
if (input instanceof InputRenderable) input.value = text
else if (input) editInput(input, text, context.renderer.widthMethod)
if (input) editInput(input, text, context.renderer.widthMethod)
},
submit() {
if (inputKind(context) === "dialog") {
Expand All @@ -321,6 +332,10 @@ function createEditorContext(context: Context): EditorContext {
}
}

function routeKey(route: ReturnType<Context["ui"]["router"]["current"]>) {
return route.type === "session" ? `session:${route.sessionID}` : route.type
}

function inputKind(context: Context): "prompt" | "dialog" | undefined {
if (context.keymap.mode.current() === SESSION_MODE) return
if (!context.renderer.currentFocusedEditor) return
Expand Down
7 changes: 7 additions & 0 deletions src/readers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ import { ReadReader } from "./read"
import { readSnapshot } from "./read/data"
import { DiffReader } from "./diff"
import { diffSnapshot } from "./diff/data"
import { SubagentReader } from "./subagent"
import type { ReaderProps } from "./types"

export function Reader(props: ReaderProps) {
props.context.ui.dialog.set({ size: "large", centered: true })
const source = props.message.source
if (
(source?.type === "tool" && source.name === "subagent") ||
(source?.type === "synthetic" && source.metadata?.source === "subagent")
) {
return <SubagentReader {...props} source={source} />
}
if (
source?.type === "shell" ||
(source?.type === "tool" && source.name === "shell") ||
Expand Down
75 changes: 75 additions & 0 deletions src/readers/subagent/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Context } from "@opencode/plugin/tui/context"
import type { TranscriptSource } from "../../transcript-items"

export type SubagentSource = Extract<TranscriptSource, { type: "tool" | "synthetic" }>
type Message = ReturnType<Context["data"]["session"]["message"]["list"]>[number]

export function subagentSnapshot(
source: SubagentSource,
messages: readonly Message[] = [],
): { description: string; status: string; response: string } {
if (source.type === "synthetic") {
const metadata = source.metadata ?? {}
const description = source.description ?? ""
const prefix = `<subagent sessionID="${metadata.childID}" state="${metadata.state}" description="${description}">\n`
return {
description,
status: metadata.state === "error" ? "Failed" : metadata.state === "cancelled" ? "Cancelled" : "Completed",
response: unwrap(source.text, prefix),
}
}

const state = source.state
if (state.status === "streaming") return { description: "", status: "Delegating…", response: "No response yet." }
const description = typeof state.input.description === "string" ? state.input.description : ""
const metadata = state.metadata ?? {}
if (state.status === "completed" && metadata.status === "running") {
// Background replies are saved as separate notices in the parent session.
// Use the first matching completion, not a later continuation's response.
for (const message of messages) {
if (message.type === "assistant") {
for (const part of message.content) {
if (
part.type === "tool" &&
part.name === "subagent" &&
part.time.created > source.time.created &&
part.state.status !== "streaming" &&
part.state.input.sessionID === metadata.sessionID
)
return { description, status: "Backgrounded", response: "No saved response for this subagent call." }
}
}
if (
message.type === "synthetic" &&
message.metadata?.source === "subagent" &&
typeof metadata.sessionID === "string" &&
message.metadata.childID === metadata.sessionID &&
message.description === description &&
message.time.created >= source.time.created
)
return subagentSnapshot(message)
}
return {
description,
status: "Backgrounded",
response: "No saved response yet. Reopen after the subagent finishes.",
}
}
if (state.status === "running") return { description, status: "Running", response: "No response yet." }

const prefix = `<subagent sessionID="${metadata.sessionID}" state="completed">\n`
let response = ""
for (const part of state.content ?? []) {
if (part.type !== "text") continue
if (response) response += "\n\n"
response += unwrap(part.text, prefix)
}
if (state.status === "error") return { description, status: "Failed", response: response || state.error.message }
return { description, status: "Completed", response: response || "Subagent completed without a text response." }
}

function unwrap(text: string, prefix: string) {
const suffix = "\n</subagent>"
if (text.startsWith(prefix) && text.endsWith(suffix)) return text.slice(prefix.length, -suffix.length)
return text
}
33 changes: 33 additions & 0 deletions src/readers/subagent/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** @jsxImportSource @opentui/solid */
import { Show } from "solid-js"
import { subagentSnapshot, type SubagentSource } from "./data"
import { TextReader } from "../text"
import type { ReaderProps } from "../types"

export function SubagentReader(props: ReaderProps & { source: SubagentSource }) {
const source = props.source
const messages =
source.type === "tool" && source.state.status === "completed" && source.state.metadata?.status === "running"
? props.context.data.session.message.list(props.sessionID)
: []
const snapshot = subagentSnapshot(source, messages)
const theme = () => props.context.theme.surface("dialog")
return (
<TextReader
{...props}
title="Subagent response"
label="Response"
status={snapshot.status}
text={snapshot.response}
details={
<Show when={snapshot.description}>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme().text.muted} truncate>
{snapshot.description}
</text>
</box>
</Show>
}
/>
)
}
14 changes: 11 additions & 3 deletions src/transcript-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,25 @@ function parts(messages: Message[]) {
) {
result.push({ id: `footer:${message.id}`, messageID: message.id, kind: "footer", author: "", text: "" })
}
} else if (message.type === "synthetic" && message.metadata?.source === "shell" && message.description?.trim()) {
} else if (
message.type === "synthetic" &&
(message.metadata?.source === "shell" || message.metadata?.source === "subagent") &&
message.description?.trim()
) {
const state = message.metadata.state
const status = state === "completed" ? "finished" : state === "error" ? "failed" : (state ?? "finished")
const shell = message.metadata.source === "shell"
const agent = typeof message.metadata.agent === "string" ? message.metadata.agent : "Subagent"
const actor = shell ? "Shell" : agent.replace(/\b\w/g, (letter) => letter.toUpperCase())
const description = shell ? message.description.replace(/\s+/g, " ").trim() : message.description
result.push({
id: message.id,
messageID: message.id,
kind: "message",
author: "Shell",
author: actor,
text: message.text,
source: message,
label: `${state === "completed" ? "↳" : "!"} Shell ${status} · ${message.description.replace(/\s+/g, " ").trim()}`,
label: `${state === "completed" ? "↳" : "!"} ${actor} ${status} · ${description}`,
})
} else {
result.push({
Expand Down
6 changes: 6 additions & 0 deletions src/vim/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type VimConfig = {
defaultMode: VimMode
diffView: DiffView
sessionKey: string
modeIndicator: ModeIndicator
keymapTimeout: number
cursorStyles: Record<VimMode, VimCursorStyle>
debug: boolean
Expand All @@ -21,6 +22,7 @@ export type VimOptions = {
defaultMode?: VimMode
diffView?: DiffView
sessionKey?: string
modeIndicator?: ModeIndicator
keymapTimeout?: number
cursorStyles?: Partial<Record<VimMode, VimCursorStyle>>
debug?: boolean
Expand All @@ -29,6 +31,7 @@ export type VimOptions = {
}

export type DiffView = "after" | "before" | "diff"
export type ModeIndicator = "full" | "none"
export type SessionAction = "switch-panel" | "passthrough"
export type VimKeymaps = Partial<Record<VimMode, Record<string, VimMappedAction>>> & {
session?: Record<string, SessionAction>
Expand All @@ -49,6 +52,7 @@ export function createVimConfig(options: unknown): VimConfig {
defaultMode: input.defaultMode ?? "insert",
diffView: input.diffView ?? "after",
sessionKey: input.sessionKey ?? "s",
modeIndicator: input.modeIndicator ?? "full",
keymapTimeout: Math.max(0, input.keymapTimeout ?? 500),
cursorStyles: {
insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
Expand All @@ -75,6 +79,8 @@ function readOptions(options: unknown): VimOptions {
? source.diffView
: undefined,
sessionKey: readSessionKey(source.sessionKey),
modeIndicator:
source.modeIndicator === "full" || source.modeIndicator === "none" ? source.modeIndicator : undefined,
keymapTimeout: readNumber(source.keymapTimeout),
cursorStyles: readCursorStyles(source.cursorStyles),
debug: typeof source.debug === "boolean" ? source.debug : undefined,
Expand Down
Loading
Loading