mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 12:45:07 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e763bac34 | |||
| bbc9307ec2 | |||
| b24b1b3f16 | |||
| f2bfdef450 | |||
| 686214dd7e | |||
| c74be8312e |
@@ -143,47 +143,44 @@ describe("Snapshot", () => {
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live(
|
||||
"isolates snapshot indexes by canonical Git worktree",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await initGit(project, true)
|
||||
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const linked = path.join(tmp.path, "linked")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
|
||||
await initGit(project, true)
|
||||
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const capture = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
const capture = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, directory)))
|
||||
expect(yield* capture(project)).toBeDefined()
|
||||
expect(yield* capture(linked)).toBeDefined()
|
||||
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
|
||||
).toBeDefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
const projectID = yield* Effect.gen(function* () {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))),
|
||||
).toBeDefined()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -387,63 +387,57 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"approves an explicit external workdir before shell execution",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
it.live("approves an explicit external workdir before shell execution", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"approves an external directory used by a directory-change command",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
it.live("approves an external directory used by a directory-change command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an expanded external home directory", () =>
|
||||
@@ -465,31 +459,28 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"does not execute after external-directory or shell denial",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
)
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
it.live("does not execute after external-directory or shell denial", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
|
||||
)
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
|
||||
reset()
|
||||
denyAction = "shell"
|
||||
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
reset()
|
||||
denyAction = "shell"
|
||||
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["shell"])
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps non-zero exits useful", () =>
|
||||
@@ -628,7 +619,7 @@ describe("ShellTool", () => {
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
@@ -639,7 +630,7 @@ describe("ShellTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })),
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
@@ -18,12 +18,15 @@ import {
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
import { marqueeCycleWidth, marqueeText } from "../util/marquee"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { moveSelection } from "../ui/select-controller"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -31,6 +34,15 @@ const FADE_WIDTH = 4
|
||||
const ADD_TAB_WIDTH = 3
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
const CONTEXT_MENU_WIDTH = 16
|
||||
const RIGHT_MOUSE_BUTTON = 2
|
||||
|
||||
type TabContextMenuState = {
|
||||
x: number
|
||||
y: number
|
||||
sessionID?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
@@ -85,7 +97,7 @@ function createMarquee(animations: () => boolean) {
|
||||
returning = false
|
||||
return scroll()
|
||||
}
|
||||
if (stringWidth(title) <= width) return
|
||||
if (!marqueeOverflows(title, width)) return
|
||||
cycleWidth = marqueeCycleWidth(title)
|
||||
setActive(sessionID)
|
||||
setOffset(0)
|
||||
@@ -154,6 +166,96 @@ function createTabMarquee(animations: () => boolean) {
|
||||
return { ...marquee, hovered, enter, leave }
|
||||
}
|
||||
|
||||
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const dialog = useDialog()
|
||||
const keymap = Keymap.use()
|
||||
const actions = createMemo(() => {
|
||||
const sessionID = props.state.sessionID
|
||||
return [
|
||||
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
|
||||
...(sessionID
|
||||
? [
|
||||
{
|
||||
title: "Rename",
|
||||
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
|
||||
},
|
||||
{ title: "Close", run: () => props.tabs.close(sessionID) },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
|
||||
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
|
||||
const run = (index: number) => {
|
||||
props.onClose()
|
||||
actions()[index]?.run()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const popMode = keymap.mode.push("modal")
|
||||
onCleanup(popMode)
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
|
||||
{
|
||||
bind: "up",
|
||||
title: "Previous tab menu item",
|
||||
group: "Tabs",
|
||||
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
|
||||
},
|
||||
{
|
||||
bind: "down",
|
||||
title: "Next tab menu item",
|
||||
group: "Tabs",
|
||||
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
|
||||
},
|
||||
{ bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
|
||||
],
|
||||
}))
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={left()}
|
||||
top={top()}
|
||||
height={actions().length}
|
||||
width={CONTEXT_MENU_WIDTH}
|
||||
zIndex={2500}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<For each={actions()}>
|
||||
{(action, index) => (
|
||||
<box
|
||||
width="100%"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setSelected(index())}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
run(index())
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{action.title}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
@@ -181,6 +283,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const hovered = marquee.hovered
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
@@ -212,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
),
|
||||
)
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenY: number } | undefined
|
||||
let rail: { screenX: number; screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
@@ -242,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
position="relative"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
@@ -258,7 +362,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => 2
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
@@ -267,7 +372,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
: Locale.takeWidth(title(), titleWidth()),
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const titleFades = createMemo(
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const detail = createMemo(() => {
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
@@ -349,13 +456,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={() => {
|
||||
marquee.enter(tab.sessionID, title(), titleWidth())
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
setDragging(undefined)
|
||||
if (!rail) return
|
||||
setContextMenu({
|
||||
x: event.x - rail.screenX,
|
||||
y: event.y - rail.screenY,
|
||||
sessionID: tab.sessionID,
|
||||
title: tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
@@ -464,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab.sessionID)
|
||||
@@ -515,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => {
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
@@ -544,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
@@ -556,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<Show when={contextMenu()}>
|
||||
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -575,7 +711,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
// so the strip never flashes the pre-drag order while the write is in flight.
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
let strip: { screenX: number } | undefined
|
||||
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
|
||||
let strip: { screenX: number; screenY: number } | undefined
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
@@ -759,8 +896,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
@@ -769,7 +906,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
@@ -820,13 +957,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={() => {
|
||||
marquee.enter(tab.sessionID, title(), availableTitleWidth())
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
setDragging(undefined)
|
||||
setContextMenu({
|
||||
x: event.x - (strip?.screenX ?? 0),
|
||||
y: event.y - (strip?.screenY ?? 0),
|
||||
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
|
||||
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
|
||||
})
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
release()
|
||||
}}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
@@ -877,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
fg={closeColor()}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
@@ -904,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
selectable={false}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => tabs.add?.()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
tabs.add?.()
|
||||
}}
|
||||
>
|
||||
{" + "}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={contextMenu()}>
|
||||
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export function marqueeCycleWidth(value: string) {
|
||||
return stringWidth(value + GAP)
|
||||
}
|
||||
|
||||
export function marqueeOverflows(value: string, width: number) {
|
||||
return stringWidth(value) > width
|
||||
}
|
||||
|
||||
export function marqueeText(value: string, width: number, offset: number) {
|
||||
if (width <= 0) return ""
|
||||
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
TabPulse,
|
||||
blendTabPulseColor,
|
||||
completionPulseOpacity,
|
||||
glowIgnitionLevel,
|
||||
@@ -8,6 +12,50 @@ import {
|
||||
} from "../../src/component/tab-pulse"
|
||||
import { tint } from "../../src/theme/color"
|
||||
|
||||
test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const flash = RGBA.fromHex("#f0f0f0")
|
||||
const [promptPulse, setPromptPulse] = createSignal(0)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
active={true}
|
||||
promptPulse={promptPulse()}
|
||||
color={background}
|
||||
flashColor={flash}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(1)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17)
|
||||
|
||||
await Bun.sleep(800)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setPromptPulse(2)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { marqueeCycleWidth, marqueeText } from "../../src/util/marquee"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee"
|
||||
import { stringWidth } from "../../src/util/string-width"
|
||||
|
||||
describe("marquee text", () => {
|
||||
@@ -7,6 +7,11 @@ describe("marquee text", () => {
|
||||
expect(marqueeText("Short", 10, 8)).toBe("Short")
|
||||
})
|
||||
|
||||
test("does not classify an exact fit as overflow", () => {
|
||||
expect(marqueeOverflows("Exact fit", 9)).toBe(false)
|
||||
expect(marqueeOverflows("Exact fit", 8)).toBe(true)
|
||||
})
|
||||
|
||||
test("starts clipped and scrolls through a long title", () => {
|
||||
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
|
||||
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
|
||||
|
||||
Reference in New Issue
Block a user