Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
83 changes: 27 additions & 56 deletions packages/opencode/src/cli/ui.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { EOL } from "os"
import { Schema } from "effect"
import { logo as glyphs } from "./logo"
const wordmark = [
` /$$$$$$$ /$$$$$$ /$$ /$$$$$$$$ /$$$$$$ /$$ /$$$$$$ `,
`| $$__ $$ /$$__ $$| $$ |__ $$__/ /$$__ $$| $$ |_ $$_/ `,
`| $$ \\ $$| $$ \\ $$| $$ | $$ | $$ \\__/| $$ | $$ `,
`| $$$$$$$ | $$ | $$| $$ | $$ | $$ | $$ | $$ `,
`| $$__ $$| $$ | $$| $$ | $$ | $$ | $$ | $$ `,
`| $$ \\ $$| $$ | $$| $$ | $$ | $$ $$| $$ | $$ `,
`| $$$$$$$/| $$$$$$/| $$$$$$$| $$ | $$$$$$/| $$$$$$$$ /$$$$$$ `,
`|_______/ \\______/ |________|__/ \\______/ |________/|______/ `,
]

// Gradient endpoints for the wordmark, matching the bolt theme's accent
// (#63d9ff) and primary (#2d7bff) colors.
const GRADIENT_FROM = [0x63, 0xd9, 0xff] as const
const GRADIENT_TO = [0x2d, 0x7b, 0xff] as const

export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("UICancelledError", {}) {}

Expand Down Expand Up @@ -49,58 +44,34 @@ export function empty() {
}

export function logo(pad?: string) {
const rows = glyphs.wordmark
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
}
return result.join("").trimEnd()
return rows.map((row) => (pad ?? "") + row).join(EOL)
}

const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: Style.TEXT_INFO,
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
const fg = (rgb: readonly [number, number, number]) => `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`
const mix = (t: number) =>
[0, 1, 2].map((i) => Math.round(GRADIENT_FROM[i]! + (GRADIENT_TO[i]! - GRADIENT_FROM[i]!) * t)) as unknown as [
number,
number,
number,
]
const field = fg([0x1e, 0x3a, 0x5f])
const width = rows[0]!.length

const result: string[] = []
rows.forEach((row, index) => {
if (pad) result.push(pad)
result.push(field, "╱".repeat(6), reset, " ")
Array.from(row).forEach((char, column) => {
if (char === " ") {
parts.push(" ")
continue
result.push(" ")
return
}
parts.push(fg, char, reset)
}
return parts.join("")
}
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(fg(mix(column / (width - 1))), char)
})
result.push(reset, " ", field, "╱".repeat(15 - index), reset)
result.push(EOL)
})
return result.join("").trimEnd()
Expand Down
70 changes: 23 additions & 47 deletions packages/tui/src/component/logo.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,35 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { For } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { logo } from "../logo"

const DIAG = "╱"
const LEFT_FIELD_WIDTH = 6
const RIGHT_FIELD_WIDTH = 15

export function Logo() {
const { theme } = useTheme()
const width = logo.wordmark[0]?.length ?? 1

const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char) => {
if (char === "_") {
return (
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
{" "}
</text>
)
}
if (char === "^") {
return (
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
▀
</text>
)
}
if (char === "~") {
return (
<text fg={shadow} attributes={attrs} selectable={false}>
▀
</text>
)
}
if (char === ",") {
return (
<text fg={shadow} attributes={attrs} selectable={false}>
▄
</text>
)
}
return (
<text fg={fg} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
// Per-column horizontal gradient across the wordmark, secondary to primary.
const gradient = (column: number) => tint(theme.secondary, theme.primary, width <= 1 ? 1 : column / (width - 1))

return (
<box>
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, RGBA.fromHex("#0062ff"), false)}</box>
{/* Safely access logo.right with fallback to empty string */}
<box flexDirection="row">{renderLine(logo.right[index()] ?? "", RGBA.fromHex("#ff0000"), true)}</box>
<For each={logo.wordmark}>
{(line, row) => (
<box flexDirection="row">
<text fg={theme.primary} selectable={false}>
{DIAG.repeat(LEFT_FIELD_WIDTH) + " "}
</text>
{Array.from(line).map((char, column) => (
<text fg={gradient(column)} selectable={false}>
{char}
</text>
))}
{/* The right field steps down one cell per row, like a wind-swept flag. */}
<text fg={theme.primary} selectable={false}>
{" " + DIAG.repeat(RIGHT_FIELD_WIDTH - row())}
</text>
</box>
)}
</For>
Expand Down
162 changes: 86 additions & 76 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1463,85 +1463,95 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
// route keys through the vim layer when enabled
if (vim.vimOnKey(e)) {
e.preventDefault()
e.stopPropagation()
return
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}
<box flexDirection="row" gap={1} width="100%">
<text
flexShrink={0}
selectable={false}
fg={store.mode === "shell" ? theme.warning : fadeColor(theme.success, 0.7)}
>
{store.mode === "shell" ? " ! " : ":::"}
</text>
<textarea
flexGrow={1}
flexShrink={1}
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: KeyEvent) => {
if (props.disabled) {
e.preventDefault()
return
}
// route keys through the vim layer when enabled
if (vim.vimOnKey(e)) {
e.preventDefault()
e.stopPropagation()
return
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}

// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()

// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
keymap.dispatchCommand("prompt.paste")
return
}
// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()

// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
keymap.dispatchCommand("prompt.paste")
return
}

// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()

await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
Object.assign(r, {
getClipboardText: (text: string) => expandPastedTextPlaceholders(text, store.prompt.parts),
})
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.cursorColor = theme.text
}, 0)
}}
onMouseDown={(r: MouseEvent) => r.target?.focus()}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={props.disabled ? theme.backgroundElement : theme.text}
syntaxStyle={syntax()}
/>
</box>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<Show when={local.agent.current()} fallback={<box height={1} />}>
Expand Down
24 changes: 3 additions & 21 deletions packages/tui/src/logo.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,7 @@
// Half-block letterforms spelling "BOLT", drawn Crush-style with a
// three-row-tall alphabet built from ▄ ▀ █ cells.
export const logo = {
left: [
" /$$$$$$$ /$$$$$$ /$$ /$$$$$$$$",
"| $$__ $$ /$$__ $$| $$ |__ $$__/",
"| $$ \\ $$| $$ \\ $$| $$ | | $$ ",
"| $$$$$$$ | $$ | $$| $$ | $$ ",
"| $$__ $$| $$ | $$| $$ | $$ ",
"| $$ \\ $$| $$ | $$| $$ | $$ ",
"| $$$$$$$/| $$$$$$/| $$$$$$$$| $$ ",
"|_______/ \\______/ |________/|__/ ",
],

right: [
" /$$$$$$ /$$ /$$$$$$",
" /$$__ $$| $$ |_ $$_/",
" | $$ \\__/| $$ | $$ ",
" | $$ | $$ | $$ ",
" | $$ | $$ | $$ ",
" | $$ $$| $$ | $$ ",
" | $$$$$$/| $$$$$$$$ /$$$$$$",
" \\______/ |________/|______/",
],
wordmark: ["█▀▀▀▄ ▄▀▀▀▄ █ ▀▀█▀▀", "█▀▀▀▄ █ █ █ █ ", "▀▀▀▀▀ ▀▀▀ ▀▀▀▀▀ ▀ "],
}

export const go = {
Expand Down
Loading
Loading