Skip to content
Draft
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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,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 @@ -63,6 +70,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,9 +83,29 @@ 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"
run: bun run test:e2e

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

- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v4
Expand Down
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
5 changes: 2 additions & 3 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 @@ -296,8 +296,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 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
5 changes: 4 additions & 1 deletion src/vim/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Input = {
cursorOffset: number
setSelection: (start: number, end: number) => void
insertText: (text: string) => void
deleteChar: () => unknown
clearSelection: () => unknown
}

Expand Down Expand Up @@ -49,7 +50,9 @@ export function editInput(input: Input, value: string, widthMethod: WidthMethod)

if (start === end) input.cursorOffset = startOffset
else input.setSelection(startOffset, startOffset + charToDisplay(before.slice(start, end), end - start, widthMethod))
input.insertText(value.slice(start, valueEnd))
const inserted = value.slice(start, valueEnd)
if (inserted) input.insertText(inserted)
else input.deleteChar()
}

function segmentAt(text: string, segments: Intl.Segments, index: number) {
Expand Down
6 changes: 5 additions & 1 deletion src/vim/editor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LineInfo, RGBA, WidthMethod } from "@opentui/core"
import type { EditBuffer, LineInfo, RGBA, WidthMethod } from "@opentui/core"

export type EditorContext = {
input: () => EditorInput | undefined
Expand All @@ -15,11 +15,15 @@ export type EditorInput = {
isDestroyed?: boolean
cursorOffset: number
plainText: string
editBuffer?: EditBuffer
undo?: () => boolean
redo?: () => boolean
visualCursor?: { visualCol: number }
editorView?: {
getLogicalLineInfo?: () => LineInfo
setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void
resetSelection?: () => void
deleteSelectedText?: () => void
}
selectionBg?: RGBA
selectionFg?: RGBA
Expand Down
Loading