feat(tui): add adaptive session tabs (#39396)
This commit is contained in:
parent
f95d04fea0
commit
37a1b80d5a
19 changed files with 1485 additions and 147 deletions
10
packages/tui/test/component/tab-pulse.test.ts
Normal file
10
packages/tui/test/component/tab-pulse.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { completionPulseOpacity } from "../../src/component/tab-pulse"
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(0.16)).toBe(1)
|
||||
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(1)).toBe(0)
|
||||
})
|
||||
|
|
@ -2,13 +2,7 @@
|
|||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
resolve,
|
||||
ConfigProvider,
|
||||
Info,
|
||||
useConfig,
|
||||
type Interface,
|
||||
} from "../src/config"
|
||||
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
|
||||
|
||||
test("validates mini replay settings", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
|
@ -20,6 +14,13 @@ test("validates mini replay settings", () => {
|
|||
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ session: { tabs: true } })).toEqual({ session: { tabs: true } })
|
||||
expect(() => decode({ session: { tabs: "on" } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
const config = resolve(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -100,7 +100,24 @@ test("resolves message navigation defaults", () => {
|
|||
test("opens the subagent picker with down", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down,<leader>down" }])
|
||||
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
|
||||
})
|
||||
|
||||
test("navigates session tabs with leader arrows", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
|
||||
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
|
||||
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
|
||||
})
|
||||
|
||||
test("preserves pinned session bindings alongside tab bindings", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
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" }])
|
||||
})
|
||||
|
||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||
|
|
|
|||
104
packages/tui/test/context/session-tabs-model.test.ts
Normal file
104
packages/tui/test/context/session-tabs-model.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
closeSessionTab,
|
||||
cycleSessionTab,
|
||||
openSessionTab,
|
||||
sessionTabComplete,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("opens each session once and refreshes its title", () => {
|
||||
const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
|
||||
expect(tabs).toEqual([{ sessionID: "a", title: "New" }])
|
||||
expect(openSessionTab(tabs, { sessionID: "b" })).toEqual([{ sessionID: "a", title: "New" }, { sessionID: "b" }])
|
||||
expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
|
||||
})
|
||||
|
||||
test("selects the right tab then the left tab after closing", () => {
|
||||
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
|
||||
tabs: [{ sessionID: "a" }, { sessionID: "c" }],
|
||||
next: "c",
|
||||
})
|
||||
expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }], "b").next).toBe("a")
|
||||
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
|
||||
})
|
||||
|
||||
test("cycles through a filtered tab set in either direction", () => {
|
||||
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
|
||||
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
|
||||
expect(cycleSessionTab(tabs, "c", -1)?.sessionID).toBe("a")
|
||||
expect(cycleSessionTab(tabs, "e", 1)?.sessionID).toBe("a")
|
||||
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
|
||||
})
|
||||
|
||||
test("reveals completion activity only after session work becomes idle", () => {
|
||||
expect(sessionTabComplete("activity", true)).toBe(false)
|
||||
expect(sessionTabComplete("activity", false)).toBe(true)
|
||||
expect(sessionTabComplete("error", false)).toBe(false)
|
||||
})
|
||||
|
||||
test("expands the active tab and keeps inactive widths equal", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
|
||||
const layout = adaptiveSessionTabLayout(tabs, "d", 76)
|
||||
|
||||
expect(layout).toMatchObject({ before: 0, after: 0, start: 0, total: 76 })
|
||||
expect(layout.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
|
||||
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
|
||||
})
|
||||
|
||||
test("only swaps old and new active width inside a sticky window", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
|
||||
const before = adaptiveSessionTabLayout(tabs, "c", 76)
|
||||
const after = adaptiveSessionTabLayout(tabs, "d", 76, before.start)
|
||||
|
||||
expect(before.start).toBe(after.start)
|
||||
expect(before.widths).toEqual([9, 9, 22, 9, 9, 9, 9])
|
||||
expect(after.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
|
||||
})
|
||||
|
||||
test("shares roomy width equally without changing widths on selection", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
const before = adaptiveSessionTabLayout(tabs, "a", 100)
|
||||
const after = adaptiveSessionTabLayout(tabs, "c", 100, before.start)
|
||||
|
||||
expect(before.widths).toEqual([32, 32, 32])
|
||||
expect(after.widths).toEqual(before.widths)
|
||||
expect(before.total).toBe(96)
|
||||
})
|
||||
|
||||
test("caps a single tab instead of stretching it across the terminal", () => {
|
||||
const layout = adaptiveSessionTabLayout([{ sessionID: "a" }], "a", 100)
|
||||
|
||||
expect(layout.widths).toEqual([32])
|
||||
expect(layout.total).toBe(32)
|
||||
})
|
||||
|
||||
test("fills roomy space equally below the maximum width", () => {
|
||||
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
|
||||
|
||||
expect(adaptiveSessionTabLayout(tabs, "b", 100).widths).toEqual([25, 25, 25, 25])
|
||||
})
|
||||
|
||||
test("expands only the active tab under compact pressure", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
|
||||
const before = adaptiveSessionTabLayout(tabs, "c", 100)
|
||||
const after = adaptiveSessionTabLayout(tabs, "d", 100, before.start)
|
||||
|
||||
expect(before.widths).toEqual([19, 19, 24, 19, 19])
|
||||
expect(after.widths).toEqual([19, 19, 19, 24, 19])
|
||||
})
|
||||
|
||||
test("moves the window only after selection crosses its edge", () => {
|
||||
const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
|
||||
const initial = adaptiveSessionTabLayout(tabs, "3", 70)
|
||||
const inside = adaptiveSessionTabLayout(tabs, "4", 70, initial.start)
|
||||
const crossed = adaptiveSessionTabLayout(tabs, "7", 70, inside.start)
|
||||
|
||||
expect(initial.start).toBe(0)
|
||||
expect(inside.start).toBe(0)
|
||||
expect(crossed.start).toBeGreaterThan(0)
|
||||
expect(crossed.tabs.some((tab) => tab.sessionID === "7")).toBe(true)
|
||||
expect(crossed.widths.reduce((total, width) => total + width, 0)).toBe(crossed.total)
|
||||
})
|
||||
})
|
||||
45
packages/tui/test/ui/animation.test.ts
Normal file
45
packages/tui/test/ui/animation.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createAnimatable, spring, tween } from "../../src/ui/animation"
|
||||
|
||||
test("animates numeric objects and arrays to their targets", async () => {
|
||||
let dispose = () => {}
|
||||
const visual = createRoot((nextDispose) => {
|
||||
dispose = nextDispose
|
||||
return createAnimatable(
|
||||
{ widths: [8, 8], selection: 0 },
|
||||
{ transition: tween({ duration: 0.02, ease: (progress) => progress }) },
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
visual.animate({ widths: [12, 4], selection: 1 })
|
||||
await Bun.sleep(80)
|
||||
expect(visual.value()).toEqual({ widths: [12, 4], selection: 1 })
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("retains spring state while retargeting and supports immediate jumps", async () => {
|
||||
let dispose = () => {}
|
||||
const visual = createRoot((nextDispose) => {
|
||||
dispose = nextDispose
|
||||
return createAnimatable({ value: 0 }, { transition: spring({ visualDuration: 0.02 }) })
|
||||
})
|
||||
|
||||
try {
|
||||
visual.animate({ value: 1 })
|
||||
await Bun.sleep(20)
|
||||
const target = visual.value().value
|
||||
visual.animate({ value: target })
|
||||
await Bun.sleep(20)
|
||||
expect(visual.value().value).not.toBe(target)
|
||||
await Bun.sleep(80)
|
||||
expect(visual.value().value).toBeCloseTo(target)
|
||||
visual.jump({ value: 0 })
|
||||
expect(visual.value().value).toBe(0)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
|
@ -7,3 +7,8 @@ test("truncates text from the right by terminal width", () => {
|
|||
expect(Locale.truncateWidth("abcdefgh", 1)).toBe("…")
|
||||
expect(Locale.truncateWidth("abcdefgh", 0)).toBe("")
|
||||
})
|
||||
|
||||
test("takes whole graphemes within terminal width", () => {
|
||||
expect(Locale.takeWidth("ab界cd", 4)).toBe("ab界")
|
||||
expect(Locale.graphemes("a👨👩👧👦b")).toEqual(["a", "👨👩👧👦", "b"])
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue