feat(v2/cli): add console login (#35969)

This commit is contained in:
Simon Klee 2026-07-09 00:00:09 +02:00 committed by GitHub
commit 19f42f7102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 584 additions and 28 deletions

View file

@ -0,0 +1,242 @@
/** @jsxImportSource @opentui/solid */
import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core"
import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { Show, createSignal } from "solid-js"
registerOpencodeSpinner()
export type TimelineHost = {
readonly signal: AbortSignal
intro(text: string): Promise<void>
item(text: string): Promise<void>
pending(text: string): Promise<void>
success(text: string): Promise<void>
failure(text: string): Promise<void>
outro(text: string): Promise<void>
close(): Promise<void>
}
type RowKind = "intro" | "item" | "success" | "failure" | "outro"
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const IDLE_TIMEOUT = 1_000
const COLORS = {
accent: RGBA.fromIndex(6),
error: RGBA.fromIndex(1),
foreground: RGBA.defaultForeground(),
muted: RGBA.fromIndex(8),
success: RGBA.fromIndex(2),
}
const ROWS: Record<RowKind, { marker: string; color: ColorInput; connector: boolean }> = {
intro: { marker: "┌", color: COLORS.muted, connector: true },
item: { marker: "●", color: COLORS.accent, connector: true },
success: { marker: "◇", color: COLORS.success, connector: true },
failure: { marker: "■", color: COLORS.error, connector: false },
outro: { marker: "└", color: COLORS.muted, connector: false },
}
function row(kind: RowKind, value: string): ScrollbackWriter {
const style = ROWS[kind]
return createScrollbackWriter(
() => (
<box width="100%" minHeight={1} flexDirection="column">
<box width="100%" minHeight={1} flexDirection="row" gap={1}>
<text fg={style.color} flexShrink={0}>
{style.marker}
</text>
<text fg={COLORS.foreground} wrapMode="word">
{value}
</text>
</box>
<Show when={style.connector}>
<text fg={COLORS.muted}></text>
</Show>
</box>
),
{ startOnNewLine: true, trailingNewline: !style.connector },
)
}
function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) {
useKeyboard((event) => {
if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
event.preventDefault()
props.cancel()
})
return (
<box width="100%" height={1} flexDirection="row" gap={1}>
<Show when={props.pending()}>
{(text) => (
<>
<spinner frames={SPINNER_FRAMES} interval={80} color={COLORS.accent} />
<text fg={COLORS.foreground} wrapMode="none" truncate>
{text()}
</text>
</>
)}
</Show>
</box>
)
}
function bounded(task: Promise<unknown>) {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, IDLE_TIMEOUT)
timer.unref()
const finish = () => {
clearTimeout(timer)
resolve()
}
void task.then(finish, finish)
})
}
async function shutdown(renderer: CliRenderer): Promise<void> {
await bounded(renderer.idle())
try {
renderer.externalOutputMode = "passthrough"
} finally {
try {
renderer.screenMode = "main-screen"
} finally {
if (!renderer.isDestroyed) renderer.destroy()
}
}
}
export async function createTimelineHost(): Promise<TimelineHost> {
const stdout = process.stdout
const controller = new AbortController()
const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"]
const cancel = () => {
if (!controller.signal.aborted) controller.abort()
}
signals.forEach((signal) => process.on(signal, cancel))
if (!stdout.isTTY || !process.stdin.isTTY) {
let closed = false
let writing = false
let active: Promise<void> | undefined
let closeTask: Promise<void> | undefined
const write = async (kind: RowKind | "pending", text: string) => {
if (closed) throw new Error("timeline closed")
if (writing) throw new Error("timeline write already in progress")
writing = true
try {
const style = kind === "pending" ? undefined : ROWS[kind]
const marker = kind === "pending" ? "." : ROWS[kind].marker
const connector = style?.connector ? "│\n" : ""
active = new Promise<void>((resolve, reject) => {
stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve()))
})
await active
} finally {
writing = false
active = undefined
}
}
const close = () => {
if (closeTask) return closeTask
closed = true
closeTask = (async () => {
await active?.catch(() => { })
signals.forEach((signal) => process.off(signal, cancel))
})()
return closeTask
}
return {
signal: controller.signal,
intro: (text) => write("intro", text),
item: (text) => write("item", text),
pending: (text) => write("pending", text),
success: (text) => write("success", text),
failure: (text) => write("failure", text),
outro: (text) => write("outro", text),
close,
}
}
let renderer: CliRenderer | undefined
try {
// Start on a fresh row so delayed SSH cursor reports cannot make
// split-footer overwrite the shell command.
process.stdout.write("\n")
renderer = await createCliRenderer({
stdin: process.stdin,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
exitSignals: [],
screenMode: "split-footer",
footerHeight: 1,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const activeRenderer = renderer
const [pending, setPending] = createSignal<string>()
const renderTask = render(() => <TimelineFooter pending={pending} cancel={cancel} />, activeRenderer)
void renderTask.catch(cancel)
await bounded(activeRenderer.idle())
let closed = false
let writing = false
let active: Promise<void> | undefined
let closeTask: Promise<void> | undefined
const write = (kind: RowKind | "pending", text: string) => {
if (closed) return Promise.reject(new Error("timeline closed"))
if (writing) return Promise.reject(new Error("timeline write already in progress"))
writing = true
active = (async () => {
if (kind === "pending") {
setPending(text)
activeRenderer.requestRender()
} else {
if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined)
activeRenderer.writeToScrollback(row(kind, text))
activeRenderer.requestRender()
}
await bounded(activeRenderer.idle())
})().finally(() => {
writing = false
active = undefined
})
return active
}
const close = () => {
if (closeTask) return closeTask
closed = true
closeTask = (async () => {
await active?.catch(() => { })
try {
await shutdown(activeRenderer)
await bounded(renderTask)
} finally {
signals.forEach((signal) => process.off(signal, cancel))
}
})()
return closeTask
}
return {
signal: controller.signal,
intro: (text) => write("intro", text),
item: (text) => write("item", text),
pending: (text) => write("pending", text),
success: (text) => write("success", text),
failure: (text) => write("failure", text),
outro: (text) => write("outro", text),
close,
}
} catch (error) {
try {
if (renderer) await shutdown(renderer)
} finally {
signals.forEach((signal) => process.off(signal, cancel))
}
throw error
}
}