Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 21 additions & 1 deletion ui-tui/src/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { DEFAULT_THEME, fromSkin } from '../theme.js'
import { DARK_THEME, DEFAULT_THEME, fromSkin, LIGHT_THEME } from '../theme.js'

describe('DEFAULT_THEME', () => {
it('has brand defaults', () => {
Expand All @@ -15,6 +15,26 @@ describe('DEFAULT_THEME', () => {
})
})

describe('LIGHT_THEME', () => {
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => {
expect(LIGHT_THEME.color.gold).not.toBe('#FFD700')
expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00')
expect(LIGHT_THEME.color.dim).not.toBe('#B8860B')
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
})

it('keeps the same shape as DARK_THEME', () => {
expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
})
})

describe('DEFAULT_THEME aliasing', () => {
it('defaults to DARK_THEME when HERMES_TUI_LIGHT is unset', () => {
expect(DEFAULT_THEME).toBe(DARK_THEME)
})
})

describe('fromSkin', () => {
it('overrides banner colors', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
Expand Down
2 changes: 2 additions & 0 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ActivityItem,
ApprovalReq,
ClarifyReq,
ConfirmReq,
DetailsMode,
Msg,
PanelSection,
Expand Down Expand Up @@ -53,6 +54,7 @@ export interface GatewayProviderProps {
export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
picker: boolean
Expand Down
5 changes: 3 additions & 2 deletions ui-tui/src/app/overlayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { OverlayState } from './interfaces.js'
const buildOverlayState = (): OverlayState => ({
approval: null,
clarify: null,
confirm: null,
modelPicker: false,
pager: null,
picker: false,
Expand All @@ -17,8 +18,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())

export const $isBlocked = computed(
$overlayState,
({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo)
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
)

export const getOverlayState = () => $overlayState.get()
Expand Down
24 changes: 22 additions & 2 deletions ui-tui/src/app/slash/commands/core.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
Expand Down Expand Up @@ -82,8 +83,27 @@ export const coreCommands: SlashCommand[] = [
return
}

patchUiState({ status: 'forging session…' })
ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined)
const isNew = cmd.startsWith('/new')

const commit = () => {
patchUiState({ status: 'forging session…' })
ctx.session.newSession(isNew ? 'new session started' : undefined)
}

if (NO_CONFIRM_DESTRUCTIVE) {
return commit()
}

patchOverlayState({
confirm: {
cancelLabel: 'No, keep going',
confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session',
danger: true,
detail: 'This ends the current conversation and clears the transcript.',
onConfirm: commit,
title: isNew ? 'Start a new session?' : 'Clear the current session?'
}
})
}
},

Expand Down
19 changes: 18 additions & 1 deletion ui-tui/src/components/appOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'

Expand All @@ -31,6 +31,23 @@ export function PromptZone({
)
}

if (overlay.confirm) {
const req = overlay.confirm

const onConfirm = () => {
patchOverlayState({ confirm: null })
req.onConfirm()
}

const onCancel = () => patchOverlayState({ confirm: null })

return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={ui.theme} />
</Box>
)
}

if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
Expand Down
4 changes: 2 additions & 2 deletions ui-tui/src/components/modelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const idx = off + i

return (
<Text color={providerIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
<Text color={providerIdx === idx ? t.color.cornsilk : t.color.dim} key={providers[idx]?.slug ?? `row-${idx}`}>
{providerIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
Expand Down Expand Up @@ -212,7 +212,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const idx = off + i

return (
<Text color={modelIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
<Text color={modelIdx === idx ? t.color.cornsilk : t.color.dim} key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}>
{modelIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
Expand Down
83 changes: 82 additions & 1 deletion ui-tui/src/components/prompts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'

import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq } from '../types.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'

import { TextInput } from './textInput.js'

Expand Down Expand Up @@ -151,6 +151,80 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
)
}

export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) {
const [sel, setSel] = useState(0)

useInput((ch, key) => {
if (key.escape || (key.ctrl && ch.toLowerCase() === 'c')) {
onCancel()

return
}

const lower = ch.toLowerCase()

if (lower === 'y') {
onConfirm()

return
}

if (lower === 'n') {
onCancel()

return
}

if (key.upArrow && sel > 0) {
setSel(0)
}

if (key.downArrow && sel < 1) {
setSel(1)
}

if (key.return) {
sel === 0 ? onCancel() : onConfirm()
}
})

const accent = req.danger ? t.color.error : t.color.warn
const confirmLabel = req.confirmLabel ?? 'Yes'
const cancelLabel = req.cancelLabel ?? 'No'

const rows = [
{ color: t.color.cornsilk, label: cancelLabel },
{ color: req.danger ? t.color.error : t.color.cornsilk, label: confirmLabel }
]

return (
<Box borderColor={accent} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={accent}>
{req.danger ? '⚠' : '?'} {req.title}
</Text>

{req.detail ? (
<Box paddingLeft={1}>
<Text color={t.color.cornsilk} wrap="truncate-end">
{req.detail}
</Text>
</Box>
) : null}

<Text />

{rows.map((row, i) => (
<Text key={row.label}>
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text>
</Text>
))}

<Text color={t.color.dim}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
</Box>
)
}

interface ApprovalPromptProps {
onChoice: (s: string) => void
req: ApprovalReq
Expand All @@ -164,3 +238,10 @@ interface ClarifyPromptProps {
req: ClarifyReq
t: Theme
}

interface ConfirmPromptProps {
onCancel: () => void
onConfirm: () => void
req: ConfirmReq
t: Theme
}
3 changes: 3 additions & 0 deletions ui-tui/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim())
export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test(
(process.env.HERMES_TUI_NO_CONFIRM ?? '').trim()
)
67 changes: 58 additions & 9 deletions ui-tui/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,17 @@ function mix(a: string, b: string, t: number) {

// ── Defaults ─────────────────────────────────────────────────────────

export const DEFAULT_THEME: Theme = {
const BRAND: ThemeBrand = {
name: 'Hermes Agent',
icon: '⚕',
prompt: '❯',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊',
helpHeader: '(^_^)? Commands'
}

export const DARK_THEME: Theme = {
color: {
gold: '#FFD700',
amber: '#FFBF00',
Expand Down Expand Up @@ -112,20 +122,59 @@ export const DEFAULT_THEME: Theme = {
shellDollar: '#4dabf7'
},

brand: {
name: 'Hermes Agent',
icon: '⚕',
prompt: '❯',
welcome: 'Type your message or /help for commands.',
goodbye: 'Goodbye! ⚕',
tool: '┊',
helpHeader: '(^_^)? Commands'
brand: BRAND,

bannerLogo: '',
bannerHero: ''
}

// Light-terminal palette: darker golds/ambers that stay legible on white
// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top
// cleanly (#11300).
export const LIGHT_THEME: Theme = {
color: {
gold: '#8B6914',
amber: '#A0651C',
bronze: '#7A4F1F',
cornsilk: '#3D2F13',
dim: '#7A5A0F',
completionBg: '#F5F5F5',
completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25),

label: '#7A5A0F',
ok: '#2E7D32',
error: '#C62828',
warn: '#E65100',

prompt: '#2B2014',
sessionLabel: '#7A5A0F',
sessionBorder: '#7A5A0F',

statusBg: '#F5F5F5',
statusFg: '#333333',
statusGood: '#2E7D32',
statusWarn: '#8B6914',
statusBad: '#D84315',
statusCritical: '#B71C1C',
selectionBg: '#D4E4F7',

diffAdded: 'rgb(200,240,200)',
diffRemoved: 'rgb(240,200,200)',
diffAddedWord: 'rgb(27,94,32)',
diffRemovedWord: 'rgb(183,28,28)',
shellDollar: '#1565C0'
},

brand: BRAND,

bannerLogo: '',
bannerHero: ''
}

const LIGHT_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_LIGHT ?? '').trim())

export const DEFAULT_THEME: Theme = LIGHT_MODE ? LIGHT_THEME : DARK_THEME

// ── Skin → Theme ─────────────────────────────────────────────────────

export function fromSkin(
Expand Down
9 changes: 9 additions & 0 deletions ui-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export interface ApprovalReq {
description: string
}

export interface ConfirmReq {
cancelLabel?: string
confirmLabel?: string
danger?: boolean
detail?: string
onConfirm: () => void
title: string
}

export interface ClarifyReq {
choices: string[] | null
question: string
Expand Down
Loading