Files
hermes-agent/ui-opentui/src/view/homeHint.tsx
T
alt-glitch ae11a636dc feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui
Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
2026-06-09 16:16:48 +00:00

204 lines
9.5 KiB
TypeScript

/**
* HomeHint — the empty-transcript home screen (items 12 + 9; Ink `branding.tsx`
* parity). The HERMES-AGENT banner + a tagline, then a session info block
* (model · Nous Research / dir / Session id), then SEPARATE collapsible sections —
* Available Tools (enabled toolsets + their tools), Available Skills, MCP Servers —
* and a summary line. Fully themed; decorative, so `selectable={false}` (item 4).
*/
import { createSignal, For, type JSX, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { truncate } from '../logic/toolOutput.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze.
const BANNER: ReadonlyArray<readonly [string, 'primary' | 'accent' | 'border']> = [
['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'],
['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'],
['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'],
['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'],
['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'],
['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border']
]
const BANNER_W = 102
const TOOLSETS_MAX = 10
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`. */
const shortModel = (m: string) => (m.includes('/') ? (m.split('/').at(-1) ?? m) : m)
const HOME = process.env.HOME ?? ''
const shortCwd = (cwd: string) => (HOME && cwd.startsWith(HOME) ? '~' + cwd.slice(HOME.length) : cwd)
export function HomeHint(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useDimensions()
const wide = () => dims().width >= BANNER_W
const cat = () => props.store.state.catalog
const info = () => props.store.state.info
const enabledToolsets = () => (cat()?.tools.toolsets ?? []).filter(t => t.enabled)
// A collapsible section: ▸/▾ accent chevron + label title + optional muted suffix.
function Section(p: { title: string; suffix?: string; open?: boolean; children: JSX.Element }) {
const [open, setOpen] = createSignal(p.open ?? false)
return (
<box style={{ flexDirection: 'column', marginTop: 1 }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => setOpen(o => !o)}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{open() ? '▾ ' : '▸ '}</span>
<span style={{ fg: theme().color.label }}>{p.title}</span>
<Show when={p.suffix}>
<span style={{ fg: theme().color.muted }}>{` ${p.suffix}`}</span>
</Show>
</text>
</box>
<Show when={open()}>
<box
style={{ flexDirection: 'column', marginLeft: 2, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
>
{p.children}
</box>
</Show>
</box>
)
}
return (
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
{/* banner — full block logo when there's room, else a compact brand line */}
<Show
when={wide()}
fallback={
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
</text>
}
>
<For each={BANNER}>
{([line, tone]) => (
<text selectable={false}>
<span style={{ fg: theme().color[tone] }}>{line}</span>
</text>
)}
</For>
</Show>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.muted }}>Nous Research · Messenger of the Digital Gods</span>
</text>
{/* framed session panel (Ink SessionPanel parity) — the bordered box is the
key "this is a designed home screen, not log output" signal. */}
<box
style={{ flexDirection: 'column', marginTop: 1, paddingLeft: 1, paddingRight: 1 }}
border
borderColor={theme().color.border}
>
{/* session info block: model · Nous Research / dir / Session id */}
<box style={{ flexDirection: 'column' }}>
<Show when={info().model}>
{model => (
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{shortModel(model())}</span>
<span style={{ fg: theme().color.muted }}> · Nous Research</span>
</text>
)}
</Show>
<Show when={info().cwd}>
{cwd => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{shortCwd(cwd())}</span>
<Show when={info().branch}>
<span style={{ fg: theme().color.muted }}>{` (${info().branch})`}</span>
</Show>
</text>
)}
</Show>
<Show when={props.store.state.sessionId}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>Session: </span>
<span style={{ fg: theme().color.border }}>{props.store.state.sessionId}</span>
</text>
</Show>
</box>
{/* SEPARATE collapsible sections (Ink parity) + summary */}
<Show when={cat()}>
{c => (
<box style={{ flexDirection: 'column' }}>
<Section title="Available Tools" open>
<For each={enabledToolsets().slice(0, TOOLSETS_MAX)}>
{ts => (
<text selectable={false}>
<span style={{ fg: theme().color.label }}>{`${ts.name}: `}</span>
<span style={{ fg: theme().color.muted }}>
{truncate(
ts.tools.join(', ') || `${ts.count} tools`,
Math.max(20, dims().width - ts.name.length - 8)
)}
</span>
</text>
)}
</For>
<Show when={enabledToolsets().length > TOOLSETS_MAX}>
<text selectable={false}>
<span
style={{ fg: theme().color.muted }}
>{`(and ${enabledToolsets().length - TOOLSETS_MAX} more toolsets…)`}</span>
</text>
</Show>
</Section>
<Section
title={`Available Skills (${c().skills.total})`}
suffix={`in ${c().skills.categories.length} categories`}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
{c()
.skills.categories.map(s => `${s.name} (${s.count})`)
.join(' ')}
</span>
</text>
</Section>
<Section
title={`MCP Servers (${c().mcp.servers.length})`}
suffix={c().mcp.servers.length ? 'connected' : ''}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{c().mcp.servers.join(' ') || 'none configured'}</span>
</text>
</Section>
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.text }}>{`${c().tools.total} tools`}</span>
<span
style={{ fg: theme().color.muted }}
>{` · ${c().skills.total} skills · ${c().mcp.servers.length} MCP · `}</span>
<span style={{ fg: theme().color.accent }}>/help</span>
<span style={{ fg: theme().color.muted }}> for commands</span>
</text>
</box>
</box>
)}
</Show>
</box>
{/* end framed session panel */}
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
Type to chat · ↑↓ history · @file to mention · Ctrl+C to stop/quit
</span>
</text>
</box>
</box>
)
}