Compare commits
7 commits
dev
...
nxl/compac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80cbafa3c3 | ||
|
|
c7a17aea4b | ||
|
|
b5c6763d11 | ||
|
|
4f191187b3 | ||
|
|
cfcc3d973b | ||
|
|
25f92bba8d | ||
|
|
fbce48f597 |
9 changed files with 112 additions and 31 deletions
|
|
@ -543,8 +543,22 @@ export function Autocomplete(props: {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function insertSlashCommand(name: string) {
|
||||||
|
const newText = `/${name} `
|
||||||
|
const cursor = props.input().logicalCursor
|
||||||
|
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||||
|
props.input().insertText(newText)
|
||||||
|
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||||
|
}
|
||||||
|
|
||||||
const commands = createMemo((): AutocompleteOption[] => {
|
const commands = createMemo((): AutocompleteOption[] => {
|
||||||
const results: AutocompleteOption[] = [...slashes()]
|
const results: AutocompleteOption[] = slashes().map((command) => {
|
||||||
|
if (!command.input) return command
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
onSelect: () => insertSlashCommand(command.name),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
for (const serverCommand of sync.data.command) {
|
for (const serverCommand of sync.data.command) {
|
||||||
if (serverCommand.source === "skill") continue
|
if (serverCommand.source === "skill") continue
|
||||||
|
|
@ -552,13 +566,7 @@ export function Autocomplete(props: {
|
||||||
results.push({
|
results.push({
|
||||||
display: "/" + serverCommand.name + label,
|
display: "/" + serverCommand.name + label,
|
||||||
description: serverCommand.description,
|
description: serverCommand.description,
|
||||||
onSelect: () => {
|
onSelect: () => insertSlashCommand(serverCommand.name),
|
||||||
const newText = "/" + serverCommand.name + " "
|
|
||||||
const cursor = props.input().logicalCursor
|
|
||||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
|
||||||
props.input().insertText(newText)
|
|
||||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,14 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||||
import { useArgs } from "@tui/context/args"
|
import { useArgs } from "@tui/context/args"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { type WorkspaceStatus } from "../workspace-label"
|
import { type WorkspaceStatus } from "../workspace-label"
|
||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
import {
|
||||||
|
OPENCODE_BASE_MODE,
|
||||||
|
useBindings,
|
||||||
|
useCommandShortcut,
|
||||||
|
useCommandSlashes,
|
||||||
|
useLeaderActive,
|
||||||
|
useOpencodeKeymap,
|
||||||
|
} from "../../keymap"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
import { useTuiConfig } from "../../context/tui-config"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
|
|
@ -151,6 +158,7 @@ export function Prompt(props: PromptProps) {
|
||||||
const history = usePromptHistory()
|
const history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
|
const slashCommands = useCommandSlashes()
|
||||||
const agentShortcut = useCommandShortcut("agent.cycle")
|
const agentShortcut = useCommandShortcut("agent.cycle")
|
||||||
const paletteShortcut = useCommandShortcut("command.palette.show")
|
const paletteShortcut = useCommandShortcut("command.palette.show")
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
@ -1132,6 +1140,8 @@ export function Prompt(props: PromptProps) {
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const slash = parseSlashCommand(inputText)
|
||||||
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
void sdk.client.session.shell({
|
void sdk.client.session.shell({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|
@ -1143,25 +1153,13 @@ export function Prompt(props: PromptProps) {
|
||||||
command: inputText,
|
command: inputText,
|
||||||
})
|
})
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
} else if (
|
} else if (slash && slashCommand(slash.name)?.input) {
|
||||||
inputText.startsWith("/") &&
|
slashCommand(slash.name)?.onSelect()
|
||||||
iife(() => {
|
} else if (slash && sync.data.command.some((x) => x.name === slash.name)) {
|
||||||
const firstLine = inputText.split("\n")[0]
|
|
||||||
const command = firstLine.split(" ")[0].slice(1)
|
|
||||||
return sync.data.command.some((x) => x.name === command)
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
// Parse command from first line, preserve multi-line content in arguments
|
|
||||||
const firstLineEnd = inputText.indexOf("\n")
|
|
||||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
|
||||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
|
||||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
|
||||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
|
||||||
|
|
||||||
void sdk.client.session.command({
|
void sdk.client.session.command({
|
||||||
sessionID,
|
sessionID,
|
||||||
command: command.slice(1),
|
command: slash.name,
|
||||||
arguments: args,
|
arguments: slash.arguments,
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||||
messageID,
|
messageID,
|
||||||
|
|
@ -1220,6 +1218,22 @@ export function Prompt(props: PromptProps) {
|
||||||
input.clear()
|
input.clear()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSlashCommand(text: string) {
|
||||||
|
if (!text.startsWith("/")) return
|
||||||
|
const firstLineEnd = text.indexOf("\n")
|
||||||
|
const firstLine = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd)
|
||||||
|
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||||
|
const restOfInput = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1)
|
||||||
|
return {
|
||||||
|
name: command.slice(1),
|
||||||
|
arguments: firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slashCommand(name: string) {
|
||||||
|
return slashCommands().find((item) => item.name === name || item.aliases?.includes(`/${name}`))
|
||||||
|
}
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
|
|
||||||
function pasteText(text: string, virtualText: string) {
|
function pasteText(text: string, virtualText: string) {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ export { useBindings, useKeymapSelector }
|
||||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||||
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
|
||||||
type CommandSlashEntry = {
|
type CommandSlashEntry = {
|
||||||
|
name: string
|
||||||
display: string
|
display: string
|
||||||
description?: string
|
description?: string
|
||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
|
input?: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
}
|
}
|
||||||
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
|
||||||
|
|
@ -256,6 +258,7 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
||||||
if (typeof slashName !== "string" || !slashName) return []
|
if (typeof slashName !== "string" || !slashName) return []
|
||||||
const slashAliases = entry.command.slashAliases
|
const slashAliases = entry.command.slashAliases
|
||||||
return {
|
return {
|
||||||
|
name: slashName,
|
||||||
display: `/${slashName}`,
|
display: `/${slashName}`,
|
||||||
description:
|
description:
|
||||||
typeof entry.command.desc === "string"
|
typeof entry.command.desc === "string"
|
||||||
|
|
@ -266,6 +269,7 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
|
||||||
aliases: Array.isArray(slashAliases)
|
aliases: Array.isArray(slashAliases)
|
||||||
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
input: entry.command.slashInput === true,
|
||||||
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
onSelect: () => keymap.dispatchCommand(entry.command.name),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,17 @@ export function Session() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
|
function slashArguments(name: string) {
|
||||||
|
const input = prompt?.current.input
|
||||||
|
if (!input?.startsWith("/")) return
|
||||||
|
const firstLineEnd = input.indexOf("\n")
|
||||||
|
const firstLine = firstLineEnd === -1 ? input : input.slice(0, firstLineEnd)
|
||||||
|
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||||
|
if (command !== `/${name}`) return
|
||||||
|
const restOfInput = firstLineEnd === -1 ? "" : input.slice(firstLineEnd + 1)
|
||||||
|
return firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||||
|
}
|
||||||
|
|
||||||
event.on("session.status", (evt) => {
|
event.on("session.status", (evt) => {
|
||||||
if (evt.properties.sessionID !== route.sessionID) return
|
if (evt.properties.sessionID !== route.sessionID) return
|
||||||
if (evt.properties.status.type !== "retry") return
|
if (evt.properties.status.type !== "retry") return
|
||||||
|
|
@ -546,6 +557,7 @@ export function Session() {
|
||||||
slash: {
|
slash: {
|
||||||
name: "compact",
|
name: "compact",
|
||||||
aliases: ["summarize"],
|
aliases: ["summarize"],
|
||||||
|
input: true,
|
||||||
},
|
},
|
||||||
run: () => {
|
run: () => {
|
||||||
const selectedModel = local.model.current()
|
const selectedModel = local.model.current()
|
||||||
|
|
@ -557,11 +569,14 @@ export function Session() {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void sdk.client.session.summarize({
|
const instructions = slashArguments("compact") ?? slashArguments("summarize")
|
||||||
|
const payload = {
|
||||||
sessionID: route.sessionID,
|
sessionID: route.sessionID,
|
||||||
modelID: selectedModel.modelID,
|
modelID: selectedModel.modelID,
|
||||||
providerID: selectedModel.providerID,
|
providerID: selectedModel.providerID,
|
||||||
})
|
...(instructions?.trim() ? { $body_instructions: instructions.trim() } : {}),
|
||||||
|
} satisfies Parameters<typeof sdk.client.session.summarize>[0] & { $body_instructions?: string }
|
||||||
|
void sdk.client.session.summarize(payload)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1046,6 +1061,7 @@ export function Session() {
|
||||||
desc: "description" in command ? command.description : undefined,
|
desc: "description" in command ? command.description : undefined,
|
||||||
slashName: "slash" in command ? command.slash?.name : undefined,
|
slashName: "slash" in command ? command.slash?.name : undefined,
|
||||||
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
|
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
|
||||||
|
slashInput: "slash" in command ? command.slash?.input : undefined,
|
||||||
...command,
|
...command,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const SummarizePayload = Schema.Struct({
|
||||||
providerID: ProviderID,
|
providerID: ProviderID,
|
||||||
modelID: ModelID,
|
modelID: ModelID,
|
||||||
auto: Schema.optional(Schema.Boolean),
|
auto: Schema.optional(Schema.Boolean),
|
||||||
|
instructions: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
|
||||||
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
|
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||||
modelID: ctx.payload.modelID,
|
modelID: ctx.payload.modelID,
|
||||||
},
|
},
|
||||||
auto: ctx.payload.auto ?? false,
|
auto: ctx.payload.auto ?? false,
|
||||||
|
instructions: ctx.payload.instructions,
|
||||||
})
|
})
|
||||||
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,12 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
||||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendInstructions(prompt: string, instructions: string | undefined) {
|
||||||
|
const trimmed = instructions?.trim()
|
||||||
|
if (!trimmed) return prompt
|
||||||
|
return [prompt, "Additional user instructions for this compaction:", trimmed].join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||||
return (
|
return (
|
||||||
input.cfg.compaction?.preserve_recent_tokens ??
|
input.cfg.compaction?.preserve_recent_tokens ??
|
||||||
|
|
@ -202,6 +208,7 @@ export interface Interface {
|
||||||
model: { providerID: ProviderID; modelID: ModelID }
|
model: { providerID: ProviderID; modelID: ModelID }
|
||||||
auto: boolean
|
auto: boolean
|
||||||
overflow?: boolean
|
overflow?: boolean
|
||||||
|
instructions?: string
|
||||||
}) => Effect.Effect<void>
|
}) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,7 +407,10 @@ export const layer = Layer.effect(
|
||||||
{ sessionID: input.sessionID },
|
{ sessionID: input.sessionID },
|
||||||
{ context: [], prompt: undefined },
|
{ context: [], prompt: undefined },
|
||||||
)
|
)
|
||||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
const nextPrompt = appendInstructions(
|
||||||
|
compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }),
|
||||||
|
compactionPart?.instructions,
|
||||||
|
)
|
||||||
const msgs = structuredClone(selected.head)
|
const msgs = structuredClone(selected.head)
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||||
|
|
@ -587,6 +597,7 @@ export const layer = Layer.effect(
|
||||||
model: { providerID: ProviderID; modelID: ModelID }
|
model: { providerID: ProviderID; modelID: ModelID }
|
||||||
auto: boolean
|
auto: boolean
|
||||||
overflow?: boolean
|
overflow?: boolean
|
||||||
|
instructions?: string
|
||||||
}) {
|
}) {
|
||||||
const msg = yield* session.updateMessage({
|
const msg = yield* session.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
|
|
@ -603,6 +614,7 @@ export const layer = Layer.effect(
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
auto: input.auto,
|
auto: input.auto,
|
||||||
overflow: input.overflow,
|
overflow: input.overflow,
|
||||||
|
instructions: input.instructions,
|
||||||
})
|
})
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@ export const CompactionPart = Schema.Struct({
|
||||||
type: Schema.Literal("compaction"),
|
type: Schema.Literal("compaction"),
|
||||||
auto: Schema.Boolean,
|
auto: Schema.Boolean,
|
||||||
overflow: Schema.optional(Schema.Boolean),
|
overflow: Schema.optional(Schema.Boolean),
|
||||||
|
instructions: Schema.optional(Schema.String),
|
||||||
tail_start_id: Schema.optional(MessageID),
|
tail_start_id: Schema.optional(MessageID),
|
||||||
}).annotate({ identifier: "CompactionPart" })
|
}).annotate({ identifier: "CompactionPart" })
|
||||||
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
|
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,8 @@ function compactionProcessLayer(options?: CompactionProcessOptions) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSummaryCompaction(sessionID: SessionID) {
|
function createSummaryCompaction(sessionID: SessionID, instructions?: string) {
|
||||||
return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false })
|
return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false, instructions })
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCompactionPart(sessionID: SessionID) {
|
function readCompactionPart(sessionID: SessionID) {
|
||||||
|
|
@ -959,6 +959,30 @@ describe("session.compaction.process", () => {
|
||||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
|
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
itCompaction.instance(
|
||||||
|
"appends stored instructions to compaction prompt",
|
||||||
|
() => {
|
||||||
|
const stub = llm()
|
||||||
|
let captured = ""
|
||||||
|
stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const ssn = yield* SessionNs.Service
|
||||||
|
const session = yield* ssn.create({})
|
||||||
|
yield* createUserMessage(session.id, "first")
|
||||||
|
yield* createSummaryCompaction(session.id, "focus on unresolved TODOs")
|
||||||
|
|
||||||
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||||
|
const parent = msgs.at(-1)?.info.id
|
||||||
|
expect(parent).toBeTruthy()
|
||||||
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
||||||
|
|
||||||
|
expect(captured).toContain("Additional user instructions for this compaction")
|
||||||
|
expect(captured).toContain("focus on unresolved TODOs")
|
||||||
|
}).pipe(withCompaction({ llm: stub.layer }))
|
||||||
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
itCompaction.instance(
|
itCompaction.instance(
|
||||||
"falls back to full summary when even one recent turn exceeds preserve token budget",
|
"falls back to full summary when even one recent turn exceeds preserve token budget",
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue