Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 6cc7ccd3c1 feat(tui): preserve scroll position on submit 2026-07-31 14:37:47 +00:00
39 changed files with 242 additions and 634 deletions
-2
View File
@@ -67,7 +67,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -521,7 +520,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
-1
View File
@@ -59,7 +59,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -12,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import { sessionTitle } from "@/utils/session-title"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
})
const projectName = createMemo(() => {
@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import { sessionTitle } from "@/utils/session-title"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => displayLabel(props.session)
const title = () => sessionTitle(props.session.title, props.session.parentID)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={displayLabel(props.session)}
value={sessionTitle(props.session.title, props.session.parentID)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,10 +296,11 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
return sessionTitle(titleValue(), session.parentID)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
@@ -316,7 +317,7 @@ export function MessageTimeline(props: {
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
@@ -920,7 +921,7 @@ export function MessageTimeline(props: {
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test"
import { sessionTitle } from "./session-title"
describe("sessionTitle", () => {
test("uses a display fallback without persisting it", () => {
expect(sessionTitle(undefined)).toBe("New session")
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
expect(sessionTitle("Generated title")).toBe("Generated title")
})
})
+7
View File
@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string, parentID?: string) {
if (!title) return parentID ? "Child session" : "New session"
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: withTimestampedFallback(input),
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
agent: input.agent,
model: input.model,
version: "",
+3 -2
View File
@@ -8,7 +8,6 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -214,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
title:
session.title ??
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
+1 -1
View File
@@ -148,7 +148,7 @@ const layer = Layer.effect(
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return yield* Effect.filter(rows.map(fromRow), (project) => fs.existsSafe(project.canonical))
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
+1 -5
View File
@@ -6,7 +6,6 @@ import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@@ -40,10 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
})
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
+7 -22
View File
@@ -15,16 +15,8 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
describe("Project.list", () => {
it.live("returns existing projects ordered by recent update", () =>
it.effect("returns complete projects ordered by recent update", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const older = abs(path.join(tmp.path, "older"))
const newer = abs(path.join(tmp.path, "newer"))
const deleted = abs(path.join(tmp.path, "deleted"))
yield* Effect.promise(() => Promise.all([fs.mkdir(older), fs.mkdir(newer)]))
const db = (yield* Database.Service).db
const project = yield* Project.Service
yield* db
@@ -32,49 +24,42 @@ describe("Project.list", () => {
.values([
{
id: Project.ID.make("older"),
worktree: older,
worktree: abs("/older"),
vcs: "git",
name: "Older",
icon_color: "#000000",
commands: { start: "bun dev" },
sandboxes: [abs(path.join(older, "sandbox"))],
sandboxes: [abs("/older/sandbox")],
time_created: 1,
time_updated: 1,
},
{
id: Project.ID.make("newer"),
worktree: newer,
worktree: abs("/newer"),
sandboxes: [],
time_created: 2,
time_updated: 2,
time_initialized: 3,
},
{
id: Project.ID.make("deleted"),
worktree: deleted,
sandboxes: [],
time_created: 3,
time_updated: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: newer,
canonical: abs("/newer"),
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: older,
canonical: abs("/older"),
vcs: "git",
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
sandboxes: [abs(path.join(older, "sandbox"))],
sandboxes: [abs("/older/sandbox")],
},
])
}),
-1
View File
@@ -16,7 +16,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -159,7 +158,11 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(() => withTimestampedFallback(info()))
const title = createMemo(
() =>
info().title ??
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
)
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
+3 -6
View File
@@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
@@ -537,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (route.data.type === "session") {
const title = session?.title
if (!title || isFallbackTitle(title)) {
if (!title || isDefaultTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}
@@ -646,10 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
dialog.clear()
},
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
@@ -159,10 +159,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (b.location === b.root.directory) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(
1,
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
)
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
+6 -7
View File
@@ -2,8 +2,8 @@ import path from "path"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import { dialogWidth, useDialog } from "../ui/dialog"
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -16,7 +16,7 @@ import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
@@ -80,11 +80,10 @@ export function DialogOpen() {
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: withTimestampedFallback(session),
title: sessionTitle(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
onSelect: () => location.set(session.location),
gutter: running
? () => <Spinner />
: tabs.has(session.id)
@@ -105,8 +104,8 @@ export function DialogOpen() {
.map((project) => {
const title = project.name ?? path.basename(project.canonical)
const footer = abbreviateHome(project.canonical, paths.home)
const width =
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
// Dialog padding, the gutter column, title padding, and the separating space use nine columns.
const width = Math.min(60, dimensions().width - 2) - 9 - stringWidth(title)
return {
title,
footer: truncateFilePath(footer, width),
@@ -18,8 +18,7 @@ import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
export function DialogSessionList() {
const dialog = useDialog()
@@ -31,15 +30,12 @@ export function DialogSessionList() {
const client = useClient()
const local = useLocal()
const sessionTabs = useSessionTabs()
const config = useConfig().data
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [prefs, updatePrefs] = useStorage().store("session-list", {
initial: { allProjects: config.tabs?.scope !== "cwd" },
})
const [prefs, updatePrefs] = useStorage().store("session-list", { initial: { allProjects: false } })
const allProjects = () => prefs.allProjects
const [searchResults, { mutate: setSearchResults }] = createResource(
@@ -82,9 +78,7 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter(
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
)
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter().trim()
@@ -146,9 +140,7 @@ export function DialogSessionList() {
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
: withTimestampedFallback(session),
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
value: session.id,
category,
footer,
@@ -244,9 +236,7 @@ export function DialogSessionList() {
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
: result,
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
+5 -23
View File
@@ -45,7 +45,6 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
@@ -62,7 +61,7 @@ type LocationData = {
websearch?: WebSearchProvider[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, ShellWithLocation>
shell?: Record<string, ShellInfo>
skill?: SkillInfo[]
}
@@ -375,11 +374,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.catch((error) => console.error("Failed to load projected model switch message", error))
break
case "session.renamed":
// Preserve the live title when it races the session's initial read.
void result.session.sync(event.data.sessionID).then(() => {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
})
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.moved":
if (store.session.info[event.data.sessionID]) {
@@ -855,10 +851,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "shell.created":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
shell: {
...data?.shell,
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
},
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
}))
break
case "shell.exited":
@@ -1113,18 +1106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
})
})
},
+18 -30
View File
@@ -3,7 +3,7 @@ import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../util/session"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
@@ -78,21 +78,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const root = (sessionID: string) => data.session.root(sessionID)
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {}),
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
if (route.data.type === "home") return true
@@ -130,29 +115,32 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
const session = data.session.get(sessionID)
const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined)
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
delete draft.unread[sessionID]
})
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
if (isDeepEqual(next, state())) return
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
const session = data.session.get(sessionID)
return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title })
}, [])
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
const sessionID = root(entry[0])
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
return result
}, {})
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
draft.unread = next.unread
draft.tabs = next
draft.unread = unread
})
})
+3 -3
View File
@@ -10,7 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "../util/session"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
@@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isFallbackTitle(title)) {
if (title && !isDefaultTitle(title)) {
return {
title,
showSession: true,
@@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const setTitle = (title?: string) => {
if (input.host.platform !== "linux") return
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
}
setTitle(input.sessionTitle)
@@ -2,6 +2,7 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useData } from "../../../context/data"
import { useLocation } from "../../../context/location"
import { useClient } from "../../../context/client"
import { useTheme } from "../../../context/theme"
import { Keymap } from "../../../context/keymap"
@@ -9,14 +10,13 @@ import { useComposerTab } from "./index"
export function ShellTab(props: { sessionID: string }) {
const data = useData()
const location = useLocation()
const client = useClient()
const theme = useTheme()
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() =>
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
)
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
const [store, setStore] = createStore({ selected: 0 })
let scroll: ScrollBoxRenderable | undefined
@@ -83,9 +83,10 @@ export function ShellTab(props: { sessionID: string }) {
run() {
const entry = selectedEntry()
if (!entry) return
const ref = location.current
void client.api.shell.remove({
id: entry.id,
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
})
},
},
@@ -8,7 +8,7 @@ import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../../util/session"
interface SubagentEntry {
sessionID: string
@@ -39,7 +39,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const title = withTimestampedFallback(sibling)
const title = sessionTitle(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
@@ -58,7 +58,7 @@ export function SubagentsTab(props: { sessionID: string }) {
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const title = withTimestampedFallback(child)
const title = sessionTitle(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
+81 -46
View File
@@ -92,7 +92,7 @@ import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../util/session"
addDefaultParsers(parsers.parsers)
@@ -143,8 +143,8 @@ export function Session() {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
createEffect(() => currentLocation.set(location()))
@@ -295,6 +295,7 @@ export function Session() {
let seeded = false
let sent = false
let scroll: ScrollBoxRenderable
const [showJumpToBottom, setShowJumpToBottom] = createSignal(false)
const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => {
setPrompt(r)
@@ -314,6 +315,11 @@ export function Session() {
})
}
const updateJumpToBottom = () => {
if (!scroll || scroll.isDestroyed) return
setShowJumpToBottom(scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - 1)
}
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
@@ -322,6 +328,10 @@ export function Session() {
const [hiddenRows, setHiddenRows] = createSignal<number>()
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
createEffect(() => {
visibleRows().length
afterLayout(updateJumpToBottom)
})
createEffect(() => {
const current = hidden()
if (current === 0) return
@@ -409,6 +419,7 @@ export function Session() {
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
}, 50)
}
@@ -421,6 +432,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
updateJumpToBottom()
dialog.clear()
},
},
@@ -432,6 +444,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
updateJumpToBottom()
dialog.clear()
},
},
@@ -443,6 +456,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
updateJumpToBottom()
dialog.clear()
},
},
@@ -454,6 +468,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
updateJumpToBottom()
dialog.clear()
},
},
@@ -465,6 +480,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
updateJumpToBottom()
dialog.clear()
},
},
@@ -476,6 +492,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
updateJumpToBottom()
dialog.clear()
},
},
@@ -490,6 +507,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
updateJumpToBottom()
dialog.clear()
},
},
@@ -501,6 +519,7 @@ export function Session() {
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
dialog.clear()
},
},
@@ -960,48 +979,67 @@ export function Session() {
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
<box flexGrow={1} minHeight={0}>
<scrollbox
id="session-transcript"
ref={(r) => (scroll = r)}
onMouseScroll={() => setTimeout(updateJumpToBottom, 0)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<Show when={showJumpToBottom()}>
<box
id="session-jump-to-bottom"
position="absolute"
zIndex={1000}
right={showScrollbar() ? 2 : 0}
bottom={0}
backgroundColor={theme.raise(theme.background.default)}
paddingLeft={1}
paddingRight={1}
onMouseUp={toBottom}
>
<text fg={theme.text.default}> jump to bottom</text>
</box>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box flexShrink={0}>
<Composer
sessionID={route.sessionID}
@@ -1034,9 +1072,6 @@ export function Session() {
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>
@@ -3308,7 +3343,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
})
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
return `# ${sessionTitle(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
}
export function parseApplyPatchFiles(value: unknown) {
+2 -2
View File
@@ -3,7 +3,7 @@ import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "../../util/session"
import { getScrollAcceleration } from "../../util/scroll"
@@ -39,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
<b>{sessionTitle(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
-5
View File
@@ -83,11 +83,6 @@ export interface DialogSelectOption<T = any> {
onSelect?: (ctx: DialogContext) => void
}
export function dialogSelectContentWidth(dialogWidth: number) {
// Scroll padding, row padding, the gutter, title padding, and the separating gap.
return dialogWidth - 12
}
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
+9 -11
View File
@@ -8,17 +8,9 @@ import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
export type DialogSize = "medium" | "large" | "xlarge"
export function dialogWidth(size: DialogSize) {
if (size === "xlarge") return 116
if (size === "large") return 88
return 60
}
export function Dialog(
props: ParentProps<{
size?: DialogSize
size?: "medium" | "large" | "xlarge"
centered?: boolean
onClose: () => void
}>,
@@ -28,6 +20,12 @@ export function Dialog(
const renderer = useRenderer()
let dismiss = false
const width = () => {
if (props.size === "xlarge") return 116
if (props.size === "large") return 88
return 60
}
return (
<box
onMouseDown={() => {
@@ -59,7 +57,7 @@ export function Dialog(
dismiss = false
e.stopPropagation()
}}
width={dialogWidth(props.size ?? "medium")}
width={width()}
maxWidth={dimensions().width - 2}
backgroundColor={theme.background.default}
paddingTop={1}
@@ -76,7 +74,7 @@ function init() {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as DialogSize,
size: "medium" as "medium" | "large" | "xlarge",
centered: false,
})
+13 -1
View File
@@ -1,6 +1,18 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
export function isDefaultTitle(title?: string) {
return (
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
)
}
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
return (
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
-88
View File
@@ -138,94 +138,6 @@ test("session lifecycle updates the terminal title and prints the epilogue after
}
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OC | Generated title") generatedTitle.resolve()
setTitle(title)
}
const sessionRequested = Promise.withResolvers<void>()
const renameSyncRequested = Promise.withResolvers<void>()
const releaseSession = Promise.withResolvers<void>()
let sessionRequests = 0
const session = {
id: "dummy",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const events = createEventStream()
const calls = createFetch(async (url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy") {
sessionRequests++
sessionRequested.resolve()
if (sessionRequests === 2) renameSyncRequested.resolve()
await releaseSession.promise
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Generated title" },
})
await Promise.race([
renameSyncRequested.promise,
Bun.sleep(2_000).then(() => {
throw new Error("rename sync did not start")
}),
])
releaseSession.resolve()
await Promise.race([
generatedTitle.promise,
Bun.sleep(2_000).then(() => {
throw new Error("generated title was not shown")
}),
])
await Bun.sleep(20)
const generated = titles.lastIndexOf("OC | Generated title")
expect(generated).toBeGreaterThan(-1)
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
+8 -44
View File
@@ -9,11 +9,7 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { Composer } from "../../../src/routes/session/composer"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -1786,19 +1782,12 @@ test("refreshes references after updates", async () => {
test("keeps shell state scoped to location", async () => {
const events = createEventStream()
const other = "/tmp/opencode/other"
const workspace = "ws_other"
let removed: URL | undefined
const calls = createFetch((url, request) => {
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
removed = url
return new Response(null, { status: 204 })
}
const calls = createFetch((url) => {
if (url.pathname !== "/api/shell") return
const requestDirectory = url.searchParams.get("location[directory]")
return json({
location: {
directory: requestDirectory ?? directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "proj_test", directory: requestDirectory ?? directory },
},
data: [
@@ -1809,7 +1798,7 @@ test("keeps shell state scoped to location", async () => {
cwd: requestDirectory ?? directory,
shell: "/bin/sh",
file: "/tmp/opencode-shell",
metadata: { sessionID: "ses_shared" },
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
time: { started: 1 },
},
],
@@ -1819,15 +1808,7 @@ test("keeps shell state scoped to location", async () => {
function Probe() {
data = useData()
return (
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
</ThemeProvider>
</Keymap.Provider>
</RouteProvider>
)
return <box />
}
const app = await testRender(() => (
@@ -1841,31 +1822,19 @@ test("keeps shell state scoped to location", async () => {
</ClientProvider>
</TestTuiContexts>
))
app.renderer.start()
try {
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
await data.shell.sync({ directory: other, workspaceID: workspace })
await data.shell.sync({ directory: other })
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
["sh_default", directory],
["sh_other", other],
])
await app.waitForFrame((frame) => frame.includes("pnpm dev"))
app.mockInput.pressArrow("down")
app.mockInput.pressKey("d", { ctrl: true })
await wait(() => removed !== undefined)
expect(removed?.searchParams.get("location[directory]")).toBe(other)
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other, workspaceID: workspace },
location: { directory: other },
data: {
info: {
id: "sh_live_other",
@@ -1874,18 +1843,13 @@ test("keeps shell state scoped to location", async () => {
cwd: other,
shell: "/bin/sh",
file: "/tmp/opencode-shell-live",
metadata: { sessionID: "ses_shared" },
metadata: { sessionID: "ses_other" },
time: { started: 2 },
},
},
})
await wait(() =>
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
)
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
other,
)
} finally {
app.renderer.destroy()
}
@@ -1,106 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { onMount } from "solid-js"
import { DialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTabsProvider } from "../../../src/context/session-tabs"
import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const events = createEventStream()
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/session") return
return json({
data: [
{
id: "ses_remote",
projectID: "proj_remote",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Remote session",
location: remote,
},
],
cursor: {},
})
}, events)
let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
function Probe() {
const dialog = useDialog()
route = useRoute()
location = useLocation()
data = useData()
onMount(() => dialog.replace(() => <DialogOpen />))
return null
}
const app = await testRender(
() => (
<TestTuiContexts paths={{ state }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
try {
await app.waitForFrame((frame) => frame.includes("Remote session"))
expect(data.session.get("ses_remote")).toBeUndefined()
app.mockInput.pressEnter()
await app.waitFor(() => route.data.type === "session")
expect(route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(location.ref).toEqual(remote)
} finally {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
}
})
@@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import { dialogWidth } from "../../../src/ui/dialog"
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
import { truncateFilePath } from "../../../src/ui/file-path"
import { stringWidth } from "../../../src/util/string-width"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -150,28 +147,6 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[],
return { app, moved, replaceOptions, selected }
}
test("budgets option content for constrained and full-width large dialogs", () => {
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 62 - 2)) - 7).toBe(41)
expect(dialogSelectContentWidth(Math.min(dialogWidth("large"), 100 - 2)) - 7).toBe(69)
})
test("renders the complete truncated footer within the option row", async () => {
await using tmp = await tmpdir()
const title = "Project"
const footer = truncateFilePath(
"/tmp/opencode/projects/a-very-long-project-directory/distinctive-tail.tsx",
dialogSelectContentWidth(dialogWidth("medium")) - stringWidth(title),
)
const select = await mountSelect(tmp.path, [{ title, footer, value: "project" }])
try {
await select.app.waitForFrame((frame) => frame.includes(footer))
expect(select.app.captureCharFrame()).toContain(footer)
} finally {
select.app.renderer.destroy()
}
})
test("renders actions with a current selection", async () => {
await using tmp = await tmpdir()
const app = await renderSelect(
@@ -2,56 +2,41 @@
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync, watch } from "fs"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { DataProvider } from "../../src/context/data"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
import { StorageProvider } from "../../src/context/storage"
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function wait(fn: () => boolean, timeout = 2_000) {
const start = Date.now()
while (!(await fn())) {
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
async function renderSessionTabs(initialSessionID: string) {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: initialSessionID,
title: options?.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
}, events)
const calls = createFetch(undefined, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
data = useData()
return <box />
}
@@ -79,12 +64,11 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
return {
tabs,
route,
data,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
if (!options?.state) rmSync(state, { recursive: true, force: true })
rmSync(state, { recursive: true, force: true })
},
}
}
@@ -104,50 +88,6 @@ test("stores session tabs globally by default", async () => {
}
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
try {
titled = await renderSessionTabs("shared", { state, title: "Generated title" })
untitled = await renderSessionTabs("shared", { state })
const file = path.join(state, "test", "tui", "tabs.json")
await titled.data.session.sync("shared")
await wait(async () => {
if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
})
const observed = ["Generated title"]
const pending = new Set<Promise<void>>()
const watcher = watch(path.dirname(file), (_, name) => {
if (name !== path.basename(file)) return
const read = Bun.file(file)
.json()
.then((value) => {
const title = value.global.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title)
})
.catch(() => undefined)
.finally(() => pending.delete(read))
pending.add(read)
})
try {
await untitled.data.session.sync("shared")
await Bun.sleep(500)
} finally {
watcher.close()
await Promise.allSettled(pending)
}
expect(observed).toEqual(["Generated title"])
} finally {
titled?.destroy()
untitled?.destroy()
rmSync(state, { recursive: true, force: true })
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage } from "../../src/util/session"
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -13,6 +13,22 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
})
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle(undefined)).toBeTrue()
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("derives display-only titles for untitled sessions", () => {
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
"New session - 1970-01-01T00:00:00.000Z",
)
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
"Child session - 1970-01-01T00:00:00.000Z",
)
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
-1
View File
@@ -28,7 +28,6 @@
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -1,36 +0,0 @@
export * as SessionTitleFallback from "./session-title-fallback.js"
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
interface Info {
readonly title?: string
readonly parentID?: string
readonly time: {
readonly created: number
}
}
/** Supplies the timestamped title required by compatibility surfaces. */
export function withTimestampedFallback(info: Info) {
return info.title ?? fallback(info)
}
/** Supplies a compact human label and collapses historical timestamped fallbacks. */
export function displayLabel(info: Pick<Info, "title" | "parentID">) {
if (!info.title) return info.parentID ? "Child session" : "New session"
return info.title.match(pattern)?.[1] ?? info.title
}
/** Recognizes missing and historical root or child fallback titles. */
export function isFallbackTitle(title?: string) {
return title === undefined || pattern.test(title)
}
/** Recognizes a missing title or the exact root fallback for this session. */
export function isExactRootFallback(info: Pick<Info, "title" | "time">) {
return info.title === undefined || info.title === fallback({ time: info.time })
}
function fallback(info: Pick<Info, "parentID" | "time">) {
return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}`
}
@@ -1,49 +0,0 @@
import { describe, expect, test } from "bun:test"
import { SessionTitleFallback } from "../src/session-title-fallback.js"
const root = { title: undefined, time: { created: 0 } }
const child = { ...root, parentID: "ses_parent" }
describe("SessionTitleFallback", () => {
test("supplies timestamped compatibility titles", () => {
expect(SessionTitleFallback.withTimestampedFallback(root)).toBe("New session - 1970-01-01T00:00:00.000Z")
expect(SessionTitleFallback.withTimestampedFallback(child)).toBe("Child session - 1970-01-01T00:00:00.000Z")
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "Generated title" })).toBe("Generated title")
expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "" })).toBe("")
})
test("supplies compact display labels", () => {
expect(SessionTitleFallback.displayLabel(root)).toBe("New session")
expect(SessionTitleFallback.displayLabel(child)).toBe("Child session")
expect(SessionTitleFallback.displayLabel({ title: "", parentID: "ses_parent" })).toBe("Child session")
expect(SessionTitleFallback.displayLabel({ title: "New session - 2026-07-30T18:45:03.662Z" })).toBe("New session")
expect(SessionTitleFallback.displayLabel({ title: "Child session - 2026-07-30T18:45:03.662Z" })).toBe(
"Child session",
)
expect(SessionTitleFallback.displayLabel({ title: "Generated title" })).toBe("Generated title")
})
test("recognizes historical fallback titles", () => {
expect(SessionTitleFallback.isFallbackTitle(undefined)).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("New session - 2026-07-30T18:45:03.662Z")).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("Child session - 2026-07-30T18:45:03.662Z")).toBeTrue()
expect(SessionTitleFallback.isFallbackTitle("")).toBeFalse()
expect(SessionTitleFallback.isFallbackTitle("New session - custom")).toBeFalse()
})
test("recognizes only the fallback associated with a session", () => {
expect(SessionTitleFallback.isExactRootFallback(root)).toBeTrue()
expect(
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 1970-01-01T00:00:00.000Z" }),
).toBeTrue()
expect(
SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 2099-01-01T00:00:00.000Z" }),
).toBeFalse()
expect(
SessionTitleFallback.isExactRootFallback({
...child,
title: "Child session - 1970-01-01T00:00:00.000Z",
}),
).toBeFalse()
})
})