mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 01:43:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3910bc84 |
@@ -303,6 +303,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly hasLocalToolCalls: boolean
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
readonly pendingFinish?: {
|
||||
@@ -668,7 +669,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
const mapFinishReason = (reason: string | null | undefined, hasLocalToolCalls: boolean): FinishReason => {
|
||||
if (
|
||||
hasLocalToolCalls &&
|
||||
(reason === undefined || reason === null || reason === "end_turn" || reason === "stop_sequence")
|
||||
)
|
||||
return "tool-calls"
|
||||
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
|
||||
if (reason === "max_tokens" || reason === "model_context_window_exceeded") return "length"
|
||||
if (reason === "tool_use") return "tool-calls"
|
||||
@@ -931,7 +937,17 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
events.push(...resultEvents)
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
delete reasoningSignatures[event.index]
|
||||
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
hasLocalToolCalls:
|
||||
state.hasLocalToolCalls || resultEvents.some((item) => item.type === "tool-call" && !item.providerExecuted),
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
reasoningSignatures,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
})
|
||||
|
||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
@@ -942,7 +958,7 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
normalized: mapFinishReason(event.delta?.stop_reason, state.hasLocalToolCalls),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
@@ -1013,6 +1029,7 @@ export const protocol = Protocol.make({
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
hasLocalToolCalls: false,
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
|
||||
@@ -444,7 +444,6 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
}
|
||||
|
||||
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
|
||||
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
|
||||
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
|
||||
if (finishReason === "MAX_TOKENS") return "length"
|
||||
if (
|
||||
|
||||
@@ -915,6 +915,30 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps a local tool call with end_turn as tool-calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed server tool input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -601,34 +601,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps tool calls without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns unique ids to multiple streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -10,7 +10,7 @@ import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -54,11 +54,16 @@ type Prepared =
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: Target
|
||||
})
|
||||
|
||||
interface Target extends LocationMutation.Target {
|
||||
readonly entry: string
|
||||
interface Target {
|
||||
readonly canonical: string
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: {
|
||||
readonly directory: string
|
||||
readonly resource: string
|
||||
}
|
||||
}
|
||||
|
||||
export const Plugin = {
|
||||
@@ -66,7 +71,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -102,46 +107,26 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const resources: string[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const resolved = yield* mutation.resolve({ path: hunk.path, kind: "file" })
|
||||
const removesSource =
|
||||
hunk.type === "delete" || (hunk.type === "update" && hunk.movePath !== undefined)
|
||||
const entryDirectory = removesSource
|
||||
? yield* mutation.resolve({ path: path.dirname(hunk.path), kind: "directory" })
|
||||
: undefined
|
||||
const target = {
|
||||
...resolved,
|
||||
entry: entryDirectory
|
||||
? path.join(entryDirectory.canonical, path.basename(hunk.path))
|
||||
: resolved.canonical,
|
||||
} satisfies Target
|
||||
resources.push(target.resource)
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.canonical,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (
|
||||
entryDirectory?.externalDirectory &&
|
||||
entryDirectory.externalDirectory.resource !== target.externalDirectory?.resource
|
||||
) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(entryDirectory.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
if (entryDirectory?.externalDirectory) {
|
||||
const entryResource = target.entry.replaceAll("\\", "/")
|
||||
if (entryResource !== target.resource) resources.push(entryResource)
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({
|
||||
...hunk,
|
||||
@@ -200,13 +185,17 @@ export const Plugin = {
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
const moveTarget = hunk.movePath
|
||||
? yield* mutation.resolve({ path: hunk.movePath, kind: "file" })
|
||||
: undefined
|
||||
if (moveTarget) resources.push(moveTarget.resource)
|
||||
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
|
||||
action: "external_directory",
|
||||
resources: [moveTarget.externalDirectory.resource],
|
||||
save: [moveTarget.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: moveTarget.canonical,
|
||||
parentDir: moveTarget.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
@@ -233,10 +222,10 @@ export const Plugin = {
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(resources)],
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
filepath: resources.join(", "),
|
||||
filepath: targets.map((target) => target.resource).join(", "),
|
||||
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
|
||||
files: patchFiles,
|
||||
},
|
||||
@@ -269,14 +258,14 @@ export const Plugin = {
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
yield* fs
|
||||
.remove(change.target.entry)
|
||||
.remove(change.target.canonical)
|
||||
.pipe(
|
||||
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
|
||||
)
|
||||
applied.push({
|
||||
type: change.type,
|
||||
resource: change.target.resource,
|
||||
target: change.target.entry,
|
||||
target: change.target.canonical,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -285,7 +274,7 @@ export const Plugin = {
|
||||
yield* fs
|
||||
.writeWithDirs(moveTarget.canonical, change.content)
|
||||
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
|
||||
yield* fs.remove(change.target.entry).pipe(
|
||||
yield* fs.remove(change.target.canonical).pipe(
|
||||
Effect.mapError((error) =>
|
||||
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
|
||||
),
|
||||
@@ -425,3 +414,24 @@ function trimDiff(diff: string) {
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function resolveTarget(location: Location.Interface, value: string): Target {
|
||||
const canonical =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePath(path.resolve(location.directory, value))
|
||||
: path.resolve(location.directory, value)
|
||||
const projectRoot = path.parse(location.project.directory).root
|
||||
const external =
|
||||
!FSUtil.contains(location.directory, canonical) &&
|
||||
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
|
||||
const directory = path.dirname(canonical)
|
||||
const resource =
|
||||
process.platform === "win32"
|
||||
? FSUtil.normalizePathPattern(path.join(directory, "*"))
|
||||
: path.join(directory, "*").replaceAll("\\", "/")
|
||||
return {
|
||||
canonical,
|
||||
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
|
||||
externalDirectory: external ? { directory, resource } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -404,72 +403,6 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("authorizes external symlink entries when deleting and moving", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
if (process.platform === "win32") return Effect.void
|
||||
const active = path.join(tmp.path, "active")
|
||||
const outside = path.join(tmp.path, "outside")
|
||||
const other = path.join(tmp.path, "other")
|
||||
const target = path.join(other, "target.txt")
|
||||
const link = path.join(outside, "link.txt")
|
||||
const moved = path.join(active, "moved.txt")
|
||||
return Effect.promise(async () => {
|
||||
await Promise.all([fs.mkdir(active), fs.mkdir(outside), fs.mkdir(other)])
|
||||
await fs.writeFile(target, "before\n")
|
||||
await fs.symlink(target, link)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call(`*** Begin Patch\n*** Delete File: ${link}\n*** End Patch`)),
|
||||
).toMatchObject({ status: "completed" })
|
||||
const targetRoot = yield* Effect.promise(() => fs.realpath(other))
|
||||
const entryRoot = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "external_directory", resources: [path.join(targetRoot, "*")] },
|
||||
{ action: "external_directory", resources: [path.join(entryRoot, "*")] },
|
||||
{
|
||||
action: "edit",
|
||||
resources: [path.join(targetRoot, "target.txt"), path.join(entryRoot, "link.txt")],
|
||||
},
|
||||
])
|
||||
expect(yield* exists(link)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
|
||||
reset()
|
||||
yield* Effect.promise(() => fs.symlink(target, link))
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
`*** Begin Patch\n*** Update File: ${link}\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch`,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "external_directory", resources: [path.join(targetRoot, "*")] },
|
||||
{ action: "external_directory", resources: [path.join(entryRoot, "*")] },
|
||||
{
|
||||
action: "edit",
|
||||
resources: [path.join(targetRoot, "target.txt"), path.join(entryRoot, "link.txt"), "moved.txt"],
|
||||
},
|
||||
])
|
||||
expect(yield* exists(link)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("includes move file info in output and metadata", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -895,7 +828,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external permission for a sibling path inside the project worktree", () =>
|
||||
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -914,17 +847,7 @@ describe("PatchTool", () => {
|
||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ status: "completed" })
|
||||
const root = yield* Effect.promise(() => fs.realpath(tmp.path))
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
action: "external_directory",
|
||||
resources: [path.join(root, "*").replaceAll("\\", "/")],
|
||||
},
|
||||
{
|
||||
action: "edit",
|
||||
resources: [path.join(root, "sibling.txt").replaceAll("\\", "/")],
|
||||
},
|
||||
])
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
tmp.path,
|
||||
|
||||
@@ -88,10 +88,10 @@ export const Definitions = {
|
||||
session_new: keybind("<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,alt+down", "Switch to next open tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,alt+up", "Switch to previous open tab"),
|
||||
session_tab_next_unread: keybind("alt+shift+down", "Switch to next unread tab"),
|
||||
session_tab_previous_unread: keybind("alt+shift+up", "Switch to previous unread tab"),
|
||||
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
|
||||
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
|
||||
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
|
||||
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
|
||||
session_tab_close: keybind("<leader>w", "Close current tab"),
|
||||
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
|
||||
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||
@@ -150,10 +150,10 @@ export const Definitions = {
|
||||
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
|
||||
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||
messages_next: keybind("none", "Navigate to next message"),
|
||||
messages_previous: keybind("none", "Navigate to previous message"),
|
||||
messages_next_user: keybind("none", "Navigate to next user message"),
|
||||
messages_previous_user: keybind("none", "Navigate to previous user message"),
|
||||
messages_next: keybind("alt+down", "Navigate to next message"),
|
||||
messages_previous: keybind("alt+up", "Navigate to previous message"),
|
||||
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
|
||||
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
|
||||
messages_last_user: keybind("alt+end", "Navigate to last user message"),
|
||||
messages_copy: keybind("<leader>y", "Copy message"),
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
|
||||
@@ -82,19 +82,11 @@ export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: num
|
||||
return next
|
||||
}
|
||||
|
||||
export function cycleSessionTab(
|
||||
tabs: readonly SessionTab[],
|
||||
active: string | undefined,
|
||||
direction: 1 | -1,
|
||||
matches: (tab: SessionTab) => boolean = () => true,
|
||||
) {
|
||||
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
|
||||
if (tabs.length === 0) return
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === active)
|
||||
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
|
||||
return Array.from(
|
||||
{ length: tabs.length },
|
||||
(_, offset) => tabs[(start + direction * (offset + 1) + tabs.length * 2) % tabs.length],
|
||||
).find(matches)
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
|
||||
|
||||
@@ -298,8 +298,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
},
|
||||
cycleUnread(direction: 1 | -1) {
|
||||
if (!enabled()) return
|
||||
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
|
||||
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
const tab = cycleSessionTab(
|
||||
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
||||
current(),
|
||||
direction,
|
||||
)
|
||||
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
||||
},
|
||||
|
||||
@@ -90,10 +90,10 @@ test("resolves message navigation defaults", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
|
||||
expect(config.keybinds.get("session.message.previous")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.next")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.user.next")).toEqual([])
|
||||
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
|
||||
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
|
||||
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
|
||||
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
|
||||
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
|
||||
})
|
||||
|
||||
@@ -116,13 +116,15 @@ test("opens the subagent picker with down", () => {
|
||||
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
|
||||
})
|
||||
|
||||
test("navigates session tabs with option arrows", () => {
|
||||
test("navigates session tabs with leader arrows", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
|
||||
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
|
||||
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
|
||||
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
|
||||
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
|
||||
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
|
||||
])
|
||||
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", () => {
|
||||
|
||||
@@ -93,17 +93,6 @@ describe("session tabs", () => {
|
||||
expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
|
||||
})
|
||||
|
||||
test("cycles to the nearest matching tab from an unmatched active tab", () => {
|
||||
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
|
||||
const unread = new Set(["a", "d"])
|
||||
const matches = (tab: { sessionID: string }) => unread.has(tab.sessionID)
|
||||
|
||||
expect(cycleSessionTab(tabs, "c", 1, matches)?.sessionID).toBe("d")
|
||||
expect(cycleSessionTab(tabs, "c", -1, matches)?.sessionID).toBe("a")
|
||||
expect(cycleSessionTab(tabs, "e", 1, matches)?.sessionID).toBe("a")
|
||||
expect(cycleSessionTab(tabs, "a", -1, matches)?.sessionID).toBe("d")
|
||||
})
|
||||
|
||||
test("moves backward and forward through selection history", () => {
|
||||
const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
|
||||
const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
||||
|
||||
Reference in New Issue
Block a user