Repository navigation
feat(tui): crush-inspired gradient UI, session header, and prompt styling #89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
e89af16
6f830dc
ced99ed
670a60e
bd77e66
02b4a80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", {}) {} | ||
|
|
||
|
|
@@ -49,58 +44,49 @@ export function empty() { | |
| } | ||
|
|
||
| export function logo(pad?: string) { | ||
| const leftWidth = glyphs.left[0]!.length | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const totalWidth = leftWidth + 1 + glyphs.right[0]!.length | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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 glyphs.left.map((row, index) => (pad ?? "") + row + " " + (glyphs.right[index] ?? "")).join(EOL) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| 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 shadow = "\x1b[38;5;236m" | ||
| const fg = (rgb: readonly [number, number, number]) => `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m` | ||
| const gradient = (column: number) => { | ||
| const t = column / (totalWidth - 1) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return fg( | ||
| [0, 1, 2].map((i) => Math.round(GRADIENT_FROM[i]! + (GRADIENT_TO[i]! - GRADIENT_FROM[i]!) * t)) as unknown as [ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| number, | ||
| number, | ||
| number, | ||
| ], | ||
| ) | ||
| } | ||
| const gap = " " | ||
| const draw = (line: string, fg: string, shadow: string, bg: string) => { | ||
|
|
||
| const draw = (line: string, offset: number, bold: boolean) => { | ||
| 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 | ||
| } | ||
| Array.from(line).forEach((char, column) => { | ||
| if (char === " ") { | ||
| parts.push(" ") | ||
| continue | ||
| return | ||
| } | ||
| if (char === "_") { | ||
| parts.push(shadow, "_", reset) | ||
| return | ||
| } | ||
| parts.push(fg, char, reset) | ||
| } | ||
| parts.push(gradient(offset + column), bold ? "\x1b[1m" : "", char, reset) | ||
| }) | ||
| return parts.join("") | ||
| } | ||
|
|
||
| const result: string[] = [] | ||
| 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(draw(row, 0, false)) | ||
| result.push(" ") | ||
| result.push(draw(glyphs.right[index] ?? "", leftWidth + 1, true)) | ||
| result.push(EOL) | ||
| }) | ||
| return result.join("").trimEnd() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { TextAttributes } from "@opentui/core" | ||
| import { createMemo, For, Show } from "solid-js" | ||
| import { useSync } from "../../context/sync" | ||
| import { tint, useTheme } from "../../context/theme" | ||
| import { Locale } from "../../util/locale" | ||
|
|
||
| const BRAND = "BOLT CLI" | ||
|
|
||
| // Three-line session header: a gradient brand row with a thin rule, the | ||
| // current session line, and a subagent activity line. | ||
| export function SessionHeader(props: { sessionID: string; width: number }) { | ||
| const { theme } = useTheme() | ||
| const sync = useSync() | ||
|
|
||
| const session = createMemo(() => sync.session.get(props.sessionID)) | ||
| const sessions = createMemo(() => sync.data.session.filter((x) => !x.parentID)) | ||
| const subagents = createMemo(() => { | ||
| const parentID = session()?.parentID ?? props.sessionID | ||
| return sync.data.session.filter((x) => x.parentID === parentID).toSorted((a, b) => a.time.created - b.time.created) | ||
| }) | ||
| const working = (id: string) => (sync.data.session_status[id]?.type ?? "idle") !== "idle" | ||
|
|
||
| const gradient = (column: number) => tint(theme.accent, theme.primary, column / (BRAND.length - 1)) | ||
|
|
||
| const label = (title: string, max: number) => { | ||
| const match = title.match(/@(\w+) subagent/) | ||
| const text = match ? Locale.titlecase(match[1]!) : title | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return text.length > max ? text.slice(0, max - 1) + "…" : text | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| return ( | ||
| <box flexShrink={0} paddingTop={1}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <box flexDirection="row" gap={1}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <text selectable={false}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {Array.from(BRAND).map((char, column) => ( | ||
| <span style={{ fg: gradient(column), attributes: TextAttributes.BOLD }}>{char}</span> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ))} | ||
| </text> | ||
| <text fg={theme.border} selectable={false} wrapMode="none"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {"─".repeat(Math.max(0, props.width - BRAND.length - 1))} | ||
| </text> | ||
| </box> | ||
| <box flexDirection="row" gap={1}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <text fg={theme.primary}>●</text> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <text fg={theme.text} wrapMode="none"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {label(session()?.title ?? "untitled", Math.max(8, props.width - 20))} | ||
| </text> | ||
| <text fg={theme.textMuted} wrapMode="none"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| · {sessions().length} {sessions().length === 1 ? "session" : "sessions"} | ||
| </text> | ||
| </box> | ||
| <Show | ||
| when={subagents().length > 0} | ||
| fallback={ | ||
| <text fg={theme.textMuted} wrapMode="none"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| no subagent work | ||
| </text> | ||
| } | ||
| > | ||
| <box flexDirection="row" gap={2}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <For each={subagents().slice(-3)}> | ||
| {(child) => ( | ||
| <box flexDirection="row" gap={1}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <text fg={working(child.id) ? theme.success : theme.textMuted}>{working(child.id) ? "◉" : "○"}</text> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <text fg={working(child.id) ? theme.text : theme.textMuted} wrapMode="none"> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| {label(child.title, 24)} | ||
| </text> | ||
| </box> | ||
| )} | ||
| </For> | ||
| <Show when={subagents().length > 3}> | ||
| <text fg={theme.textMuted}>+{subagents().length - 3} more</text> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| </Show> | ||
| </box> | ||
| </Show> | ||
| </box> | ||
| ) | ||
| } | ||
|
Comment on lines
+11
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ import { DialogTodos } from "../../component/dialog-todos" | |
|
|
||
| import { Sidebar } from "./sidebar" | ||
| import { SubagentFooter } from "./subagent-footer.tsx" | ||
| import { SessionHeader } from "./header.tsx" | ||
| import { filetype } from "../../util/filetype" | ||
| import parsers from "../../parsers-config" | ||
| import { errorMessage } from "../../util/error" | ||
|
|
@@ -1244,6 +1245,7 @@ export function Session() { | |
| <box flexDirection="row" flexGrow={1} minHeight={0}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| <Show when={session()}> | ||
| <SessionHeader sessionID={route.sessionID} width={contentWidth()} /> | ||
| <scrollbox | ||
| ref={(r) => (scroll = r)} | ||
| viewportOptions={{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using non-null assertions cancels out the benefits of strict null-checking, and introduces the possibility of runtime errors. Avoid non-null assertions unless absolutely necessary. If you still need to use one, write a skipcq comment to explain why it is safe.