Compare commits

...

12 Commits

Author SHA1 Message Date
Matt Silverlock
8b298a233e github: add OIDC_BASE_URL for custom GitHub App installs (#5756) 2025-12-18 11:31:13 -06:00
Adam
6f43d03043 fix(desktop): checkbox render in safari fml 2025-12-18 11:16:33 -06:00
Adam
c868a4088d fix(desktop): rendering shell mode messages 2025-12-18 11:16:33 -06:00
Adam
83d8a88c90 fix(desktop): error styles 2025-12-18 11:16:33 -06:00
Adam
268f37f8c9 fix(desktop): prompt history nav, optimistic prompt dup 2025-12-18 11:16:33 -06:00
Adam
b0aaf04957 fix(desktop): session ordered by most recent 2025-12-18 11:16:32 -06:00
Adam
b7875256f3 feat(desktop): shell mode 2025-12-18 11:16:32 -06:00
Adam
7bc47fb904 chore: cleanup 2025-12-18 11:16:32 -06:00
GitHub Action
5cf8e54372 chore: format code 2025-12-18 16:39:21 +00:00
Ariane Emory
7437ccd6f4 feat(tui): fork slash command for keyboard-friendly session forking (resolves #5599) (#5610)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-18 10:38:19 -06:00
Jeon Suyeol
4bf882ba81 fix(command): validate model before executing slash command (#5740) 2025-12-18 10:35:40 -06:00
Frank
d5dcc55a47 Revert "add client header"
This reverts commit 2fb89161c8.
2025-12-18 11:21:22 -05:00
26 changed files with 765 additions and 524 deletions

View File

@@ -26,6 +26,10 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false
runs:
using: "composite"
steps:
@@ -62,3 +66,4 @@ runs:
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}

View File

@@ -39,9 +39,9 @@ const url =
export function App() {
return (
<ErrorBoundary fallback={ErrorPage}>
<MetaProvider>
<Font />
<MetaProvider>
<Font />
<ErrorBoundary fallback={ErrorPage}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
@@ -82,7 +82,7 @@ export function App() {
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</MetaProvider>
</ErrorBoundary>
</ErrorBoundary>
</MetaProvider>
)
}

View File

@@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
import { useProviders } from "@/hooks/use-providers"
import { useCommand, formatKeybind } from "@/context/command"
import { persisted } from "@/utils/persist"
import { Identifier } from "@opencode-ai/util/identifier"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -99,6 +100,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placeholder: number
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
}>({
popover: null,
historyIndex: -1,
@@ -106,6 +109,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false,
imageAttachments: [],
mode: "normal",
applyingHistory: false,
})
const MAX_HISTORY = 100
@@ -133,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, length)
setStore("applyingHistory", false)
})
}
@@ -427,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)
if (shouldReset) {
setStore("popover", null)
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0)
}
return
}
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
const shellMode = store.mode === "shell"
if (!shellMode) {
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
} else {
setStore("popover", null)
}
} else {
setStore("popover", null)
}
if (store.historyIndex >= 0) {
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
@@ -579,6 +607,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
setStore("mode", "shell")
setStore("popover", null)
event.preventDefault()
return
}
}
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Escape") {
setStore("mode", "normal")
event.preventDefault()
return
}
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
}
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
@@ -665,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
: ""
return {
id: Identifier.ascending("part"),
type: "file" as const,
mime: "text/plain",
url: `file://${absolute}${query}`,
@@ -682,16 +734,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
}))
const isShellMode = store.mode === "shell"
tabs().setActive(undefined)
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
setStore("mode", "normal")
const model = {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
}
const agent = local.agent.current()!.name
if (isShellMode) {
sdk.client.session.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
@@ -702,27 +773,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent: local.agent.current()!.name,
model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
agent,
model: `${model.providerID}/${model.modelID}`,
})
return
}
}
const model = {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
const messageID = Identifier.ascending("message")
const textPart = {
id: Identifier.ascending("part"),
type: "text" as const,
text,
}
const agent = local.agent.current()!.name
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
messageID,
}))
sync.session.addOptimisticMessage({
sessionID: existing.id,
text,
parts: [
{ type: "text", text } as import("@opencode-ai/sdk/v2/client").Part,
...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
],
messageID,
parts: optimisticParts,
agent,
model,
})
@@ -731,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: existing.id,
agent,
model,
parts: [
{
type: "text",
text,
},
...fileAttachmentParts,
...imageAttachmentParts,
],
messageID,
parts: requestParts,
})
}
@@ -879,34 +947,50 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
{store.mode === "shell"
? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1">
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
<Icon name="console" size="small" class="text-icon-primary" />
<span class="text-12-regular text-text-primary">Shell</span>
<span class="text-12-regular text-text-weak">esc to exit</span>
</div>
</Match>
<Match when={store.mode === "normal"}>
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() =>
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
)
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 absolute right-2 bottom-2">
<input

View File

@@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
data-prevent-autofocus
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,

View File

@@ -107,7 +107,7 @@ function createGlobalSync() {
.slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
// Include sessions up to the limit, plus any updated in the last hour
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
const updated = new Date(s.time.updated).getTime()

View File

@@ -33,14 +33,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
addOptimisticMessage(input: {
sessionID: string
text: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
}) {
const messageID = crypto.randomUUID()
const message: Message = {
id: messageID,
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
@@ -53,15 +52,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (!messages) {
draft.message[input.sessionID] = [message]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[messageID] = input.parts.map((part, i) => ({
...part,
id: `${messageID}-${i}`,
sessionID: input.sessionID,
messageID,
}))
draft.part[input.messageID] = input.parts.slice()
}),
)
},

View File

@@ -1,11 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind";
:root {
html,
body {
touch-action: manipulation;
}
a {
cursor: default;
}

View File

@@ -60,9 +60,9 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center">
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="h-8 w-auto text-text-strong" />
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
<p class="text-sm text-text-weak">An error occurred while loading the application.</p>

View File

@@ -122,10 +122,18 @@ export default function Layout(props: ParentProps) {
}
}
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync
.child(directory)[0]
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
return flattenSessions(sessions ?? [])
}
const currentSessions = createMemo(() => {
if (!params.dir) return []
const directory = base64Decode(params.dir)
return flattenSessions(globalSync.child(directory)[0].session ?? [])
return projectSessions(directory)
})
function navigateSessionByOffset(offset: number) {
@@ -162,7 +170,7 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex]
if (!nextProject) return
const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
const nextProjectSessions = projectSessions(nextProject.worktree)
if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree)
return
@@ -350,7 +358,7 @@ export default function Layout(props: ParentProps) {
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
<div class="relative size-5 shrink-0 rounded-sm">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
@@ -511,7 +519,9 @@ export default function Layout(props: ParentProps) {
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session ?? [])
const sessions = createMemo(() =>
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const childSessionsByParent = createMemo(() => {
const map = new Map<string, Session[]>()
@@ -526,7 +536,7 @@ export default function Layout(props: ParentProps) {
})
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 10)
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
const [expanded, setExpanded] = createSignal(true)

View File

@@ -327,11 +327,15 @@ export default function Page() {
])
const handleKeyDown = (event: KeyboardEvent) => {
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
if (isProtected || isInput) return
}
if (dialog.active) return
const focused = document.activeElement === inputRef
if (focused) {
if (activeElement === inputRef) {
if (event.key === "Escape") inputRef?.blur()
return
}

View File

@@ -395,6 +395,7 @@ export const GithubRunCommand = cmd({
const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo
const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
@@ -572,6 +573,12 @@ export const GithubRunCommand = cmd({
throw new Error(`Invalid use_github_token value: ${value}. Must be a boolean.`)
}
function normalizeOidcBaseUrl(): string {
const value = process.env["OIDC_BASE_URL"]
if (!value) return "https://api.opencode.ai"
return value.replace(/\/+$/, "")
}
function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent,
): event is IssueCommentEvent {
@@ -809,14 +816,14 @@ export const GithubRunCommand = cmd({
async function exchangeForAppToken(token: string) {
const response = token.startsWith("github_pat_")
? await fetch("https://api.opencode.ai/exchange_github_app_token_with_pat", {
? await fetch(`${oidcBaseUrl}/exchange_github_app_token_with_pat`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ owner, repo }),
})
: await fetch("https://api.opencode.ai/exchange_github_app_token", {
: await fetch(`${oidcBaseUrl}/exchange_github_app_token`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,

View File

@@ -270,6 +270,11 @@ export function Autocomplete(props: {
description: "jump to message",
onSelect: () => command.trigger("session.timeline"),
},
{
display: "/fork",
description: "fork from message",
onSelect: () => command.trigger("session.fork"),
},
{
display: "/thinking",
description: "toggle thinking visibility",

View File

@@ -0,0 +1,51 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
onMount(() => {
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const result = [] as DialogSelectOption<string>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
(x) => x.type === "text" && !x.synthetic && !x.ignored,
) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: async (dialog) => {
const forked = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: message.id,
})
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
})
}
result.reverse()
return result
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
}

View File

@@ -24,7 +24,9 @@ export function DialogTimeline(props: {
const result = [] as DialogSelectOption<string>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
const part = (sync.data.part[message.id] ?? []).find(
(x) => x.type === "text" && !x.synthetic && !x.ignored,
) as TextPart
if (!part) continue
result.push({
title: part.text.replace(/\n/g, " "),

View File

@@ -53,6 +53,7 @@ import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -295,6 +296,25 @@ export function Session() {
))
},
},
{
title: "Fork from message",
value: "session.fork",
keybind: "session_fork",
category: "Session",
onSelect: (dialog) => {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
sessionID={route.sessionID}
/>
))
},
},
{
title: "Compact session",
value: "session.compact",

View File

@@ -440,6 +440,8 @@ export namespace Config {
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("none").describe("Rename session"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),

View File

@@ -1,73 +1,19 @@
import z from "zod"
import { randomBytes } from "crypto"
import { Identifier as SharedIdentifier } from "@opencode-ai/util/identifier"
export namespace Identifier {
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
export type Prefix = SharedIdentifier.Prefix
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
export const schema = (prefix: Prefix) => SharedIdentifier.schema(prefix)
export function ascending(prefix: Prefix, given?: string) {
return SharedIdentifier.ascending(prefix, given)
}
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, false, given)
export function descending(prefix: Prefix, given?: string) {
return SharedIdentifier.descending(prefix, given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, true, given)
}
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = descending ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
export function create(prefix: Prefix, descending: boolean, timestamp?: number) {
return SharedIdentifier.createPrefixed(prefix, descending, timestamp)
}
}

View File

@@ -92,7 +92,6 @@ export namespace ModelsDev {
const result = await fetch("https://models.dev/api.json", {
headers: {
"User-Agent": Installation.USER_AGENT,
"x-opencode-client": Flag.OPENCODE_CLIENT,
},
signal: AbortSignal.timeout(10 * 1000),
}).catch((e) => {

View File

@@ -1333,6 +1333,20 @@ export namespace SessionPrompt {
if (input.model) return Provider.parseModel(input.model)
return await lastModel(input.sessionID)
})()
try {
await Provider.getModel(model.providerID, model.modelID)
} catch (e) {
if (Provider.ModelNotFoundError.isInstance(e)) {
const { providerID, modelID, suggestions } = e.data
const hint = suggestions?.length ? ` Did you mean: ${suggestions.join(", ")}?` : ""
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(),
})
}
throw e
}
const agent = await Agent.get(agentName)
const parts =

View File

@@ -842,6 +842,14 @@ export type KeybindsConfig = {
* Show session timeline
*/
session_timeline?: string
/**
* Fork session from message
*/
session_fork?: string
/**
* Rename session
*/
session_rename?: string
/**
* Share current session
*/

View File

@@ -7077,6 +7077,16 @@
"default": "<leader>g",
"type": "string"
},
"session_fork": {
"description": "Fork session from message",
"default": "none",
"type": "string"
},
"session_rename": {
"description": "Rename session",
"default": "none",
"type": "string"
},
"session_share": {
"description": "Share current session",
"default": "none",

View File

@@ -17,7 +17,7 @@ export function Checkbox(props: CheckboxProps) {
<Kobalte.Control data-slot="checkbox-checkbox-control">
<Kobalte.Indicator data-slot="checkbox-checkbox-indicator">
{local.icon || (
<svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 7.17905L5.02703 8.85135L9 3.5"
stroke="currentColor"

View File

@@ -152,9 +152,22 @@
align-items: flex-start;
justify-content: flex-start;
[data-component="markdown"] {
width: 100%;
min-width: 0;
pre {
margin: 0;
padding: 0;
background-color: transparent !important;
border: none !important;
}
}
pre {
margin: 0;
padding: 0;
background: none;
}
&[data-scrollable] {

View File

@@ -69,6 +69,7 @@ export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
}
export type PartComponent = Component<MessagePartProps>
@@ -208,7 +209,13 @@ export function Part(props: MessagePartProps) {
const component = createMemo(() => PART_MAPPING[props.part.type])
return (
<Show when={component()}>
<Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
<Dynamic
component={component()}
part={props.part}
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
/>
</Show>
)
}
@@ -219,6 +226,7 @@ export interface ToolProps {
tool: string
output?: string
hideDetails?: boolean
defaultOpen?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -286,6 +294,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
metadata={metadata}
output={part.state.status === "completed" ? part.state.output : undefined}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
/>
</Match>
</Switch>
@@ -326,6 +335,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
icon="glasses"
trigger={{
title: "Read",
@@ -340,7 +350,11 @@ ToolRegistry.register({
name: "list",
render(props) {
return (
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
<BasicTool
{...props}
icon="bullet-list"
trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
>
<Show when={props.output}>
{(output) => (
<div data-component="tool-output" data-scrollable>
@@ -358,6 +372,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
icon="magnifying-glass-menu"
trigger={{
title: "Glob",
@@ -385,6 +400,7 @@ ToolRegistry.register({
if (props.input.include) args.push("include=" + props.input.include)
return (
<BasicTool
{...props}
icon="magnifying-glass-menu"
trigger={{
title: "Grep",
@@ -409,6 +425,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
icon="window-cursor"
trigger={{
title: "Webfetch",
@@ -438,6 +455,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
icon="task"
trigger={{
title: `${props.input.subagent_type || props.tool} Agent`,
@@ -462,6 +480,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
icon="console"
trigger={{
title: "Shell",
@@ -485,6 +504,7 @@ ToolRegistry.register({
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
@@ -534,6 +554,7 @@ ToolRegistry.register({
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
@@ -575,6 +596,7 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
{...props}
defaultOpen
icon="checklist"
trigger={{

View File

@@ -7,7 +7,7 @@ import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Swi
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -35,29 +35,133 @@ export function SessionTurn(
) {
const data = useData()
const diffComponent = useDiffComponent()
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
const messages = createMemo(() => data.store.message[props.sessionID] ?? [])
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
const lastUserMessage = createMemo(() => userMessages().at(-1)!)
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)!)
const status = createMemo(
() =>
data.store.session_status[props.sessionID] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle" && message()?.id === userMessages().at(-1)?.id)
const working = createMemo(() => status().type !== "idle" && message().id === lastUserMessage().id)
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const assistantMessages = createMemo(() => {
return messages().filter((m) => m.role === "assistant" && m.parentID == message().id) as AssistantMessage[]
})
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const parts = createMemo(() => data.store.part[message().id])
const lastTextPart = createMemo(() =>
assistantParts()
.filter((p) => p?.type === "text")
.at(-1),
)
const summary = createMemo(() => message().summary?.body)
const response = createMemo(() => lastTextPart()?.text)
const currentTask = createMemo(
() =>
assistantParts().findLast(
(p) =>
p &&
p.type === "tool" &&
p.tool === "task" &&
p.state &&
"metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart,
)
const resolvedParts = createMemo(() => {
let resolved = assistantParts()
const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
(m) => m.role === "assistant",
)
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
}
return resolved
})
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const last = lastPart()
if (!last) return undefined
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work"
case "todowrite":
case "todoread":
return "Planning next steps"
case "read":
return "Gathering context"
case "list":
case "grep":
case "glob":
return "Searching the codebase"
case "webfetch":
return "Searching the web"
case "edit":
case "write":
return "Making edits"
case "bash":
return "Running commands"
default:
break
}
} else if (last.type === "reasoning") {
const text = last.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return `Thinking · ${match[1].trim()}`
return "Thinking"
} else if (last.type === "text") {
return "Gathering thoughts"
}
return undefined
})
const hasDiffs = createMemo(() => message().summary?.diffs?.length)
const isShellMode = createMemo(() => {
if (parts().some((p) => p.type !== "text" || !p.synthetic)) return false
if (assistantParts().length !== 1) return false
const assistantPart = assistantParts()[0]
if (assistantPart.type !== "tool") return false
if (assistantPart.tool !== "bash") return false
return true
})
function duration() {
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(message().time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
return interval.toDuration(unit).normalize().toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
}
let scrollRef: HTMLDivElement | undefined
let lastScrollTop = 0
const [state, setState] = createStore({
const [store, setStore] = createStore({
contentRef: undefined as HTMLDivElement | undefined,
stickyTitleRef: undefined as HTMLDivElement | undefined,
stickyTriggerRef: undefined as HTMLDivElement | undefined,
@@ -65,418 +169,312 @@ export function SessionTurn(
userScrolled: false,
stickyHeaderHeight: 0,
retrySeconds: 0,
status: rawStatus(),
stepsExpanded: props.stepsExpanded ?? working(),
duration: duration(),
})
createEffect(() => {
const r = retry()
if (!r) {
setState("retrySeconds", 0)
setStore("retrySeconds", 0)
return
}
const updateSeconds = () => {
const next = r.next
if (next) setState("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
}
updateSeconds()
const timer = setInterval(updateSeconds, 1000)
onCleanup(() => clearInterval(timer))
})
function handleScroll() {
if (!scrollRef || state.autoScrolled) return
if (!scrollRef || store.autoScrolled) return
const { scrollTop } = scrollRef
// only mark as user scrolled if they actively scrolled upward
// content growth increases scrollHeight but never decreases scrollTop
const scrolledUp = scrollTop < lastScrollTop - 10
if (scrolledUp && working()) {
setState("userScrolled", true)
setStore("userScrolled", true)
}
lastScrollTop = scrollTop
}
function handleInteraction() {
if (working()) {
setState("userScrolled", true)
}
if (working()) setStore("userScrolled", true)
}
function scrollToBottom() {
if (!scrollRef || state.userScrolled || !working()) return
setState("autoScrolled", true)
if (!scrollRef || store.userScrolled || !working()) return
setStore("autoScrolled", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
requestAnimationFrame(() => {
lastScrollTop = scrollRef?.scrollTop ?? 0
setState("autoScrolled", false)
setStore("autoScrolled", false)
})
})
}
createResizeObserver(() => state.contentRef, scrollToBottom)
createResizeObserver(() => store.contentRef, scrollToBottom)
createEffect(() => {
if (!working()) {
setState("userScrolled", false)
}
if (!working()) setStore("userScrolled", false)
})
createResizeObserver(
() => state.stickyTitleRef,
() => store.stickyTitleRef,
({ height }) => {
const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
setState("stickyHeaderHeight", height + triggerHeight + 8)
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", height + triggerHeight + 8)
},
)
createResizeObserver(
() => state.stickyTriggerRef,
() => store.stickyTriggerRef,
({ height }) => {
const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
setState("stickyHeaderHeight", titleHeight + height + 8)
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
setStore("stickyHeaderHeight", titleHeight + height + 8)
},
)
createEffect(() => {
if (props.stepsExpanded !== undefined) {
setStore("stepsExpanded", props.stepsExpanded)
}
})
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
}, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
createEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
props.onStepsExpandedChange?.(true)
}
if (prev && !isWorking && !store.userScrolled) {
setStore("stepsExpanded", false)
props.onStepsExpandedChange?.(false)
}
return isWorking
}, working())
return (
<div data-component="session-turn" class={props.classes?.root}>
<div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
<div onClick={handleInteraction}>
<Show when={message()}>
{(message) => {
const assistantMessages = createMemo(() => {
return messages()?.filter(
(m) => m.role === "assistant" && m.parentID == message().id,
) as AssistantMessage[]
})
const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const parts = createMemo(() => data.store.part[message().id])
const lastTextPart = createMemo(() =>
assistantMessageParts()
.filter((p) => p?.type === "text")
?.at(-1),
)
const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
const lastTextPartShown = createMemo(
() => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
)
const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
const currentTask = createMemo(
() =>
assistantParts().findLast(
(p) =>
p &&
p.type === "tool" &&
p.tool === "task" &&
p.state &&
"metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart,
)
const resolvedParts = createMemo(() => {
let resolved = assistantParts()
const task = currentTask()
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
(m) => m.role === "assistant",
)
resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
}
return resolved
})
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
const last = lastPart()
if (!last) return undefined
if (last.type === "tool") {
switch (last.tool) {
case "task":
return "Delegating work"
case "todowrite":
case "todoread":
return "Planning next steps"
case "read":
return "Gathering context"
case "list":
case "grep":
case "glob":
return "Searching the codebase"
case "webfetch":
return "Searching the web"
case "edit":
case "write":
return "Making edits"
case "bash":
return "Running commands"
default:
break
}
} else if (last.type === "reasoning") {
const text = last.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return `Thinking · ${match[1].trim()}`
return "Thinking"
} else if (last.type === "text") {
return "Gathering thoughts"
}
return undefined
})
function duration() {
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(message()!.time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
return interval.toDuration(unit).normalize().toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
}
const [store, setStore] = createStore({
status: rawStatus(),
stepsExpanded: props.stepsExpanded ?? working(),
duration: duration(),
})
createEffect(() => {
if (props.stepsExpanded !== undefined) {
setStore("stepsExpanded", props.stepsExpanded)
}
})
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
}, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
createEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
props.onStepsExpandedChange?.(true)
}
if (prev && !isWorking && !state.userScrolled) {
setStore("stepsExpanded", false)
props.onStepsExpandedChange?.(false)
}
return isWorking
}, working())
return (
<div
ref={(el) => setState("contentRef", el)}
data-message={message().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }}
>
{/* Title (sticky) */}
<div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Switch>
<Match when={working()}>
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
</Match>
<Match when={true}>
<h1>{message().summary?.title}</h1>
</Match>
</Switch>
</div>
</div>
</div>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={message()} parts={parts()} />
</div>
{/* Trigger (sticky) */}
<div ref={(el) => setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={() => {
if (assistantMessages().length === 0) return
const next = !store.stepsExpanded
setStore("stepsExpanded", next)
props.onStepsExpandedChange?.(next)
}}
>
<Show when={working()}>
<Spinner />
</Show>
<div
ref={(el) => setStore("contentRef", el)}
data-message={message().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
>
<Switch>
<Match when={isShellMode()}>
<Part part={assistantParts()[0]} message={message()} defaultOpen />
</Match>
<Match when={true}>
{/* Title (sticky) */}
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {state.retrySeconds > 0 ? `in ${state.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
<Match when={working()}>
<Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
</Match>
<Match when={true}>
<h1>{message().summary?.title ?? "New message"}</h1>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={store.stepsExpanded}>Hide steps</Match>
<Match when={!store.stepsExpanded}>Show steps</Match>
</Switch>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
</div>
{/* Response */}
<Show when={store.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
const last = createMemo(() =>
parts()
.filter((p) => p?.type === "text")
.at(-1),
)
return (
<Switch>
<Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
/>
</Match>
<Match when={true}>
<Message message={assistantMessage} parts={parts()} />
</Match>
</Switch>
)
}}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Show>
{/* Summary */}
<Show when={!working()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">
</div>
</div>
{/* User Message */}
<div data-slot="session-turn-message-content">
<Message message={message()} parts={parts()} />
</div>
{/* Trigger (sticky) */}
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={() => {
if (assistantMessages().length === 0) return
const next = !store.stepsExpanded
setStore("stepsExpanded", next)
props.onStepsExpandedChange?.(next)
}}
>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
})()}
</span>
<span data-slot="session-turn-retry-seconds">
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={store.stepsExpanded}>Hide steps</Match>
<Match when={!store.stepsExpanded}>Show steps</Match>
</Switch>
<span>·</span>
<span>{store.duration}</span>
<Show when={assistantMessages().length > 0}>
<Icon name="chevron-grabber-vertical" size="small" />
</Show>
</Button>
</div>
{/* Response */}
<Show when={store.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
const last = createMemo(() =>
parts()
.filter((p) => p?.type === "text")
.at(-1),
)
return (
<Switch>
<Match when={message().summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
<Match when={response() && lastTextPart()?.id === last()?.id}>
<Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} />
</Match>
<Match when={true}>
<Message message={assistantMessage} parts={parts()} />
</Match>
</Switch>
</h2>
<Show when={summary()}>
)
}}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Show>
{/* Summary */}
<Show when={!working()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<Switch>
<Match when={summary()}>
{(summary) => (
<Markdown
data-slot="session-turn-markdown"
data-diffs={!!message().summary?.diffs?.length}
text={summary()}
/>
<>
<h2 data-slot="session-turn-summary-title">Summary</h2>
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={summary()} />
</>
)}
</Show>
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={message().summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-accordion-trigger-content">
<div data-slot="session-turn-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-turn-file-icon"
/>
<div data-slot="session-turn-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
</div>
</div>
<div data-slot="session-turn-accordion-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</Match>
<Match when={response()}>
{(response) => (
<>
<h2 data-slot="session-turn-summary-title">Response</h2>
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response()} />
</>
)}
</Match>
</Switch>
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={message().summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-accordion-trigger-content">
<div data-slot="session-turn-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-turn-file-icon"
/>
<div data-slot="session-turn-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
<Dynamic
component={diffComponent}
before={{
name: diff.file!,
contents: diff.before!,
cacheKey: checksum(diff.before!),
}}
after={{
name: diff.file!,
contents: diff.after!,
cacheKey: checksum(diff.after!),
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !store.stepsExpanded}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
)
}}
</Show>
<div data-slot="session-turn-accordion-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
<Dynamic
component={diffComponent}
before={{
name: diff.file!,
contents: diff.before!,
cacheKey: checksum(diff.before!),
}}
after={{
name: diff.file!,
contents: diff.after!,
cacheKey: checksum(diff.after!),
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !store.stepsExpanded}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</Match>
</Switch>
</div>
{props.children}
</div>
</div>

View File

@@ -1,48 +1,99 @@
import { randomBytes } from "crypto"
import z from "zod"
export namespace Identifier {
const LENGTH = 26
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
export type Prefix = keyof typeof prefixes
type CryptoLike = {
getRandomValues<T extends ArrayBufferView>(array: T): T
}
const TOTAL_LENGTH = 26
const RANDOM_LENGTH = TOTAL_LENGTH - 12
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending() {
return create(false)
const fillRandomBytes = (buffer: Uint8Array) => {
const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto
if (cryptoLike?.getRandomValues) {
cryptoLike.getRandomValues(buffer)
return buffer
}
for (let i = 0; i < buffer.length; i++) {
buffer[i] = Math.floor(Math.random() * 256)
}
return buffer
}
export function descending() {
return create(true)
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const randomBase62 = (length: number) => {
const bytes = fillRandomBytes(new Uint8Array(length))
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
result += BASE62[bytes[i] % BASE62.length]
}
return result
}
export function create(descending: boolean, timestamp?: number): string {
const createSuffix = (descending: boolean, timestamp?: number) => {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
counter += 1
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
let value = BigInt(currentTimestamp) * 0x1000n + BigInt(counter)
if (descending) value = ~value
now = descending ? ~now : now
const timeBytes = Buffer.alloc(6)
const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
timeBytes[i] = Number((value >> BigInt(40 - 8 * i)) & 0xffn)
}
const hex = Array.from(timeBytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
return hex + randomBase62(RANDOM_LENGTH)
}
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
const generateID = (prefix: Prefix, descending: boolean, given?: string, timestamp?: number) => {
if (given) {
const expected = `${prefixes[prefix]}_`
if (!given.startsWith(expected)) throw new Error(`ID ${given} does not start with ${expected}`)
return given
}
return `${prefixes[prefix]}_${createSuffix(descending, timestamp)}`
}
export const schema = (prefix: Prefix) => z.string().startsWith(`${prefixes[prefix]}_`)
export function ascending(): string
export function ascending(prefix: Prefix, given?: string): string
export function ascending(prefix?: Prefix, given?: string) {
if (prefix) return generateID(prefix, false, given)
return create(false)
}
export function descending(): string
export function descending(prefix: Prefix, given?: string): string
export function descending(prefix?: Prefix, given?: string) {
if (prefix) return generateID(prefix, true, given)
return create(true)
}
export function create(descending: boolean, timestamp?: number) {
return createSuffix(descending, timestamp)
}
export function createPrefixed(prefix: Prefix, descending: boolean, timestamp?: number) {
return generateID(prefix, descending, undefined, timestamp)
}
}