Skip to content

Commit d7f0f2c

Browse files
saravmajesticclaude
andcommitted
refactor: [AI-7520] isolate onboarding into an altimate-owned file (rebase safety)
Shrink our footprint on the upstream opencode `dialog-model.tsx` so future upstream merges are easier to rebase: - Move `useReady`/`markSetupComplete`, `DialogModelWelcome`, and `DialogBigPickleConfirm` into a new altimate-owned `component/altimate-onboarding.tsx` (zero rebase surface — our file) - `dialog-model.tsx` (424 → 160 lines) now holds only pristine `useConnected` plus the `DialogModel` READY/NEEDS-SETUP restructure, fully wrapped in `altimate_change` markers so an upstream conflict is confined and human-resolvable - Repoint imports in `app.tsx`, `home.tsx`, `dialog-provider.tsx`, `welcome-panel.tsx` The onboarding file imports `DialogModel`/`useConnected` back from `dialog-model`, but only inside callbacks/JSX — the circular reference is runtime-only and safe. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent df61fdf commit d7f0f2c

6 files changed

Lines changed: 298 additions & 278 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
1313
import { SDKProvider, useSDK } from "@tui/context/sdk"
1414
import { SyncProvider, useSync } from "@tui/context/sync"
1515
import { LocalProvider, useLocal } from "@tui/context/local"
16-
import { DialogModel, DialogModelWelcome, useConnected, useReady } from "@tui/component/dialog-model"
16+
import { DialogModel, useConnected } from "@tui/component/dialog-model"
17+
import { DialogModelWelcome, useReady } from "@tui/component/altimate-onboarding"
1718
// altimate_change — /auth (gateway sign-in) + /logout commands
1819
import { DialogAltimateAuth } from "@tui/component/dialog-provider"
1920
import { AltimateApi } from "../../../altimate/api/client"
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Altimate onboarding layer — kept in a dedicated, altimate-owned file so it does
2+
// NOT enlarge the rebase surface of the upstream `dialog-model.tsx`. Holds the
3+
// first-run readiness state, the curated welcome/provider picker, and the Big
4+
// Pickle interstitial. Imports back into dialog-model are runtime-only (used inside
5+
// callbacks/JSX), so the circular reference is safe.
6+
import { createMemo, createSignal, For, Show, onMount } from "solid-js"
7+
import { useLocal } from "@tui/context/local"
8+
import { useDialog } from "@tui/ui/dialog"
9+
import { useTheme, selectedForeground } from "@tui/context/theme"
10+
import { TextAttributes, RGBA } from "@opentui/core"
11+
import { useKeyboard } from "@opentui/solid"
12+
import { createDialogProviderOptions } from "./dialog-provider"
13+
import { DialogModel, useConnected } from "./dialog-model"
14+
15+
// Session-scoped "setup complete" flag. Set when the user picks a ready model,
16+
// chooses the free Big Pickle option, or finishes the gateway flow. Combined with
17+
// useConnected() (real credentials) via useReady(), it gates the first-run chat
18+
// lock. Module-global so it is shared across the app and resets on every process
19+
// launch (so a fresh relaunch is a clean fresh-user state).
20+
const [setupComplete, setSetupComplete] = createSignal(false)
21+
export function markSetupComplete() {
22+
setSetupComplete(true)
23+
}
24+
export function useReady() {
25+
const connected = useConnected()
26+
return createMemo(() => connected() || setupComplete())
27+
}
28+
29+
// First-run welcome picker (presentation only; reuses the same action handlers as
30+
// DialogModel/createDialogProviderOptions). A curated six: five recommended
31+
// providers + a "Search all providers…" row that hands off to the full DialogModel
32+
// picker. The long tail stays behind search.
33+
const NAME_W = 24
34+
type WelcomeTone = "success" | "warning" | "muted"
35+
36+
interface WelcomeRow {
37+
name: string
38+
note: string
39+
tone: WelcomeTone
40+
activate: () => void
41+
// Identifies the row for the "currently selected" tick. providerID alone matches
42+
// any model of that provider; add modelID to match a specific model (Big Pickle).
43+
providerID?: string
44+
modelID?: string
45+
}
46+
47+
export function DialogModelWelcome(props: { intro?: string }) {
48+
const { theme } = useTheme()
49+
const dialog = useDialog()
50+
const local = useLocal()
51+
const providers = createDialogProviderOptions()
52+
const [selected, setSelected] = createSignal(0)
53+
54+
onMount(() => dialog.setSize("large"))
55+
56+
function connectProvider(id: string) {
57+
// Reuse the exact provider onSelect (gateway flow for altimate-backend,
58+
// auth-method screens for the BYOK providers).
59+
providers()
60+
.find((o) => o.value === id)
61+
?.onSelect?.()
62+
}
63+
64+
function chooseBigPickle() {
65+
dialog.replace(() => <DialogBigPickleConfirm origin="welcome" />)
66+
}
67+
68+
function openFullCatalog() {
69+
dialog.replace(() => <DialogModel />)
70+
}
71+
72+
const rows = createMemo<WelcomeRow[]>(() => [
73+
{
74+
name: "Altimate LLM Gateway",
75+
note: "Recommended · best tool-calling · 10M free tokens",
76+
tone: "success",
77+
providerID: "altimate-backend",
78+
activate: () => connectProvider("altimate-backend"),
79+
},
80+
{ name: "Anthropic (Claude)", note: "bring your own API key", tone: "muted", providerID: "anthropic", activate: () => connectProvider("anthropic") },
81+
{ name: "OpenAI (GPT)", note: "bring your own API key", tone: "muted", providerID: "openai", activate: () => connectProvider("openai") },
82+
{ name: "Google (Gemini)", note: "bring your own API key", tone: "muted", providerID: "google", activate: () => connectProvider("google") },
83+
{
84+
name: "Big Pickle",
85+
note: "free, no signup — slower, unreliable tool-calling",
86+
tone: "warning",
87+
providerID: "opencode",
88+
modelID: "big-pickle",
89+
activate: chooseBigPickle,
90+
},
91+
{ name: "Search all providers…", note: "/", tone: "muted", activate: openFullCatalog },
92+
])
93+
94+
// The currently active model → drives the green "selected" tick.
95+
const current = createMemo(() => local.model.current())
96+
const isCurrent = (row: WelcomeRow) => {
97+
const c = current()
98+
if (!row.providerID || !c || c.providerID !== row.providerID) return false
99+
return row.modelID ? c.modelID === row.modelID : true
100+
}
101+
102+
// Indices 0-4 are providers, 5 is the search row (rendered below a divider).
103+
const COUNT = 6
104+
function move(direction: number) {
105+
setSelected((prev) => (prev + direction + COUNT) % COUNT)
106+
}
107+
108+
useKeyboard((evt) => {
109+
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) return move(-1)
110+
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) return move(1)
111+
if (evt.name === "return") {
112+
evt.preventDefault()
113+
evt.stopPropagation()
114+
rows()[selected()].activate()
115+
return
116+
}
117+
// "/", ctrl+a, or any letter/number reveals the full searchable catalog.
118+
if (evt.name === "/" || (evt.ctrl && evt.name === "a") || /^[a-z0-9]$/i.test(evt.name ?? "")) {
119+
evt.preventDefault()
120+
openFullCatalog()
121+
}
122+
})
123+
124+
const selFg = selectedForeground(theme)
125+
const transparent = RGBA.fromInts(0, 0, 0, 0)
126+
const noteColor = (tone: WelcomeTone) =>
127+
tone === "success" ? theme.success : tone === "warning" ? theme.warning : theme.textMuted
128+
129+
const Row = (props: { row: WelcomeRow; index: number }) => {
130+
const active = createMemo(() => selected() === props.index)
131+
return (
132+
<box flexDirection="row" gap={1} onMouseMove={() => setSelected(props.index)} onMouseUp={() => props.row.activate()}>
133+
<text flexShrink={0} fg={theme.primary}>
134+
{active() ? "›" : " "}
135+
</text>
136+
<box width={NAME_W} flexShrink={0} paddingLeft={1} paddingRight={1} backgroundColor={active() ? theme.primary : transparent}>
137+
<text fg={active() ? selFg : theme.text} attributes={active() ? TextAttributes.BOLD : undefined} wrapMode="none">
138+
{props.row.name}
139+
</text>
140+
</box>
141+
{/* bright green so it reads clearly even where ANSI green renders dim */}
142+
<text flexShrink={0} fg={theme.diffHighlightAdded} attributes={TextAttributes.BOLD}>
143+
{isCurrent(props.row) ? "✓" : " "}
144+
</text>
145+
<text flexGrow={1} fg={noteColor(props.row.tone)} wrapMode="none">
146+
{isCurrent(props.row) ? `${props.row.note} · selected` : props.row.note}
147+
</text>
148+
</box>
149+
)
150+
}
151+
152+
return (
153+
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
154+
<Show when={props.intro}>
155+
<box paddingBottom={1} paddingLeft={1}>
156+
<text fg={theme.textMuted}>{props.intro}</text>
157+
</box>
158+
</Show>
159+
<box
160+
border
161+
borderStyle="rounded"
162+
borderColor={theme.border}
163+
title=" Altimate Code "
164+
titleAlignment="left"
165+
paddingLeft={2}
166+
paddingRight={2}
167+
paddingTop={1}
168+
paddingBottom={1}
169+
gap={1}
170+
>
171+
<text wrapMode="none">
172+
<span style={{ fg: theme.text }}>
173+
<b>Select a provider</b>
174+
</span>
175+
<span style={{ fg: theme.textMuted }}> — you can change this anytime with /model</span>
176+
</text>
177+
<box gap={0}>
178+
<For each={rows().slice(0, 5)}>{(row, i) => <Row row={row} index={i()} />}</For>
179+
</box>
180+
<box border={["top"]} borderColor={theme.border} />
181+
<Row row={rows()[5]} index={5} />
182+
</box>
183+
</box>
184+
)
185+
}
186+
187+
// Big Pickle interstitial — one confirm, default No. Custom component (not
188+
// DialogSelect) so the full warning wraps instead of clipping; y/n keys work,
189+
// enter accepts the highlighted row (No by default).
190+
export function DialogBigPickleConfirm(props: { origin: "welcome" | "model" }) {
191+
const { theme } = useTheme()
192+
const dialog = useDialog()
193+
const local = useLocal()
194+
const [selected, setSelected] = createSignal(0) // 0 = No (default)
195+
196+
function no() {
197+
dialog.replace(() => (props.origin === "welcome" ? <DialogModelWelcome /> : <DialogModel />))
198+
}
199+
function yes() {
200+
dialog.clear()
201+
local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { recent: true })
202+
markSetupComplete()
203+
}
204+
const options = [
205+
{ label: "No — pick something else", hint: "(default)", run: no },
206+
{ label: "Yes — continue with Big Pickle", hint: "", run: yes },
207+
]
208+
209+
useKeyboard((evt) => {
210+
if (evt.name === "up" || evt.name === "down") {
211+
setSelected((prev) => (prev + 1) % 2)
212+
evt.preventDefault()
213+
return
214+
}
215+
if (evt.name === "return") {
216+
evt.preventDefault()
217+
evt.stopPropagation()
218+
options[selected()].run()
219+
return
220+
}
221+
if (evt.name === "y" && !evt.ctrl && !evt.meta) {
222+
evt.preventDefault()
223+
yes()
224+
return
225+
}
226+
if (evt.name === "n" && !evt.ctrl && !evt.meta) {
227+
evt.preventDefault()
228+
no()
229+
}
230+
})
231+
232+
const selFg = selectedForeground(theme)
233+
const transparent = RGBA.fromInts(0, 0, 0, 0)
234+
235+
return (
236+
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
237+
<box flexDirection="row" justifyContent="space-between">
238+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
239+
Use Big Pickle?
240+
</text>
241+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
242+
esc
243+
</text>
244+
</box>
245+
<text fg={theme.textMuted} wrapMode="word" width="100%">
246+
Big Pickle works for chat but often fails at data tasks. The Gateway is free to start (10M tokens). Continue?
247+
[y/N]
248+
</text>
249+
<box>
250+
<For each={options}>
251+
{(option, index) => (
252+
<box
253+
flexDirection="row"
254+
gap={1}
255+
onMouseMove={() => setSelected(index())}
256+
onMouseUp={() => option.run()}
257+
>
258+
<text flexShrink={0} fg={theme.primary}>
259+
{selected() === index() ? "›" : " "}
260+
</text>
261+
<box
262+
paddingLeft={1}
263+
paddingRight={1}
264+
backgroundColor={selected() === index() ? theme.primary : transparent}
265+
>
266+
<text
267+
fg={selected() === index() ? selFg : theme.text}
268+
attributes={selected() === index() ? TextAttributes.BOLD : undefined}
269+
>
270+
{option.label}
271+
</text>
272+
</box>
273+
<Show when={option.hint}>
274+
<text fg={theme.textMuted}>{option.hint}</text>
275+
</Show>
276+
</box>
277+
)}
278+
</For>
279+
</box>
280+
</box>
281+
)
282+
}

0 commit comments

Comments
 (0)