Merge branch 'dev' into opencode-remote-voice
This commit is contained in:
commit
36b51cad33
211 changed files with 69053 additions and 34594 deletions
|
|
@ -15,6 +15,16 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
|||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
const seedModel = (() => {
|
||||
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||
).split("/")
|
||||
return {
|
||||
providerID: providerID || "opencode",
|
||||
modelID: modelID || "big-pickle",
|
||||
}
|
||||
})()
|
||||
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
|
|
@ -125,7 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
||||
const win = window as E2EWindow
|
||||
win.__opencode_e2e = {
|
||||
...win.__opencode_e2e,
|
||||
|
|
@ -143,12 +153,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
|||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
recent: [model],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}, seedModel)
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|||
}
|
||||
|
||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||
test.skip(true, "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-comment-${Date.now()}`
|
||||
|
|
@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||
})
|
||||
|
||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||
test.skip(true, "Flaky in CI for now.")
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-file-comment-${Date.now()}`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const serverEnv = {
|
|||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
||||
OPENCODE_CLIENT: "app",
|
||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||
} satisfies Record<string, string>
|
||||
|
|
|
|||
|
|
@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
const images = imageAttachments()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
const text = `/${cmd.trigger} `
|
||||
setEditorText(text)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||
focusEditorEnd()
|
||||
return
|
||||
}
|
||||
|
||||
clearEditor()
|
||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||
command.trigger(cmd.id, "slash")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
|
|||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
|
||||
return (
|
||||
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
|
||||
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
|
||||
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Option(props: {
|
||||
multi: boolean
|
||||
picked: boolean
|
||||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{props.label}</span>
|
||||
<Show when={props.description}>
|
||||
<span data-slot="option-description">{props.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
|
@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
|
|
@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
|
||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
||||
const pick = (answer: string, custom: boolean = false) => {
|
||||
setStore("answers", store.tab, [answer])
|
||||
if (custom) setStore("custom", store.tab, answer)
|
||||
|
|
@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
customUpdate(input())
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const focusCustom = (el: HTMLTextAreaElement) => {
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
resizeInput(el)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const toggleCustomMark = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
customToggle()
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (sending()) return
|
||||
if (store.editing) commitCustom()
|
||||
|
|
@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
|
|
@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
|
|
@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
disabled={sending()}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
|
@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
|
|
@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,132 @@ function FileCommentMenu(props: {
|
|||
)
|
||||
}
|
||||
|
||||
type ScrollPos = { x: number; y: number }
|
||||
|
||||
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: ScrollPos | undefined
|
||||
let code: HTMLElement[] = []
|
||||
|
||||
const getCode = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const save = (next: ScrollPos) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
input.view().setScroll(input.tab(), out)
|
||||
})
|
||||
}
|
||||
|
||||
const onCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
save({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const next = getCode()
|
||||
if (next.length === code.length && next.every((el, i) => el === code[i])) return
|
||||
|
||||
for (const item of code) {
|
||||
item.removeEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
|
||||
code = next
|
||||
|
||||
for (const item of code) {
|
||||
item.addEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const pos = input.view().scroll(input.tab())
|
||||
if (!pos) return
|
||||
|
||||
sync()
|
||||
|
||||
if (code.length > 0) {
|
||||
for (const item of code) {
|
||||
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
|
||||
if (code.length > 0) return
|
||||
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restore()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (code.length === 0) sync()
|
||||
|
||||
save({
|
||||
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const setViewport = (el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restore()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of code) {
|
||||
item.removeEventListener("scroll", onCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
queueRestore,
|
||||
setViewport,
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
|
|
@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
const search = {
|
||||
|
|
@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
|
|||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
const scrollSync = createScrollSync({
|
||||
tab: () => props.tab,
|
||||
view,
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
return previewSelectedLines(source, {
|
||||
|
|
@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
|
|||
})
|
||||
}
|
||||
|
||||
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
|
|
@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview =
|
||||
input.preview ??
|
||||
(() => {
|
||||
if (input.file === path()) return selectionPreview(contents(), selection)
|
||||
const source = file.get(input.file)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
})()
|
||||
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||
|
||||
const saved = comments.add({
|
||||
file: input.file,
|
||||
|
|
@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview =
|
||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
||||
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
|
|
@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
const getCodeScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return []
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return []
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return []
|
||||
|
||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||
)
|
||||
}
|
||||
|
||||
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
||||
pending = next
|
||||
if (scrollFrame !== undefined) return
|
||||
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = undefined
|
||||
|
||||
const out = pending
|
||||
pending = undefined
|
||||
if (!out) return
|
||||
|
||||
view().setScroll(props.tab, out)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCodeScroll = (event: Event) => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
|
||||
queueScrollUpdate({
|
||||
x: target.scrollLeft,
|
||||
y: el.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const syncCodeScroll = () => {
|
||||
const next = getCodeScroll()
|
||||
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
codeScroll = next
|
||||
|
||||
for (const item of codeScroll) {
|
||||
item.addEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
}
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
|
||||
const s = view().scroll(props.tab)
|
||||
if (!s) return
|
||||
|
||||
syncCodeScroll()
|
||||
|
||||
if (codeScroll.length > 0) {
|
||||
for (const item of codeScroll) {
|
||||
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
||||
}
|
||||
}
|
||||
|
||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
||||
if (codeScroll.length > 0) return
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restoreScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
queueScrollUpdate({
|
||||
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||
y: event.currentTarget.scrollTop,
|
||||
})
|
||||
}
|
||||
|
||||
const cancelCommenting = () => {
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, null)
|
||||
|
|
@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
scrollSync.queueRestore()
|
||||
})
|
||||
|
||||
const renderFile = (source: string) => (
|
||||
|
|
@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
queueRestore()
|
||||
scrollSync.queueRestore()
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
|
|
@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: queueRestore,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
|
|
@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||
|
||||
return (
|
||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||
<ScrollView
|
||||
class="h-full"
|
||||
viewportRef={(el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
}}
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
|
|
|
|||
|
|
@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
}
|
||||
command.register("session", () => {
|
||||
const share =
|
||||
sync.data.config.share === "disabled"
|
||||
? []
|
||||
: [
|
||||
sessionCommand({
|
||||
id: "session.share",
|
||||
title: info()?.share?.url
|
||||
? language.t("session.share.copy.copyLink")
|
||||
: language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
const write = async (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return true
|
||||
}
|
||||
|
||||
const write = (value: string) => {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
if (body) {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = value
|
||||
textarea.setAttribute("readonly", "")
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.style.pointerEvents = "none"
|
||||
body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand("copy")
|
||||
body.removeChild(textarea)
|
||||
if (copied) return Promise.resolve(true)
|
||||
}
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return false
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
||||
return clipboard.writeText(value).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
const copyShare = async (url: string, existing: boolean) => {
|
||||
if (!(await write(url))) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const copy = async (url: string, existing: boolean) => {
|
||||
const ok = await write(url)
|
||||
if (!ok) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.copyFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
showToast({
|
||||
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: existing
|
||||
? language.t("session.share.copy.copied")
|
||||
: language.t("toast.session.share.success.title"),
|
||||
description: language.t("toast.session.share.success.description"),
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
const share = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copy(existing, true)
|
||||
return
|
||||
}
|
||||
const existing = info()?.share?.url
|
||||
if (existing) {
|
||||
await copyShare(existing, true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID: params.id })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const url = await sdk.client.session
|
||||
.share({ sessionID })
|
||||
.then((res) => res.data?.share?.url)
|
||||
.catch(() => undefined)
|
||||
if (!url) {
|
||||
showToast({
|
||||
title: language.t("toast.session.share.failed.title"),
|
||||
description: language.t("toast.session.share.failed.description"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await copy(url, false)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: async () => {
|
||||
if (!params.id) return
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID: params.id })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
await copyShare(url, false)
|
||||
}
|
||||
|
||||
const unshare = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
await sdk.client.session
|
||||
.unshare({ sessionID })
|
||||
.then(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.success.title"),
|
||||
description: language.t("toast.session.unshare.success.description"),
|
||||
variant: "success",
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showToast({
|
||||
title: language.t("toast.session.unshare.failed.title"),
|
||||
description: language.t("toast.session.unshare.failed.description"),
|
||||
variant: "error",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
}
|
||||
|
||||
const addSelection = () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
}
|
||||
|
||||
const openTerminal = () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
const chooseMcp = () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
}
|
||||
|
||||
const toggleAutoAccept = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
|
||||
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
|
||||
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!next) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(last)
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.revert({ sessionID, messageID: next.id })
|
||||
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||
setActiveMessage(prev)
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
}
|
||||
|
||||
const fork = () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
}
|
||||
|
||||
const shareCmds = () => {
|
||||
if (sync.data.config.share === "disabled") return []
|
||||
return [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: () => {
|
||||
const tab = closableTab()
|
||||
if (!tab) return
|
||||
tabs().close(tab)
|
||||
},
|
||||
}),
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: () => {
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
|
||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||
if (!range) {
|
||||
showToast({
|
||||
title: language.t("toast.context.noLineSelection.title"),
|
||||
description: language.t("toast.context.noLineSelection.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addSelectionToContext(path, selectionFromLines(range))
|
||||
},
|
||||
}),
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => {
|
||||
if (terminal.all().length > 0) terminal.new()
|
||||
view().terminal.open()
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
id: "session.share",
|
||||
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||
description: info()?.share?.url
|
||||
? language.t("toast.session.share.success.description")
|
||||
: language.t("command.session.share.description"),
|
||||
slash: "share",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
onSelect: share,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
id: "session.unshare",
|
||||
title: language.t("command.session.unshare"),
|
||||
description: language.t("command.session.unshare.description"),
|
||||
slash: "unshare",
|
||||
disabled: !params.id || !info()?.share?.url,
|
||||
onSelect: unshare,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (status().type !== "idle") {
|
||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||
}
|
||||
const revert = info()?.revert?.messageID
|
||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||
if (!message) return
|
||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||
const parts = sync.data.part[message.id]
|
||||
if (parts) {
|
||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||
prompt.set(restored)
|
||||
}
|
||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
||||
setActiveMessage(priorMessage)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const revertMessageID = info()?.revert?.messageID
|
||||
if (!revertMessageID) return
|
||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
||||
if (!nextMessage) {
|
||||
await sdk.client.session.unrevert({ sessionID })
|
||||
prompt.reset()
|
||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||
setActiveMessage(lastMsg)
|
||||
return
|
||||
}
|
||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
||||
setActiveMessage(priorMsg)
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const model = local.model.current()
|
||||
if (!model) {
|
||||
showToast({
|
||||
title: language.t("toast.model.none.title"),
|
||||
description: language.t("toast.model.none.description"),
|
||||
})
|
||||
return
|
||||
}
|
||||
await sdk.client.session.summarize({
|
||||
sessionID,
|
||||
modelID: model.id,
|
||||
providerID: model.provider.id,
|
||||
})
|
||||
},
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
...share,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const sessionCmds = () => [
|
||||
sessionCommand({
|
||||
id: "session.new",
|
||||
title: language.t("command.session.new"),
|
||||
keybind: "mod+shift+s",
|
||||
slash: "new",
|
||||
onSelect: () => navigate(`/${params.dir}/session`),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.undo",
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: undo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !params.id || !info()?.revert?.messageID,
|
||||
onSelect: redo,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
title: language.t("command.session.compact"),
|
||||
description: language.t("command.session.compact.description"),
|
||||
slash: "compact",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: fork,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => [
|
||||
fileCommand({
|
||||
id: "file.open",
|
||||
title: language.t("command.file.open"),
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: openFile,
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
title: language.t("command.tab.close"),
|
||||
keybind: "mod+w",
|
||||
disabled: !closableTab(),
|
||||
onSelect: closeTab,
|
||||
}),
|
||||
]
|
||||
|
||||
const contextCmds = () => [
|
||||
contextCommand({
|
||||
id: "context.addSelection",
|
||||
title: language.t("command.context.addSelection"),
|
||||
description: language.t("command.context.addSelection.description"),
|
||||
keybind: "mod+shift+l",
|
||||
disabled: !canAddSelectionContext(),
|
||||
onSelect: addSelection,
|
||||
}),
|
||||
]
|
||||
|
||||
const viewCmds = () => [
|
||||
viewCommand({
|
||||
id: "terminal.toggle",
|
||||
title: language.t("command.terminal.toggle"),
|
||||
keybind: "ctrl+`",
|
||||
slash: "terminal",
|
||||
onSelect: () => view().terminal.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "review.toggle",
|
||||
title: language.t("command.review.toggle"),
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => view().reviewPanel.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "fileTree.toggle",
|
||||
title: language.t("command.fileTree.toggle"),
|
||||
keybind: "mod+\\",
|
||||
onSelect: () => layout.fileTree.toggle(),
|
||||
}),
|
||||
viewCommand({
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: focusInput,
|
||||
}),
|
||||
]
|
||||
|
||||
const terminalCmds = () => [
|
||||
terminalCommand({
|
||||
id: "terminal.new",
|
||||
title: language.t("command.terminal.new"),
|
||||
description: language.t("command.terminal.new.description"),
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: openTerminal,
|
||||
}),
|
||||
]
|
||||
|
||||
const messageCmds = () => [
|
||||
sessionCommand({
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
]
|
||||
|
||||
const modelCmds = () => [
|
||||
modelCommand({
|
||||
id: "model.choose",
|
||||
title: language.t("command.model.choose"),
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: chooseModel,
|
||||
}),
|
||||
modelCommand({
|
||||
id: "model.variant.cycle",
|
||||
title: language.t("command.model.variant.cycle"),
|
||||
description: language.t("command.model.variant.cycle.description"),
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => local.model.variant.cycle(),
|
||||
}),
|
||||
]
|
||||
|
||||
const mcpCmds = () => [
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
title: language.t("command.mcp.toggle"),
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: chooseMcp,
|
||||
}),
|
||||
]
|
||||
|
||||
const agentCmds = () => [
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle.reverse",
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
const permissionsCmds = () => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
onSelect: toggleAutoAccept,
|
||||
}),
|
||||
]
|
||||
|
||||
command.register("session", () => [
|
||||
...sessionCmds(),
|
||||
...shareCmds(),
|
||||
...fileCmds(),
|
||||
...contextCmds(),
|
||||
...viewCmds(),
|
||||
...terminalCmds(),
|
||||
...messageCmds(),
|
||||
...modelCmds(),
|
||||
...mcpCmds(),
|
||||
...agentCmds(),
|
||||
...permissionsCmds(),
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -331,8 +331,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "ألغِ في أي وقت.",
|
||||
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
|
||||
"go.faq.a5.body":
|
||||
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
|
||||
"go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
|
||||
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
|
||||
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",
|
||||
|
|
|
|||
|
|
@ -338,8 +338,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Cancele a qualquer momento.",
|
||||
"go.faq.q5": "E sobre dados e privacidade?",
|
||||
"go.faq.a5.body":
|
||||
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
|
||||
"go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
|
||||
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
|
||||
"go.faq.a5.exceptionsLink": "seguintes exceções",
|
||||
|
|
|
|||
|
|
@ -336,8 +336,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Annuller til enhver tid.",
|
||||
"go.faq.q5": "Hvad med data og privatliv?",
|
||||
"go.faq.a5.body":
|
||||
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
|
||||
"go.faq.a5.contactAfter": "hvis du har spørgsmål.",
|
||||
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
|
||||
"go.faq.a5.exceptionsLink": "følgende undtagelser",
|
||||
|
|
|
|||
|
|
@ -338,8 +338,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Jederzeit kündbar.",
|
||||
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
|
||||
"go.faq.a5.body":
|
||||
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
|
||||
"go.faq.a5.contactAfter": "wenn du Fragen hast.",
|
||||
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
|
||||
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",
|
||||
|
|
|
|||
|
|
@ -331,8 +331,8 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Cancel any time.",
|
||||
"go.faq.q5": "What about data and privacy?",
|
||||
"go.faq.a5.body":
|
||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
|
||||
"go.faq.a5.contactAfter": "if you have any questions.",
|
||||
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
|
||||
"go.faq.a5.exceptionsLink": "following exceptions",
|
||||
|
|
|
|||
|
|
@ -338,8 +338,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Cancela en cualquier momento.",
|
||||
"go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
|
||||
"go.faq.a5.body":
|
||||
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
|
||||
"go.faq.a5.contactAfter": "si tienes alguna pregunta.",
|
||||
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
|
||||
"go.faq.a5.exceptionsLink": "siguientes excepciones",
|
||||
|
|
|
|||
|
|
@ -340,8 +340,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Annulez à tout moment.",
|
||||
"go.faq.q5": "Et pour les données et la confidentialité ?",
|
||||
"go.faq.a5.body":
|
||||
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
|
||||
"go.faq.a5.contactAfter": "si vous avez des questions.",
|
||||
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
|
||||
"go.faq.a5.exceptionsLink": "exceptions suivantes",
|
||||
|
|
|
|||
|
|
@ -334,8 +334,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Annulla in qualsiasi momento.",
|
||||
"go.faq.q5": "E per quanto riguarda dati e privacy?",
|
||||
"go.faq.a5.body":
|
||||
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
|
||||
"go.faq.a5.contactAfter": "se hai domande.",
|
||||
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
|
||||
"go.faq.a5.exceptionsLink": "seguenti eccezioni",
|
||||
|
|
|
|||
|
|
@ -335,8 +335,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "いつでもキャンセル可能です。",
|
||||
"go.faq.q5": "データとプライバシーは?",
|
||||
"go.faq.a5.body":
|
||||
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
|
||||
"go.faq.a5.contactAfter": "ご質問がございましたら。",
|
||||
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
|
||||
"go.faq.a5.exceptionsLink": "以下の例外",
|
||||
|
|
|
|||
|
|
@ -331,8 +331,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "언제든지 취소할 수 있습니다.",
|
||||
"go.faq.q5": "데이터와 프라이버시는 어떤가요?",
|
||||
"go.faq.a5.body":
|
||||
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
|
||||
"go.faq.a5.contactAfter": "질문이 있으시면 언제든지 문의해 주세요.",
|
||||
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
|
||||
"go.faq.a5.exceptionsLink": "다음 예외",
|
||||
|
|
|
|||
|
|
@ -335,8 +335,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Avslutt når som helst.",
|
||||
"go.faq.q5": "Hva med data og personvern?",
|
||||
"go.faq.a5.body":
|
||||
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
|
||||
"go.faq.a5.contactAfter": "hvis du har spørsmål.",
|
||||
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
|
||||
"go.faq.a5.exceptionsLink": "følgende unntak",
|
||||
|
|
|
|||
|
|
@ -336,8 +336,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Anuluj w dowolnym momencie.",
|
||||
"go.faq.q5": "A co z danymi i prywatnością?",
|
||||
"go.faq.a5.body":
|
||||
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
|
||||
"go.faq.a5.contactAfter": "jeśli masz jakiekolwiek pytania.",
|
||||
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
|
||||
"go.faq.a5.exceptionsLink": "następującymi wyjątkami",
|
||||
|
|
|
|||
|
|
@ -340,8 +340,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "Отмена в любое время.",
|
||||
"go.faq.q5": "Как насчет данных и приватности?",
|
||||
"go.faq.a5.body":
|
||||
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
|
||||
"go.faq.a5.contactAfter": "если у вас есть вопросы.",
|
||||
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
|
||||
"go.faq.a5.exceptionsLink": "следующими исключениями",
|
||||
|
|
|
|||
|
|
@ -333,8 +333,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา",
|
||||
"go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?",
|
||||
"go.faq.a5.body":
|
||||
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
|
||||
"go.faq.a5.contactAfter": "หากคุณมีคำถามใดๆ",
|
||||
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
|
||||
"go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้",
|
||||
|
|
|
|||
|
|
@ -339,8 +339,7 @@ export const dict = {
|
|||
"go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.",
|
||||
"go.faq.q5": "Veri ve gizlilik ne olacak?",
|
||||
"go.faq.a5.body":
|
||||
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
|
||||
"go.faq.a5.contactAfter": "herhangi bir sorunuz varsa.",
|
||||
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
|
||||
"go.faq.a5.exceptionsLink": "aşağıdaki istisnalar",
|
||||
|
|
|
|||
|
|
@ -319,8 +319,8 @@ export const dict = {
|
|||
"go.faq.a4.p2.accountLink": "账户",
|
||||
"go.faq.a4.p3": "中管理订阅。随时取消。",
|
||||
"go.faq.q5": "数据和隐私如何?",
|
||||
"go.faq.a5.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
|
||||
"go.faq.a5.contactAfter": "如果您有任何问题。",
|
||||
"go.faq.a5.body":
|
||||
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。",
|
||||
"go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
|
||||
"go.faq.a5.exceptionsLink": "以下例外情况除外",
|
||||
"go.faq.q6": "我可以充值余额吗?",
|
||||
|
|
|
|||
|
|
@ -319,8 +319,8 @@ export const dict = {
|
|||
"go.faq.a4.p2.accountLink": "帳戶",
|
||||
"go.faq.a4.p3": "中管理訂閱。隨時取消。",
|
||||
"go.faq.q5": "資料與隱私怎麼辦?",
|
||||
"go.faq.a5.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
|
||||
"go.faq.a5.contactAfter": "如果你有任何問題。",
|
||||
"go.faq.a5.body":
|
||||
"該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。",
|
||||
"go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
|
||||
"go.faq.a5.exceptionsLink": "以下例外",
|
||||
"go.faq.q6": "我可以儲值額度嗎?",
|
||||
|
|
|
|||
|
|
@ -432,10 +432,7 @@ export default function Home() {
|
|||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q5")}>
|
||||
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>{" "}
|
||||
{i18n.t("go.faq.a5.contactAfter")}
|
||||
</Faq>
|
||||
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>
|
||||
|
|
|
|||
|
|
@ -139,19 +139,16 @@ export async function handler(
|
|||
const startTimestamp = Date.now()
|
||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
|
||||
const reqBody = JSON.stringify(
|
||||
providerInfo.modifyBody(
|
||||
{
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerInfo.payloadMappings ?? {})
|
||||
.map(([k, v]) => [k, input.request.headers.get(v)])
|
||||
.filter(([_k, v]) => !!v),
|
||||
),
|
||||
},
|
||||
authInfo?.workspaceID,
|
||||
),
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerInfo.payloadMappings ?? {})
|
||||
.map(([k, v]) => [k, input.request.headers.get(v)])
|
||||
.filter(([_k, v]) => !!v),
|
||||
),
|
||||
}),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
||||
|
|
@ -470,15 +467,17 @@ export async function handler(
|
|||
...(() => {
|
||||
const providerProps = zenData.providers[modelProvider.id]
|
||||
const format = providerProps.format
|
||||
const providerModel = modelProvider.model
|
||||
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
|
||||
if (format === "google") return googleHelper({ reqModel, providerModel })
|
||||
if (format === "openai") return openaiHelper({ reqModel, providerModel })
|
||||
return oaCompatHelper({
|
||||
const opts = {
|
||||
reqModel,
|
||||
providerModel,
|
||||
providerModel: modelProvider.model,
|
||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||
})
|
||||
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
|
||||
workspaceID: authInfo?.workspaceID,
|
||||
}
|
||||
if (format === "anthropic") return anthropicHelper(opts)
|
||||
if (format === "google") return googleHelper(opts)
|
||||
if (format === "openai") return openaiHelper(opts)
|
||||
return oaCompatHelper(opts)
|
||||
})(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,17 +21,18 @@ type Usage = {
|
|||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
|
||||
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
|
||||
return {
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
|
||||
}
|
||||
},
|
||||
createBinaryStreamDecoder: () => undefined,
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ type Usage = {
|
|||
total_tokens?: number
|
||||
}
|
||||
|
||||
export const openaiHelper: ProviderHelper = () => ({
|
||||
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
format: "openai",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/responses",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
|
||||
modifyBody: (body: Record<string, any>) => ({
|
||||
...body,
|
||||
...(workspaceID ? { safety_identifier: workspaceID } : {}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -33,11 +33,17 @@ export type UsageInfo = {
|
|||
cacheWrite1hTokens?: number
|
||||
}
|
||||
|
||||
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
|
||||
export type ProviderHelper = (input: {
|
||||
reqModel: string
|
||||
providerModel: string
|
||||
adjustCacheUsage?: boolean
|
||||
safetyIdentifier?: string
|
||||
workspaceID?: string
|
||||
}) => {
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, isStream?: boolean) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
|
||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
|
||||
streamSeparator: string
|
||||
createUsageParser: () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
39
packages/console/core/script/freeze-workspace.ts
Normal file
39
packages/console/core/script/freeze-workspace.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Billing } from "../src/billing.js"
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { BillingTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { microCentsToCents } from "../src/util/price.js"
|
||||
|
||||
// get input from command line
|
||||
const workspaceID = process.argv[2]
|
||||
|
||||
if (!workspaceID) {
|
||||
console.error("Usage: bun freeze-workspace.ts <workspaceID>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// check workspace exists
|
||||
const workspace = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.id, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!workspace) {
|
||||
console.error("Error: Workspace not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
const amountInDollars = microCentsToCents(billing.balance) / 100
|
||||
await Billing.grantCredit(workspaceID, 0 - amountInDollars)
|
||||
|
||||
console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)
|
||||
|
|
@ -18,8 +18,9 @@ import { ModelTable } from "../src/schema/model.sql.js"
|
|||
|
||||
// get input from command line
|
||||
const identifier = process.argv[2]
|
||||
const verbose = process.argv[process.argv.length - 1] === "-v"
|
||||
if (!identifier) {
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -223,93 +224,68 @@ async function printWorkspace(workspaceID: string) {
|
|||
),
|
||||
)
|
||||
|
||||
await printTable("28-Day Usage", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
|
||||
requests: sql<number>`COUNT(*)`.as("requests"),
|
||||
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
|
||||
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
|
||||
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
|
||||
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
|
||||
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
|
||||
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
|
||||
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UsageTable.workspaceID, workspace.id),
|
||||
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
|
||||
if (verbose) {
|
||||
await printTable("28-Day Usage", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
|
||||
requests: sql<number>`COUNT(*)`.as("requests"),
|
||||
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
|
||||
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
|
||||
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
|
||||
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
|
||||
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
|
||||
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
|
||||
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(UsageTable.workspaceID, workspace.id),
|
||||
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
|
||||
),
|
||||
)
|
||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
|
||||
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
|
||||
.then((rows) => {
|
||||
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
|
||||
const mapped = rows.map((row) => ({
|
||||
...row,
|
||||
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
|
||||
}))
|
||||
if (mapped.length > 0) {
|
||||
mapped.push({
|
||||
date: "TOTAL",
|
||||
requests: null as any,
|
||||
inputTokens: null as any,
|
||||
outputTokens: null as any,
|
||||
reasoningTokens: null as any,
|
||||
cacheReadTokens: null as any,
|
||||
cacheWrite5mTokens: null as any,
|
||||
cacheWrite1hTokens: null as any,
|
||||
cost: `$${(totalCost / 100000000).toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
return mapped
|
||||
}),
|
||||
)
|
||||
await printTable("Disabled Models", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
timeCreated: ModelTable.timeCreated,
|
||||
})
|
||||
.from(ModelTable)
|
||||
.where(eq(ModelTable.workspaceID, workspace.id))
|
||||
.orderBy(sql`${ModelTable.timeCreated} DESC`)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
model: row.model,
|
||||
timeCreated: formatDate(row.timeCreated),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
|
||||
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
|
||||
.then((rows) => {
|
||||
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
|
||||
const mapped = rows.map((row) => ({
|
||||
...row,
|
||||
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
|
||||
}))
|
||||
if (mapped.length > 0) {
|
||||
mapped.push({
|
||||
date: "TOTAL",
|
||||
requests: null as any,
|
||||
inputTokens: null as any,
|
||||
outputTokens: null as any,
|
||||
reasoningTokens: null as any,
|
||||
cacheReadTokens: null as any,
|
||||
cacheWrite5mTokens: null as any,
|
||||
cacheWrite1hTokens: null as any,
|
||||
cost: `$${(totalCost / 100000000).toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
return mapped
|
||||
}),
|
||||
)
|
||||
/*
|
||||
await printTable("Usage", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: UsageTable.model,
|
||||
provider: UsageTable.provider,
|
||||
inputTokens: UsageTable.inputTokens,
|
||||
outputTokens: UsageTable.outputTokens,
|
||||
reasoningTokens: UsageTable.reasoningTokens,
|
||||
cacheReadTokens: UsageTable.cacheReadTokens,
|
||||
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
|
||||
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
|
||||
cost: UsageTable.cost,
|
||||
timeCreated: UsageTable.timeCreated,
|
||||
})
|
||||
.from(UsageTable)
|
||||
.where(eq(UsageTable.workspaceID, workspace.id))
|
||||
.orderBy(sql`${UsageTable.timeCreated} DESC`)
|
||||
.limit(10)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
cost: `$${(row.cost / 100000000).toFixed(2)}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
await printTable("Disabled Models", (tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
timeCreated: ModelTable.timeCreated,
|
||||
})
|
||||
.from(ModelTable)
|
||||
.where(eq(ModelTable.workspaceID, workspace.id))
|
||||
.orderBy(sql`${ModelTable.timeCreated} DESC`)
|
||||
.then((rows) =>
|
||||
rows.map((row) => ({
|
||||
model: row.model,
|
||||
timeCreated: formatDate(row.timeCreated),
|
||||
})),
|
||||
),
|
||||
)
|
||||
*/
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMicroCents(value: number | null | undefined) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
|
|||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||
const PARTS = 30
|
||||
|
||||
// read the secret
|
||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const lines = ret.split("\n")
|
||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||
const value = lines
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import os from "os"
|
|||
import { Subscription } from "../src/subscription"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
||||
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
|
||||
// read value
|
||||
const lines = secrets.split("\n")
|
||||
|
|
@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
|||
Subscription.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_LIMITS ${newValue}`
|
||||
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import os from "os"
|
|||
import { ZenData } from "../src/model"
|
||||
|
||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||
const models = await $`bun sst secret list`.cwd(root).text()
|
||||
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||
const PARTS = 30
|
||||
|
||||
// read the line starting with "ZEN_MODELS"
|
||||
|
|
@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
|
|||
|
||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
||||
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export namespace ZenData {
|
|||
disabled: z.boolean().optional(),
|
||||
storeModel: z.string().optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
safetyIdentifier: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
export function centsToMicroCents(amount: number) {
|
||||
return Math.round(amount * 1000000)
|
||||
}
|
||||
|
||||
export function microCentsToCents(amount: number) {
|
||||
return Math.round(amount / 1000000)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.3"
|
||||
version = "1.3.10"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.10",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
"@ai-sdk/provider-utils": "4.0.21",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.74",
|
||||
"@ai-sdk/xai": "3.0.75",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
|
|
@ -103,8 +103,8 @@
|
|||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||
"@opentui/core": "0.1.91",
|
||||
"@opentui/solid": "0.1.91",
|
||||
"@opentui/core": "0.1.93",
|
||||
"@opentui/solid": "0.1.93",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
|
|
@ -144,6 +144,7 @@
|
|||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
|||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
||||
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
|
||||
const requirePaid = process.env.OPENCODE_E2E_REQUIRE_PAID === "true"
|
||||
const parts = model.split("/")
|
||||
const providerID = parts[0] ?? "opencode"
|
||||
const modelID = parts[1] ?? "gpt-5-nano"
|
||||
|
|
@ -11,6 +12,7 @@ const seed = async () => {
|
|||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { Provider } = await import("../src/provider/provider")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
|
|
@ -25,6 +27,19 @@ const seed = async () => {
|
|||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
|
||||
if (requirePaid && providerID === "opencode" && !process.env.OPENCODE_API_KEY) {
|
||||
throw new Error("OPENCODE_API_KEY is required when OPENCODE_E2E_REQUIRE_PAID=true")
|
||||
}
|
||||
|
||||
const info = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID))
|
||||
if (requirePaid) {
|
||||
const paid =
|
||||
info.cost.input > 0 || info.cost.output > 0 || info.cost.cache.read > 0 || info.cost.cache.write > 0
|
||||
if (!paid) {
|
||||
throw new Error(`OPENCODE_E2E_MODEL must resolve to a paid model: ${providerID}/${modelID}`)
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
|
|
|
|||
|
|
@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Worktree` — `worktree/index.ts`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [x] `Session` — `session/index.ts`
|
||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
||||
- [x] `SessionProcessor` — `session/processor.ts`
|
||||
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
|
||||
Other services not yet migrated:
|
||||
Still open:
|
||||
|
||||
- [ ] `SessionSummary` — `session/summary.ts`
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
|
|
@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
|
|||
|
||||
1. Migrate each tool to return Effects
|
||||
2. Update `Tool.define()` factory to work with Effects
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
||||
|
||||
Individual tools, ordered by value:
|
||||
|
||||
|
|
|
|||
|
|
@ -84,17 +84,27 @@ export default plugin
|
|||
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
|
||||
- A single module cannot export both `server` and `tui`.
|
||||
- `tui` signature is `(api, options, meta) => Promise<void>`.
|
||||
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
|
||||
- If package `exports` contains `./tui`, the loader resolves that entrypoint.
|
||||
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
||||
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
||||
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
||||
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
|
||||
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
||||
- File/path plugins must export a non-empty `id`.
|
||||
- npm plugins may omit `id`; package `name` is used.
|
||||
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
|
||||
- If a path spec points at a directory, that directory must have `package.json` with `main`.
|
||||
- If a path spec points at a directory, server loading can use `package.json` `main`.
|
||||
- TUI path loading never uses `package.json` `main`.
|
||||
- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
|
||||
- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
|
||||
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
|
||||
|
||||
## Package manifest and install
|
||||
|
||||
Package manifest is read from `package.json` field `oc-plugin`.
|
||||
Install target detection is inferred from `package.json` entrypoints:
|
||||
|
||||
- `server` target when `exports["./server"]` exists or `main` is set.
|
||||
- `tui` target when `exports["./tui"]` exists.
|
||||
|
||||
Example:
|
||||
|
||||
|
|
@ -102,14 +112,20 @@ Example:
|
|||
{
|
||||
"name": "@acme/opencode-plugin",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"main": "./dist/server.js",
|
||||
"exports": {
|
||||
"./server": {
|
||||
"import": "./dist/server.js",
|
||||
"config": { "custom": true }
|
||||
},
|
||||
"./tui": {
|
||||
"import": "./dist/tui.js",
|
||||
"config": { "compact": true }
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"opencode": "^1.0.0"
|
||||
},
|
||||
"oc-plugin": [
|
||||
["server", { "custom": true }],
|
||||
["tui", { "compact": true }]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -138,10 +154,16 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
|||
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
||||
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
|
||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
||||
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
||||
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
|
||||
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
|
||||
- Without `--force`, an already-configured npm package name is a no-op.
|
||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
|
||||
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
|
||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||
- A package can target `server`, `tui`, or both.
|
||||
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
||||
|
|
@ -164,7 +186,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
|||
- `api.app.version`
|
||||
- `api.command.register(cb)` / `api.command.trigger(value)`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
|
||||
- `api.keybind.match`, `print`, `create`
|
||||
- `api.tuiConfig`
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
|
|
@ -210,6 +232,7 @@ Command behavior:
|
|||
|
||||
- `ui.Dialog` is the base dialog wrapper.
|
||||
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||
- `ui.Prompt` renders the same prompt component used by the host app.
|
||||
- `ui.toast(...)` shows a toast.
|
||||
- `ui.dialog` exposes the host dialog stack:
|
||||
- `replace(render, onClose?)`
|
||||
|
|
@ -266,7 +289,9 @@ Theme install behavior:
|
|||
|
||||
- Relative theme paths are resolved from the plugin root.
|
||||
- Theme name is the JSON basename.
|
||||
- Install is skipped if that theme name already exists.
|
||||
- First install writes only when the destination file is missing.
|
||||
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
|
||||
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
|
||||
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
|
||||
- Global plugins persist installed themes under the global `themes` dir.
|
||||
- Invalid or unreadable theme files are ignored.
|
||||
|
|
@ -277,6 +302,7 @@ Current host slot names:
|
|||
|
||||
- `app`
|
||||
- `home_logo`
|
||||
- `home_prompt` with props `{ workspace_id? }`
|
||||
- `home_bottom`
|
||||
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||
- `sidebar_content` with props `{ session_id }`
|
||||
|
|
@ -289,7 +315,7 @@ Slot notes:
|
|||
- `api.slots.register(plugin)` does not return an unregister function.
|
||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||
- Plugin-provided `id` is not allowed.
|
||||
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
|
||||
- Plugins cannot define new slot names in this branch.
|
||||
|
||||
### Plugin control and lifecycle
|
||||
|
|
@ -305,7 +331,6 @@ Slot notes:
|
|||
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
||||
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export namespace Agent {
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
|||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
|
|
@ -53,17 +53,13 @@ export namespace Auth {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
},
|
||||
catch: fail("Failed to read auth data"),
|
||||
}),
|
||||
)
|
||||
const all = Effect.fn("Auth.all")(function* () {
|
||||
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||
})
|
||||
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
|
|
@ -74,10 +70,9 @@ export namespace Auth {
|
|||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
delete data[norm + "/"]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
yield* fsys
|
||||
.writeJson(file, { ...data, [norm]: info }, 0o600)
|
||||
.pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
|
|
@ -85,17 +80,16 @@ export namespace Auth {
|
|||
const data = yield* all()
|
||||
delete data[key]
|
||||
delete data[norm]
|
||||
yield* Effect.tryPromise({
|
||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||
catch: fail("Failed to write auth data"),
|
||||
})
|
||||
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export namespace BunProc {
|
|||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
|
|
@ -82,6 +82,7 @@ export namespace BunProc {
|
|||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
|
|
|
|||
|
|
@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
|
|||
|
||||
if (manifest.code === "manifest_no_targets") {
|
||||
inspect.stop("No plugin targets found", 1)
|
||||
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
|
||||
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
|
||||
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
||||
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,9 +121,11 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
|||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
|
|
@ -249,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
|
|
@ -588,6 +589,18 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
local.model.variant.cycle()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch model variant",
|
||||
value: "variant.list",
|
||||
category: "Agent",
|
||||
hidden: local.model.variant.list().length === 0,
|
||||
slash: {
|
||||
name: "variants",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle reverse",
|
||||
value: "agent.cycle.reverse",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,13 @@ export function DialogModel(props: { providerID?: string }) {
|
|||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
if (local.model.variant.list().length > 0) {
|
||||
const list = local.model.variant.list()
|
||||
const cur = local.model.variant.selected()
|
||||
if (cur === "default" || (cur && list.includes(cur))) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (list.length > 0) {
|
||||
dialog.replace(() => <DialogVariant />)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,32 @@ export function DialogVariant() {
|
|||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(undefined)
|
||||
},
|
||||
},
|
||||
}))
|
||||
...local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.current()}
|
||||
current={local.model.variant.selected()}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ export type PromptProps = {
|
|||
ref?: (ref: PromptRef) => void
|
||||
hint?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
shell?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptRef = {
|
||||
|
|
@ -57,13 +61,16 @@ export type PromptRef = {
|
|||
submit(): void
|
||||
}
|
||||
|
||||
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
|
||||
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function randomIndex(count: number) {
|
||||
if (count <= 0) return 0
|
||||
return Math.floor(Math.random() * count)
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
|
|
@ -83,6 +90,8 @@ export function Prompt(props: PromptProps) {
|
|||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
|
|
@ -152,7 +161,7 @@ export function Prompt(props: PromptProps) {
|
|||
interrupt: number
|
||||
placeholder: number
|
||||
}>({
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
placeholder: randomIndex(list().length),
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
|
|
@ -166,7 +175,7 @@ export function Prompt(props: PromptProps) {
|
|||
on(
|
||||
() => props.sessionID,
|
||||
() => {
|
||||
setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length))
|
||||
setStore("placeholder", randomIndex(list().length))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
|
@ -801,12 +810,14 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.sessionID) return undefined
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
if (store.mode === "shell") {
|
||||
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
|
||||
if (!shell().length) return undefined
|
||||
const example = shell()[store.placeholder % shell().length]
|
||||
return `Run a command... "${example}"`
|
||||
}
|
||||
return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"`
|
||||
if (!list().length) return undefined
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
|
|
@ -870,6 +881,7 @@ export function Prompt(props: PromptProps) {
|
|||
>
|
||||
<textarea
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={keybind.leader ? theme.textMuted : theme.text}
|
||||
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
|
||||
minHeight={1}
|
||||
|
|
@ -921,7 +933,7 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
}
|
||||
if (e.name === "!" && input.visualCursor.offset === 0) {
|
||||
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setStore("mode", "shell")
|
||||
e.preventDefault()
|
||||
return
|
||||
|
|
@ -1096,7 +1108,7 @@ export function Prompt(props: PromptProps) {
|
|||
/>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={<text />}>
|
||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
|
|
|
|||
|
|
@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
},
|
||||
variant: {
|
||||
current() {
|
||||
selected() {
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
return modelStore.variant[key]
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (!v) return undefined
|
||||
if (!this.list().includes(v)) return undefined
|
||||
return v
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
if (!m) return []
|
||||
|
|
@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
const m = currentModel()
|
||||
if (!m) return
|
||||
const key = `${m.providerID}/${m.modelID}`
|
||||
setModelStore("variant", key, value)
|
||||
setModelStore("variant", key, value ?? "default")
|
||||
save()
|
||||
},
|
||||
cycle() {
|
||||
|
|
|
|||
|
|
@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
|
|||
return true
|
||||
}
|
||||
|
||||
export function upsertTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (customThemes[name] !== undefined) {
|
||||
customThemes[name] = theme
|
||||
} else {
|
||||
pluginThemes[name] = theme
|
||||
}
|
||||
syncThemes()
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@
|
|||
"light": "frappeText"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "frappeSubtext1",
|
||||
"light": "frappeSubtext1"
|
||||
"dark": "frappeOverlay2",
|
||||
"light": "frappeOverlay2"
|
||||
},
|
||||
"background": {
|
||||
"dark": "frappeBase",
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@
|
|||
"light": "macText"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "macSubtext1",
|
||||
"light": "macSubtext1"
|
||||
"dark": "macOverlay2",
|
||||
"light": "macOverlay2"
|
||||
},
|
||||
"background": {
|
||||
"dark": "macBase",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
||||
"info": { "dark": "darkTeal", "light": "lightTeal" },
|
||||
"text": { "dark": "darkText", "light": "lightText" },
|
||||
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
|
||||
"textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
|
||||
"background": { "dark": "darkBase", "light": "lightBase" },
|
||||
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
|
||||
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { Global } from "@/global"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const dir = createMemo(() => {
|
||||
const dir = props.api.state.path.directory || process.cwd()
|
||||
const out = dir.replace(Global.Path.home, "~")
|
||||
const branch = props.api.state.vcs?.branch
|
||||
if (branch) return out + ":" + branch
|
||||
return out
|
||||
})
|
||||
|
||||
return <text fg={theme().textMuted}>{dir()}</text>
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.mcp())
|
||||
const has = createMemo(() => list().length > 0)
|
||||
const err = createMemo(() => list().some((item) => item.status === "failed"))
|
||||
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
|
||||
|
||||
return (
|
||||
<Show when={has()}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme().text}>
|
||||
<Switch>
|
||||
<Match when={err()}>
|
||||
<span style={{ fg: theme().error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{count()} MCP
|
||||
</text>
|
||||
<text fg={theme().textMuted}>/status</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Version(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme().textMuted}>{props.api.app.version}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory api={props.api} />
|
||||
<Mcp api={props.api} />
|
||||
<box flexGrow={1} />
|
||||
<Version api={props.api} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
home_footer() {
|
||||
return <View api={api} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
|
@ -14,6 +14,7 @@ import { DialogAlert } from "../ui/dialog-alert"
|
|||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||
import { Prompt } from "../component/prompt"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import { Installation } from "@/installation"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
|
@ -287,6 +288,19 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
|
|||
/>
|
||||
)
|
||||
},
|
||||
Prompt(props) {
|
||||
return (
|
||||
<Prompt
|
||||
workspaceID={props.workspaceID}
|
||||
visible={props.visible}
|
||||
disabled={props.disabled}
|
||||
onSubmit={props.onSubmit}
|
||||
hint={props.hint}
|
||||
showPlaceholder={props.showPlaceholder}
|
||||
placeholders={props.placeholders}
|
||||
/>
|
||||
)
|
||||
},
|
||||
toast(inputToast) {
|
||||
input.toast.show({
|
||||
title: inputToast.title,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import HomeFooter from "../feature-plugins/home/footer"
|
||||
import HomeTips from "../feature-plugins/home/tips"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
|
|
@ -14,6 +15,7 @@ export type InternalTuiPlugin = TuiPluginModule & {
|
|||
}
|
||||
|
||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
|
|
|
|||
|
|
@ -18,38 +18,29 @@ import { Log } from "@/util/log"
|
|||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
isDeprecatedPlugin,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
readV1Plugin,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "@/plugin/shared"
|
||||
import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
|
||||
import { PluginLoader } from "@/plugin/loader"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||
import { addTheme, hasTheme } from "../context/theme"
|
||||
import { hasTheme, upsertTheme } from "../context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
|
||||
type PluginLoad = {
|
||||
item?: Config.PluginSpec
|
||||
options: Config.PluginOptions | undefined
|
||||
spec: string
|
||||
target: string
|
||||
retry: boolean
|
||||
source: PluginSource | "internal"
|
||||
id: string
|
||||
module: TuiPluginModule
|
||||
install_theme: TuiTheme["install"]
|
||||
theme_meta: TuiConfig.PluginMeta
|
||||
theme_root: string
|
||||
}
|
||||
|
||||
type Api = HostPluginApi
|
||||
|
|
@ -64,8 +55,8 @@ type PluginEntry = {
|
|||
id: string
|
||||
load: PluginLoad
|
||||
meta: TuiPluginMeta
|
||||
themes: Record<string, PluginMeta.Theme>
|
||||
plugin: TuiPlugin
|
||||
options: Config.PluginOptions | undefined
|
||||
enabled: boolean
|
||||
scope?: PluginScope
|
||||
}
|
||||
|
|
@ -76,13 +67,7 @@ type RuntimeState = {
|
|||
slots: HostSlots
|
||||
plugins: PluginEntry[]
|
||||
plugins_by_id: Map<string, PluginEntry>
|
||||
pending: Map<
|
||||
string,
|
||||
{
|
||||
item: Config.PluginSpec
|
||||
meta: TuiConfig.PluginMeta
|
||||
}
|
||||
>
|
||||
pending: Map<string, TuiConfig.PluginRecord>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
|
|
@ -102,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
|
|||
console.error(`[tui.plugin] ${text}`, next)
|
||||
}
|
||||
|
||||
function warn(message: string, data: Record<string, unknown>) {
|
||||
log.warn(message, data)
|
||||
console.warn(`[tui.plugin] ${message}`, data)
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
|
|
@ -143,12 +133,54 @@ function resolveRoot(root: string) {
|
|||
return path.resolve(process.cwd(), root)
|
||||
}
|
||||
|
||||
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||
function createThemeInstaller(
|
||||
meta: TuiConfig.PluginMeta,
|
||||
root: string,
|
||||
spec: string,
|
||||
plugin: PluginEntry,
|
||||
): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||
const theme = path.basename(src, path.extname(src))
|
||||
if (hasTheme(theme)) return
|
||||
const name = path.basename(src, path.extname(src))
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${name}.json`)
|
||||
const stat = await Filesystem.statAsync(src)
|
||||
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
|
||||
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
|
||||
const exists = hasTheme(name)
|
||||
const prev = plugin.themes[name]
|
||||
|
||||
if (exists) {
|
||||
if (plugin.meta.state !== "updated") return
|
||||
if (!prev) {
|
||||
if (await Filesystem.exists(dest)) {
|
||||
plugin.themes[name] = {
|
||||
src,
|
||||
dest,
|
||||
mtime,
|
||||
size,
|
||||
}
|
||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
||||
log.warn("failed to track tui plugin theme", {
|
||||
path: spec,
|
||||
id: plugin.id,
|
||||
theme: src,
|
||||
dest,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (prev.dest !== dest) return
|
||||
if (prev.mtime === mtime && prev.size === size) return
|
||||
}
|
||||
|
||||
const text = await Filesystem.readText(src).catch((error) => {
|
||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||
|
|
@ -170,90 +202,110 @@ function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: st
|
|||
return
|
||||
}
|
||||
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
if (exists || !(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text).catch((error) => {
|
||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||
})
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
upsertTheme(name, data)
|
||||
plugin.themes[name] = {
|
||||
src,
|
||||
dest,
|
||||
mtime,
|
||||
size,
|
||||
}
|
||||
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
|
||||
log.warn("failed to track tui plugin theme", {
|
||||
path: spec,
|
||||
id: plugin.id,
|
||||
theme: src,
|
||||
dest,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalPlugin(
|
||||
item: Config.PluginSpec,
|
||||
meta: TuiConfig.PluginMeta | undefined,
|
||||
retry = false,
|
||||
): Promise<PluginLoad | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading tui plugin", { path: spec, retry })
|
||||
const resolved = await resolvePluginTarget(spec).catch((error) => {
|
||||
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!resolved) return
|
||||
async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
|
||||
const plan = PluginLoader.plan(cfg.item)
|
||||
if (plan.deprecated) return
|
||||
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
fail("tui plugin incompatible", { path: spec, retry, error })
|
||||
return false
|
||||
log.info("loading tui plugin", { path: plan.spec, retry })
|
||||
const resolved = await PluginLoader.resolve(plan, "tui")
|
||||
if (!resolved.ok) {
|
||||
if (resolved.stage === "missing") {
|
||||
warn("tui plugin has no entrypoint", {
|
||||
path: plan.spec,
|
||||
retry,
|
||||
message: resolved.message,
|
||||
})
|
||||
if (!ok) return
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.stage === "install") {
|
||||
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
||||
return
|
||||
}
|
||||
if (resolved.stage === "compatibility") {
|
||||
fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
|
||||
return
|
||||
}
|
||||
fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
|
||||
return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
if (!meta) {
|
||||
fail("missing tui plugin metadata", {
|
||||
path: spec,
|
||||
const loaded = await PluginLoader.load(resolved.value)
|
||||
if (!loaded.ok) {
|
||||
fail("failed to load tui plugin", {
|
||||
path: plan.spec,
|
||||
target: resolved.value.entry,
|
||||
retry,
|
||||
error: loaded.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const root = resolveRoot(source === "file" ? spec : target)
|
||||
const install_theme = createThemeInstaller(meta, root, spec)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
|
||||
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry)
|
||||
.then((raw) => {
|
||||
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule
|
||||
const mod = await Promise.resolve()
|
||||
.then(() => {
|
||||
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
|
||||
fail("failed to load tui plugin", {
|
||||
path: plan.spec,
|
||||
target: loaded.value.entry,
|
||||
retry,
|
||||
error,
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target, retry, error })
|
||||
const id = await resolvePluginId(
|
||||
loaded.value.source,
|
||||
plan.spec,
|
||||
loaded.value.target,
|
||||
readPluginId(mod.id, plan.spec),
|
||||
loaded.value.pkg,
|
||||
).catch((error) => {
|
||||
fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!id) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
options: plan.options,
|
||||
spec: plan.spec,
|
||||
target: loaded.value.target,
|
||||
retry,
|
||||
source,
|
||||
source: loaded.value.source,
|
||||
id,
|
||||
module: mod,
|
||||
install_theme,
|
||||
theme_meta: {
|
||||
scope: cfg.scope,
|
||||
source: cfg.source,
|
||||
},
|
||||
theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,20 +343,18 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
|||
const target = spec
|
||||
|
||||
return {
|
||||
options: undefined,
|
||||
spec,
|
||||
target,
|
||||
retry: false,
|
||||
source: "internal",
|
||||
id: item.id,
|
||||
module: item,
|
||||
install_theme: createThemeInstaller(
|
||||
{
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
process.cwd(),
|
||||
spec,
|
||||
),
|
||||
theme_meta: {
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
theme_root: process.cwd(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -436,10 +486,10 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
|
|||
if (plugin.scope) return true
|
||||
|
||||
const scope = createPluginScope(plugin.load, plugin.id)
|
||||
const api = pluginApi(state, plugin.load, scope, plugin.id)
|
||||
const api = pluginApi(state, plugin, scope, plugin.id)
|
||||
const ok = await Promise.resolve()
|
||||
.then(async () => {
|
||||
await plugin.plugin(api, plugin.options, plugin.meta)
|
||||
await plugin.plugin(api, plugin.load.options, plugin.meta)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -479,9 +529,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
|
|||
return deactivatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
|
||||
function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
|
||||
const api = runtime.api
|
||||
const host = runtime.slots
|
||||
const load = plugin.load
|
||||
const command: TuiPluginApi["command"] = {
|
||||
register(cb) {
|
||||
return scope.track(api.command.register(cb))
|
||||
|
|
@ -504,7 +555,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
|
|||
}
|
||||
|
||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||
install: load.install_theme,
|
||||
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
|
||||
})
|
||||
|
||||
const event: TuiPluginApi["event"] = {
|
||||
|
|
@ -563,20 +614,6 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope,
|
|||
}
|
||||
}
|
||||
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
return [
|
||||
{
|
||||
id: load.id,
|
||||
load,
|
||||
meta,
|
||||
plugin: load.module.tui,
|
||||
options,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
||||
if (state.plugins_by_id.has(plugin.id)) {
|
||||
fail("duplicate tui plugin id", {
|
||||
|
|
@ -600,12 +637,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveExternalPlugins(
|
||||
list: Config.PluginSpec[],
|
||||
wait: () => Promise<void>,
|
||||
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
|
||||
) {
|
||||
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
|
||||
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
|
||||
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
|
||||
const ready: PluginLoad[] = []
|
||||
let deps: Promise<void> | undefined
|
||||
|
||||
|
|
@ -614,13 +647,12 @@ async function resolveExternalPlugins(
|
|||
if (!entry) {
|
||||
const item = list[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (pluginSource(spec) !== "file") continue
|
||||
if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
|
||||
deps ??= wait().catch((error) => {
|
||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||
})
|
||||
await deps
|
||||
entry = await loadExternalPlugin(item, meta(item), true)
|
||||
entry = await loadExternalPlugin(item, true)
|
||||
}
|
||||
if (!entry) continue
|
||||
ready.push(entry)
|
||||
|
|
@ -661,20 +693,28 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
|
|||
}
|
||||
|
||||
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, row)) {
|
||||
if (!addPluginEntry(state, plugin)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
plugins.push(plugin)
|
||||
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
|
||||
const plugin: PluginEntry = {
|
||||
id: entry.id,
|
||||
load: entry,
|
||||
meta: row,
|
||||
themes,
|
||||
plugin: entry.module.tui,
|
||||
enabled: true,
|
||||
}
|
||||
if (!addPluginEntry(state, plugin)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
plugins.push(plugin)
|
||||
}
|
||||
|
||||
return { plugins, ok }
|
||||
}
|
||||
|
||||
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
|
||||
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
|
||||
return {
|
||||
item: spec,
|
||||
scope: "local",
|
||||
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
||||
}
|
||||
|
|
@ -712,36 +752,27 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||
const spec = raw.trim()
|
||||
if (!spec) return false
|
||||
|
||||
const pending = state.pending.get(spec)
|
||||
const item = pending?.item ?? spec
|
||||
const nextSpec = Config.pluginSpecifier(item)
|
||||
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
|
||||
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
|
||||
const next = Config.pluginSpecifier(cfg.item)
|
||||
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
|
||||
const meta = pending?.meta ?? defaultPluginMeta(state)
|
||||
|
||||
const ready = await Instance.provide({
|
||||
directory: state.directory,
|
||||
fn: () =>
|
||||
resolveExternalPlugins(
|
||||
[item],
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
() => meta,
|
||||
),
|
||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
||||
}).catch((error) => {
|
||||
fail("failed to add tui plugin", { path: nextSpec, error })
|
||||
fail("failed to add tui plugin", { path: next, error })
|
||||
return [] as PluginLoad[]
|
||||
})
|
||||
if (!ready.length) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
return false
|
||||
}
|
||||
|
||||
const first = ready[0]
|
||||
if (!first) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
fail("failed to add tui plugin", { path: next })
|
||||
return false
|
||||
}
|
||||
if (state.plugins_by_id.has(first.id)) {
|
||||
|
|
@ -758,7 +789,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||
|
||||
if (ok) state.pending.delete(spec)
|
||||
if (!ok) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
fail("failed to add tui plugin", { path: next })
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
|
@ -806,7 +837,7 @@ async function installPluginBySpec(
|
|||
if (manifest.code === "manifest_no_targets") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `"${spec}" does not declare supported targets in package.json`,
|
||||
message: `"${spec}" does not expose plugin entrypoints in package.json`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -841,12 +872,11 @@ async function installPluginBySpec(
|
|||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||
if (tui) {
|
||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||
const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
|
||||
state.pending.set(spec, {
|
||||
item: tui.opts ? [spec, tui.opts] : spec,
|
||||
meta: {
|
||||
scope: global ? "global" : "local",
|
||||
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||
},
|
||||
item,
|
||||
scope: global ? "global" : "local",
|
||||
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -929,25 +959,26 @@ export namespace TuiPluginRuntime {
|
|||
directory: cwd,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
|
||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
|
||||
}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, meta)) {
|
||||
addPluginEntry(next, plugin)
|
||||
}
|
||||
addPluginEntry(next, {
|
||||
id: entry.id,
|
||||
load: entry,
|
||||
meta,
|
||||
themes: {},
|
||||
plugin: entry.module.tui,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
const ready = await resolveExternalPlugins(
|
||||
plugins,
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
|
||||
)
|
||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||
await addExternalPluginEntries(next, ready)
|
||||
|
||||
applyInitialPluginEnabledState(next, config)
|
||||
|
|
|
|||
|
|
@ -1,59 +1,31 @@
|
|||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createEffect, on, onMount } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useDirectory } from "../context/directory"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPluginRuntime } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
const placeholder = {
|
||||
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
|
||||
shell: ["ls -la", "git status", "pwd"],
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
|
||||
const connectedMcpCount = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||
})
|
||||
|
||||
const Hint = (
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
let prompt: PromptRef | undefined
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (!prompt) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
once = true
|
||||
|
|
@ -69,13 +41,13 @@ export function Home() {
|
|||
() => sync.ready && local.model.ready,
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
if (!prompt) return
|
||||
if (!args.prompt) return
|
||||
if (prompt.current?.input !== args.prompt) return
|
||||
prompt.submit()
|
||||
},
|
||||
),
|
||||
)
|
||||
const directory = useDirectory()
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -89,41 +61,23 @@ export function Home() {
|
|||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
hint={Hint}
|
||||
workspaceID={route.workspaceID}
|
||||
/>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
workspaceID={route.workspaceID}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</box>
|
||||
<TuiPluginRuntime.Slot name="home_bottom" />
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<Toast />
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{connectedMcpCount()} MCP
|
||||
</text>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ export function Session() {
|
|||
if (children().length === 1) return
|
||||
|
||||
const sessions = children().filter((x) => !!x.parentID)
|
||||
let next = sessions.findIndex((x) => x.id === session()?.id) + direction
|
||||
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
|
||||
|
||||
if (next >= sessions.length) next = 0
|
||||
if (next < 0) next = sessions.length - 1
|
||||
|
|
@ -1228,7 +1228,6 @@ function UserMessage(props: {
|
|||
const local = useLocal()
|
||||
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
|
||||
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||
|
|
@ -1614,17 +1613,6 @@ function GenericTool(props: ToolProps<any>) {
|
|||
)
|
||||
}
|
||||
|
||||
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text paddingLeft={3} fg={props.when ? theme.textMuted : theme.text}>
|
||||
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
|
||||
<span style={{ bold: true }}>{props.icon}</span> {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
|
|
@ -1962,10 +1950,7 @@ function WebSearch(props: ToolProps<any>) {
|
|||
}
|
||||
|
||||
function Task(props: ToolProps<typeof TaskTool>) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const { navigate } = useRoute()
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -1996,7 +1981,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||
|
||||
const content = createMemo(() => {
|
||||
if (!props.input.description) return ""
|
||||
let content = [`Task ${props.input.description}`]
|
||||
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`]
|
||||
|
||||
if (isRunning() && tools().length > 0) {
|
||||
// content[0] += ` · ${tools().length} toolcalls`
|
||||
|
|
|
|||
|
|
@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
|||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.textMuted}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,23 @@ export function SubagentFooter() {
|
|||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const session = createMemo(() => sync.session.get(route.sessionID))
|
||||
|
||||
const subagentInfo = createMemo(() => {
|
||||
const s = session()
|
||||
if (!s) return { label: "Subagent", index: 0, total: 0 }
|
||||
const agentMatch = s.title.match(/@(\w+) subagent/)
|
||||
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
|
||||
if (!s.parentID) return { label, index: 0, total: 0 }
|
||||
|
||||
const siblings = sync.data.session
|
||||
.filter((x) => x.parentID === s.parentID)
|
||||
.toSorted((a, b) => a.time.created - b.time.created)
|
||||
const index = siblings.findIndex((x) => x.id === s.id)
|
||||
|
||||
return { label, index: index + 1, total: siblings.length }
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
const msg = messages()
|
||||
|
|
@ -58,10 +75,15 @@ export function SubagentFooter() {
|
|||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
<b>{subagentInfo().label}</b>
|
||||
</text>
|
||||
<Show when={subagentInfo().total > 0}>
|
||||
<text style={{ fg: theme.textMuted }}>
|
||||
({subagentInfo().index} of {subagentInfo().total})
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage()}>
|
||||
{(item) => (
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.defaultFilename}
|
||||
placeholder="Enter filename"
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.text}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={props.busy ? theme.textMuted : theme.text}
|
||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
}, 1)
|
||||
}}
|
||||
placeholder={props.placeholder ?? "Search"}
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
|
|
@ -122,7 +121,10 @@ export namespace Config {
|
|||
const gitignore = path.join(dir, ".gitignore")
|
||||
const ignore = await Filesystem.exists(gitignore)
|
||||
if (!ignore) {
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
||||
|
|
@ -366,33 +368,18 @@ export namespace Config {
|
|||
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
if (!isPathPluginSpec(spec)) return plugin
|
||||
if (spec.startsWith("file://")) {
|
||||
const resolved = await resolvePathPluginTarget(spec).catch(() => spec)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
}
|
||||
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) {
|
||||
const base = pathToFileURL(spec).href
|
||||
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
}
|
||||
try {
|
||||
const base = import.meta.resolve!(spec, configFilepath)
|
||||
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
try {
|
||||
const require = createRequire(configFilepath)
|
||||
const base = pathToFileURL(require.resolve(spec)).href
|
||||
const resolved = await resolvePathPluginTarget(base).catch(() => base)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
|
||||
const base = path.dirname(configFilepath)
|
||||
const file = (() => {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
|
||||
return pathToFileURL(path.resolve(base, spec)).href
|
||||
})()
|
||||
|
||||
const resolved = await resolvePathPluginTarget(file).catch(() => file)
|
||||
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1556,7 +1543,7 @@ export namespace Config {
|
|||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export namespace TuiConfig {
|
|||
source: string
|
||||
}
|
||||
|
||||
export type PluginRecord = {
|
||||
item: Config.PluginSpec
|
||||
scope: PluginMeta["scope"]
|
||||
source: string
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
item: Config.PluginSpec
|
||||
meta: PluginMeta
|
||||
|
|
@ -33,7 +39,8 @@ export namespace TuiConfig {
|
|||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
plugin_meta?: Record<string, PluginMeta>
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_records?: PluginRecord[]
|
||||
}
|
||||
|
||||
function pluginScope(file: string): PluginMeta["scope"] {
|
||||
|
|
@ -149,10 +156,13 @@ export namespace TuiConfig {
|
|||
|
||||
const merged = dedupePlugins(acc.entries)
|
||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||
acc.result.plugin = merged.map((item) => item.item)
|
||||
acc.result.plugin_meta = merged.length
|
||||
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta]))
|
||||
: undefined
|
||||
const list = merged.map((item) => ({
|
||||
item: item.item,
|
||||
scope: item.meta.scope,
|
||||
source: item.meta.source,
|
||||
}))
|
||||
acc.result.plugin = list.map((item) => item.item)
|
||||
acc.result.plugin_records = list.length ? list : undefined
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: L
|
|||
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromiseExit(service.use(fn), options),
|
||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromise(service.use(fn), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||
|
|
|
|||
216
packages/opencode/src/effect/runner.ts
Normal file
216
packages/opencode/src/effect/runner.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
|
||||
|
||||
export interface Runner<A, E = never> {
|
||||
readonly state: Runner.State<A, E>
|
||||
readonly busy: boolean
|
||||
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly cancel: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export namespace Runner {
|
||||
export class Cancelled extends Schema.TaggedErrorClass<Cancelled>()("RunnerCancelled", {}) {}
|
||||
|
||||
interface RunHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
}
|
||||
|
||||
interface ShellHandle<A, E> {
|
||||
id: number
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
abort: AbortController
|
||||
}
|
||||
|
||||
interface PendingHandle<A, E> {
|
||||
id: number
|
||||
done: Deferred.Deferred<A, E | Cancelled>
|
||||
work: Effect.Effect<A, E>
|
||||
}
|
||||
|
||||
export type State<A, E> =
|
||||
| { readonly _tag: "Idle" }
|
||||
| { readonly _tag: "Running"; readonly run: RunHandle<A, E> }
|
||||
| { readonly _tag: "Shell"; readonly shell: ShellHandle<A, E> }
|
||||
| { readonly _tag: "ShellThenRun"; readonly shell: ShellHandle<A, E>; readonly run: PendingHandle<A, E> }
|
||||
|
||||
export const make = <A, E = never>(
|
||||
scope: Scope.Scope,
|
||||
opts?: {
|
||||
onIdle?: Effect.Effect<void>
|
||||
onBusy?: Effect.Effect<void>
|
||||
onInterrupt?: Effect.Effect<A, E>
|
||||
busy?: () => never
|
||||
},
|
||||
): Runner<A, E> => {
|
||||
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: "Idle" })
|
||||
const idle = opts?.onIdle ?? Effect.void
|
||||
const busy = opts?.onBusy ?? Effect.void
|
||||
const onInterrupt = opts?.onInterrupt
|
||||
let ids = 0
|
||||
|
||||
const state = () => SynchronizedRef.getUnsafe(ref)
|
||||
const next = () => {
|
||||
ids += 1
|
||||
return ids
|
||||
}
|
||||
|
||||
const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
||||
? Deferred.fail(done, new Cancelled()).pipe(Effect.asVoid)
|
||||
: Deferred.done(done, exit).pipe(Effect.asVoid)
|
||||
|
||||
const idleIfCurrent = () =>
|
||||
SynchronizedRef.modify(ref, (st) => [st._tag === "Idle" ? idle : Effect.void, st] as const).pipe(Effect.flatten)
|
||||
|
||||
const finishRun = (id: number, done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
|
||||
SynchronizedRef.modify(
|
||||
ref,
|
||||
(st) =>
|
||||
[
|
||||
Effect.gen(function* () {
|
||||
if (st._tag === "Running" && st.run.id === id) yield* idle
|
||||
yield* complete(done, exit)
|
||||
}),
|
||||
st._tag === "Running" && st.run.id === id ? ({ _tag: "Idle" } as const) : st,
|
||||
] as const,
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const startRun = (work: Effect.Effect<A, E>, done: Deferred.Deferred<A, E | Cancelled>) =>
|
||||
Effect.gen(function* () {
|
||||
const id = next()
|
||||
const fiber = yield* work.pipe(
|
||||
Effect.onExit((exit) => finishRun(id, done, exit)),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
return { id, done, fiber } satisfies RunHandle<A, E>
|
||||
})
|
||||
|
||||
const finishShell = (id: number) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag === "Shell" && st.shell.id === id) return [idle, { _tag: "Idle" }] as const
|
||||
if (st._tag === "ShellThenRun" && st.shell.id === id) {
|
||||
const run = yield* startRun(st.run.work, st.run.done)
|
||||
return [Effect.void, { _tag: "Running", run }] as const
|
||||
}
|
||||
return [Effect.void, st] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const stopShell = (shell: ShellHandle<A, E>) =>
|
||||
Effect.gen(function* () {
|
||||
shell.abort.abort()
|
||||
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
|
||||
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
|
||||
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
|
||||
})
|
||||
|
||||
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
switch (st._tag) {
|
||||
case "Running":
|
||||
case "ShellThenRun":
|
||||
return [Deferred.await(st.run.done), st] as const
|
||||
case "Shell": {
|
||||
const run = {
|
||||
id: next(),
|
||||
done: yield* Deferred.make<A, E | Cancelled>(),
|
||||
work,
|
||||
} satisfies PendingHandle<A, E>
|
||||
return [Deferred.await(run.done), { _tag: "ShellThenRun", shell: st.shell, run }] as const
|
||||
}
|
||||
case "Idle": {
|
||||
const done = yield* Deferred.make<A, E | Cancelled>()
|
||||
const run = yield* startRun(work, done)
|
||||
return [Deferred.await(done), { _tag: "Running", run }] as const
|
||||
}
|
||||
}
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catch(
|
||||
(e): Effect.Effect<A, E> => (e instanceof Cancelled ? (onInterrupt ?? Effect.die(e)) : Effect.fail(e as E)),
|
||||
),
|
||||
)
|
||||
|
||||
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
if (st._tag !== "Idle") {
|
||||
return [
|
||||
Effect.sync(() => {
|
||||
if (opts?.busy) opts.busy()
|
||||
throw new Error("Runner is busy")
|
||||
}),
|
||||
st,
|
||||
] as const
|
||||
}
|
||||
yield* busy
|
||||
const id = next()
|
||||
const abort = new AbortController()
|
||||
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && onInterrupt) return yield* onInterrupt
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}),
|
||||
{ _tag: "Shell", shell },
|
||||
] as const
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const cancel = SynchronizedRef.modify(ref, (st) => {
|
||||
switch (st._tag) {
|
||||
case "Idle":
|
||||
return [Effect.void, st] as const
|
||||
case "Running":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Fiber.interrupt(st.run.fiber)
|
||||
yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
case "Shell":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
case "ShellThenRun":
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid)
|
||||
yield* stopShell(st.shell)
|
||||
yield* idleIfCurrent()
|
||||
}),
|
||||
{ _tag: "Idle" } as const,
|
||||
] as const
|
||||
}
|
||||
}).pipe(Effect.flatten)
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state()
|
||||
},
|
||||
get busy() {
|
||||
return state()._tag !== "Idle"
|
||||
},
|
||||
ensureRunning,
|
||||
startShell,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -541,7 +541,7 @@ export namespace File {
|
|||
const exists = yield* appFs.existsSafe(full)
|
||||
if (!exists) return { type: "text" as const, content: "" }
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const mimeType = AppFileSystem.mimeType(full)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace FileTime {
|
||||
|
|
@ -12,21 +12,9 @@ export namespace FileTime {
|
|||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
})
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
|
@ -53,7 +41,17 @@ export namespace FileTime {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
||||
size: info ? Number(info.size) : undefined,
|
||||
}
|
||||
})
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("FileTime.state")(() =>
|
||||
Effect.succeed({
|
||||
|
|
@ -92,7 +90,7 @@ export namespace FileTime {
|
|||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
const changed = next.mtime !== time.mtime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
|
|
@ -108,7 +106,9 @@ export namespace FileTime {
|
|||
}),
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
|
|
|
|||
|
|
@ -375,38 +375,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||
delete provider.models[modelId]
|
||||
}
|
||||
|
||||
if (!provider.models["gpt-5.3-codex"]) {
|
||||
const model = {
|
||||
id: ModelID.make("gpt-5.3-codex"),
|
||||
providerID: ProviderID.openai,
|
||||
api: {
|
||||
id: "gpt-5.3-codex",
|
||||
url: "https://chatgpt.com/backend-api/codex",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
name: "GPT-5.3 Codex",
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 400_000, input: 272_000, output: 128_000 },
|
||||
status: "active" as const,
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-02-05",
|
||||
variants: {} as Record<string, Record<string, any>>,
|
||||
family: "gpt-codex",
|
||||
}
|
||||
model.variants = ProviderTransform.variants(model)
|
||||
provider.models["gpt-5.3-codex"] = model
|
||||
}
|
||||
|
||||
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
|
|
|
|||
|
|
@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
isDeprecatedPlugin,
|
||||
parsePluginSpecifier,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
readV1Plugin,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "./shared"
|
||||
import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
|
@ -36,11 +25,7 @@ export namespace Plugin {
|
|||
}
|
||||
|
||||
type Loaded = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
source: PluginSource
|
||||
mod: Record<string, unknown>
|
||||
row: PluginLoader.Loaded
|
||||
}
|
||||
|
||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||
|
|
@ -93,91 +78,22 @@ export namespace Plugin {
|
|||
return result
|
||||
}
|
||||
|
||||
async function resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = errorMessage(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!target) return
|
||||
return target
|
||||
}
|
||||
|
||||
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const resolved = await resolvePlugin(spec)
|
||||
if (!resolved) return
|
||||
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.warn("plugin incompatible", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Plugin ${spec} skipped: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return true
|
||||
})
|
||||
if (incompatible) return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry).catch((err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: spec, target: entry, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
source,
|
||||
mod,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
|
||||
if (plugin) {
|
||||
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec))
|
||||
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item)))
|
||||
await resolvePluginId(
|
||||
load.row.source,
|
||||
load.row.spec,
|
||||
load.row.target,
|
||||
readPluginId(plugin.id, load.row.spec),
|
||||
load.row.pkg,
|
||||
)
|
||||
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
|
||||
return
|
||||
}
|
||||
|
||||
for (const server of getLegacyPlugins(load.mod)) {
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
for (const server of getLegacyPlugins(load.row.mod)) {
|
||||
hooks.push(await server(input, load.row.options))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +148,82 @@ export namespace Plugin {
|
|||
}
|
||||
if (plugins.length) yield* config.waitForDependencies()
|
||||
|
||||
const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
plugins.map(async (item) => {
|
||||
const plan = PluginLoader.plan(item)
|
||||
if (plan.deprecated) return
|
||||
log.info("loading plugin", { path: plan.spec })
|
||||
|
||||
const resolved = await PluginLoader.resolve(plan, "server")
|
||||
if (!resolved.ok) {
|
||||
if (resolved.stage === "missing") {
|
||||
log.warn("plugin has no server entrypoint", {
|
||||
path: plan.spec,
|
||||
message: resolved.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const cause =
|
||||
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
||||
const message = errorMessage(cause)
|
||||
|
||||
if (resolved.stage === "install") {
|
||||
const parsed = parsePluginSpecifier(plan.spec)
|
||||
log.error("failed to install plugin", {
|
||||
pkg: parsed.pkg,
|
||||
version: parsed.version,
|
||||
error: message,
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.stage === "compatibility") {
|
||||
log.warn("plugin incompatible", { path: plan.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Plugin ${plan.spec} skipped: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to resolve plugin server entry", {
|
||||
path: plan.spec,
|
||||
error: message,
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await PluginLoader.load(resolved.value)
|
||||
if (!mod.ok) {
|
||||
const message = errorMessage(mod.error)
|
||||
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plan.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
row: mod.value,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
|
|
@ -242,14 +233,14 @@ export namespace Plugin {
|
|||
try: () => applyPlugin(load, input, hooks),
|
||||
catch: (err) => {
|
||||
const message = errorMessage(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
log.error("failed to load plugin", { path: load.row.spec, error: message })
|
||||
return message
|
||||
},
|
||||
}).pipe(
|
||||
Effect.catch((message) =>
|
||||
bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
message: `Failed to load plugin ${load.row.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
}),
|
||||
),
|
||||
|
|
@ -292,7 +283,7 @@ export namespace Plugin {
|
|||
for (const hook of state.hooks) {
|
||||
const fn = hook[name] as any
|
||||
if (!fn) continue
|
||||
yield* Effect.promise(() => fn(input, output))
|
||||
yield* Effect.promise(async () => fn(input, output))
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
|
|||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flock } from "@/util/flock"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
||||
|
||||
|
|
@ -94,33 +95,91 @@ function pluginSpec(item: unknown) {
|
|||
return item[0]
|
||||
}
|
||||
|
||||
function parseTarget(item: unknown): Target | undefined {
|
||||
if (item === "server" || item === "tui") return { kind: item }
|
||||
if (!Array.isArray(item)) return
|
||||
if (item[0] !== "server" && item[0] !== "tui") return
|
||||
if (item.length < 2) return { kind: item[0] }
|
||||
const opt = item[1]
|
||||
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
|
||||
return {
|
||||
kind: item[0],
|
||||
opts: opt,
|
||||
}
|
||||
function pluginList(data: unknown) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return
|
||||
const item = data as { plugin?: unknown }
|
||||
if (!Array.isArray(item.plugin)) return
|
||||
return item.plugin
|
||||
}
|
||||
|
||||
function parseTargets(raw: unknown) {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const map = new Map<Kind, Target>()
|
||||
for (const item of raw) {
|
||||
const hit = parseTarget(item)
|
||||
function exportValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const next = value.trim()
|
||||
if (next) return next
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
for (const key of ["import", "default"]) {
|
||||
const next = value[key]
|
||||
if (typeof next !== "string") continue
|
||||
const hit = next.trim()
|
||||
if (!hit) continue
|
||||
map.set(hit.kind, hit)
|
||||
return hit
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
|
||||
function exportOptions(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(value)) return
|
||||
const config = value.config
|
||||
if (!isRecord(config)) return
|
||||
return config
|
||||
}
|
||||
|
||||
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
|
||||
const exports = pkg.exports
|
||||
if (!isRecord(exports)) return
|
||||
const value = exports[`./${kind}`]
|
||||
const entry = exportValue(value)
|
||||
if (!entry) return
|
||||
return {
|
||||
opts: exportOptions(value),
|
||||
}
|
||||
}
|
||||
|
||||
function hasMainTarget(pkg: Record<string, unknown>) {
|
||||
const main = pkg.main
|
||||
if (typeof main !== "string") return false
|
||||
return Boolean(main.trim())
|
||||
}
|
||||
|
||||
function packageTargets(pkg: Record<string, unknown>) {
|
||||
const targets: Target[] = []
|
||||
const server = exportTarget(pkg, "server")
|
||||
if (server) {
|
||||
targets.push({ kind: "server", opts: server.opts })
|
||||
} else if (hasMainTarget(pkg)) {
|
||||
targets.push({ kind: "server" })
|
||||
}
|
||||
|
||||
const tui = exportTarget(pkg, "tui")
|
||||
if (tui) {
|
||||
targets.push({ kind: "tui", opts: tui.opts })
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, path, value, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
isArrayInsertion: insert,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function patchPluginList(
|
||||
text: string,
|
||||
list: unknown[] | undefined,
|
||||
spec: string,
|
||||
next: unknown,
|
||||
force = false,
|
||||
): { mode: Mode; text: string } {
|
||||
const pkg = parsePluginSpecifier(spec).pkg
|
||||
const rows = list.map((item, i) => ({
|
||||
const rows = (list ?? []).map((item, i) => ({
|
||||
item,
|
||||
i,
|
||||
spec: pluginSpec(item),
|
||||
|
|
@ -133,16 +192,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
|||
})
|
||||
|
||||
if (!dup.length) {
|
||||
if (!list) {
|
||||
return {
|
||||
mode: "add",
|
||||
text: patch(text, ["plugin"], [next]),
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: "add",
|
||||
list: [...list, next],
|
||||
text: patch(text, ["plugin", list.length], next, true),
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,29 +215,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
|||
if (!keep) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
if (dup.length === 1 && keep.spec === spec) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = new Set(dup.map((item) => item.i))
|
||||
let out = text
|
||||
if (typeof keep.item === "string") {
|
||||
out = patch(out, ["plugin", keep.i], next)
|
||||
}
|
||||
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
|
||||
out = patch(out, ["plugin", keep.i, 0], spec)
|
||||
}
|
||||
|
||||
const del = dup
|
||||
.map((item) => item.i)
|
||||
.filter((i) => i !== keep.i)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
for (const i of del) {
|
||||
out = patch(out, ["plugin", i], undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "replace",
|
||||
list: rows.flatMap((row) => {
|
||||
if (!idx.has(row.i)) return [row.item]
|
||||
if (row.i !== keep.i) return []
|
||||
if (typeof row.item === "string") return [next]
|
||||
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
|
||||
return [[spec, ...row.item.slice(1)]]
|
||||
}
|
||||
return [row.item]
|
||||
}),
|
||||
text: out,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
|
|||
}
|
||||
}
|
||||
|
||||
const targets = parseTargets(pkg.item.json["oc-plugin"])
|
||||
const targets = packageTargets(pkg.item.json)
|
||||
if (!targets.length) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -289,10 +362,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||
}
|
||||
}
|
||||
|
||||
const list: unknown[] =
|
||||
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
|
||||
const item = target.opts ? [spec, target.opts] : spec
|
||||
const out = patchPluginList(list, spec, item, force)
|
||||
const list = pluginList(data)
|
||||
const item = target.opts ? ([spec, target.opts] as const) : spec
|
||||
const out = patchPluginList(text, list, spec, item, force)
|
||||
if (out.mode === "noop") {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -304,13 +376,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||
}
|
||||
}
|
||||
|
||||
const edits = modify(text, ["plugin"], out.list, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
})
|
||||
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
|
||||
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
|
||||
if (write instanceof Error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
|
|||
137
packages/opencode/src/plugin/loader.ts
Normal file
137
packages/opencode/src/plugin/loader.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Config } from "@/config/config"
|
||||
import { Installation } from "@/installation"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
createPluginEntry,
|
||||
isDeprecatedPlugin,
|
||||
resolvePluginTarget,
|
||||
type PluginKind,
|
||||
type PluginPackage,
|
||||
type PluginSource,
|
||||
} from "./shared"
|
||||
|
||||
export namespace PluginLoader {
|
||||
export type Plan = {
|
||||
item: Config.PluginSpec
|
||||
spec: string
|
||||
options: Config.PluginOptions | undefined
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
export type Resolved = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
entry: string
|
||||
pkg?: PluginPackage
|
||||
}
|
||||
|
||||
export type Loaded = Resolved & {
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function plan(item: Config.PluginSpec): Plan {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
options: Config.pluginOptions(item),
|
||||
deprecated: isDeprecatedPlugin(spec),
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(
|
||||
plan: Plan,
|
||||
kind: PluginKind,
|
||||
): Promise<
|
||||
| { ok: true; value: Resolved }
|
||||
| { ok: false; stage: "missing"; message: string }
|
||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||
> {
|
||||
let target = ""
|
||||
try {
|
||||
target = await resolvePluginTarget(plan.spec)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "install",
|
||||
error,
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "install",
|
||||
error: new Error(`Plugin ${plan.spec} target is empty`),
|
||||
}
|
||||
}
|
||||
|
||||
let base
|
||||
try {
|
||||
base = await createPluginEntry(plan.spec, target, kind)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "entry",
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
if (!base.entry) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "missing",
|
||||
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
||||
}
|
||||
}
|
||||
|
||||
if (base.source === "npm") {
|
||||
try {
|
||||
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: "compatibility",
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...plan,
|
||||
source: base.source,
|
||||
target: base.target,
|
||||
entry: base.entry,
|
||||
pkg: base.pkg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||
let mod
|
||||
try {
|
||||
mod = await import(row.entry)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
if (!mod) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Plugin ${row.spec} module is empty`),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...row,
|
||||
mod,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared"
|
|||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
|
||||
export type Theme = {
|
||||
src: string
|
||||
dest: string
|
||||
mtime?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type Entry = {
|
||||
id: string
|
||||
source: Source
|
||||
|
|
@ -24,6 +31,7 @@ export namespace PluginMeta {
|
|||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
themes?: Record<string, Theme>
|
||||
}
|
||||
|
||||
export type State = "first" | "updated" | "same"
|
||||
|
|
@ -35,7 +43,7 @@ export namespace PluginMeta {
|
|||
}
|
||||
|
||||
type Store = Record<string, Entry>
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
|
||||
type Row = Touch & { core: Core }
|
||||
|
||||
function storePath() {
|
||||
|
|
@ -52,11 +60,11 @@ export namespace PluginMeta {
|
|||
return
|
||||
}
|
||||
|
||||
function modifiedAt(file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
async function modifiedAt(file: string) {
|
||||
const stat = await Filesystem.statAsync(file)
|
||||
if (!stat) return
|
||||
const value = stat.mtimeMs
|
||||
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||
const mtime = stat.mtimeMs
|
||||
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
|
||||
}
|
||||
|
||||
function resolvedTarget(target: string) {
|
||||
|
|
@ -66,7 +74,7 @@ export namespace PluginMeta {
|
|||
|
||||
async function npmVersion(target: string) {
|
||||
const resolved = resolvedTarget(target)
|
||||
const stat = Filesystem.stat(resolved)
|
||||
const stat = await Filesystem.statAsync(resolved)
|
||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||
.then((item) => item.version)
|
||||
|
|
@ -84,7 +92,7 @@ export namespace PluginMeta {
|
|||
source,
|
||||
spec,
|
||||
target,
|
||||
modified: file ? modifiedAt(file) : undefined,
|
||||
modified: file ? await modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +130,7 @@ export namespace PluginMeta {
|
|||
time_changed: prev?.time_changed ?? now,
|
||||
load_count: (prev?.load_count ?? 0) + 1,
|
||||
fingerprint: fingerprint(core),
|
||||
themes: prev?.themes,
|
||||
}
|
||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||
if (state === "updated") entry.time_changed = now
|
||||
|
|
@ -158,6 +167,20 @@ export namespace PluginMeta {
|
|||
})
|
||||
}
|
||||
|
||||
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
|
||||
const file = storePath()
|
||||
await Flock.withLock(lock(file), async () => {
|
||||
const store = await read(file)
|
||||
const entry = store[id]
|
||||
if (!entry) return
|
||||
entry.themes = {
|
||||
...(entry.themes ?? {}),
|
||||
[name]: theme,
|
||||
}
|
||||
await Filesystem.writeJson(file, store)
|
||||
})
|
||||
}
|
||||
|
||||
export async function list(): Promise<Store> {
|
||||
const file = storePath()
|
||||
return Flock.withLock(lock(file), async () => read(file))
|
||||
|
|
|
|||
|
|
@ -23,19 +23,31 @@ export type PluginSource = "file" | "npm"
|
|||
export type PluginKind = "server" | "tui"
|
||||
type PluginMode = "strict" | "detect"
|
||||
|
||||
export function pluginSource(spec: string): PluginSource {
|
||||
return spec.startsWith("file://") ? "file" : "npm"
|
||||
export type PluginPackage = {
|
||||
dir: string
|
||||
pkg: string
|
||||
json: Record<string, unknown>
|
||||
}
|
||||
|
||||
function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
|
||||
if (!isRecord(json.exports)) return false
|
||||
return `./${kind}` in json.exports
|
||||
export type PluginEntry = {
|
||||
spec: string
|
||||
source: PluginSource
|
||||
target: string
|
||||
pkg?: PluginPackage
|
||||
entry?: string
|
||||
}
|
||||
|
||||
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
||||
|
||||
export function pluginSource(spec: string): PluginSource {
|
||||
if (isPathPluginSpec(spec)) return "file"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
function resolveExportPath(raw: string, dir: string) {
|
||||
if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
|
||||
if (raw.startsWith("file://")) return fileURLToPath(raw)
|
||||
return raw
|
||||
if (path.isAbsolute(raw)) return raw
|
||||
return path.resolve(dir, raw)
|
||||
}
|
||||
|
||||
function extractExportValue(value: unknown): string | undefined {
|
||||
|
|
@ -48,26 +60,92 @@ function extractExportValue(value: unknown): string | undefined {
|
|||
return undefined
|
||||
}
|
||||
|
||||
export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
|
||||
const pkg = await readPluginPackage(target).catch(() => undefined)
|
||||
if (!pkg) return target
|
||||
if (!hasEntrypoint(pkg.json, kind)) return target
|
||||
|
||||
const exports = pkg.json.exports
|
||||
if (!isRecord(exports)) return target
|
||||
const raw = extractExportValue(exports[`./${kind}`])
|
||||
if (!raw) return target
|
||||
function packageMain(pkg: PluginPackage) {
|
||||
const value = pkg.json.main
|
||||
if (typeof value !== "string") return
|
||||
const next = value.trim()
|
||||
if (!next) return
|
||||
return next
|
||||
}
|
||||
|
||||
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
|
||||
const resolved = resolveExportPath(raw, pkg.dir)
|
||||
const root = Filesystem.resolve(pkg.dir)
|
||||
const next = Filesystem.resolve(resolved)
|
||||
if (!Filesystem.contains(root, next)) {
|
||||
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
|
||||
}
|
||||
|
||||
return pathToFileURL(next).href
|
||||
}
|
||||
|
||||
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
|
||||
const exports = pkg.json.exports
|
||||
if (isRecord(exports)) {
|
||||
const raw = extractExportValue(exports[`./${kind}`])
|
||||
if (raw) return resolvePackagePath(spec, raw, kind, pkg)
|
||||
}
|
||||
|
||||
if (kind !== "server") return
|
||||
const main = packageMain(pkg)
|
||||
if (!main) return
|
||||
return resolvePackagePath(spec, main, kind, pkg)
|
||||
}
|
||||
|
||||
function targetPath(target: string) {
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
if (path.isAbsolute(target)) return target
|
||||
}
|
||||
|
||||
async function resolveDirectoryIndex(dir: string) {
|
||||
for (const name of INDEX_FILES) {
|
||||
const file = path.join(dir, name)
|
||||
if (await Filesystem.exists(file)) return file
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetDirectory(target: string) {
|
||||
const file = targetPath(target)
|
||||
if (!file) return
|
||||
const stat = await Filesystem.stat(file)
|
||||
if (!stat?.isDirectory()) return
|
||||
return file
|
||||
}
|
||||
|
||||
async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
|
||||
const source = pluginSource(spec)
|
||||
const hit =
|
||||
pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
|
||||
if (!hit) return target
|
||||
|
||||
const entry = resolvePackageEntrypoint(spec, kind, hit)
|
||||
if (entry) return entry
|
||||
|
||||
const dir = await resolveTargetDirectory(target)
|
||||
|
||||
if (kind === "tui") {
|
||||
if (source === "file" && dir) {
|
||||
const index = await resolveDirectoryIndex(dir)
|
||||
if (index) return pathToFileURL(index).href
|
||||
}
|
||||
|
||||
if (source === "npm") return
|
||||
if (dir) return
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
if (dir && isRecord(hit.json.exports)) {
|
||||
if (source === "file") {
|
||||
const index = await resolveDirectoryIndex(dir)
|
||||
if (index) return pathToFileURL(index).href
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
export function isPathPluginSpec(spec: string) {
|
||||
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
|
||||
}
|
||||
|
|
@ -81,19 +159,21 @@ export async function resolvePathPluginTarget(spec: string) {
|
|||
return pathToFileURL(file).href
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
|
||||
if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
|
||||
if (typeof pkg.main !== "string" || !pkg.main.trim()) {
|
||||
throw new Error(`Plugin directory ${file} must define package.json main`)
|
||||
if (await Filesystem.exists(path.join(file, "package.json"))) {
|
||||
return pathToFileURL(file).href
|
||||
}
|
||||
return pathToFileURL(path.resolve(file, pkg.main)).href
|
||||
|
||||
const index = await resolveDirectoryIndex(file)
|
||||
if (index) return pathToFileURL(index).href
|
||||
|
||||
throw new Error(`Plugin directory ${file} is missing package.json or index file`)
|
||||
}
|
||||
|
||||
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
|
||||
export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
|
||||
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
|
||||
const pkg = await readPluginPackage(target).catch(() => undefined)
|
||||
if (!pkg) return
|
||||
const engines = pkg.json.engines
|
||||
const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
|
||||
if (!hit) return
|
||||
const engines = hit.json.engines
|
||||
if (!isRecord(engines)) return
|
||||
const range = engines.opencode
|
||||
if (typeof range !== "string") return
|
||||
|
|
@ -104,10 +184,10 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
|
|||
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
||||
return BunProc.install(parsed.pkg, parsed.version)
|
||||
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
|
||||
}
|
||||
|
||||
export async function readPluginPackage(target: string) {
|
||||
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
||||
const file = target.startsWith("file://") ? fileURLToPath(target) : target
|
||||
const stat = await Filesystem.stat(file)
|
||||
const dir = stat?.isDirectory() ? file : path.dirname(file)
|
||||
|
|
@ -116,6 +196,20 @@ export async function readPluginPackage(target: string) {
|
|||
return { dir, pkg, json }
|
||||
}
|
||||
|
||||
export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
|
||||
const source = pluginSource(spec)
|
||||
const pkg =
|
||||
source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
|
||||
return {
|
||||
spec,
|
||||
source,
|
||||
target,
|
||||
pkg,
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPluginId(id: unknown, spec: string) {
|
||||
if (id === undefined) return
|
||||
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
|
||||
|
|
@ -158,15 +252,21 @@ export function readV1Plugin(
|
|||
return value
|
||||
}
|
||||
|
||||
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
|
||||
export async function resolvePluginId(
|
||||
source: PluginSource,
|
||||
spec: string,
|
||||
target: string,
|
||||
id: string | undefined,
|
||||
pkg?: PluginPackage,
|
||||
) {
|
||||
if (source === "file") {
|
||||
if (id) return id
|
||||
throw new TypeError(`Path plugin ${spec} must export id`)
|
||||
}
|
||||
if (id) return id
|
||||
const pkg = await readPluginPackage(target)
|
||||
if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
|
||||
throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
|
||||
const hit = pkg ?? (await readPluginPackage(target))
|
||||
if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
|
||||
throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
|
||||
}
|
||||
return pkg.json.name.trim()
|
||||
return hit.json.name.trim()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export namespace ProviderAuth {
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -176,7 +176,7 @@ export namespace Pty {
|
|||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
const args = input.args || []
|
||||
if (command.endsWith("sh")) {
|
||||
if (Shell.login(command)) {
|
||||
args.push("-l")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import { Session } from "../session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
|
|
@ -20,6 +21,9 @@ export function errorHandler(log: Log.Logger): ErrorHandler {
|
|||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ export const SessionRoutes = lazy(() =>
|
|||
}),
|
||||
),
|
||||
async (c) => {
|
||||
SessionPrompt.cancel(c.req.valid("param").sessionID)
|
||||
await SessionPrompt.cancel(c.req.valid("param").sessionID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
|
@ -699,7 +699,7 @@ export const SessionRoutes = lazy(() =>
|
|||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
SessionPrompt.assertNotBusy(params.sessionID)
|
||||
await SessionPrompt.assertNotBusy(params.sessionID)
|
||||
await Session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ import { Agent } from "@/agent/agent"
|
|||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { isOverflow as overflow } from "./overflow"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
|
@ -31,7 +31,6 @@ export namespace SessionCompaction {
|
|||
),
|
||||
}
|
||||
|
||||
const COMPACTION_BUFFER = 20_000
|
||||
export const PRUNE_MINIMUM = 20_000
|
||||
export const PRUNE_PROTECT = 40_000
|
||||
const PRUNE_PROTECTED_TOOLS = ["skill"]
|
||||
|
|
@ -46,7 +45,6 @@ export namespace SessionCompaction {
|
|||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) => Effect.Effect<"continue" | "stop">
|
||||
|
|
@ -64,7 +62,7 @@ export namespace SessionCompaction {
|
|||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service
|
||||
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service | SessionProcessor.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -73,26 +71,13 @@ export namespace SessionCompaction {
|
|||
const session = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const processors = yield* SessionProcessor.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.compaction?.auto === false) return false
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return false
|
||||
|
||||
const count =
|
||||
input.tokens.total ||
|
||||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
|
||||
|
||||
const reserved =
|
||||
cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
const usable = input.model.limit.input
|
||||
? input.model.limit.input - reserved
|
||||
: context - ProviderTransform.maxOutputTokens(input.model)
|
||||
return count >= usable
|
||||
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
|
||||
})
|
||||
|
||||
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
|
||||
|
|
@ -149,20 +134,28 @@ export namespace SessionCompaction {
|
|||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
|
||||
const parent = input.messages.findLast((m) => m.info.id === input.parentID)
|
||||
if (!parent || parent.info.role !== "user") {
|
||||
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
|
||||
}
|
||||
const userMessage = parent.info
|
||||
|
||||
let messages = input.messages
|
||||
let replay: MessageV2.WithParts | undefined
|
||||
let replay:
|
||||
| {
|
||||
info: MessageV2.User
|
||||
parts: MessageV2.Part[]
|
||||
}
|
||||
| undefined
|
||||
if (input.overflow) {
|
||||
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const msg = input.messages[i]
|
||||
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
|
||||
replay = msg
|
||||
replay = { info: msg.info, parts: msg.parts }
|
||||
messages = input.messages.slice(0, i)
|
||||
break
|
||||
}
|
||||
|
|
@ -181,38 +174,6 @@ export namespace SessionCompaction {
|
|||
? Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||
)
|
||||
const msg = (yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: input.parentID,
|
||||
sessionID: input.sessionID,
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
})) as MessageV2.Assistant
|
||||
const processor = SessionProcessor.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
abort: input.abort,
|
||||
})
|
||||
// Allow plugins to inject context or replace compaction prompt.
|
||||
const compacting = yield* plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
|
|
@ -222,6 +183,7 @@ export namespace SessionCompaction {
|
|||
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
|
||||
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
|
||||
The summary that you construct will be used so that another agent can read it and continue the work.
|
||||
Do not call any tools. Respond only with the summary text.
|
||||
|
||||
When constructing the summary, try to stick to this template:
|
||||
---
|
||||
|
|
@ -251,11 +213,42 @@ When constructing the summary, try to stick to this template:
|
|||
const msgs = structuredClone(messages)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
||||
const result = yield* Effect.promise(() =>
|
||||
processor.process({
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: input.parentID,
|
||||
sessionID: input.sessionID,
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
output: 0,
|
||||
input: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
}
|
||||
yield* session.updateMessage(msg)
|
||||
const processor = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
})
|
||||
const result = yield* processor
|
||||
.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
abort: input.abort,
|
||||
sessionID: input.sessionID,
|
||||
tools: {},
|
||||
system: [],
|
||||
|
|
@ -267,8 +260,8 @@ When constructing the summary, try to stick to this template:
|
|||
},
|
||||
],
|
||||
model,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.onInterrupt(() => processor.abort()))
|
||||
|
||||
if (result === "compact") {
|
||||
processor.message.error = new MessageV2.ContextOverflowError({
|
||||
|
|
@ -283,7 +276,7 @@ When constructing the summary, try to stick to this template:
|
|||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info as MessageV2.User
|
||||
const original = replay.info
|
||||
const replayMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
|
|
@ -383,6 +376,7 @@ When constructing the summary, try to stick to this template:
|
|||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
|
|
@ -401,16 +395,16 @@ When constructing the summary, try to stick to this template:
|
|||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export async function process(input: {
|
||||
parentID: MessageID
|
||||
messages: MessageV2.WithParts[]
|
||||
sessionID: SessionID
|
||||
abort: AbortSignal
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
}) {
|
||||
return runPromise((svc) => svc.process(input))
|
||||
}
|
||||
export const process = fn(
|
||||
z.object({
|
||||
parentID: MessageID.zod,
|
||||
messages: z.custom<MessageV2.WithParts[]>(),
|
||||
sessionID: SessionID.zod,
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.process(input)),
|
||||
)
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import { ModelID, ProviderID } from "@/provider/schema"
|
|||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
|
|
@ -265,27 +264,12 @@ export namespace Session {
|
|||
0) as number,
|
||||
)
|
||||
|
||||
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
|
||||
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
|
||||
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
|
||||
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
|
||||
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
|
||||
const adjustedInputTokens = safe(
|
||||
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
|
||||
)
|
||||
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
|
||||
// (including Anthropic/Bedrock which previously excluded them). Always subtract cache
|
||||
// tokens to get the non-cached input count for separate cost calculation.
|
||||
const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
|
||||
|
||||
const total = iife(() => {
|
||||
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
|
||||
// don't compute from components
|
||||
if (
|
||||
input.model.api.npm === "@ai-sdk/anthropic" ||
|
||||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
|
||||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
|
||||
) {
|
||||
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
|
||||
}
|
||||
return input.usage.totalTokens
|
||||
})
|
||||
const total = input.usage.totalTokens
|
||||
|
||||
const tokens = {
|
||||
total,
|
||||
|
|
@ -350,14 +334,14 @@ export namespace Session {
|
|||
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
|
||||
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
|
||||
readonly remove: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly updateMessage: (msg: MessageV2.Info) => Effect.Effect<MessageV2.Info>
|
||||
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
|
||||
readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
|
||||
readonly removePart: (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
}) => Effect.Effect<PartID>
|
||||
readonly updatePart: (part: MessageV2.Part) => Effect.Effect<MessageV2.Part>
|
||||
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
||||
readonly updatePartDelta: (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
|
|
@ -485,26 +469,23 @@ export namespace Session {
|
|||
}
|
||||
})
|
||||
|
||||
const updateMessage = Effect.fn("Session.updateMessage")(function* (msg: MessageV2.Info) {
|
||||
yield* Effect.sync(() =>
|
||||
SyncEvent.run(MessageV2.Event.Updated, {
|
||||
sessionID: msg.sessionID,
|
||||
info: msg,
|
||||
}),
|
||||
)
|
||||
return msg
|
||||
})
|
||||
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
|
||||
return msg
|
||||
}).pipe(Effect.withSpan("Session.updateMessage"))
|
||||
|
||||
const updatePart = Effect.fn("Session.updatePart")(function* (part: MessageV2.Part) {
|
||||
yield* Effect.sync(() =>
|
||||
SyncEvent.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID: part.sessionID,
|
||||
part: structuredClone(part),
|
||||
time: Date.now(),
|
||||
}),
|
||||
)
|
||||
return part
|
||||
})
|
||||
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sync(() =>
|
||||
SyncEvent.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID: part.sessionID,
|
||||
part: structuredClone(part),
|
||||
time: Date.now(),
|
||||
}),
|
||||
)
|
||||
return part
|
||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||
|
||||
const create = Effect.fn("Session.create")(function* (input?: {
|
||||
parentID?: SessionID
|
||||
|
|
@ -867,7 +848,10 @@ export namespace Session {
|
|||
|
||||
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
|
||||
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
|
||||
export const updateMessage = fn(MessageV2.Info, (msg) => runPromise((svc) => svc.updateMessage(msg)))
|
||||
export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
|
||||
MessageV2.Info.parse(msg)
|
||||
return runPromise((svc) => svc.updateMessage(msg))
|
||||
}
|
||||
|
||||
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
|
||||
runPromise((svc) => svc.removeMessage(input)),
|
||||
|
|
@ -878,7 +862,10 @@ export namespace Session {
|
|||
(input) => runPromise((svc) => svc.removePart(input)),
|
||||
)
|
||||
|
||||
export const updatePart = fn(MessageV2.Part, (part) => runPromise((svc) => svc.updatePart(part)))
|
||||
export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
|
||||
MessageV2.Part.parse(part)
|
||||
return runPromise((svc) => svc.updatePart(part))
|
||||
}
|
||||
|
||||
export const updatePartDelta = fn(
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const log = Log.create({ service: "instruction" })
|
|||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { Provider } from "@/provider/provider"
|
||||
import { Log } from "@/util/log"
|
||||
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
|
||||
import * as Queue from "effect/Queue"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
|
|
@ -26,7 +29,6 @@ export namespace LLM {
|
|||
agent: Agent.Info
|
||||
permission?: Permission.Ruleset
|
||||
system: string[]
|
||||
abort: AbortSignal
|
||||
messages: ModelMessage[]
|
||||
small?: boolean
|
||||
tools: Record<string, Tool>
|
||||
|
|
@ -34,7 +36,57 @@ export namespace LLM {
|
|||
toolChoice?: "auto" | "required" | "none"
|
||||
}
|
||||
|
||||
export async function stream(input: StreamInput) {
|
||||
export type StreamRequest = StreamInput & {
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||
|
||||
export interface Interface {
|
||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LLM") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
stream(input) {
|
||||
const stream: Stream.Stream<Event, unknown> = Stream.scoped(
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const ctrl = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => new AbortController()),
|
||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||
)
|
||||
const queue = yield* Queue.unbounded<Event, unknown | Cause.Done>()
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const result = await LLM.stream({ ...input, abort: ctrl.signal })
|
||||
for await (const event of result.fullStream) {
|
||||
if (!Queue.offerUnsafe(queue, event)) break
|
||||
}
|
||||
Queue.endUnsafe(queue)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => void Queue.failCauseUnsafe(queue, cause))),
|
||||
Effect.onInterrupt(() => Effect.sync(() => ctrl.abort())),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Stream.fromQueue(queue)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return stream
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export async function stream(input: StreamRequest) {
|
||||
const l = log
|
||||
.clone()
|
||||
.tag("providerID", input.model.providerID)
|
||||
|
|
@ -121,7 +173,7 @@ export namespace LLM {
|
|||
"chat.params",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider,
|
||||
message: input.user,
|
||||
|
|
@ -140,7 +192,7 @@ export namespace LLM {
|
|||
"chat.headers",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider,
|
||||
message: input.user,
|
||||
|
|
@ -168,11 +220,19 @@ export namespace LLM {
|
|||
input.model.providerID.toLowerCase().includes("litellm") ||
|
||||
input.model.api.id.toLowerCase().includes("litellm")
|
||||
|
||||
// LiteLLM/Bedrock rejects requests where the message history contains tool
|
||||
// calls but no tools param is present. When there are no active tools (e.g.
|
||||
// during compaction), inject a stub tool to satisfy the validation requirement.
|
||||
// The stub description explicitly tells the model not to call it.
|
||||
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
|
||||
tools["_noop"] = tool({
|
||||
description:
|
||||
"Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
|
||||
inputSchema: jsonSchema({ type: "object", properties: {} }),
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string", description: "Unused" },
|
||||
},
|
||||
}),
|
||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||
})
|
||||
}
|
||||
|
|
@ -265,7 +325,6 @@ export namespace LLM {
|
|||
specificationVersion: "v3" as const,
|
||||
async transformParams(args) {
|
||||
if (args.type === "stream") {
|
||||
// TODO: verify that LanguageModelV3Prompt is still compat here!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
// @ts-expect-error
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||
}
|
||||
|
|
@ -284,17 +343,12 @@ export namespace LLM {
|
|||
})
|
||||
}
|
||||
|
||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
for (const tool of Object.keys(input.tools)) {
|
||||
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
||||
delete input.tools[tool]
|
||||
}
|
||||
}
|
||||
return input.tools
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
}
|
||||
|
||||
// Check if messages contain any tool-call content
|
||||
|
|
|
|||
22
packages/opencode/src/session/overflow.ts
Normal file
22
packages/opencode/src/session/overflow.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Config } from "@/config/config"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const COMPACTION_BUFFER = 20_000
|
||||
|
||||
export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
if (input.cfg.compaction?.auto === false) return false
|
||||
const context = input.model.limit.context
|
||||
if (context === 0) return false
|
||||
|
||||
const count =
|
||||
input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
|
||||
|
||||
const reserved =
|
||||
input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
|
||||
const usable = input.model.limit.input
|
||||
? input.model.limit.input - reserved
|
||||
: context - ProviderTransform.maxOutputTokens(input.model)
|
||||
return count >= usable
|
||||
}
|
||||
|
|
@ -1,430 +1,521 @@
|
|||
import { MessageV2 } from "./message-v2"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util/log"
|
||||
import { Session } from "."
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { Bus } from "@/bus"
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { isOverflow } from "./overflow"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID } from "./schema"
|
||||
import { SessionRetry } from "./retry"
|
||||
import { SessionStatus } from "./status"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SessionSummary } from "./summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { LLM } from "./llm"
|
||||
import { Config } from "@/config/config"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Permission } from "@/permission"
|
||||
import { Question } from "@/question"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID, MessageID } from "./schema"
|
||||
|
||||
export namespace SessionProcessor {
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
||||
export type Info = Awaited<ReturnType<typeof create>>
|
||||
export type Result = Awaited<ReturnType<Info["process"]>>
|
||||
export type Result = "compact" | "stop" | "continue"
|
||||
|
||||
export function create(input: {
|
||||
export type Event = LLM.Event
|
||||
|
||||
export interface Handle {
|
||||
readonly message: MessageV2.Assistant
|
||||
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
||||
readonly abort: () => Effect.Effect<void>
|
||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
type Input = {
|
||||
assistantMessage: MessageV2.Assistant
|
||||
sessionID: SessionID
|
||||
model: Provider.Model
|
||||
abort: AbortSignal
|
||||
}) {
|
||||
const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
||||
let snapshot: string | undefined
|
||||
let blocked = false
|
||||
let attempt = 0
|
||||
let needsCompaction = false
|
||||
}
|
||||
|
||||
const result = {
|
||||
get message() {
|
||||
return input.assistantMessage
|
||||
},
|
||||
partFromToolCall(toolCallID: string) {
|
||||
return toolcalls[toolCallID]
|
||||
},
|
||||
async process(streamInput: LLM.StreamInput) {
|
||||
log.info("process")
|
||||
needsCompaction = false
|
||||
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
|
||||
while (true) {
|
||||
try {
|
||||
let currentText: MessageV2.TextPart | undefined
|
||||
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
|
||||
const stream = await LLM.stream(streamInput)
|
||||
export interface Interface {
|
||||
readonly create: (input: Input) => Effect.Effect<Handle>
|
||||
}
|
||||
|
||||
for await (const value of stream.fullStream) {
|
||||
input.abort.throwIfAborted()
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
await SessionStatus.set(input.sessionID, { type: "busy" })
|
||||
break
|
||||
interface ProcessorContext extends Input {
|
||||
toolcalls: Record<string, MessageV2.ToolPart>
|
||||
shouldBreak: boolean
|
||||
snapshot: string | undefined
|
||||
blocked: boolean
|
||||
needsCompaction: boolean
|
||||
currentText: MessageV2.TextPart | undefined
|
||||
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
||||
}
|
||||
|
||||
case "reasoning-start":
|
||||
if (value.id in reasoningMap) {
|
||||
continue
|
||||
}
|
||||
const reasoningPart = {
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
type: "reasoning" as const,
|
||||
text: "",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
reasoningMap[value.id] = reasoningPart
|
||||
await Session.updatePart(reasoningPart)
|
||||
break
|
||||
type StreamEvent = Event
|
||||
|
||||
case "reasoning-delta":
|
||||
if (value.id in reasoningMap) {
|
||||
const part = reasoningMap[value.id]
|
||||
part.text += value.text
|
||||
if (value.providerMetadata) part.metadata = value.providerMetadata
|
||||
await Session.updatePartDelta({
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
partID: part.id,
|
||||
field: "text",
|
||||
delta: value.text,
|
||||
})
|
||||
}
|
||||
break
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||
|
||||
case "reasoning-end":
|
||||
if (value.id in reasoningMap) {
|
||||
const part = reasoningMap[value.id]
|
||||
part.text = part.text.trimEnd()
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
| Session.Service
|
||||
| Config.Service
|
||||
| Bus.Service
|
||||
| Snapshot.Service
|
||||
| Agent.Service
|
||||
| LLM.Service
|
||||
| Permission.Service
|
||||
| Plugin.Service
|
||||
| SessionStatus.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const agents = yield* Agent.Service
|
||||
const llm = yield* LLM.Service
|
||||
const permission = yield* Permission.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const status = yield* SessionStatus.Service
|
||||
|
||||
part.time = {
|
||||
...part.time,
|
||||
end: Date.now(),
|
||||
}
|
||||
if (value.providerMetadata) part.metadata = value.providerMetadata
|
||||
await Session.updatePart(part)
|
||||
delete reasoningMap[value.id]
|
||||
}
|
||||
break
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
const ctx: ProcessorContext = {
|
||||
assistantMessage: input.assistantMessage,
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
toolcalls: {},
|
||||
shouldBreak: false,
|
||||
snapshot: undefined,
|
||||
blocked: false,
|
||||
needsCompaction: false,
|
||||
currentText: undefined,
|
||||
reasoningMap: {},
|
||||
}
|
||||
let aborted = false
|
||||
|
||||
case "tool-input-start":
|
||||
const part = await Session.updatePart({
|
||||
id: toolcalls[value.id]?.id ?? PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
callID: value.id,
|
||||
state: {
|
||||
status: "pending",
|
||||
input: {},
|
||||
raw: "",
|
||||
},
|
||||
})
|
||||
toolcalls[value.id] = part as MessageV2.ToolPart
|
||||
break
|
||||
const parse = (e: unknown) =>
|
||||
MessageV2.fromError(e, {
|
||||
providerID: input.model.providerID,
|
||||
aborted,
|
||||
})
|
||||
|
||||
case "tool-input-delta":
|
||||
break
|
||||
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
return
|
||||
|
||||
case "tool-input-end":
|
||||
break
|
||||
|
||||
case "tool-call": {
|
||||
const match = toolcalls[value.toolCallId]
|
||||
if (match) {
|
||||
const part = await Session.updatePart({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: {
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
|
||||
|
||||
const parts = await MessageV2.parts(input.assistantMessage.id)
|
||||
const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||
|
||||
if (
|
||||
lastThree.length === DOOM_LOOP_THRESHOLD &&
|
||||
lastThree.every(
|
||||
(p) =>
|
||||
p.type === "tool" &&
|
||||
p.tool === value.toolName &&
|
||||
p.state.status !== "pending" &&
|
||||
JSON.stringify(p.state.input) === JSON.stringify(value.input),
|
||||
)
|
||||
) {
|
||||
const agent = await Agent.get(input.assistantMessage.agent)
|
||||
await Permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.toolName],
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
metadata: {
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
},
|
||||
always: [value.toolName],
|
||||
ruleset: agent.permission,
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool-result": {
|
||||
const match = toolcalls[value.toolCallId]
|
||||
if (match && match.state.status === "running") {
|
||||
await Session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input ?? match.state.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
|
||||
delete toolcalls[value.toolCallId]
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const match = toolcalls[value.toolCallId]
|
||||
if (match && match.state.status === "running") {
|
||||
await Session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input ?? match.state.input,
|
||||
error: value.error instanceof Error ? value.error.message : String(value.error),
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (
|
||||
value.error instanceof Permission.RejectedError ||
|
||||
value.error instanceof Question.RejectedError
|
||||
) {
|
||||
blocked = shouldBreak
|
||||
}
|
||||
delete toolcalls[value.toolCallId]
|
||||
}
|
||||
break
|
||||
}
|
||||
case "error":
|
||||
throw value.error
|
||||
|
||||
case "start-step":
|
||||
snapshot = await Snapshot.track()
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.sessionID,
|
||||
snapshot,
|
||||
type: "step-start",
|
||||
})
|
||||
break
|
||||
|
||||
case "finish-step":
|
||||
const usage = Session.getUsage({
|
||||
model: input.model,
|
||||
usage: value.usage,
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
input.assistantMessage.finish = value.finishReason
|
||||
input.assistantMessage.cost += usage.cost
|
||||
input.assistantMessage.tokens = usage.tokens
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.finishReason,
|
||||
snapshot: await Snapshot.track(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
type: "step-finish",
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
})
|
||||
await Session.updateMessage(input.assistantMessage)
|
||||
if (snapshot) {
|
||||
const patch = await Snapshot.patch(snapshot)
|
||||
if (patch.files.length) {
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "patch",
|
||||
hash: patch.hash,
|
||||
files: patch.files,
|
||||
})
|
||||
}
|
||||
snapshot = undefined
|
||||
}
|
||||
SessionSummary.summarize({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.assistantMessage.parentID,
|
||||
})
|
||||
if (
|
||||
!input.assistantMessage.summary &&
|
||||
(await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model }))
|
||||
) {
|
||||
needsCompaction = true
|
||||
}
|
||||
break
|
||||
|
||||
case "text-start":
|
||||
currentText = {
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
type: "text",
|
||||
text: "",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
await Session.updatePart(currentText)
|
||||
break
|
||||
|
||||
case "text-delta":
|
||||
if (currentText) {
|
||||
currentText.text += value.text
|
||||
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||
await Session.updatePartDelta({
|
||||
sessionID: currentText.sessionID,
|
||||
messageID: currentText.messageID,
|
||||
partID: currentText.id,
|
||||
field: "text",
|
||||
delta: value.text,
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case "text-end":
|
||||
if (currentText) {
|
||||
currentText.text = currentText.text.trimEnd()
|
||||
const textOutput = await Plugin.trigger(
|
||||
"experimental.text.complete",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.assistantMessage.id,
|
||||
partID: currentText.id,
|
||||
},
|
||||
{ text: currentText.text },
|
||||
)
|
||||
currentText.text = textOutput.text
|
||||
currentText.time = {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
}
|
||||
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||
await Session.updatePart(currentText)
|
||||
}
|
||||
currentText = undefined
|
||||
break
|
||||
|
||||
case "finish":
|
||||
break
|
||||
|
||||
default:
|
||||
log.info("unhandled", {
|
||||
...value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (needsCompaction) break
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error("process", {
|
||||
error: e,
|
||||
stack: JSON.stringify(e.stack),
|
||||
})
|
||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
needsCompaction = true
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error,
|
||||
})
|
||||
} else {
|
||||
const retry = SessionRetry.retryable(error)
|
||||
if (retry !== undefined) {
|
||||
attempt++
|
||||
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
||||
await SessionStatus.set(input.sessionID, {
|
||||
type: "retry",
|
||||
attempt,
|
||||
message: retry,
|
||||
next: Date.now() + delay,
|
||||
})
|
||||
await SessionRetry.sleep(delay, input.abort).catch(() => {})
|
||||
continue
|
||||
}
|
||||
input.assistantMessage.error = error
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID: input.assistantMessage.sessionID,
|
||||
error: input.assistantMessage.error,
|
||||
})
|
||||
await SessionStatus.set(input.sessionID, { type: "idle" })
|
||||
}
|
||||
}
|
||||
if (snapshot) {
|
||||
const patch = await Snapshot.patch(snapshot)
|
||||
if (patch.files.length) {
|
||||
await Session.updatePart({
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
ctx.reasoningMap[value.id] = {
|
||||
id: PartID.ascending(),
|
||||
messageID: input.assistantMessage.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
time: { start: Date.now() },
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
||||
return
|
||||
|
||||
case "reasoning-delta":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.reasoningMap[value.id].sessionID,
|
||||
messageID: ctx.reasoningMap[value.id].messageID,
|
||||
partID: ctx.reasoningMap[value.id].id,
|
||||
field: "text",
|
||||
delta: value.text,
|
||||
})
|
||||
return
|
||||
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
||||
delete ctx.reasoningMap[value.id]
|
||||
return
|
||||
|
||||
case "tool-input-start":
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
ctx.toolcalls[value.id] = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
callID: value.id,
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
} satisfies MessageV2.ToolPart)
|
||||
return
|
||||
|
||||
case "tool-input-delta":
|
||||
return
|
||||
|
||||
case "tool-input-end":
|
||||
return
|
||||
|
||||
case "tool-call": {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match) return
|
||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
||||
metadata: value.providerMetadata,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||
|
||||
if (
|
||||
recentParts.length !== DOOM_LOOP_THRESHOLD ||
|
||||
!recentParts.every(
|
||||
(part) =>
|
||||
part.type === "tool" &&
|
||||
part.tool === value.toolName &&
|
||||
part.state.status !== "pending" &&
|
||||
JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
||||
yield* permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.toolName],
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
metadata: { tool: value.toolName, input: value.input },
|
||||
always: [value.toolName],
|
||||
ruleset: agent.permission,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match || match.state.status !== "running") return
|
||||
yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input ?? match.state.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
time: { start: match.state.time.start, end: Date.now() },
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match || match.state.status !== "running") return
|
||||
yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input ?? match.state.input,
|
||||
error: value.error instanceof Error ? value.error.message : String(value.error),
|
||||
time: { start: match.state.time.start, end: Date.now() },
|
||||
},
|
||||
})
|
||||
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
return
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw value.error
|
||||
|
||||
case "start-step":
|
||||
ctx.snapshot = yield* snapshot.track()
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.sessionID,
|
||||
snapshot: ctx.snapshot,
|
||||
type: "step-start",
|
||||
})
|
||||
return
|
||||
|
||||
case "finish-step": {
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.usage,
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.finishReason,
|
||||
snapshot: yield* snapshot.track(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "step-finish",
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
})
|
||||
yield* session.updateMessage(ctx.assistantMessage)
|
||||
if (ctx.snapshot) {
|
||||
const patch = yield* snapshot.patch(ctx.snapshot)
|
||||
if (patch.files.length) {
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "patch",
|
||||
hash: patch.hash,
|
||||
files: patch.files,
|
||||
})
|
||||
}
|
||||
ctx.snapshot = undefined
|
||||
}
|
||||
yield* Effect.promise(() =>
|
||||
SessionSummary.summarize({
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.parentID,
|
||||
}),
|
||||
).pipe(Effect.ignoreCause({ log: true, message: "session summary failed" }), Effect.forkDetach)
|
||||
if (
|
||||
!ctx.assistantMessage.summary &&
|
||||
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
||||
) {
|
||||
ctx.needsCompaction = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case "text-start":
|
||||
ctx.currentText = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "text",
|
||||
text: "",
|
||||
time: { start: Date.now() },
|
||||
metadata: value.providerMetadata,
|
||||
}
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
return
|
||||
|
||||
case "text-delta":
|
||||
if (!ctx.currentText) return
|
||||
ctx.currentText.text += value.text
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
yield* session.updatePartDelta({
|
||||
sessionID: ctx.currentText.sessionID,
|
||||
messageID: ctx.currentText.messageID,
|
||||
partID: ctx.currentText.id,
|
||||
field: "text",
|
||||
delta: value.text,
|
||||
})
|
||||
return
|
||||
|
||||
case "text-end":
|
||||
if (!ctx.currentText) return
|
||||
ctx.currentText.text = ctx.currentText.text.trimEnd()
|
||||
ctx.currentText.text = (yield* plugin.trigger(
|
||||
"experimental.text.complete",
|
||||
{
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
partID: ctx.currentText.id,
|
||||
},
|
||||
{ text: ctx.currentText.text },
|
||||
)).text
|
||||
ctx.currentText.time = { start: Date.now(), end: Date.now() }
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
return
|
||||
|
||||
case "finish":
|
||||
return
|
||||
|
||||
default:
|
||||
log.info("unhandled", { ...value })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = Effect.fn("SessionProcessor.cleanup")(function* () {
|
||||
if (ctx.snapshot) {
|
||||
const patch = yield* snapshot.patch(ctx.snapshot)
|
||||
if (patch.files.length) {
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "patch",
|
||||
hash: patch.hash,
|
||||
files: patch.files,
|
||||
})
|
||||
}
|
||||
snapshot = undefined
|
||||
ctx.snapshot = undefined
|
||||
}
|
||||
const p = await MessageV2.parts(input.assistantMessage.id)
|
||||
for (const part of p) {
|
||||
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
status: "error",
|
||||
error: "Tool execution aborted",
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (ctx.currentText) {
|
||||
const end = Date.now()
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
}
|
||||
|
||||
for (const part of Object.values(ctx.reasoningMap)) {
|
||||
const end = Date.now()
|
||||
yield* session.updatePart({
|
||||
...part,
|
||||
time: { start: part.time.start ?? end, end },
|
||||
})
|
||||
}
|
||||
ctx.reasoningMap = {}
|
||||
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
|
||||
for (const part of parts) {
|
||||
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
|
||||
yield* session.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
status: "error",
|
||||
error: "Tool execution aborted",
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
},
|
||||
})
|
||||
}
|
||||
ctx.assistantMessage.time.completed = Date.now()
|
||||
yield* session.updateMessage(ctx.assistantMessage)
|
||||
})
|
||||
|
||||
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
||||
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
|
||||
const error = parse(e)
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
return
|
||||
}
|
||||
ctx.assistantMessage.error = error
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
error: ctx.assistantMessage.error,
|
||||
})
|
||||
yield* status.set(ctx.sessionID, { type: "idle" })
|
||||
})
|
||||
|
||||
const abort = Effect.fn("SessionProcessor.abort")(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!ctx.assistantMessage.error) {
|
||||
yield* halt(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
}
|
||||
input.assistantMessage.time.completed = Date.now()
|
||||
await Session.updateMessage(input.assistantMessage)
|
||||
if (needsCompaction) return "compact"
|
||||
if (blocked) return "stop"
|
||||
if (input.assistantMessage.error) return "stop"
|
||||
return "continue"
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (!ctx.assistantMessage.time.completed) {
|
||||
yield* cleanup()
|
||||
return
|
||||
}
|
||||
yield* session.updateMessage(ctx.assistantMessage)
|
||||
}),
|
||||
)
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
log.info("process")
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
ctx.currentText = undefined
|
||||
ctx.reasoningMap = {}
|
||||
const stream = llm.stream(streamInput)
|
||||
|
||||
yield* stream.pipe(
|
||||
Stream.tap((event) => handleEvent(event)),
|
||||
Stream.takeUntil(() => ctx.needsCompaction),
|
||||
Stream.runDrain,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.fail(Cause.squash(cause)),
|
||||
),
|
||||
Effect.retry(
|
||||
SessionRetry.policy({
|
||||
parse,
|
||||
set: (info) =>
|
||||
status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Effect.catch(halt),
|
||||
Effect.ensuring(cleanup()),
|
||||
)
|
||||
|
||||
if (aborted && !ctx.assistantMessage.error) {
|
||||
yield* abort()
|
||||
}
|
||||
if (ctx.needsCompaction) return "compact"
|
||||
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
|
||||
return "continue"
|
||||
}).pipe(Effect.onInterrupt(() => abort().pipe(Effect.asVoid)))
|
||||
})
|
||||
|
||||
return {
|
||||
get message() {
|
||||
return ctx.assistantMessage
|
||||
},
|
||||
partFromToolCall(toolCallID: string) {
|
||||
return ctx.toolcalls[toolCallID]
|
||||
},
|
||||
abort,
|
||||
process,
|
||||
} satisfies Handle
|
||||
})
|
||||
|
||||
return Service.of({ create })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(LLM.defaultLayer),
|
||||
Layer.provide(Permission.layer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,25 @@
|
|||
You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.
|
||||
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.
|
||||
|
||||
## Values
|
||||
You are guided by these core values:
|
||||
- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
|
||||
- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
|
||||
- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
|
||||
|
||||
## Interaction Style
|
||||
You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
|
||||
|
||||
You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.
|
||||
|
||||
## Escalation
|
||||
You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
|
||||
|
||||
|
||||
# General
|
||||
As an expert coding agent, your primary focus is writing code, answering questions, and helping the user complete their task in the current environment. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
|
||||
- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`)
|
||||
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.
|
||||
|
||||
## Editing Approach
|
||||
|
||||
- The best changes are often the smallest correct changes.
|
||||
- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.
|
||||
|
||||
## Autonomy and persistence
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
|
||||
|
||||
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
|
||||
|
||||
If you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
|
|
@ -41,13 +38,11 @@ As an expert coding agent, your primary focus is writing code, answering questio
|
|||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
## Autonomy and persistence
|
||||
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
|
||||
If the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
|
||||
If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
|
||||
## Frontend tasks
|
||||
|
||||
|
|
@ -60,57 +55,53 @@ Exception: If working within an existing website or design system, preserve the
|
|||
|
||||
# Working with the user
|
||||
|
||||
You interact with the user through a terminal. You have 2 ways of communicating with the users:
|
||||
- Share intermediary updates in `commentary` channel.
|
||||
- After you have completed all your work, send a message to the `final` channel.
|
||||
You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.
|
||||
## General
|
||||
|
||||
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question, ") or framing phrases.
|
||||
|
||||
Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.
|
||||
|
||||
Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
|
||||
|
||||
## Formatting rules
|
||||
|
||||
- You may format with GitHub-flavored Markdown.
|
||||
- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
|
||||
- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
|
||||
- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use markdown links (not inline code) for clickable file paths.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
- Don’t use emojis or em dashes unless explicitly instructed.
|
||||
Your responses are rendered as GitHub-flavored Markdown.
|
||||
|
||||
## Final answer instructions
|
||||
Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
|
||||
|
||||
Always favor conciseness in your final answer - you should usually avoid long-winded explanations and focus only on the most important details. For casual chit-chat, just chat. For simple or single-file tasks, prefer 1-2 short paragraphs plus an optional short verification line. Do not default to bullets. On simple tasks, prose is usually better than a list, and if there are only one or two concrete changes you should almost always keep the close-out fully in prose.
|
||||
Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
|
||||
On larger tasks, use at most 2-3 high-level sections when helpful. Each section can be a short paragraph or a few flat bullets. Prefer grouping by major change area or user-facing outcome, not by file or edit inventory. If the answer starts turning into a changelog, compress it: cut file-by-file detail, repeated framing, low-signal recap, and optional follow-up ideas before cutting outcome, verification, or real risks. Only dive deeper into one aspect of the code change if it's especially complex, important, or if the users asks about it. This also holds true for PR explanations, codebase walkthroughs, or architectural decisions: provide a high-level walkthrough unless specifically asked and cap answers at 2-3 sections.
|
||||
Use inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.
|
||||
|
||||
Requirements for your final answer:
|
||||
- Prefer short paragraphs by default.
|
||||
- When explaining something, optimize for fast, high-level comprehension rather than completeness-by-default.
|
||||
- Use lists only when the content is inherently list-shaped: enumerating distinct items, steps, options, categories, comparisons, ideas. Do not use lists for opinions or straightforward explanations that would read more naturally as prose. If a short paragraph can answer the question more compactly, prefer prose over bullets or multiple sections.
|
||||
- Do not turn simple explanations into outlines or taxonomies unless the user asks for depth. If a list is used, each bullet should be a complete standalone point.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”, "You're right to call that out") or framing phrases.
|
||||
- When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
- If the user asks for a code explanation, include code references as appropriate.
|
||||
- If you weren't able to do something, for example run tests, tell the user.
|
||||
- Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.
|
||||
Code samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.
|
||||
|
||||
## Intermediary updates
|
||||
Don’t use emojis or em dashes unless explicitly instructed.
|
||||
|
||||
- Intermediary updates go to the `commentary` channel.
|
||||
- User updates are short updates while you are working, they are NOT final answers.
|
||||
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
|
||||
- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
|
||||
- You provide user updates frequently, every 30s.
|
||||
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
|
||||
- When working for a while, keep updates informative and varied, but stay concise.
|
||||
- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
|
||||
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
|
||||
- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.
|
||||
- Tone of your updates MUST match your personality.
|
||||
## Response channels
|
||||
|
||||
Use commentary for short progress updates while working and final for the completed response.
|
||||
|
||||
### `commentary` channel
|
||||
|
||||
Only use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.
|
||||
|
||||
Send updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.
|
||||
|
||||
Do not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.
|
||||
|
||||
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question") or framing phrases.
|
||||
|
||||
Before substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.
|
||||
|
||||
After you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
|
||||
|
||||
### `final` channel
|
||||
|
||||
Use final for the completed response.
|
||||
|
||||
Structure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
|
||||
|
||||
If the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.
|
||||
|
||||
For large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldn’t be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue