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