Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions fork/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/script/build-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<channel>.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",
})
16 changes: 14 additions & 2 deletions packages/tui/src/component/dialog-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export function DialogGalleryOperations(props: { focus?: string }) {
const project = useProject()
const toast = useToast()
const seenTerminal = new Set<string>()
let baselined = false

const [ops, { refetch }] = createResource(async () => {
const res = await sdk.client.gallery.operations({ workspace: project.workspace.current() })
Expand All @@ -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") {
Expand Down
7 changes: 6 additions & 1 deletion packages/tui/src/ui/dialog-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
57 changes: 43 additions & 14 deletions packages/tui/src/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})

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,
Expand Down Expand Up @@ -305,22 +314,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {

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)
}

Expand Down Expand Up @@ -366,7 +391,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return
}
const option = selected()
if (!option) return
if (!option || option.disabled === true) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}
Expand Down Expand Up @@ -663,6 +688,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}}
onMouseUp={() => {
if (props.locked) return
if (option.disabled === true) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
Expand Down Expand Up @@ -708,6 +734,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
active={active()}
current={current()}
muted={actionFocused()}
disabled={option.disabled}
gutter={option.gutter}
/>
</box>
Expand Down Expand Up @@ -752,6 +779,7 @@ function Option(props: {
active?: boolean
current?: boolean
muted?: boolean
disabled?: boolean
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
Expand All @@ -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
Expand All @@ -782,7 +811,7 @@ function Option(props: {
<text
flexGrow={1}
fg={text()}
attributes={props.active && !props.muted ? TextAttributes.BOLD : undefined}
attributes={props.active && !props.muted && !props.disabled ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
Expand Down
Loading