Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton dd86e47762 test(core): remove directory search regression test 2026-08-04 21:42:55 -04:00
Kit Langton cdb51c6c29 fix(core): avoid eager directory snapshots 2026-08-04 21:38:16 -04:00
Kit Langton ab20a7b4a3 feat(tui): streamline tab navigation shortcuts (#40551) 2026-08-05 01:36:23 +00:00
opencode-agent[bot] ad9a95f6ee fix(ai): preserve Gemini tool finish semantics (#40546)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:29:06 -05:00
8 changed files with 74 additions and 34 deletions
+1
View File
@@ -444,6 +444,7 @@ 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 (
+28
View File
@@ -601,6 +601,34 @@ 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({
+5 -9
View File
@@ -31,10 +31,7 @@ export const ripgrepLayer = Layer.effect(
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const state = {
files: [] as string[],
directories: [] as string[],
}
const files: string[] = []
const directories = new Set<string>()
yield* ripgrep
.find({
@@ -43,10 +40,9 @@ export const ripgrepLayer = Layer.effect(
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
@@ -106,10 +102,10 @@ export const ripgrepLayer = Layer.effect(
Effect.gen(function* () {
const items =
input.type === "file"
? state.files
? files
: input.type === "directory"
? state.directories
: [...state.files, ...state.directories]
? Array.from(directories)
: [...files, ...directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
+8 -8
View File
@@ -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,<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_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_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("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_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_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
+10 -2
View File
@@ -82,11 +82,19 @@ export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: num
return next
}
export function cycleSessionTab(tabs: readonly SessionTab[], active: string | undefined, direction: 1 | -1) {
export function cycleSessionTab(
tabs: readonly SessionTab[],
active: string | undefined,
direction: 1 | -1,
matches: (tab: SessionTab) => boolean = () => true,
) {
if (tabs.length === 0) return
const index = tabs.findIndex((tab) => tab.sessionID === active)
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
return tabs[(start + direction + tabs.length) % tabs.length]
return Array.from(
{ length: tabs.length },
(_, offset) => tabs[(start + direction * (offset + 1) + tabs.length * 2) % tabs.length],
).find(matches)
}
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
+2 -4
View File
@@ -298,10 +298,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
+9 -11
View File
@@ -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")).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.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.messages_last_user")).toMatchObject([{ key: "alt+end" }])
})
@@ -116,15 +116,13 @@ test("opens the subagent picker with down", () => {
expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
})
test("navigates session tabs with leader arrows", () => {
test("navigates session tabs with option arrows", () => {
const config = resolve({}, { terminalSuspend: true })
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" }])
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" }])
})
test("preserves pinned session bindings alongside tab bindings", () => {
@@ -93,6 +93,17 @@ 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 })