feat: split apart main.tsx

This commit is contained in:
Brooklyn Nicholson 2026-04-02 20:39:52 -05:00
parent 2818dd8611
commit bbba9ed4f2
16 changed files with 1710 additions and 1653 deletions

View file

@ -0,0 +1,113 @@
import { Box, Text, useStdout } from 'ink'
import { caduceus, logo, LOGO_WIDTH } from '../banner.js'
import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SessionInfo } from '../types.js'
export function ArtLines({ lines }: { lines: [string, string][] }) {
return (
<>
{lines.map(([c, text], i) => (
<Text color={c} key={i}>
{text}
</Text>
))}
</>
)
}
export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80
return (
<Box flexDirection="column" marginBottom={1}>
{cols >= LOGO_WIDTH ? (
<ArtLines lines={logo(t.color)} />
) : (
<Text bold color={t.color.gold}>
{t.brand.icon} NOUS HERMES
</Text>
)}
<Text />
<Text>
<Text color={t.color.amber}>{t.brand.icon} Nous Research</Text>
<Text color={t.color.dim}> · Messenger of the Digital Gods</Text>
</Text>
</Box>
)
}
export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) {
const cols = useStdout().stdout?.columns ?? 100
const wide = cols >= 90
const w = wide ? cols - 46 : cols - 10
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
const truncLine = (pfx: string, items: string[]) => {
let line = ''
for (const item of items.sort()) {
const next = line ? `${line}, ${item}` : item
if (pfx.length + next.length > w) {
return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …`
}
line = next
}
return line
}
const section = (title: string, data: Record<string, string[]>, max = 8) => {
const entries = Object.entries(data).sort()
const shown = entries.slice(0, max)
const overflow = entries.length - max
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}>
Available {title}
</Text>
{shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.dim}>{strip(k)}: </Text>
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
</Text>
))}
{overflow > 0 && <Text color={t.color.dim}>(and {overflow} more)</Text>}
</Box>
)
}
return (
<Box borderColor={t.color.bronze} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={34}>
<ArtLines lines={caduceus(t.color)} />
<Text />
<Text color={t.color.dim}>Nous Research</Text>
</Box>
)}
<Box flexDirection="column" width={w}>
<Text bold color={t.color.gold}>
{t.brand.icon} {t.brand.name}
</Text>
{section('Tools', info.tools)}
{section('Skills', info.skills)}
<Text />
<Text color={t.color.cornsilk}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{' · '}
<Text color={t.color.dim}>/help for commands</Text>
</Text>
<Text color={t.color.dim}>
{info.model.split('/').pop()}
{' · '}Ctrl+C to interrupt
</Text>
</Box>
</Box>
)
}

View file

@ -0,0 +1,25 @@
import { Box, Text } from 'ink'
import { COMMANDS } from '../constants.js'
import type { Theme } from '../theme.js'
export function CommandPalette({ filter, t }: { filter: string; t: Theme }) {
const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter))
if (!matches.length) {
return null
}
return (
<Box flexDirection="column">
{matches.map(([cmd, desc]) => (
<Text key={cmd}>
<Text bold color={t.color.amber}>
{cmd}
</Text>
<Text color={t.color.dim}> {desc}</Text>
</Text>
))}
</Box>
)
}

View file

@ -0,0 +1,152 @@
import { Box, Text } from 'ink'
import type { ReactNode } from 'react'
import type { Theme } from '../theme.js'
function MdInline({ t, text }: { t: Theme; text: string }) {
const parts: ReactNode[] = []
const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g
let last = 0
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
if (match.index > last) {
parts.push(
<Text color={t.color.cornsilk} key={parts.length}>
{text.slice(last, match.index)}
</Text>
)
}
if (match[2] && match[3]) {
parts.push(
<Text color={t.color.amber} key={parts.length} underline>
{match[2]}
</Text>
)
} else if (match[4]) {
parts.push(
<Text bold color={t.color.cornsilk} key={parts.length}>
{match[4]}
</Text>
)
} else if (match[5]) {
parts.push(
<Text color={t.color.amber} dimColor key={parts.length}>
{match[5]}
</Text>
)
} else if (match[6]) {
parts.push(
<Text color={t.color.cornsilk} italic key={parts.length}>
{match[6]}
</Text>
)
} else if (match[7]) {
parts.push(
<Text color={t.color.amber} key={parts.length} underline>
{match[7]}
</Text>
)
}
last = match.index + match[0].length
}
if (last < text.length) {
parts.push(
<Text color={t.color.cornsilk} key={parts.length}>
{text.slice(last)}
</Text>
)
}
return <Text>{parts.length ? parts : <Text color={t.color.cornsilk}>{text}</Text>}</Text>
}
export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) {
const lines = text.split('\n')
const nodes: ReactNode[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]!
const key = nodes.length
if (compact && !line.trim()) {
i++
continue
}
if (line.startsWith('```')) {
const lang = line.slice(3).trim()
const block: string[] = []
for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) {
block.push(lines[i]!)
}
i++
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => (
<Text color={t.color.cornsilk} key={j}>
{l}
</Text>
))}
</Box>
)
continue
}
const heading = line.match(/^#{1,3}\s+(.*)/)
if (heading) {
nodes.push(
<Text bold color={t.color.amber} key={key}>
{heading[1]}
</Text>
)
i++
continue
}
const bullet = line.match(/^\s*[-*]\s(.*)/)
if (bullet) {
nodes.push(
<Text key={key}>
<Text color={t.color.dim}> </Text>
<MdInline t={t} text={bullet[1]!} />
</Text>
)
i++
continue
}
const numbered = line.match(/^\s*(\d+)\.\s(.*)/)
if (numbered) {
nodes.push(
<Text key={key}>
<Text color={t.color.dim}> {numbered[1]}. </Text>
<MdInline t={t} text={numbered[2]!} />
</Text>
)
i++
continue
}
nodes.push(<MdInline key={key} t={t} text={line} />)
i++
}
return <Box flexDirection="column">{nodes}</Box>
}

View file

@ -0,0 +1,46 @@
import { Box, Text } from 'ink'
import { LONG_MSG, ROLE } from '../constants.js'
import { userDisplay } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg } from '../types.js'
import { Md } from './markdown.js'
export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) {
const { body, glyph, prefix } = ROLE[msg.role](t)
const content = (() => {
if (msg.role === 'assistant') {
return <Md compact={compact} t={t} text={msg.text} />
}
if (msg.role === 'user' && msg.text.length > LONG_MSG) {
const displayed = userDisplay(msg.text)
const [head, ...rest] = displayed.split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>
[long message]
</Text>
{rest.join('')}
</Text>
)
}
return <Text color={body}>{msg.text}</Text>
})()
return (
<Box>
<Box width={3}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</Box>
{content}
</Box>
)
}

View file

@ -0,0 +1,127 @@
import { Box, Text, useInput } from 'ink'
import TextInput from 'ink-text-input'
import { useState } from 'react'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq } from '../types.js'
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
const [sel, setSel] = useState(3)
const opts = ['once', 'session', 'always', 'deny'] as const
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
useInput((ch, key) => {
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < 3) {
setSel(s => s + 1)
}
if (key.return) {
onChoice(opts[sel]!)
}
if (ch === 'o') {
onChoice('once')
}
if (ch === 's') {
onChoice('session')
}
if (ch === 'a') {
onChoice('always')
}
if (ch === 'd' || key.escape) {
onChoice('deny')
}
})
return (
<Box flexDirection="column">
<Text bold color={t.color.warn}>
DANGEROUS COMMAND: {req.description}
</Text>
<Text color={t.color.dim}> {req.command}</Text>
<Text />
{opts.map((o, i) => (
<Text key={o}>
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
[{o[0]}] {labels[o]}
</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · o/s/a/d quick pick</Text>
</Box>
)
}
export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) {
const [sel, setSel] = useState(0)
const [custom, setCustom] = useState('')
const [typing, setTyping] = useState(false)
const choices = req.choices ?? []
useInput((ch, key) => {
if (typing) {
return
}
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < choices.length) {
setSel(s => s + 1)
}
if (key.return) {
if (sel === choices.length) {
setTyping(true)
} else if (choices[sel]) {
onAnswer(choices[sel]!)
}
}
const n = parseInt(ch)
if (n >= 1 && n <= choices.length) {
onAnswer(choices[n - 1]!)
}
})
if (typing || !choices.length) {
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{req.question}
</Text>
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput onChange={setCustom} onSubmit={onAnswer} value={custom} />
</Box>
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{req.question}
</Text>
{[...choices, 'Other (type your answer)'].map((c, i) => (
<Text key={i}>
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
{i + 1}. {c}
</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · 1-{choices.length} quick pick</Text>
</Box>
)
}

View file

@ -0,0 +1,53 @@
import { Box, Text } from 'ink'
import { compactPreview } from '../lib/text.js'
import type { Theme } from '../theme.js'
export function QueuedMessages({
cols,
queueEditIdx,
queued,
t
}: {
cols: number
queueEditIdx: number | null
queued: string[]
t: Theme
}) {
if (!queued.length) {
return null
}
const qWindow = 3
const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow))
const qEnd = Math.min(queued.length, qStart + qWindow)
return (
<Box flexDirection="column">
<Text color={t.color.dim} dimColor>
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
</Text>
{qStart > 0 && (
<Text color={t.color.dim} dimColor>
{' '}
</Text>
)}
{queued.slice(qStart, qEnd).map((item, i) => {
const idx = qStart + i
const active = queueEditIdx === idx
return (
<Text color={active ? t.color.amber : t.color.dim} dimColor key={`${idx}-${item.slice(0, 16)}`}>
{active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))}
</Text>
)
})}
{qEnd < queued.length && (
<Text color={t.color.dim} dimColor>
{' '}and {queued.length - qEnd} more
</Text>
)}
</Box>
)
}

View file

@ -0,0 +1,41 @@
import { Box, Text } from 'ink'
import { useEffect, useState } from 'react'
import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js'
import { pick } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { ActiveTool } from '../types.js'
export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) {
const [frame, setFrame] = useState(0)
const [verb] = useState(() => pick(VERBS))
const [face] = useState(() => pick(FACES))
useEffect(() => {
const id = setInterval(() => setFrame(f => (f + 1) % SPINNER.length), 80)
return () => clearInterval(id)
}, [])
return (
<Box flexDirection="column">
{tools.length ? (
tools.map(tool => (
<Text color={t.color.dim} key={tool.id}>
{SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}
</Text>
))
) : (
<Text color={t.color.dim}>
{SPINNER[frame]} {face} {verb}
</Text>
)}
{reasoning && (
<Text color={t.color.dim} dimColor wrap="truncate-end">
{' 💭 '}
{reasoning.slice(-120).replace(/\n/g, ' ')}
</Text>
)}
</Box>
)
}