feat(tui): add inbox tab layout
This commit is contained in:
parent
85ea15e56d
commit
a01d24e908
15 changed files with 693 additions and 27 deletions
|
|
@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent"
|
||||||
import { DialogSessionList } from "./component/dialog-session-list"
|
import { DialogSessionList } from "./component/dialog-session-list"
|
||||||
import { DialogOpen } from "./component/dialog-open"
|
import { DialogOpen } from "./component/dialog-open"
|
||||||
import { SessionTabs } from "./component/session-tabs"
|
import { SessionTabs } from "./component/session-tabs"
|
||||||
|
import { SessionInbox } from "./component/session-inbox"
|
||||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||||
import { Home } from "./routes/home"
|
import { Home } from "./routes/home"
|
||||||
|
|
@ -519,6 +520,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||||
|
const inboxTabsEnabled = () => config.data.tabs?.layout === "inbox" && dimensions().width >= 72
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
renderer.useMouse = config.data.mouse
|
renderer.useMouse = config.data.mouse
|
||||||
|
|
@ -645,6 +647,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
category: "Session",
|
category: "Session",
|
||||||
slash: { name: "new", aliases: ["clear"] },
|
slash: { name: "new", aliases: ["clear"] },
|
||||||
run: () => {
|
run: () => {
|
||||||
|
sessionTabs.navigation.blur()
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "home",
|
type: "home",
|
||||||
location:
|
location:
|
||||||
|
|
@ -653,6 +656,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
setTimeout(() => promptRef.current?.focus(), 0)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -672,13 +676,35 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
enabled: () => !sessionTabs.enabled(),
|
enabled: () => !sessionTabs.enabled(),
|
||||||
run: () => local.session.quickSwitch(i + 1),
|
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",
|
name: "session.tab.next",
|
||||||
title: "Next tab",
|
title: "Next tab",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: sessionTabs.enabled,
|
enabled: sessionTabs.enabled,
|
||||||
run: () => sessionTabs.cycle(1),
|
run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.tab.previous",
|
name: "session.tab.previous",
|
||||||
|
|
@ -686,7 +712,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: sessionTabs.enabled,
|
enabled: sessionTabs.enabled,
|
||||||
run: () => sessionTabs.cycle(-1),
|
run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.tab.next_unread",
|
name: "session.tab.next_unread",
|
||||||
|
|
@ -694,7 +720,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: sessionTabs.enabled,
|
enabled: sessionTabs.enabled,
|
||||||
run: () => sessionTabs.cycleUnread(1),
|
run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.tab.previous_unread",
|
name: "session.tab.previous_unread",
|
||||||
|
|
@ -702,7 +728,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: sessionTabs.enabled,
|
enabled: sessionTabs.enabled,
|
||||||
run: () => sessionTabs.cycleUnread(-1),
|
run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "session.tab.close",
|
name: "session.tab.close",
|
||||||
|
|
@ -724,7 +750,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
category: "Session",
|
category: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
enabled: sessionTabs.enabled,
|
enabled: sessionTabs.enabled,
|
||||||
run: () => sessionTabs.selectIndex(i),
|
run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
name: "model.list",
|
name: "model.list",
|
||||||
|
|
@ -1214,12 +1240,23 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||||
>
|
>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
<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">
|
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||||
<Show when={plugins.ready()}>
|
<Show when={plugins.ready()}>
|
||||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
sessionTabs.enabled() &&
|
sessionTabs.enabled() &&
|
||||||
|
!inboxTabsEnabled() &&
|
||||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||||
route.data.type !== "plugin"
|
route.data.type !== "plugin"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import { useLocation } from "../../context/location"
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { PluginSlot } from "../../plugin/render"
|
import { PluginSlot } from "../../plugin/render"
|
||||||
|
import { useSessionTabs } from "../../context/session-tabs"
|
||||||
|
|
||||||
registerOpencodeSpinner()
|
registerOpencodeSpinner()
|
||||||
|
|
||||||
|
|
@ -159,6 +160,7 @@ export function Prompt(props: PromptProps) {
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const sessionTabs = useSessionTabs()
|
||||||
const keymapCommands = Keymap.useCommands()
|
const keymapCommands = Keymap.useCommands()
|
||||||
const currentLocation = useLocation()
|
const currentLocation = useLocation()
|
||||||
const config = useConfig().data
|
const config = useConfig().data
|
||||||
|
|
@ -589,7 +591,7 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
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()
|
if (input.focused) input.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -810,6 +812,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 >= 72 &&
|
||||||
|
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(() => {
|
Keymap.createLayer(() => {
|
||||||
return {
|
return {
|
||||||
target: inputTarget,
|
target: inputTarget,
|
||||||
|
|
@ -1455,6 +1486,7 @@ export function Prompt(props: PromptProps) {
|
||||||
}}
|
}}
|
||||||
onMouseDown={(r: MouseEvent) => {
|
onMouseDown={(r: MouseEvent) => {
|
||||||
if (props.disabled || r.button !== 0) return
|
if (props.disabled || r.button !== 0) return
|
||||||
|
sessionTabs.navigation.blur()
|
||||||
r.target?.focus()
|
r.target?.focus()
|
||||||
const extmark = input.extmarks
|
const extmark = input.extmarks
|
||||||
.getAtOffset(input.cursorOffset)
|
.getAtOffset(input.cursorOffset)
|
||||||
|
|
|
||||||
322
packages/tui/src/component/session-inbox.tsx
Normal file
322
packages/tui/src/component/session-inbox.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import type { SessionMessageAssistant } from "@opencode-ai/client"
|
||||||
|
import { For, Show, 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 { useRoute } from "../context/route"
|
||||||
|
import { useSessionTabs } from "../context/session-tabs"
|
||||||
|
import { sessionInboxGroup, 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 { Spinner } from "./spinner"
|
||||||
|
import type { SessionTabsStatus } from "./session-tabs"
|
||||||
|
|
||||||
|
const labels: Record<SessionInboxGroup, string> = {
|
||||||
|
running: "Active",
|
||||||
|
today: "Today",
|
||||||
|
yesterday: "Yesterday",
|
||||||
|
earlier: "Earlier",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionInboxRowInfo = {
|
||||||
|
sessionID: string
|
||||||
|
title: string
|
||||||
|
updated: number
|
||||||
|
preview: string
|
||||||
|
status: SessionTabsStatus
|
||||||
|
group: SessionInboxGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.busy && (props.row.status.unread || props.row.status.attention)}>
|
||||||
|
<text width={2} fg={feedback()} selectable={false}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box height={1}>
|
||||||
|
<Show
|
||||||
|
when={!props.pendingDone && !props.row.status.busy}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.pendingDone}
|
||||||
|
fallback={
|
||||||
|
<Spinner color={accent()}>
|
||||||
|
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
|
||||||
|
</Spinner>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
|
||||||
|
Space again to mark done
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionInbox() {
|
||||||
|
const tabs = useSessionTabs()
|
||||||
|
const data = useData()
|
||||||
|
const route = useRoute()
|
||||||
|
const theme = useTheme("elevated")
|
||||||
|
const themes = useThemes()
|
||||||
|
const config = useConfig().data
|
||||||
|
const prompt = usePromptRef()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
let scroll: ScrollBoxRenderable
|
||||||
|
const [verbCycle, setVerbCycle] = createSignal(0)
|
||||||
|
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
|
||||||
|
onCleanup(() => {
|
||||||
|
clearInterval(verbTimer)
|
||||||
|
tabs.navigation.blur()
|
||||||
|
})
|
||||||
|
const width = createMemo(() => Math.max(28, Math.min(40, Math.floor(dimensions().width * 0.28))))
|
||||||
|
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
|
||||||
|
const accent = () => theme.hue.accent[hueStep()]
|
||||||
|
const rows = createMemo(() => {
|
||||||
|
verbCycle()
|
||||||
|
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(" ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
return {
|
||||||
|
sessionID: tab.sessionID,
|
||||||
|
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
|
||||||
|
updated: session?.time.updated ?? 0,
|
||||||
|
preview: preview || "No assistant response yet",
|
||||||
|
status,
|
||||||
|
group: sessionInboxGroup(session?.time.updated ?? 0, status.busy),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.toSorted((a, b) => b.updated - a.updated)
|
||||||
|
})
|
||||||
|
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 order = () => groups().flatMap((group) => group.rows.map((row) => row.sessionID))
|
||||||
|
|
||||||
|
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)
|
||||||
|
else if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
|
||||||
|
})
|
||||||
|
|
||||||
|
const leave = () => {
|
||||||
|
tabs.navigation.blur()
|
||||||
|
prompt.current?.focus()
|
||||||
|
}
|
||||||
|
const newSession = () => {
|
||||||
|
tabs.navigation.blur()
|
||||||
|
route.navigate({
|
||||||
|
type: "home",
|
||||||
|
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
|
||||||
|
})
|
||||||
|
setTimeout(() => prompt.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, order()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bind: "down,tab",
|
||||||
|
title: "Next session",
|
||||||
|
group: "Session",
|
||||||
|
run: () => tabs.navigation.move(1, order()),
|
||||||
|
},
|
||||||
|
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
|
||||||
|
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done(order()) },
|
||||||
|
{ bind: "right", title: "Return to prompt", group: "Session", run: leave },
|
||||||
|
{ bind: "escape", title: "Return to prompt", group: "Session", run: leave },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
width={width()}
|
||||||
|
height="100%"
|
||||||
|
flexShrink={0}
|
||||||
|
flexDirection="column"
|
||||||
|
backgroundColor={theme.background.default}
|
||||||
|
border={["right"]}
|
||||||
|
borderColor={theme.border.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}
|
||||||
|
>
|
||||||
|
+ new
|
||||||
|
</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={order().indexOf(row.sessionID) + 1}
|
||||||
|
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({
|
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||||
description: "Share tabs globally or keep a separate set for each working directory",
|
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" }),
|
).annotate({ description: "Tab strip settings" }),
|
||||||
mini: Schema.optional(
|
mini: Schema.optional(
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const Definitions = {
|
||||||
session_export: keybind("<leader>x", "Export session to editor"),
|
session_export: keybind("<leader>x", "Export session to editor"),
|
||||||
session_copy: keybind("none", "Copy session transcript"),
|
session_copy: keybind("none", "Copy session transcript"),
|
||||||
session_move: keybind("none", "Move session"),
|
session_move: keybind("none", "Move session"),
|
||||||
session_new: keybind("<leader>n", "Create a new session"),
|
session_new: keybind("alt+t,ctrl+t,<leader>n", "Create a new session"),
|
||||||
session_list: keybind("<leader>l", "List all sessions"),
|
session_list: keybind("<leader>l", "List all sessions"),
|
||||||
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
|
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"),
|
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_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_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_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_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
|
||||||
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
|
session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
|
||||||
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
|
session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
|
||||||
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
|
session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
|
||||||
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
|
session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
|
||||||
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
|
session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
|
||||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
|
session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
|
||||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
|
session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
|
||||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
|
session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
|
||||||
|
|
||||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,28 @@ export const SESSION_TAB_MIN_WIDTH = 8
|
||||||
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›".
|
// 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 const sessionTabOverflowWidth = (count: number) => String(count).length + 2
|
||||||
|
|
||||||
|
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
|
||||||
|
|
||||||
|
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[] {
|
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
|
||||||
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
|
||||||
if (index === -1) return [...tabs, tab]
|
if (index === -1) return [...tabs, tab]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
moveSessionTabHistory,
|
moveSessionTabHistory,
|
||||||
NEW_SESSION_TAB_TITLE,
|
NEW_SESSION_TAB_TITLE,
|
||||||
openSessionTab,
|
openSessionTab,
|
||||||
|
orderSessionTabs,
|
||||||
recordClosedSessionTab,
|
recordClosedSessionTab,
|
||||||
recordSessionTabHistory,
|
recordSessionTabHistory,
|
||||||
reopenSessionTab,
|
reopenSessionTab,
|
||||||
|
|
@ -61,6 +62,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||||
})
|
})
|
||||||
const fallback = empty()
|
const fallback = empty()
|
||||||
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
const [promptPulses, setPromptPulses] = 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 }
|
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||||
let closedTabs: ClosedSessionTab[] = []
|
let closedTabs: ClosedSessionTab[] = []
|
||||||
|
|
@ -114,6 +118,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||||
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
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: data.session.get(sessionID)?.time.updated ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
|
|
@ -146,6 +155,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(() => {
|
createEffect(() => {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
const next = normalize(state())
|
const next = normalize(state())
|
||||||
|
|
@ -248,6 +271,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||||
tabs() {
|
tabs() {
|
||||||
return state().tabs
|
return state().tabs
|
||||||
},
|
},
|
||||||
|
recent,
|
||||||
newTab() {
|
newTab() {
|
||||||
return newTab()
|
return newTab()
|
||||||
},
|
},
|
||||||
|
|
@ -291,25 +315,77 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||||
draft.tabs = moveSessionTab(draft.tabs, session, index)
|
draft.tabs = moveSessionTab(draft.tabs, session, index)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
cycle(direction: 1 | -1) {
|
cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
|
||||||
if (!enabled()) return
|
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 })
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
},
|
},
|
||||||
cycleUnread(direction: 1 | -1) {
|
cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
const tab = cycleSessionTab(
|
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(),
|
current(),
|
||||||
direction,
|
direction,
|
||||||
)
|
)
|
||||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
},
|
},
|
||||||
selectIndex(index: number) {
|
selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
|
||||||
if (!enabled()) return
|
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 })
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||||
},
|
},
|
||||||
|
navigation: {
|
||||||
|
active: navigationActive,
|
||||||
|
selected: navigationSelection,
|
||||||
|
pendingDone: navigationPendingDone,
|
||||||
|
focus(order: readonly string[] = recent().map((tab) => tab.sessionID)) {
|
||||||
|
if (!enabled() || state().tabs.length === 0) return false
|
||||||
|
setNavigationSelection(current() ?? order.find((sessionID) => state().tabs.some((tab) => tab.sessionID === sessionID)))
|
||||||
|
setNavigationPendingDone(undefined)
|
||||||
|
setNavigationActive(true)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
blur() {
|
||||||
|
setNavigationActive(false)
|
||||||
|
setNavigationPendingDone(undefined)
|
||||||
|
},
|
||||||
|
move(direction: 1 | -1, order?: readonly string[]) {
|
||||||
|
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((sessionID) =>
|
||||||
|
state().tabs.some((tab) => tab.sessionID === 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(order?: readonly string[]) {
|
||||||
|
const sessionID = navigationSelection()
|
||||||
|
if (!navigationActive() || !sessionID) return
|
||||||
|
if (navigationPendingDone() !== sessionID) {
|
||||||
|
setNavigationPendingDone(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((id) =>
|
||||||
|
state().tabs.some((tab) => tab.sessionID === id),
|
||||||
|
)
|
||||||
|
const index = tabs.indexOf(sessionID)
|
||||||
|
const next = tabs[index + 1] ?? tabs[index - 1]
|
||||||
|
setNavigationPendingDone(undefined)
|
||||||
|
setNavigationSelection(next)
|
||||||
|
remove(sessionID, true)
|
||||||
|
if (!next) setNavigationActive(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,6 @@ export function Session() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
|
||||||
setSynced(true)
|
setSynced(true)
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
if (route.sessionID !== sessionID) return
|
if (route.sessionID !== sessionID) return
|
||||||
|
|
@ -915,8 +914,8 @@ export function Session() {
|
||||||
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// snap to bottom when session changes
|
// The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
|
||||||
createEffect(on(() => route.sessionID, toBottom))
|
// A deferred scroll here causes a visible second jump whenever tabs switch.
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => route.sessionID,
|
() => route.sessionID,
|
||||||
|
|
|
||||||
67
packages/tui/src/util/activity-verb.ts
Normal file
67
packages/tui/src/util/activity-verb.ts
Normal 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,8 +17,11 @@ test("validates mini replay settings", () => {
|
||||||
test("validates the session tabs setting", () => {
|
test("validates the session tabs setting", () => {
|
||||||
const decode = Schema.decodeUnknownSync(Info)
|
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: { enabled: "on" } })).toThrow()
|
||||||
|
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves nested config and keybind defaults", () => {
|
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", () => {
|
test("preserves pinned session bindings alongside tab bindings", () => {
|
||||||
const config = resolve({}, { terminalSuspend: true })
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,ctrl+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.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
|
||||||
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
|
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", () => {
|
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,42 @@ import {
|
||||||
moveSessionTab,
|
moveSessionTab,
|
||||||
moveSessionTabHistory,
|
moveSessionTabHistory,
|
||||||
openSessionTab,
|
openSessionTab,
|
||||||
|
orderSessionTabs,
|
||||||
recordClosedSessionTab,
|
recordClosedSessionTab,
|
||||||
recordSessionTabHistory,
|
recordSessionTabHistory,
|
||||||
reopenSessionTab,
|
reopenSessionTab,
|
||||||
seedSessionTabMotion,
|
seedSessionTabMotion,
|
||||||
|
sessionInboxGroup,
|
||||||
sessionTabComplete,
|
sessionTabComplete,
|
||||||
sessionTabOverflowWidth,
|
sessionTabOverflowWidth,
|
||||||
} from "../../src/context/session-tabs-model"
|
} from "../../src/context/session-tabs-model"
|
||||||
|
|
||||||
describe("session tabs", () => {
|
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("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
|
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
|
||||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||||
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
|
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
|
||||||
|
|
|
||||||
|
|
@ -248,3 +248,33 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
||||||
setup.destroy()
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -139,3 +139,40 @@ test("global commands stay reachable when the mode changes", async () => {
|
||||||
app.renderer.destroy()
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
9
packages/tui/test/util/activity-verb.test.ts
Normal file
9
packages/tui/test/util/activity-verb.test.ts
Normal file
|
|
@ -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(60)
|
||||||
|
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", 60))
|
||||||
|
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue