Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/opencode-config/plugin/codenomad.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
import { createContextPruneTools } from "./lib/context-prune"

let voiceModeEnabled = false

export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
const contextPruneTools = createContextPruneTools(config)

await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
Expand All @@ -29,6 +31,7 @@ export async function CodeNomadPlugin(input: PluginInput) {
return {
tool: {
...backgroundProcessTools,
...contextPruneTools,
},
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
Expand Down
87 changes: 87 additions & 0 deletions packages/opencode-config/plugin/lib/context-prune.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"

type ContextPruneRouteRequest = {
sessionID: string
indices: number[]
}

const MAX_SELECTABLE_INDICES = 1000

export function createContextPruneTools(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)

return {
select_context_range: tool({
description: "Stage context-prune badge selections in the UI using 1-based positions in the current visible pruneable conversation order. The model usually sees conversation entries as message ids like m0001, m0002, m0003; treat those as the visible chronological sequence and convert them into 1-based positions for this tool. Example: if the pruneable visible sequence is m0007, m0008, m0009, m0010, then their positions for this tool are 1, 2, 3, 4. A single call can include multiple individual positions and multiple ranges, such as 1,3-5,8,10-12. Call this tool once with the full final selection because later calls replace the staged selection.",
args: {
range: tool.schema.string().describe("Full final selection to stage in one call, using 1-based positions in the current pruneable visible conversation order. Example: if the pruneable visible sequence is m0007, m0008, m0009, m0010, use 1,2,3,4 respectively, so selecting m0008 through m0010 would be 2-4. Supports multiple single positions and inclusive ranges combined with commas, for example: 1,3-5,8,10-12. Repeated tool calls replace the previous staged selection instead of merging with it."),
},
async execute(args, context) {
const indices = parseRange(args.range)
await requester.requestVoid("/context-prune/select", {
method: "POST",
body: JSON.stringify({
sessionID: context.sessionID,
indices,
} satisfies ContextPruneRouteRequest),
})

return ""
},
}),
}
}

function parseRange(input: string): number[] {
const raw = (input ?? "").trim()
if (!raw) {
throw new Error("Range is required")
}

const values = new Set<number>()
const tokens = raw.split(",")

for (const token of tokens) {
const part = token.trim()
if (!part) {
throw new Error("Range contains an empty entry")
}

const rangeMatch = part.match(/^(\d+)-(\d+)$/)
if (rangeMatch) {
const start = Number(rangeMatch[1])
const end = Number(rangeMatch[2])
if (start < 1 || end < 1) {
throw new Error(`Invalid range: ${part}`)
}
if (start > end) {
throw new Error(`Invalid range: ${part} (start must be less than or equal to end)`)
}
for (let index = start; index <= end; index += 1) {
values.add(index)
}
continue
}

if (!/^\d+$/.test(part)) {
throw new Error(`Invalid range token: ${part}`)
}

const value = Number(part)
if (value < 1) {
throw new Error(`Invalid index: ${part}`)
}
values.add(value)
}

const indices = Array.from(values).sort((left, right) => left - right)
if (indices.length === 0) {
throw new Error("Range did not resolve to any indices")
}
if (indices.length > MAX_SELECTABLE_INDICES) {
throw new Error(`Range selects too many indices (${indices.length}). Maximum allowed is ${MAX_SELECTABLE_INDICES}.`)
}

return indices
}
22 changes: 22 additions & 0 deletions packages/server/src/server/routes/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const VoiceModeStateSchema = z.object({
connectionId: z.string().trim().min(1),
})

const ContextPruneSelectSchema = z.object({
sessionID: z.string().trim().min(1),
indices: z.array(z.number().int().positive()).min(1),
})

export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
Expand Down Expand Up @@ -92,6 +97,23 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
return
}

if (normalized === "context-prune/select" && request.method === "POST") {
const parsed = ContextPruneSelectSchema.parse(request.body ?? {})
deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: {
type: "context.prune.select",
properties: {
sessionID: parsed.sessionID,
indices: parsed.indices,
},
},
})
reply.code(204).send()
return
}

reply.code(404).send({ error: "Unknown plugin endpoint" })
}

Expand Down
55 changes: 55 additions & 0 deletions packages/ui/src/components/message-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState } from "./virtual-follow-list"
import { useConfig } from "../stores/preferences"
import { consumeContextPruneSelection, getPendingContextPruneSelection } from "../stores/context-prune-selection"
import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useI18n } from "../lib/i18n"
Expand Down Expand Up @@ -555,6 +556,50 @@ export default function MessageSection(props: MessageSectionProps) {
setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id)))
}

const stageDeleteSelectionByIndices = (indices: number[]) => {
if (!Array.isArray(indices) || indices.length === 0) {
clearDeleteMode()
return
}

// External prune-selection commands address the current deletable badge
// order using 1-based indices from oldest to newest.
const deletableSegments = timelineSegments().filter((segment) => isMessageDeletable(segment.messageId))
if (deletableSegments.length === 0) {
clearDeleteMode()
return
}

const selectedSegmentIds = new Set<string>()
for (const rawIndex of indices) {
const normalizedIndex = Math.trunc(rawIndex)
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 1) continue
const segment = deletableSegments[normalizedIndex - 1]
if (!segment) continue
selectedSegmentIds.add(segment.id)
}

if (selectedSegmentIds.size === 0) {
return
}

setSelectionMode("all")
setSelectedTimelineIds(selectedSegmentIds)

const selectedMessageIds = new Set<string>()
for (const segment of deletableSegments) {
if (!selectedSegmentIds.has(segment.id) || segment.type === "tool") continue
selectedMessageIds.add(segment.messageId)
}
setSelectedForDeletion(selectedMessageIds)

const orderedSelectedSegments = deletableSegments.filter((segment) => selectedSegmentIds.has(segment.id))
const anchorSegment = orderedSelectedSegments[orderedSelectedSegments.length - 1]
setLastSelectionAnchorId(anchorSegment?.id ?? null)
setActiveSegmentId(anchorSegment?.id ?? null)
setIsDeleteMenuOpen(false)
}

const deleteSelectedMessages = async () => {
const selected = deleteMessageIds()
const toolParts = deleteToolParts()
Expand Down Expand Up @@ -659,6 +704,16 @@ export default function MessageSection(props: MessageSectionProps) {
})
}

createEffect(() => {
const pendingCommand = getPendingContextPruneSelection(props.sessionId)
if (!pendingCommand) return

if (timelineSegments().length === 0) return

stageDeleteSelectionByIndices(pendingCommand.indices)
consumeContextPruneSelection(props.sessionId)
})

createEffect(() => {
const api = listApi()
if (!api) return
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/lib/sse-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createSignal } from "solid-js"
import { stageContextPruneSelection } from "../stores/context-prune-selection"
import {
MessageUpdateEvent,
MessageRemovedEvent,
Expand Down Expand Up @@ -61,6 +62,14 @@ interface ServerInstanceDisposedEvent {
}
}

interface ContextPruneSelectEvent {
type: "context.prune.select"
properties: {
sessionID: string
indices: number[]
}
}

type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
Expand All @@ -82,6 +91,7 @@ type SSEEvent =
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| ServerInstanceDisposedEvent
| ContextPruneSelectEvent
| { type: string; properties?: Record<string, unknown> }

type ConnectionStatus = InstanceStreamStatus
Expand Down Expand Up @@ -184,6 +194,14 @@ class SSEManager {
case "server.instance.disposed":
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
break
case "context.prune.select": {
const payload = event as ContextPruneSelectEvent
stageContextPruneSelection({
sessionId: payload.properties.sessionID,
indices: payload.properties.indices,
})
break
}
default:
log.warn("Unknown SSE event type", { type: event.type })
}
Expand Down
55 changes: 55 additions & 0 deletions packages/ui/src/stores/context-prune-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createSignal } from "solid-js"

export interface ContextPruneSelectionCommand {
sessionId: string
indices: number[]
}

const [pendingCommands, setPendingCommands] = createSignal<Map<string, ContextPruneSelectionCommand>>(new Map())

export function stageContextPruneSelection(command: ContextPruneSelectionCommand): void {
if (!command.sessionId) {
throw new Error("Context prune selection requires a sessionId")
}

const normalizedIndices = Array.from(
new Set(
command.indices
.map((value) => Math.trunc(value))
.filter((value) => Number.isFinite(value) && value > 0),
),
).sort((left, right) => left - right)

if (normalizedIndices.length === 0) {
throw new Error("Context prune selection requires at least one positive index")
}

setPendingCommands((prev) => {
const next = new Map(prev)
next.set(command.sessionId, {
sessionId: command.sessionId,
indices: normalizedIndices,
})
return next
})
}

export function getPendingContextPruneSelection(sessionId: string): ContextPruneSelectionCommand | null {
if (!sessionId) return null
return pendingCommands().get(sessionId) ?? null
}

export function consumeContextPruneSelection(sessionId: string): ContextPruneSelectionCommand | null {
if (!sessionId) return null
const command = pendingCommands().get(sessionId) ?? null
if (!command) return null

setPendingCommands((prev) => {
if (!prev.has(sessionId)) return prev
const next = new Map(prev)
next.delete(sessionId)
return next
})

return command
}
Loading