Compare commits

..

11 Commits

Author SHA1 Message Date
Ryan Vogel 094dac1541 fix(core): roll session activity into ancestors 2026-08-01 12:02:07 -04:00
Ryan Vogel 729531550c fix(tui): preserve family session recency 2026-08-01 11:48:29 -04:00
Ryan Vogel dbd934689f fix(tui): address inbox review findings 2026-08-01 15:20:41 +00:00
Ryan Vogel 4f936c2c95 Merge remote-tracking branch 'origin/v2' into tui-inbox-tabs 2026-08-01 11:06:51 -04:00
Ryan Vogel 46662b7eec fix(tui): strip markdown from inbox previews 2026-08-01 11:04:55 -04:00
Ryan Vogel 0bd75af069 fix(tui): add inbox pane gutter 2026-08-01 10:58:26 -04:00
Ryan Vogel 7c78d5ca44 Revert "feat(core): add session rename tool"
This reverts commit 1b78dd7f9c.
2026-08-01 10:55:35 -04:00
Kit Langton e872bd3c8f feat(tui): prioritize favorite model search results (#40049) 2026-08-01 14:54:46 +00:00
Ryan Vogel b20d62de6a fix(tui): remove inbox divider 2026-08-01 10:52:52 -04:00
Ryan Vogel 1b78dd7f9c feat(core): add session rename tool 2026-08-01 10:44:18 -04:00
Ryan Vogel a01d24e908 feat(tui): add inbox tab layout 2026-08-01 10:44:18 -04:00
26 changed files with 1039 additions and 180 deletions
+37 -3
View File
@@ -7,6 +7,7 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model"
import { SessionEvent } from "./event"
import type { SessionSchema } from "./schema"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
@@ -444,6 +445,39 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function runAndTouch(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
yield* run(db, event)
yield* touchAncestors(db, event.data.sessionID, DateTime.toEpochMillis(event.created))
})
}
function touchAncestors(
db: DatabaseService,
sessionID: SessionSchema.ID,
updated: number,
seen = new Set<string>(),
): Effect.Effect<void> {
if (seen.has(sessionID)) return Effect.void
seen.add(sessionID)
return Effect.gen(function* () {
const session = yield* db
.select({ parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!session) return
yield* db
.update(SessionTable)
.set({ time_updated: updated })
.where(and(eq(SessionTable.id, sessionID), lt(SessionTable.time_updated, updated)))
.run()
.pipe(Effect.orDie)
if (session.parentID) yield* touchAncestors(db, session.parentID, updated, seen)
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@@ -678,9 +712,9 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq, sql } from "drizzle-orm"
import { asc, eq, inArray, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -679,6 +679,54 @@ describe("SessionProjector", () => {
}),
)
it.effect("rolls terminal execution activity through every session ancestor", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
const root = Session.ID.make("ses_activity_root")
const child = Session.ID.make("ses_activity_child")
const grandchild = Session.ID.make("ses_activity_grandchild")
yield* db
.insert(SessionTable)
.values(
[
{ id: root },
{ id: child, parent_id: root },
{ id: grandchild, parent_id: child },
].map((session) => ({
...session,
project_id: Project.ID.global,
slug: session.id,
directory: "/project",
title: "test",
version: "test",
time_updated: -1,
})),
)
.run()
.pipe(Effect.orDie)
yield* (yield* Bus.Service).publish(SessionEvent.Execution.Interrupted, {
sessionID: grandchild,
reason: "shutdown",
})
const rows = yield* db
.select({ id: SessionTable.id, updated: SessionTable.time_updated })
.from(SessionTable)
.where(inArray(SessionTable.id, [root, child, grandchild]))
.all()
.pipe(Effect.orDie)
expect(rows).toHaveLength(3)
expect(rows.every((row) => row.updated > -1)).toBe(true)
expect(new Set(rows.map((row) => row.updated)).size).toBe(1)
}),
)
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
-5
View File
@@ -77,11 +77,6 @@
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
},
"#plugin-loader": {
"bun": "./src/plugin/loader.bun.ts",
"node": "./src/plugin/loader.node.ts",
"default": "./src/plugin/loader.node.ts"
}
},
"dependencies": {
+44 -5
View File
@@ -68,6 +68,8 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { SessionInbox } from "./component/session-inbox"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "./context/session-tabs-model"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@@ -519,6 +521,8 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const inboxTabsEnabled = () =>
config.data.tabs?.layout === "inbox" && dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -645,6 +649,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
sessionTabs.navigation.blur()
route.navigate({
type: "home",
location:
@@ -653,6 +658,7 @@ function App(props: { pair?: DialogPairCredentials }) {
: undefined,
})
dialog.clear()
setTimeout(() => promptRef.current?.focus(), 0)
},
},
{
@@ -672,13 +678,35 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1),
})),
{
name: "session.tabs.toggle_layout",
title: !sessionTabs.enabled()
? "Enable inbox tabs"
: config.data.tabs?.layout === "inbox"
? "Move tabs to top"
: "Move tabs to inbox",
category: "Session",
slash: { name: "tabs", aliases: ["tab-layout"] },
run: () => {
void config
.update((draft) => {
draft.tabs = {
...draft.tabs,
enabled: true,
layout: sessionTabs.enabled() && config.data.tabs?.layout === "inbox" ? "top" : "inbox",
}
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "session.tab.next",
title: "Next tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1),
run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.previous",
@@ -686,7 +714,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1),
run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.next_unread",
@@ -694,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1),
run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.previous_unread",
@@ -702,7 +730,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1),
run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
},
{
name: "session.tab.close",
@@ -724,7 +752,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i),
run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
})),
{
name: "model.list",
@@ -1214,12 +1242,23 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show
when={
sessionTabs.enabled() &&
inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionInbox />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show
when={
sessionTabs.enabled() &&
!inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
+10 -4
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
favorite,
description: favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
+34 -1
View File
@@ -54,6 +54,8 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "../../context/session-tabs-model"
registerOpencodeSpinner()
@@ -159,6 +161,7 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const sessionTabs = useSessionTabs()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
@@ -589,7 +592,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (props.visible === false || props.disabled || dialog.stack.length > 0 || sessionTabs.navigation.active()) {
if (input.focused) input.blur()
return
}
@@ -810,6 +813,35 @@ export function Prompt(props: PromptProps) {
}
})
Keymap.createLayer(() => ({
priority: 2,
target: inputTarget,
enabled:
inputTarget() !== undefined &&
!props.disabled &&
store.mode === "normal" &&
!auto()?.visible &&
config.tabs?.layout === "inbox" &&
dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH &&
sessionTabs.enabled() &&
sessionTabs.tabs().length > 0 &&
store.prompt.text === "" &&
store.prompt.pasted.length === 0 &&
(store.prompt.files?.length ?? 0) === 0 &&
(store.prompt.agents?.length ?? 0) === 0,
commands: [
{
bind: "left",
title: "Focus session inbox",
group: "Session",
run: () => {
if (!sessionTabs.navigation.focus()) return false
input.blur()
},
},
],
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
@@ -1455,6 +1487,7 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
sessionTabs.navigation.blur()
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -0,0 +1,320 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { SessionMessageAssistant } from "@opencode-ai/client"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { usePromptRef } from "../context/prompt"
import { useSessionTabs } from "../context/session-tabs"
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { getScrollAcceleration } from "../util/scroll"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { activityVerb } from "../util/activity-verb"
import { markdownPreview } from "../util/markdown-preview"
import { Spinner } from "./spinner"
import type { SessionTabsStatus } from "./session-tabs"
const labels: Record<SessionInboxGroup, string> = {
running: "Active",
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier",
}
type SessionInboxRowInfo = {
sessionID: string
title: string
updated: number
preview: string
status: SessionTabsStatus
group: SessionInboxGroup
}
function SessionInboxRow(props: {
row: SessionInboxRowInfo
selected?: boolean
focused?: boolean
pendingDone?: boolean
number?: number
verb: string
onSelect?: () => void
}) {
const theme = useTheme("elevated")
const themes = useThemes()
const [hovered, setHovered] = createSignal(false)
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const background = () => {
if (props.focused) return theme.background.action.primary.hovered
if (props.selected) return theme.background.surface.offset
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const feedback = () => {
if (props.row.status.attention) return theme.text.feedback.warning.default
if (props.row.status.unread === "error") return theme.text.feedback.error.default
if (props.row.status.unread) return accent()
return theme.text.subdued
}
return (
<box
height={4}
id={`session-inbox-${props.row.sessionID}`}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
backgroundColor={background()}
border={["left"]}
borderColor={props.selected || props.focused ? accent() : background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={props.onSelect}
>
<box height={2} flexDirection="row">
<Show when={props.number}>
{(number) => (
<text
width={String(number()).length + 1}
fg={props.selected || props.focused ? accent() : theme.text.subdued}
selectable={false}
>
{number()}
</text>
)}
</Show>
<box height={2} flexGrow={1} minWidth={0}>
<box height={1} flexDirection="row">
<text
flexGrow={1}
flexShrink={1}
fg={theme.text.default}
attributes={props.selected || props.focused ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
selectable={false}
>
{props.row.title}
</text>
<Show when={props.row.status.attention || (!props.row.status.busy && props.row.status.unread)}>
<text width={2} fg={feedback()} selectable={false}>
</text>
</Show>
</box>
<box height={1}>
<Switch
fallback={
<text
height={1}
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
wrapMode="none"
truncate
selectable={false}
>
{props.row.preview}
</text>
}
>
<Match when={props.pendingDone}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Space again to mark done
</text>
</Match>
<Match when={props.row.status.attention}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Action required
</text>
</Match>
<Match when={props.row.status.busy}>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb}</span>
</Spinner>
</Match>
</Switch>
</box>
</box>
</box>
</box>
)
}
export function SessionInbox() {
const tabs = useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const themes = useThemes()
const config = useConfig().data
const prompt = usePromptRef()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
let scroll: ScrollBoxRenderable
const [verbCycle, setVerbCycle] = createSignal(0)
const [groupClock, setGroupClock] = createSignal(Date.now())
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
onCleanup(() => {
clearInterval(verbTimer)
clearInterval(groupTimer)
tabs.navigation.blur()
})
const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => {
return tabs
.recent()
.map((tab) => {
const session = data.session.get(tab.sessionID)
const status = tabs.status(tab.sessionID)
const assistant = data.session.message
.list(tab.sessionID)
.findLast(
(message): message is SessionMessageAssistant =>
message.type === "assistant" && (!session?.revert?.messageID || message.id < session.revert.messageID),
)
const preview = assistant?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
const updated = tabs.updated(tab.sessionID)
return {
sessionID: tab.sessionID,
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet",
status,
group: sessionInboxGroup(updated, status.busy, groupClock()),
}
})
})
const groups = createMemo(() =>
(["running", "today", "yesterday", "earlier"] as const)
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
.filter((group) => group.rows.length > 0),
)
const numbers = createMemo(() => new Map(rows().map((row, index) => [row.sessionID, index + 1])))
createEffect(() => {
if (!tabs.navigation.active()) return
const sessionID = tabs.navigation.selected()
if (!sessionID || !scroll || scroll.isDestroyed) return
const row = scroll.getRenderable(`session-inbox-${sessionID}`)
if (!row) return
const top = scroll.scrollTop + row.y - scroll.viewport.y
const bottom = top + row.height
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
})
const leave = () => {
tabs.navigation.blur()
prompt.current?.focus()
}
const newSession = () => {
keymap.dispatch("session.new")
}
Keymap.createLayer(() => ({
mode: "global",
priority: 2,
enabled: tabs.navigation.active,
commands: [
{
bind: "up,shift+tab",
title: "Previous session",
group: "Session",
run: () => tabs.navigation.move(-1),
},
{
bind: "down,tab",
title: "Next session",
group: "Session",
run: () => tabs.navigation.move(1),
},
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
{ bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
],
}))
return (
<box
width={width()}
height="100%"
flexShrink={0}
flexDirection="column"
paddingRight={1}
backgroundColor={theme.background.default}
>
<box height={3} flexShrink={0} paddingLeft={2} paddingRight={1} flexDirection="row" alignItems="center">
<text flexGrow={1} fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<text
fg={theme.text.subdued}
onMouseUp={newSession}
selectable={false}
>
opt+T
</text>
</box>
<scrollbox
ref={(value) => (scroll = value)}
flexGrow={1}
scrollAcceleration={getScrollAcceleration(config)}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
<box flexShrink={0} paddingBottom={1}>
<Show when={groups().length > 0} fallback={<text marginLeft={2} fg={theme.text.subdued}>No open sessions</text>}>
<For each={groups()}>
{(group) => (
<box flexShrink={0}>
<box height={2} paddingLeft={2} paddingRight={1} alignItems="center" flexDirection="row">
<text flexGrow={1} fg={group.group === "running" ? accent() : theme.text.subdued}>
{labels[group.group]}
</text>
<text fg={theme.text.subdued}>{group.rows.length}</text>
</box>
<For each={group.rows}>
{(row) => (
<SessionInboxRow
row={row}
selected={tabs.current() === row.sessionID}
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
number={numbers().get(row.sessionID)}
verb={activityVerb(row.sessionID, verbCycle())}
onSelect={() => {
tabs.navigation.blur()
tabs.select(row.sessionID)
}}
/>
)}
</For>
</box>
)}
</For>
</Show>
</box>
</scrollbox>
<box height={2} flexShrink={0} paddingLeft={2} alignItems="center">
<text fg={theme.text.subdued} wrapMode="none" truncate>
{tabs.navigation.active() ? "↑↓/tab choose · enter open · space done" : "← empty prompt · /tabs layout"}
</text>
</box>
</box>
)
}
+3
View File
@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
layout: Schema.optional(Schema.Literals(["top", "inbox"])).annotate({
description: "Show tabs as a top strip or a grouped inbox rail",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
+10 -10
View File
@@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"),
session_new: keybind("<leader>n", "Create a new session"),
session_new: keybind("alt+t,<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
@@ -116,15 +116,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -22,6 +22,34 @@ export const SESSION_TAB_MIN_WIDTH = 8
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "12 " and " 12".
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
export const SESSION_INBOX_MIN_TERMINAL_WIDTH = 72
export function sessionInboxWidth(terminalWidth: number) {
return Math.max(28, Math.min(40, Math.floor(terminalWidth * 0.28)))
}
export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
if (running) return "running"
const today = new Date(now)
today.setHours(0, 0, 0, 0)
if (updated >= today.getTime()) return "today"
today.setDate(today.getDate() - 1)
if (updated >= today.getTime()) return "yesterday"
return "earlier"
}
export function orderSessionTabs(
tabs: readonly SessionTab[],
status: (sessionID: string) => { busy: boolean; updated: number },
) {
return tabs
.map((tab, index) => ({ tab, index, ...status(tab.sessionID) }))
.toSorted((a, b) => Number(b.busy) - Number(a.busy) || b.updated - a.updated || a.index - b.index)
.map((item) => item.tab)
}
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
if (index === -1) return [...tabs, tab]
+128 -11
View File
@@ -16,6 +16,7 @@ import {
moveSessionTabHistory,
NEW_SESSION_TAB_TITLE,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
@@ -61,6 +62,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
const [lastActivity, setLastActivity] = createSignal<Record<string, number>>({})
const [navigationActive, setNavigationActive] = createSignal(false)
const [navigationSelection, setNavigationSelection] = createSignal<string>()
const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
@@ -79,11 +84,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const root = (sessionID: string) => data.session.root(sessionID)
const updated = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return (members.length > 0 ? members : [session]).reduce(
(latest, id) =>
Math.max(
latest,
data.session.get(id)?.time.updated ?? 0,
lastActivity()[id] ?? 0,
),
0,
)
}
const touch = (sessionID: string, created: number) => {
if (!enabled()) return
if ((lastActivity()[sessionID] ?? 0) >= created) return
setLastActivity((value) => ({ ...value, [sessionID]: Math.max(value[sessionID] ?? 0, created) }))
}
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) => ({
const normalize = (value: TabsState): TabsState => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
@@ -114,6 +137,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
const recent = () =>
orderSessionTabs(state().tabs, (sessionID) => ({
busy: status(sessionID).busy,
updated: updated(sessionID),
}))
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
@@ -146,6 +174,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
createEffect(() => {
if (!navigationActive()) return
const tabs = state().tabs
if (tabs.length === 0) {
setNavigationActive(false)
setNavigationSelection(undefined)
setNavigationPendingDone(undefined)
return
}
if (tabs.some((tab) => tab.sessionID === navigationSelection())) return
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
@@ -182,7 +224,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
@@ -196,12 +238,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.execution.succeeded", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.interrupted", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.failed", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "error")
}),
)
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
touch(evt.data.sessionID, evt.created)
const sessionID = root(evt.data.sessionID)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
@@ -240,6 +298,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
setLastActivity((activity) => {
const family = data.session.family(target)
const members = family.length > 0 ? family : [target]
if (!members.some((id) => activity[id] !== undefined)) return activity
const next = { ...activity }
for (const id of members) delete next[id]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@@ -248,6 +314,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
recent,
updated,
newTab() {
return newTab()
},
@@ -291,25 +359,74 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = moveSessionTab(draft.tabs, session, index)
})
},
cycle(direction: 1 | -1) {
cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = cycleSessionTab(state().tabs, current(), direction)
const tab = cycleSessionTab(order === "recent" ? recent() : state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1) {
cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
(order === "recent" ? recent() : state().tabs).filter(
(tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention,
),
current(),
direction,
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
selectIndex(index: number) {
selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
if (!enabled()) return
const tab = state().tabs[index]
const tab = (order === "recent" ? recent() : state().tabs)[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
navigation: {
active: navigationActive,
selected: navigationSelection,
pendingDone: navigationPendingDone,
focus() {
if (!enabled() || state().tabs.length === 0) return false
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
setNavigationActive(true)
return true
},
blur() {
setNavigationActive(false)
setNavigationPendingDone(undefined)
},
move(direction: 1 | -1) {
const tabs = recent().map((tab) => tab.sessionID)
if (!navigationActive() || tabs.length === 0) return
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
setNavigationSelection(tabs[(start + direction + tabs.length) % tabs.length])
setNavigationPendingDone(undefined)
},
select() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
setNavigationActive(false)
setNavigationPendingDone(undefined)
route.navigate({ type: "session", sessionID })
},
done() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
if (navigationPendingDone() !== sessionID) {
setNavigationPendingDone(sessionID)
return
}
const tabs = recent().map((tab) => tab.sessionID)
const index = tabs.indexOf(sessionID)
const next = tabs[index + 1] ?? tabs[index - 1]
const selected = current() === sessionID
setNavigationPendingDone(undefined)
remove(sessionID, true)
setNavigationSelection(selected ? (current() ?? next) : next)
if (!next) setNavigationActive(false)
},
},
}
},
})
+2 -4
View File
@@ -7,7 +7,6 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { preparePlugin } from "#plugin-loader"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
@@ -216,7 +215,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
@@ -440,7 +439,6 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
state: string,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -453,7 +451,7 @@ async function resolvePlugin(
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state))
const mod: { readonly default?: unknown } = await import(version)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
-44
View File
@@ -1,44 +0,0 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const runtime = new Set([
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])
export async function preparePlugin(entrypoint: string, version: string, state: string) {
const source = fileURLToPath(entrypoint)
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
const result = await Bun.build({
entrypoints: [source],
target: "bun",
format: "esm",
sourcemap: "inline",
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
return runtimeModuleIdForSpecifier(specifier)
},
}),
],
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
specifier,
runtimeModuleIdForSpecifier(specifier),
]),
})
if (!result.success) throw new Error(result.logs.join("\n"))
const directory = path.join(state, "tui-plugin-cache")
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
await mkdir(directory, { recursive: true })
await Bun.write(output, result.outputs[0]!)
return output
}
-3
View File
@@ -1,3 +0,0 @@
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
return version
}
+13 -5
View File
@@ -73,6 +73,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH, sessionInboxWidth } from "../../context/session-tabs-model"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@@ -190,6 +192,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const sessionTabs = useSessionTabs()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
@@ -199,14 +202,20 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const wide = createMemo(() => dimensions().width > 120)
const viewportWidth = createMemo(() => {
const width = dimensions().width
if (!sessionTabs.enabled() || config.tabs?.layout !== "inbox" || width < SESSION_INBOX_MIN_TERMINAL_WIDTH)
return width
return width - sessionInboxWidth(width)
})
const wide = createMemo(() => viewportWidth() > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => viewportWidth() - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -281,7 +290,6 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -915,8 +923,8 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
// The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
// A deferred scroll here causes a visible second jump whenever tabs switch.
createEffect(
on(
() => route.sessionID,
+67
View File
@@ -0,0 +1,67 @@
export const ACTIVITY_VERBS = [
"mogging",
"glazing",
"larping",
"canoodling",
"scheming",
"fuming",
"coping",
"seething",
"plotting",
"yapping",
"fumbling",
"malding",
"grandstanding",
"doomscrolling",
"clowning",
"gremlining",
"goblining",
"waffling",
"dillydallying",
"catastrophizing",
"overthinking",
"underdelivering",
"vibecoding",
"yak-shaving",
"bikeshedding",
"nitpicking",
"procrastinating",
"overengineering",
"spaghettiweaving",
"bugfarming",
"cachepoisoning",
"stacktracing",
"tokenburning",
"contextstuffing",
"promptwrangling",
"diffdivining",
"lintworshipping",
"testdodging",
"mergebegging",
"branchhaunting",
"commitcosplaying",
"semicolonhoarding",
"typesquinting",
"regexsummoning",
"dependencyjuggling",
"logstaring",
"packetwhispering",
"daemonpoking",
"terminalpeacocking",
"sandboxrattling",
"copypasting",
"tabcollecting",
"scopecreeping",
"deadlinehaunting",
"yakstacking",
"slopshoveling",
"codefermenting",
"pixelslandering",
"buildgaslighting",
"syntaxgrooming",
] as const
export function activityVerb(key: string, offset = 0) {
const hash = Array.from(key).reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0)
return ACTIVITY_VERBS[(hash + offset) % ACTIVITY_VERBS.length]
}
+17
View File
@@ -0,0 +1,17 @@
export function markdownPreview(input: string) {
return input
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
.replace(/^ {0,3}(?:#{1,6}\s+|>\s?|(?:[-+*]|\d+[.)])\s+)/gm, "")
.replace(/(?<!\\)~~(?=\S)([\s\S]*?\S)(?<!\\)~~/g, "$1")
.replace(/(?<!\\)\*\*(?=\S)([\s\S]*?\S)(?<!\\)\*\*/g, "$1")
.replace(/(?<!\\)__(?=\S)([\s\S]*?\S)(?<!\\)__/g, "$1")
.replace(/(?<![\\*])\*(?=\S)([^*\n]*?\S)(?<!\\)\*(?!\*)/g, "$1")
.replace(/(?<![\\_])_(?=\S)([^_\n]*?\S)(?<!\\)_(?!_)/g, "$1")
.replace(/\\([\\`*_[\]{}()#+\-.!>|~])/g, "$1")
.replace(/\s+/g, " ")
.trim()
}
@@ -1,5 +1,23 @@
import { describe, expect, test } from "bun:test"
import { sortModelOptions } from "../../../../src/component/dialog-model"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
"Second favorite match",
"Best match",
"Second best match",
])
})
})
describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => {
+4 -1
View File
@@ -17,8 +17,11 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(decode({ tabs: { enabled: true, layout: "inbox" } })).toEqual({
tabs: { enabled: true, layout: "inbox" },
})
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
+3 -1
View File
@@ -117,9 +117,11 @@ test("navigates session tabs with leader arrows", () => {
test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,<leader>n" }])
expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1,alt+1" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
@@ -6,15 +6,49 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionInboxGroup,
sessionInboxWidth,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("orders running sessions first and completed sessions by recent update", () => {
const tabs = ["old", "running-old", "new", "running-new"].map((sessionID) => ({ sessionID }))
const state = {
old: { busy: false, updated: 10 },
"running-old": { busy: true, updated: 20 },
new: { busy: false, updated: 40 },
"running-new": { busy: true, updated: 30 },
}
expect(orderSessionTabs(tabs, (sessionID) => state[sessionID as keyof typeof state]).map((tab) => tab.sessionID)).toEqual([
"running-new",
"running-old",
"new",
"old",
])
})
test("groups inbox tabs by running state and local calendar day", () => {
const now = new Date(2026, 6, 31, 12).getTime()
expect(sessionInboxGroup(new Date(2026, 6, 20).getTime(), true, now)).toBe("running")
expect(sessionInboxGroup(new Date(2026, 6, 31, 1).getTime(), false, now)).toBe("today")
expect(sessionInboxGroup(new Date(2026, 6, 30, 1).getTime(), false, now)).toBe("yesterday")
expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
})
test("sizes the inbox within its minimum and maximum rails", () => {
expect(sessionInboxWidth(72)).toBe(28)
expect(sessionInboxWidth(120)).toBe(33)
expect(sessionInboxWidth(200)).toBe(40)
})
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
+146 -9
View File
@@ -49,22 +49,43 @@ function stateDir(prefix: string) {
return dir
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
type SessionFixture = { parentID?: string; title?: string; updated?: number }
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; sessions?: Record<string, SessionFixture> },
) {
const state = options?.state ?? stateDir("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,
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
fixture.parentID === parentID ? [session(id, fixture)] : [],
),
cursor: {},
})
}
const match = /^\/api\/session\/([^/]+)$/.exec(url.pathname)
if (!match) return
const id = match[1]!
const fixture = options?.sessions?.[id] ?? (id === initialSessionID ? { title: options?.title } : undefined)
if (!fixture) return
return json({ data: session(id, fixture) })
function session(id: string, fixture: SessionFixture) {
return {
id,
parentID: fixture.parentID,
title: fixture.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
time: { created: 0, updated: fixture.updated ?? 0 },
}
}
}, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
@@ -218,6 +239,68 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
}
})
test("tracks live inbox recency beyond cached session metadata", async () => {
const setup = await renderSessionTabs("background")
try {
expect(setup.tabs.updated("background")).toBe(0)
setup.emit({
id: "evt_admitted",
created: 100,
type: "session.input.admitted",
durable: { aggregateID: "background", seq: 1, version: 1 },
data: {
sessionID: "background",
inputID: "msg_1",
input: { type: "user", data: { text: "work" }, delivery: "steer" },
},
})
await wait(() => setup.tabs.updated("background") === 100)
setup.emit({
id: "evt_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "background", seq: 2, version: 1 },
data: { sessionID: "background" },
})
await wait(() => setup.tabs.updated("background") === 200)
} finally {
setup.destroy()
}
})
test("tracks child activity before family hydration and after restart", async () => {
const state = stateDir("opencode-session-tabs-recency-")
const sessions: Record<string, SessionFixture> = { child: { parentID: "parent" } }
const first = await renderSessionTabs("parent", { state, sessions })
try {
first.emit({
id: "evt_child_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
})
expect(first.tabs.updated("parent")).toBe(0)
await first.data.session.sync("child")
await wait(() => first.tabs.updated("parent") === 200)
} finally {
first.destroy()
}
sessions.child.updated = 200
const second = await renderSessionTabs("parent", { state, sessions })
try {
expect(second.tabs.updated("parent")).toBe(0)
await second.data.session.sync("parent", { children: true })
await wait(() => second.tabs.updated("parent") === 200)
} finally {
second.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
@@ -248,3 +331,57 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.destroy()
}
})
test("navigates the inbox without changing sessions and confirms done twice", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first")
expect(setup.tabs.navigation.focus()).toBe(true)
expect(setup.tabs.navigation.selected()).toBe("first")
setup.tabs.navigation.move(1)
expect(setup.tabs.navigation.selected()).toBe("second")
expect(setup.tabs.current()).toBe("first")
setup.tabs.navigation.done()
expect(setup.tabs.navigation.pendingDone()).toBe("second")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 1)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
expect(setup.tabs.current()).toBe("first")
expect(setup.tabs.navigation.selected()).toBe("first")
} finally {
setup.destroy()
}
})
test("keeps inbox focus aligned with history after marking the current session done", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second")
setup.route.navigate({ type: "session", sessionID: "third" })
await wait(() => setup.tabs.current() === "third")
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().length === 3)
setup.tabs.navigation.focus()
setup.tabs.navigation.done()
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 2)
expect(setup.tabs.current()).toBe("third")
expect(setup.tabs.navigation.selected()).toBe("third")
} finally {
setup.destroy()
}
})
+37
View File
@@ -139,3 +139,40 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("dispatches direct and leader tab-number bindings", async () => {
const calls: number[] = []
function Harness() {
Keymap.createLayer(() => ({
mode: "global",
commands: Array.from({ length: 2 }, (_, index) => ({
id: `session.tab.select.${index + 1}`,
run: () => void calls.push(index + 1),
})),
}))
Keymap.createLayer(() => ({
mode: "global",
bindings: ["session.tab.select.1", "session.tab.select.2"],
}))
return <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
app.mockInput.pressKey("1", { ctrl: true })
expect(calls).toEqual([])
app.mockInput.pressKey("1", { meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("2")
expect(calls).toEqual([1, 2])
} finally {
app.renderer.destroy()
}
})
@@ -1,72 +0,0 @@
import { expect, test } from "bun:test"
import { createComponent, createSignal, type JSX } from "solid-js"
import { testRender } from "@opentui/solid"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { preparePlugin } from "../src/plugin/loader.bun"
import "../src/plugin/runtime-plugin-support.bun"
import { tmpdir } from "./fixture/fixture"
test("an external TSX plugin uses the host Solid runtime", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, ".opencode")
const source = path.join(root, "plugins", "tui", "reactive.tsx")
const helper = path.join(root, "plugins", "tui", "signal.ts")
const localSolid = path.join(root, "node_modules", "solid-js")
const localOpenTui = path.join(root, "node_modules", "@opentui", "solid")
await Promise.all([
mkdir(path.dirname(source), { recursive: true }),
mkdir(localSolid, { recursive: true }),
mkdir(localOpenTui, { recursive: true }),
])
await Promise.all([
writeFile(
path.join(localSolid, "package.json"),
JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }),
),
writeFile(
path.join(localSolid, "index.js"),
"export const createSignal = () => { throw new Error('local Solid used') }\n",
),
writeFile(
path.join(localOpenTui, "package.json"),
JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }),
),
writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"),
writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'),
writeFile(
source,
`
import { createSignal, onCleanup } from "./signal"
export const signal = createSignal
export default {
id: "test.reactive",
setup(context: any) {
context.ui.slot("home.footer", () => {
const [count, setCount] = createSignal(0)
const timer = setTimeout(() => setCount(1), 10)
onCleanup(() => clearTimeout(timer))
return <box><text>count:{count()}</text></box>
})
},
}
`,
),
])
const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path))
expect(plugin.signal).toBe(createSignal)
let slot: ((input: object) => JSX.Element) | undefined
await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } })
if (!slot) throw new Error("Plugin did not register its slot")
const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 })
try {
expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0")
expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1")
} finally {
setup.renderer.destroy()
}
})
@@ -0,0 +1,9 @@
import { expect, test } from "bun:test"
import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
test("rotates through 60 stable activity verbs", () => {
expect(ACTIVITY_VERBS).toHaveLength(60)
expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
})
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test"
import { markdownPreview } from "../../src/util/markdown-preview"
test("strips common Markdown presentation syntax from previews", () => {
expect(markdownPreview("The capture shows **Reset** and a _short-lived_ **override**.")).toBe(
"The capture shows Reset and a short-lived override.",
)
expect(markdownPreview("Use [`sessionRename`](https://example.com) with ~~old~~ new behavior.")).toBe(
"Use sessionRename with old new behavior.",
)
expect(markdownPreview("![diagram](image.png) Review [the result][result].")).toBe("diagram Review the result.")
})
test("flattens block syntax and code without losing its content", () => {
expect(markdownPreview("# Result\n\n- First item\n- Second `item`\n> Finished")).toBe(
"Result First item Second item Finished",
)
expect(markdownPreview("```ts\nconst answer = 42\n```")).toBe("const answer = 42")
})
test("preserves literal asterisks, globs, and escaped punctuation", () => {
expect(markdownPreview("Search **/*.ts, calculate 2 * 3, and keep \\*literal\\*. ")).toBe(
"Search **/*.ts, calculate 2 * 3, and keep *literal*.",
)
})