Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80cbafa3c3 | ||
|
|
c7a17aea4b | ||
|
|
b5c6763d11 | ||
|
|
4f191187b3 | ||
|
|
cfcc3d973b | ||
|
|
25f92bba8d | ||
|
|
fbce48f597 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user