mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a631ce4f47 | |||
| e872bd3c8f |
@@ -43,7 +43,6 @@ import { QuestionTool } from "../tool/plugin/question"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ReadTool } from "../tool/plugin/read"
|
||||
import { ShellTool } from "../tool/plugin/shell"
|
||||
import { SessionRenameTool } from "../tool/plugin/session-rename"
|
||||
import { SkillTool } from "../tool/plugin/skill"
|
||||
import { SubagentTool } from "../tool/plugin/subagent"
|
||||
import { Tool } from "../tool"
|
||||
@@ -150,7 +149,6 @@ const pre = [
|
||||
QuestionTool.Plugin,
|
||||
ReadTool.Plugin,
|
||||
ShellTool.Plugin,
|
||||
SessionRenameTool.Plugin,
|
||||
SkillTool.Plugin,
|
||||
SubagentTool.Plugin,
|
||||
WebFetchTool.Plugin,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
export * as SessionRenameTool from "./session-rename"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
|
||||
export const name = "sessionRename"
|
||||
|
||||
export const description =
|
||||
"Rename the current session. Use a short, specific title that summarizes the work being done. This tool can only rename the session you are currently working in."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
title: Schema.String.check(
|
||||
Schema.isMinLength(1, { message: "Title must not be empty" }),
|
||||
Schema.isMaxLength(100, { message: "Title must be 100 characters or fewer" }),
|
||||
).annotate({ description: "New title for the current session" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
title: Schema.String,
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.session-rename",
|
||||
effect: Effect.fn("SessionRenameTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const title = input.title.replace(/\s+/g, " ").trim()
|
||||
if (!title) return yield* new ToolFailure({ message: "Session title must not be empty" })
|
||||
yield* permission
|
||||
.assert({
|
||||
action: name,
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: { title },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, callID: context.callID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: "Permission denied: sessionRename", error }),
|
||||
),
|
||||
)
|
||||
yield* runtime.session
|
||||
.rename({ sessionID: context.sessionID, title })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: "Unable to rename the current session", error }),
|
||||
),
|
||||
)
|
||||
return {
|
||||
output: { title },
|
||||
content: `Renamed the current session to: ${title}`,
|
||||
metadata: { title },
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
}
|
||||
@@ -552,9 +552,8 @@ describe("LocationServiceMap", () => {
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"sessionRename",
|
||||
"skill",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
@@ -586,7 +585,6 @@ describe("LocationServiceMap", () => {
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"sessionRename",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
@@ -606,7 +604,6 @@ describe("LocationServiceMap", () => {
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"sessionRename",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { SessionRenameTool } from "@opencode-ai/core/tool/plugin/session-rename"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolDefinitions, toolIdentity } from "./lib/tool"
|
||||
|
||||
const currentSessionID = Session.ID.make("ses_session_rename_current")
|
||||
const otherSessionID = Session.ID.make("ses_session_rename_other")
|
||||
const renamed: Array<{ sessionID: Session.ID; title: string }> = []
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
let deny = false
|
||||
|
||||
const runtime = Layer.succeed(
|
||||
PluginRuntime.Service,
|
||||
PluginRuntime.Service.of({
|
||||
session: {
|
||||
get: () => Effect.die("unused"),
|
||||
create: () => Effect.die("unused"),
|
||||
messages: () => Effect.die("unused"),
|
||||
prompt: () => Effect.die("unused"),
|
||||
generate: () => Effect.die("unused"),
|
||||
command: () => Effect.die("unused"),
|
||||
resume: () => Effect.die("unused"),
|
||||
interrupt: () => Effect.die("unused"),
|
||||
synthetic: () => Effect.die("unused"),
|
||||
rename: (input) => Effect.sync(() => void renamed.push(input)),
|
||||
wait: () => Effect.die("unused"),
|
||||
},
|
||||
job: {
|
||||
start: () => Effect.die("unused"),
|
||||
wait: () => Effect.die("unused"),
|
||||
block: () => Effect.die("unused"),
|
||||
background: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: () => Effect.die("unused"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
Permission.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
deny
|
||||
? Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const sessionRenameToolNode = makeLocationNode({
|
||||
name: "test/session-rename-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(SessionRenameTool.Plugin)),
|
||||
deps: [Tool.node, Permission.node, PluginRuntime.node],
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, sessionRenameToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[PluginRuntime.node, runtime],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("SessionRenameTool", () => {
|
||||
it.effect("registers directly and confines rename to the invocation session", () =>
|
||||
Effect.gen(function* () {
|
||||
renamed.length = 0
|
||||
assertions.length = 0
|
||||
deny = false
|
||||
const registry = yield* Tool.Service
|
||||
const snapshot = yield* registry.snapshot()
|
||||
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["sessionRename", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.some((tool) => tool.path === "sessionRename")).toBe(false)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: currentSessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-session-rename",
|
||||
name: "sessionRename",
|
||||
input: { title: "\n Focused\n title\t", sessionID: otherSessionID },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
status: "completed",
|
||||
output: { title: "Focused title" },
|
||||
content: [{ type: "text", text: "Renamed the current session to: Focused title" }],
|
||||
metadata: { title: "Focused title" },
|
||||
})
|
||||
expect(renamed).toEqual([{ sessionID: currentSessionID, title: "Focused title" }])
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
action: "sessionRename",
|
||||
resources: ["*"],
|
||||
sessionID: currentSessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
source: { type: "tool", messageID: toolIdentity.messageID, callID: "call-session-rename" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters catalog denial and enforces leaf permission", () =>
|
||||
Effect.gen(function* () {
|
||||
renamed.length = 0
|
||||
deny = true
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "sessionRename", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual(["execute"])
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: currentSessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-session-rename-denied",
|
||||
name: "sessionRename",
|
||||
input: { title: "Denied title" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
status: "error",
|
||||
error: { type: "permission.rejected", message: "Permission denied: sessionRename" },
|
||||
})
|
||||
expect(renamed).toEqual([])
|
||||
deny = false
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => {
|
||||
const provider = providers().get(model.providerID)
|
||||
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
return {
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
providerID: model.providerID,
|
||||
providerName: provider?.name ?? model.providerID,
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
favorite,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
if (needle) {
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
return prioritizeFavorites(
|
||||
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||
)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||
}
|
||||
|
||||
export function sortModelOptions<
|
||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||
>(options: T[]) {
|
||||
|
||||
@@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
|
||||
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
}
|
||||
|
||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
const drafts = new Map<string, { prompt: PromptInfo; cursor: number }>()
|
||||
|
||||
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
const head = parseSlashHead(input, /\s/)
|
||||
@@ -172,6 +172,20 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const draftKey = (sessionID?: string) => sessionID ?? "new"
|
||||
const saveDraft = (sessionID?: string) => {
|
||||
const key = draftKey(sessionID)
|
||||
if (
|
||||
!store.prompt.text &&
|
||||
store.prompt.pasted.length === 0 &&
|
||||
(store.prompt.files?.length ?? 0) === 0 &&
|
||||
(store.prompt.agents?.length ?? 0) === 0
|
||||
) {
|
||||
drafts.delete(key)
|
||||
return
|
||||
}
|
||||
drafts.set(key, { prompt: structuredClone(unwrap(store.prompt)), cursor: input.cursorOffset })
|
||||
}
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
@@ -561,6 +575,7 @@ export function Prompt(props: PromptProps) {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(props.sessionID))
|
||||
},
|
||||
submit() {
|
||||
void submit()
|
||||
@@ -568,10 +583,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const saved = stashed
|
||||
stashed = undefined
|
||||
void history.load(props.sessionID)
|
||||
const saved = drafts.get(draftKey(props.sessionID))
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
if (saved) {
|
||||
input.setText(saved.prompt.text)
|
||||
setStore("prompt", saved.prompt)
|
||||
restoreExtmarksFromPrompt(saved.prompt)
|
||||
@@ -579,10 +594,25 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sessionID,
|
||||
(sessionID, previous) => {
|
||||
saveDraft(previous)
|
||||
const saved = drafts.get(draftKey(sessionID))
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", saved?.prompt ?? emptyPrompt())
|
||||
restoreExtmarksFromPrompt(saved?.prompt ?? emptyPrompt())
|
||||
input.cursorOffset = saved?.cursor ?? 0
|
||||
void history.load(sessionID)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
saveDraft(props.sessionID)
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
@@ -854,7 +884,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
const item = history.move(props.sessionID, -1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -893,7 +923,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(1, input.plainText)
|
||||
const item = history.move(props.sessionID, 1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -1117,13 +1147,14 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
}
|
||||
history.append({
|
||||
history.append(sessionID, {
|
||||
...store.prompt,
|
||||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(sessionID))
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
@@ -1273,7 +1304,7 @@ export function Prompt(props: PromptProps) {
|
||||
(store.prompt.files?.length ?? 0) > 0 ||
|
||||
(store.prompt.agents?.length ?? 0) > 0
|
||||
) {
|
||||
history.append({
|
||||
history.append(props.sessionID, {
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
@@ -1282,6 +1313,7 @@ export function Prompt(props: PromptProps) {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(props.sessionID))
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client"
|
||||
import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
@@ -61,56 +60,67 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const paths = useTuiPaths()
|
||||
const historyPath = path.join(paths.state, "prompt-history.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
|
||||
setStore("history", lines)
|
||||
|
||||
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
|
||||
if (lines.length > 0)
|
||||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
const stores = new Map<string, { index: number; history: PromptInfo[] }>()
|
||||
const loaded = new Set<string>()
|
||||
const key = (sessionID?: string) => sessionID ?? "new"
|
||||
const historyPath = (sessionID?: string) =>
|
||||
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
|
||||
const store = (sessionID?: string) => {
|
||||
const id = key(sessionID)
|
||||
const current = stores.get(id)
|
||||
if (current) return current
|
||||
const next = { index: 0, history: [] as PromptInfo[] }
|
||||
stores.set(id, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
async load(sessionID?: string) {
|
||||
const id = key(sessionID)
|
||||
if (loaded.has(id)) return
|
||||
loaded.add(id)
|
||||
const lines = parsePromptHistory(await readText(historyPath(sessionID)).catch(() => ""))
|
||||
const current = stores.get(id)
|
||||
const history = [...lines, ...(current?.history ?? [])]
|
||||
.filter((entry, index, entries) => !isDuplicateEntry(entries[index - 1], entry))
|
||||
.slice(-MAX_HISTORY_ENTRIES)
|
||||
stores.set(id, { index: current?.index ?? 0, history })
|
||||
if (lines.length > 0)
|
||||
writeText(historyPath(sessionID), history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
|
||||
() => {},
|
||||
)
|
||||
},
|
||||
move(sessionID: string | undefined, direction: 1 | -1, input: string) {
|
||||
const state = store(sessionID)
|
||||
if (!state.history.length) return undefined
|
||||
const current = state.history.at(state.index)
|
||||
if (!current) return undefined
|
||||
if (current.text !== input && input.length) return
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length || next > 0) return
|
||||
setStore("index", next)
|
||||
const next = state.index + direction
|
||||
if (Math.abs(next) > state.history.length || next > 0) return
|
||||
state.index = next
|
||||
if (next === 0) return emptyPrompt()
|
||||
return store.history.at(next)
|
||||
return state.history.at(next)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
append(sessionID: string | undefined, item: PromptInfo) {
|
||||
const state = store(sessionID)
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
if (isDuplicateEntry(state.history.at(-1), entry)) {
|
||||
state.index = 0
|
||||
return
|
||||
}
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(entry)
|
||||
if (draft.history.length > MAX_HISTORY_ENTRIES) {
|
||||
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
state.history.push(entry)
|
||||
const trimmed = state.history.length > MAX_HISTORY_ENTRIES
|
||||
if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
|
||||
state.index = 0
|
||||
|
||||
if (trimmed) {
|
||||
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
writeText(historyPath(sessionID), state.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
|
||||
() => {},
|
||||
)
|
||||
return
|
||||
}
|
||||
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
|
||||
appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("prioritizeFavorites", () => {
|
||||
test("moves favorites first while preserving fuzzy result order", () => {
|
||||
const prioritized = prioritizeFavorites([
|
||||
{ title: "Best match", favorite: false },
|
||||
{ title: "Favorite match", favorite: true },
|
||||
{ title: "Second best match", favorite: false },
|
||||
{ title: "Second favorite match", favorite: true },
|
||||
])
|
||||
|
||||
expect(prioritized.map((model) => model.title)).toEqual([
|
||||
"Favorite match",
|
||||
"Second favorite match",
|
||||
"Best match",
|
||||
"Second best match",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortModelOptions", () => {
|
||||
test("orders opencode models before other providers", () => {
|
||||
|
||||
@@ -27,11 +27,15 @@ test("down rejects at the newest history item with an empty prompt", async () =>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
|
||||
history!.append("ses_one", { text: "previous", files: [], agents: [], pasted: [] })
|
||||
|
||||
expect(history!.move(1, "")).toBeUndefined()
|
||||
expect(history!.move(-1, "")?.text).toBe("previous")
|
||||
expect(history!.move(1, "previous")?.text).toBe("")
|
||||
expect(history!.move("ses_one", 1, "")).toBeUndefined()
|
||||
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
|
||||
expect(history!.move("ses_one", 1, "previous")?.text).toBe("")
|
||||
|
||||
history!.append("ses_two", { text: "other", files: [], agents: [], pasted: [] })
|
||||
expect(history!.move("ses_two", -1, "")?.text).toBe("other")
|
||||
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user