Compare commits

...
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 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) {
if (serverCommand.source === "skill") continue
@@ -552,13 +566,7 @@ export function Autocomplete(props: {
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
onSelect: () => {
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)
},
onSelect: () => insertSlashCommand(serverCommand.name),
})
}
@@ -59,7 +59,14 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
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"
export type PromptProps = {
@@ -151,6 +158,7 @@ export function Prompt(props: PromptProps) {
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
const slashCommands = useCommandSlashes()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const renderer = useRenderer()
@@ -1132,6 +1140,8 @@ export function Prompt(props: PromptProps) {
]
: []
const slash = parseSlashCommand(inputText)
if (store.mode === "shell") {
void sdk.client.session.shell({
sessionID,
@@ -1143,25 +1153,13 @@ export function Prompt(props: PromptProps) {
command: inputText,
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => {
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 : "")
} else if (slash && slashCommand(slash.name)?.input) {
slashCommand(slash.name)?.onSelect()
} else if (slash && sync.data.command.some((x) => x.name === slash.name)) {
void sdk.client.session.command({
sessionID,
command: command.slice(1),
arguments: args,
command: slash.name,
arguments: slash.arguments,
agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
@@ -1220,6 +1218,22 @@ export function Prompt(props: PromptProps) {
input.clear()
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()
function pasteText(text: string, virtualText: string) {
@@ -25,9 +25,11 @@ export { useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
name: string
display: string
description?: string
aliases?: string[]
input?: boolean
onSelect: () => void
}
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
@@ -256,6 +258,7 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
name: slashName,
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
@@ -266,6 +269,7 @@ export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
input: entry.command.slashInput === true,
onSelect: () => keymap.dispatchCommand(entry.command.name),
}
}),
@@ -314,6 +314,17 @@ export function Session() {
const dialog = useDialog()
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) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
@@ -546,6 +557,7 @@ export function Session() {
slash: {
name: "compact",
aliases: ["summarize"],
input: true,
},
run: () => {
const selectedModel = local.model.current()
@@ -557,11 +569,14 @@ export function Session() {
})
return
}
void sdk.client.session.summarize({
const instructions = slashArguments("compact") ?? slashArguments("summarize")
const payload = {
sessionID: route.sessionID,
modelID: selectedModel.modelID,
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()
},
},
@@ -1046,6 +1061,7 @@ export function Session() {
desc: "description" in command ? command.description : undefined,
slashName: "slash" in command ? command.slash?.name : undefined,
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
slashInput: "slash" in command ? command.slash?.input : undefined,
...command,
})),
)
@@ -62,6 +62,7 @@ export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.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,
},
auto: ctx.payload.auto ?? false,
instructions: ctx.payload.instructions,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
return true
+13 -1
View File
@@ -133,6 +133,12 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) {
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 }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@@ -202,6 +208,7 @@ export interface Interface {
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
instructions?: string
}) => Effect.Effect<void>
}
@@ -400,7 +407,10 @@ export const layer = Layer.effect(
{ sessionID: input.sessionID },
{ 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)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -587,6 +597,7 @@ export const layer = Layer.effect(
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
instructions?: string
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
@@ -603,6 +614,7 @@ export const layer = Layer.effect(
type: "compaction",
auto: input.auto,
overflow: input.overflow,
instructions: input.instructions,
})
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Compaction.Started, {
@@ -186,6 +186,7 @@ export const CompactionPart = Schema.Struct({
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.optional(Schema.Boolean),
instructions: Schema.optional(Schema.String),
tail_start_id: Schema.optional(MessageID),
}).annotate({ identifier: "CompactionPart" })
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
@@ -287,8 +287,8 @@ function compactionProcessLayer(options?: CompactionProcessOptions) {
)
}
function createSummaryCompaction(sessionID: SessionID) {
return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false })
function createSummaryCompaction(sessionID: SessionID, instructions?: string) {
return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false, instructions })
}
function readCompactionPart(sessionID: SessionID) {
@@ -959,6 +959,30 @@ describe("session.compaction.process", () => {
}).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(
"falls back to full summary when even one recent turn exceeds preserve token budget",
() => {