Compare commits

..

8 Commits

Author SHA1 Message Date
opencode-agent[bot] 8648696477 Apply PR #38403: fix(ui): standardize v2 tooltip delay 2026-08-03 19:39:48 +00:00
opencode-agent[bot] 12d7cb2ff6 Apply PR #37927: fix(app): navigate tabs by selection history 2026-08-03 19:37:56 +00:00
opencode-agent[bot] b41180e524 Apply PR #29948: fix(tui): keep command palette available in questions 2026-08-03 19:35:43 +00:00
YB e917c12c80 docs: remove obsolete vouch system section (#40266) 2026-08-03 14:17:27 -05:00
Luke Parker f77f5a343e fix(ui): standardize tooltip delay 2026-07-27 13:24:01 +08:00
LukeParkerDev 985ee1e2ec fix(app): navigate tabs by selection history 2026-07-20 23:15:53 +10:00
Test 83bee1e776 Merge remote-tracking branch 'origin/dev' into beta-sync-29948
# Conflicts:
#	packages/tui/test/keymap.test.tsx
2026-06-25 07:02:20 +00:00
opencode-agent[bot] 124714ca3a fix(tui): keep command palette available in questions 2026-05-29 23:19:01 +00:00
22 changed files with 246 additions and 70 deletions
-27
View File
@@ -252,33 +252,6 @@ These are not strictly enforced, they are just general guidelines:
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Trust & Vouch System
This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td).
### How it works
- **Vouched users** are explicitly trusted contributors.
- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord)
- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs.
### For maintainers
Collaborators with write access can manage the vouch list by commenting on any issue:
- `vouch` — vouch for the issue author
- `vouch @username` — vouch for a specific user
- `denounce` — denounce the issue author
- `denounce @username` — denounce a specific user
- `denounce @username <reason>` — denounce with a reason
- `unvouch` / `unvouch @username` — remove someone from the list
Changes are committed automatically to `.github/VOUCHED.td`.
### Denouncement policy
Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes.
## Issue Requirements
All issues **must** use one of our issue templates:
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
delay="intent"
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
delay="intent"
value={
<ModelTooltip
model={item}
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
openDelay={800}
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
>
<div
classList={{
@@ -52,12 +52,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
+16
View File
@@ -338,6 +338,22 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: tabs.previous,
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: tabs.next,
},
].filter((v) => v !== undefined)
})
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
function history(): TabHistory {
return { stack: [], index: -1 }
}
describe("tab history", () => {
test("moves backward and forward through selected tabs", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const available = new Set(selected.stack)
const previous = previousTab(selected, available)
expect(previous?.key).toBe("b")
const first = previousTab(previous!.state, available)
expect(first?.key).toBe("a")
const next = nextTab(first!.state, available)
expect(next?.key).toBe("b")
})
test("replaces forward history after a new selection", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const previous = previousTab(selected, new Set(selected.stack))
const next = rememberTab(previous!.state, "d")
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
})
test("skips tabs that are no longer open", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
})
test("skips a repeated current tab after closing the previous selection", () => {
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
})
})
+28
View File
@@ -0,0 +1,28 @@
const MAX_TAB_HISTORY = 100
export type TabHistory = {
stack: string[]
index: number
}
export function rememberTab(state: TabHistory, key: string): TabHistory {
if (state.stack[state.index] === key) return state
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
return { stack, index: stack.length - 1 }
}
export function previousTab(state: TabHistory, available: Set<string>) {
return move(state, -1, available)
}
export function nextTab(state: TabHistory, available: Set<string>) {
return move(state, 1, available)
}
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
const current = state.stack[state.index]
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
const key = state.stack[index]
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
}
}
+16
View File
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
export type SessionTab = {
type: "session"
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let history: TabHistory = { stack: [], index: -1 }
let recentWrite = 0
let recentValue: string | undefined
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
history = rememberTab(history, tabKey(tab))
setRecentKey(tabKey(tab))
navigate(href)
}
const moveHistory = (direction: "previous" | "next") => {
const available = new Set(store.map(tabKey))
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
if (!result) return
const tab = store.find((item) => tabKey(item) === result.key)
if (!tab) return
history = result.state
navigateTab(tab)
}
const removeTab = (index: number) => {
const tab = store[index]
if (!tab) return
@@ -357,8 +370,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
select: navigateTab,
remember(tab: Tab) {
const key = tabKey(tab)
history = rememberTab(history, key)
if (recentKey() !== key) setRecentKey(key)
},
previous: () => moveHistory("previous"),
next: () => moveHistory("next"),
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
+11
View File
@@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("navigates between tabs", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
)
expect(items).toEqual([
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
])
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
+2 -2
View File
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
@@ -143,7 +143,7 @@ function ProviderTip() {
<TooltipV2
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
delay="intent"
value={language.t("common.dismiss")}
>
<button
@@ -1270,6 +1270,11 @@ export function options(input: {
result["gateway"] = { caching: "auto" }
}
if (input.model.api.npm === "@ai-sdk/azure" && input.model.api.id.includes("gpt-5.5")) {
result["reasoningSummary"] = "auto"
return result
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
@@ -93,7 +93,6 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
input.model.api.npm === "@ai-sdk/azure" &&
(input.provider.options.useCompletionUrls || input.model.options.useCompletionUrls || options.useCompletionUrls)
) {
delete options.reasoningEffort
delete options.reasoningSummary
delete options.include
}
@@ -252,7 +252,7 @@ describe("ProviderTransform.options - setCacheKey", () => {
expect(result.promptCacheKey).toBeUndefined()
})
test("should keep the Azure cache key for gpt-5.5 Responses defaults", () => {
test("should keep the Azure cache key for gpt-5.5 early return", () => {
const result = ProviderTransform.options({
model: {
...mockModel,
@@ -525,7 +525,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
expect(result.include).toBeUndefined()
})
test("azure chat completions omit unsupported reasoning options after variants merge", async () => {
test("azure chat completions omit Responses-only reasoning options after variants merge", async () => {
const model = {
...createGpt5Model("gpt-5.4"),
id: "azure/gpt-5.4",
@@ -580,7 +580,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
isWorkflow: false,
}),
)
expect(result.params.options.reasoningEffort).toBeUndefined()
expect(result.params.options.reasoningEffort).toBe("high")
expect(result.params.options.reasoningSummary).toBeUndefined()
expect(result.params.options.include).toBeUndefined()
expect(result.tools.lookup.strict).toBe(false)
@@ -681,15 +681,14 @@ describe("ProviderTransform.options - gpt-5 reasoningEffort", () => {
expect(result.reasoningEffort).toBeUndefined()
})
test("gpt-5.5 should set Responses reasoning options", () => {
test("gpt-5.5 should NOT set reasoningEffort", () => {
const result = ProviderTransform.options({
model: createModel("gpt-5.5"),
sessionID,
providerOptions: {},
})
expect(result.reasoningEffort).toBe("medium")
expect(result.reasoningSummary).toBe("auto")
expect(result.reasoningEffort).toBeUndefined()
})
})
@@ -32,7 +32,6 @@ export function CommentCardV2(props: {
return (
<TooltipV2
placement="top"
openDelay={1000}
value={props.title ?? props.comment}
disabled={!props.tooltip || !truncated()}
class={props.wide ? "w-full" : undefined}
@@ -387,12 +387,7 @@ export function PromptInputV2Attachments(props: {
<For each={props.comments ?? []}>
{(comment) => (
<div class="relative group shrink-0">
<TooltipV2
value={comment.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
<CommentCardV2
comment={comment.comment ?? ""}
path={comment.path}
@@ -216,7 +216,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
</Show>
<div class="flex items-center">
<TooltipV2
openDelay={2000}
inactive={!prev()}
value={
<>
@@ -236,7 +235,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
inactive={!next()}
value={
<>
@@ -270,12 +268,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
@@ -291,12 +289,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
+5 -1
View File
@@ -104,7 +104,6 @@ const appGlobalBindingCommands = [
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -963,6 +962,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
commands: appCommands(),
}))
useBindings(() => ({
enabled: () => dialog.stack.length === 0,
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
+34 -4
View File
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
import {
COMMAND_PALETTE_COMMAND,
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "../src/keymap"
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
const keybinds = TuiKeybind.parse(input)
@@ -73,12 +79,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: COMMAND_PALETTE_COMMAND, run() {} },
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
@@ -95,7 +103,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
commands: [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
"session.first",
"model.list",
],
}),
([command, bindings]) => [command, bindings.length],
),
@@ -125,9 +140,24 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
base: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 1,
},
question: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
autocomplete: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
@@ -0,0 +1,35 @@
const OPEN_DELAY = 1_000
// Kobalte warms every tooltip globally. Keep intent previews isolated so opening
// a model picker never inherits warm state from an unrelated tooltip.
let warm = false
let reset: ReturnType<typeof setTimeout> | undefined
export function openTooltipIntent(open: () => void) {
clearTimeout(reset)
reset = undefined
if (warm) {
open()
return
}
const timer = setTimeout(() => {
warm = true
open()
}, OPEN_DELAY)
return () => clearTimeout(timer)
}
export function closeTooltipIntent() {
clearTimeout(reset)
// Adjacent triggers enter before the next task; leaving the group does not.
reset = setTimeout(() => {
warm = false
reset = undefined
})
}
export function resetTooltipIntent() {
clearTimeout(reset)
reset = undefined
warm = false
}
+36 -6
View File
@@ -2,19 +2,22 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { closeTooltipIntent, openTooltipIntent, resetTooltipIntent } from "./tooltip-intent"
import "./tooltip-v2.css"
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
value: JSX.Element
class?: string
contentClass?: string
contentStyle?: JSX.CSSProperties
inactive?: boolean
delay?: "standard" | "intent"
forceOpen?: boolean
}
export function TooltipV2(props: TooltipV2Props) {
let ref: HTMLDivElement | undefined
let cancelIntent: (() => void) | undefined
const [state, setState] = createStore({
open: false,
block: false,
@@ -26,19 +29,37 @@ export function TooltipV2(props: TooltipV2Props) {
"contentClass",
"contentStyle",
"inactive",
"delay",
"forceOpen",
"ignoreSafeArea",
"value",
])
const close = () => setState("open", false)
const inside = () => {
const active = document.activeElement
if (!ref || !active) return false
return ref.contains(active)
}
const close = () => {
cancelIntent?.()
cancelIntent = undefined
if (local.delay === "intent") closeTooltipIntent()
setState("open", false)
}
const show = () => {
if (local.delay !== "intent" || inside()) {
setState("open", true)
return
}
if (cancelIntent) return
cancelIntent = openTooltipIntent(() => {
cancelIntent = undefined
setState("open", true)
})
}
const drop = (expand = state.expand) => {
if (expand) return
if (ref?.matches(":hover")) return
@@ -80,6 +101,11 @@ export function TooltipV2(props: TooltipV2Props) {
onCleanup(() => obs.disconnect())
})
onCleanup(() => {
cancelIntent?.()
if (local.delay === "intent") resetTooltipIntent()
})
let justClickedTrigger = false
return (
@@ -88,8 +114,8 @@ export function TooltipV2(props: TooltipV2Props) {
<Match when={true}>
<KobalteTooltip
gutter={4}
openDelay={400}
skipDelayDuration={300}
openDelay={local.delay === "intent" ? 0 : 400}
skipDelayDuration={local.delay === "intent" ? 0 : 300}
{...others}
closeDelay={0}
ignoreSafeArea={local.ignoreSafeArea ?? true}
@@ -101,7 +127,11 @@ export function TooltipV2(props: TooltipV2Props) {
justClickedTrigger = false
return
}
setState("open", open)
if (open) {
show()
return
}
close()
}}
>
<KobalteTooltip.Trigger