diff --git a/fork/manifest.json b/fork/manifest.json index 350d1b614841..5177daf3e00c 100644 --- a/fork/manifest.json +++ b/fork/manifest.json @@ -612,6 +612,11 @@ "marker": "displacing", "note": "Fork edit to an upstream file (+17/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, + { + "file": "packages/tui/src/ui/dialog-confirm.tsx", + "marker": "Defaults to the safe side", + "note": "Fork edit to an upstream file. Defaults DialogConfirm's focus to \"cancel\" instead of \"confirm\" so a stray or fast-follow Enter press can't confirm a consequential action (e.g. a model install) with zero deliberate input. Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + }, { "file": "packages/tui/test/cli/cmd/tui/provider-options.test.ts", "marker": "fork: LAN discovery for llama-skein host", diff --git a/packages/opencode/script/build-local.ts b/packages/opencode/script/build-local.ts index 5fc0328c10de..7dfccf38fa15 100644 --- a/packages/opencode/script/build-local.ts +++ b/packages/opencode/script/build-local.ts @@ -18,13 +18,21 @@ import { fileURLToPath } from "url" import pkg from "../package.json" const dir = path.dirname(fileURLToPath(import.meta.url)) -const branch = (await $`git branch --show-current`.text()).trim() || "local" const sha = (await $`git rev-parse --short HEAD`.text().catch(() => "unknown")).trim() const dirty = (await $`git status --porcelain`.text()).trim().length > 0 ? "-dirty" : "" const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z") +// The channel picks which `opencode-.db` session database the built +// binary opens (core/src/database/database.ts). It used to default to +// `git branch --show-current`, so every differently-named branch a local +// build ran from silently forked off its own session history — a build from +// a feature branch made the whole session list "disappear" until you +// switched back. `sha`+`dirty`+`timestamp` in OPENCODE_VERSION already give +// every build a distinguishable identity, so the channel no longer needs to +// track the branch: pin it to a stable value and let a real channel split +// (e.g. isolated testing) be an explicit `OPENCODE_CHANNEL=` override. await $`bun run ${path.join(dir, "build.ts")} --single ${process.argv.slice(2)}`.env({ ...process.env, OPENCODE_VERSION: process.env.OPENCODE_VERSION ?? `${pkg.version}-dev+${sha}${dirty}.${timestamp}`, - OPENCODE_CHANNEL: process.env.OPENCODE_CHANNEL ?? branch, + OPENCODE_CHANNEL: process.env.OPENCODE_CHANNEL ?? "dev", }) diff --git a/packages/tui/src/component/dialog-gallery.tsx b/packages/tui/src/component/dialog-gallery.tsx index 9714b4808625..e22df434993d 100644 --- a/packages/tui/src/component/dialog-gallery.tsx +++ b/packages/tui/src/component/dialog-gallery.tsx @@ -270,6 +270,7 @@ export function DialogGalleryOperations(props: { focus?: string }) { const project = useProject() const toast = useToast() const seenTerminal = new Set() + let baselined = false const [ops, { refetch }] = createResource(async () => { const res = await sdk.client.gallery.operations({ workspace: project.workspace.current() }) @@ -280,9 +281,20 @@ export function DialogGalleryOperations(props: { focus?: string }) { onCleanup(() => clearInterval(timer)) // A finished install means a new model on that host: refresh the picker so - // it shows up without a restart (§7.4). + // it shows up without a restart (§7.4). `seenTerminal` is per-mount, so the + // first resolution has to record already-terminal operations as a baseline + // rather than toast for them — otherwise reopening this screen replays a + // success/failure toast for every install that finished while it was + // closed, every single time. createEffect(() => { - for (const op of ops.latest ?? []) { + if (ops.loading) return + const list = ops.latest ?? [] + if (!baselined) { + baselined = true + for (const op of list) if (TERMINAL.has(op.phase)) seenTerminal.add(op.id) + return + } + for (const op of list) { if (!TERMINAL.has(op.phase) || seenTerminal.has(op.id)) continue seenTerminal.add(op.id) if (op.phase === "succeeded") { diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index a09847af2f9b..cb97908d3e10 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -19,8 +19,13 @@ export type DialogConfirmResult = boolean | undefined export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() + // Defaults to the safe side: this dialog exists to gate a consequential + // action (install a model, delete something, etc.), and an Enter press + // that arrives before the user has looked at it — terminal input lag, a + // fast-follow keypress from whatever opened this dialog — must not + // confirm that action by default. const [store, setStore] = createStore({ - active: "confirm" as "confirm" | "cancel", + active: "cancel" as "confirm" | "cancel", }) useBindings(() => ({ diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index a94fda28652b..63eb0b5b310e 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -168,7 +168,16 @@ export function DialogSelect(props: DialogSelectProps) { }) const filtered = createMemo(() => { - if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) + // Every current caller that sets `disabled: true` (a loading/status line, + // an offline or incompatible entry with a reason in its description) does + // so to show something informational and inert — not to make the row + // vanish. Dropping it here made it invisible instead, and left + // `store.selected` — a bare index into whatever's left — free to silently + // resolve onto a real, unrelated row once the list reshuffled around it + // (e.g. once async data replaced a loading placeholder), with no user + // input in between. moveTo/submit/onMouseUp now refuse a disabled target + // directly, so keeping disabled rows in place is safe as well as visible. + if (props.skipFilter || props.renderFilter === false) return props.options const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -305,22 +314,38 @@ export function DialogSelect(props: DialogSelectProps) { function move(direction: number) { if (props.locked) return - if (flat().length === 0) return - let next = store.selected + direction - if (next < 0) next = flat().length - 1 - if (next >= flat().length) next = 0 - moveTo(next, true) + const list = flat() + if (list.length === 0) return + // A disabled row is an informational/status line, never a destination — + // step past it in the requested direction rather than landing on it. + // Bounded by list.length so an all-disabled list (nothing selectable) + // just leaves the selection where it is instead of spinning forever. + let next = store.selected + for (let i = 0; i < list.length; i++) { + next += direction + if (next < 0) next = list.length - 1 + if (next >= list.length) next = 0 + if (list[next]?.disabled !== true) { + moveTo(next, true) + return + } + } } function moveTo(next: number, center = false, preserve = true) { + // Every caller of moveTo (arrow-key nav, mouse hover/down, the + // props.current/filter-change effects) ends up here, so this is the one + // place that has to refuse a disabled target — otherwise a row that's + // disabled today can silently become "selected" the instant the list + // reshuffles under it (e.g. once async data arrives), with no user input + // in between, and a stray Enter or click fires whatever it landed on. + const option = flat()[next] + if (!option || option.disabled === true) return setFocusedAction(undefined) setStore("selected", next) - const option = selected() - if (option) { - selection = option - resetSelection = !preserve - } - if (option) props.onMove?.(option) + selection = option + resetSelection = !preserve + props.onMove?.(option) scrollToSelection(center) } @@ -366,7 +391,7 @@ export function DialogSelect(props: DialogSelectProps) { return } const option = selected() - if (!option) return + if (!option || option.disabled === true) return option.onSelect?.(dialog) props.onSelect?.(option) } @@ -663,6 +688,7 @@ export function DialogSelect(props: DialogSelectProps) { }} onMouseUp={() => { if (props.locked) return + if (option.disabled === true) return option.onSelect?.(dialog) props.onSelect?.(option) }} @@ -708,6 +734,7 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} muted={actionFocused()} + disabled={option.disabled} gutter={option.gutter} /> @@ -752,6 +779,7 @@ function Option(props: { active?: boolean current?: boolean muted?: boolean + disabled?: boolean footer?: JSX.Element | string titleWidth?: number truncateTitle?: boolean | "left" @@ -761,6 +789,7 @@ function Option(props: { const { theme } = useTheme() const fg = selectedForeground(theme) const text = createMemo(() => { + if (props.disabled) return theme.textMuted if (props.active && !props.muted) return fg if (props.muted && (props.active || props.current)) return theme.textMuted if (props.current) return theme.primary @@ -782,7 +811,7 @@ function Option(props: {