mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 08:25:00 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 094dac1541 | |||
| 729531550c | |||
| dbd934689f | |||
| 4f936c2c95 | |||
| 46662b7eec | |||
| 0bd75af069 | |||
| 7c78d5ca44 | |||
| e872bd3c8f | |||
| b20d62de6a | |||
| 1b78dd7f9c | |||
| a01d24e908 |
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
|
||||
return version
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(" 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*.",
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user