Merge pull request #12312 from NousResearch/bb/tui-ux-pack
feat(tui): UX pack — stable picker keys, /clear confirm, light-theme preset
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
describe('DEFAULT_THEME', () => {
|
||||||
it('has brand defaults', () => {
|
it('has brand defaults', () => {
|
||||||
@@ -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', () => {
|
describe('fromSkin', () => {
|
||||||
it('overrides banner colors', () => {
|
it('overrides banner colors', () => {
|
||||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
|
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ActivityItem,
|
ActivityItem,
|
||||||
ApprovalReq,
|
ApprovalReq,
|
||||||
ClarifyReq,
|
ClarifyReq,
|
||||||
|
ConfirmReq,
|
||||||
DetailsMode,
|
DetailsMode,
|
||||||
Msg,
|
Msg,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
@@ -53,6 +54,7 @@ export interface GatewayProviderProps {
|
|||||||
export interface OverlayState {
|
export interface OverlayState {
|
||||||
approval: ApprovalReq | null
|
approval: ApprovalReq | null
|
||||||
clarify: ClarifyReq | null
|
clarify: ClarifyReq | null
|
||||||
|
confirm: ConfirmReq | null
|
||||||
modelPicker: boolean
|
modelPicker: boolean
|
||||||
pager: null | PagerState
|
pager: null | PagerState
|
||||||
picker: boolean
|
picker: boolean
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { OverlayState } from './interfaces.js'
|
|||||||
const buildOverlayState = (): OverlayState => ({
|
const buildOverlayState = (): OverlayState => ({
|
||||||
approval: null,
|
approval: null,
|
||||||
clarify: null,
|
clarify: null,
|
||||||
|
confirm: null,
|
||||||
modelPicker: false,
|
modelPicker: false,
|
||||||
pager: null,
|
pager: null,
|
||||||
picker: false,
|
picker: false,
|
||||||
@@ -17,8 +18,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
|||||||
|
|
||||||
export const $isBlocked = computed(
|
export const $isBlocked = computed(
|
||||||
$overlayState,
|
$overlayState,
|
||||||
({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||||
Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo)
|
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getOverlayState = () => $overlayState.get()
|
export const getOverlayState = () => $overlayState.get()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
|
||||||
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
||||||
import { HOTKEYS } from '../../../content/hotkeys.js'
|
import { HOTKEYS } from '../../../content/hotkeys.js'
|
||||||
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
||||||
@@ -82,8 +83,27 @@ export const coreCommands: SlashCommand[] = [
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
patchUiState({ status: 'forging session…' })
|
const isNew = cmd.startsWith('/new')
|
||||||
ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined)
|
|
||||||
|
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?'
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js'
|
|||||||
import { FloatBox } from './appChrome.js'
|
import { FloatBox } from './appChrome.js'
|
||||||
import { MaskedPrompt } from './maskedPrompt.js'
|
import { MaskedPrompt } from './maskedPrompt.js'
|
||||||
import { ModelPicker } from './modelPicker.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 { SessionPicker } from './sessionPicker.js'
|
||||||
import { SkillsHub } from './skillsHub.js'
|
import { SkillsHub } from './skillsHub.js'
|
||||||
|
|
||||||
@@ -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) {
|
if (overlay.clarify) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
const idx = off + i
|
const idx = off + i
|
||||||
|
|
||||||
return (
|
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 ? '▸ ' : ' '}
|
{providerIdx === idx ? '▸ ' : ' '}
|
||||||
{i + 1}. {row}
|
{i + 1}. {row}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -212,7 +212,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
|
|||||||
const idx = off + i
|
const idx = off + i
|
||||||
|
|
||||||
return (
|
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 ? '▸ ' : ' '}
|
{modelIdx === idx ? '▸ ' : ' '}
|
||||||
{i + 1}. {row}
|
{i + 1}. {row}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import type { Theme } from '../theme.js'
|
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'
|
import { TextInput } from './textInput.js'
|
||||||
|
|
||||||
@@ -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 {
|
interface ApprovalPromptProps {
|
||||||
onChoice: (s: string) => void
|
onChoice: (s: string) => void
|
||||||
req: ApprovalReq
|
req: ApprovalReq
|
||||||
@@ -164,3 +238,10 @@ interface ClarifyPromptProps {
|
|||||||
req: ClarifyReq
|
req: ClarifyReq
|
||||||
t: Theme
|
t: Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConfirmPromptProps {
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
req: ConfirmReq
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
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 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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -78,7 +78,17 @@ function mix(a: string, b: string, t: number) {
|
|||||||
|
|
||||||
// ── Defaults ─────────────────────────────────────────────────────────
|
// ── 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: {
|
color: {
|
||||||
gold: '#FFD700',
|
gold: '#FFD700',
|
||||||
amber: '#FFBF00',
|
amber: '#FFBF00',
|
||||||
@@ -112,20 +122,59 @@ export const DEFAULT_THEME: Theme = {
|
|||||||
shellDollar: '#4dabf7'
|
shellDollar: '#4dabf7'
|
||||||
},
|
},
|
||||||
|
|
||||||
brand: {
|
brand: BRAND,
|
||||||
name: 'Hermes Agent',
|
|
||||||
icon: '⚕',
|
|
||||||
prompt: '❯',
|
|
||||||
welcome: 'Type your message or /help for commands.',
|
|
||||||
goodbye: 'Goodbye! ⚕',
|
|
||||||
tool: '┊',
|
|
||||||
helpHeader: '(^_^)? Commands'
|
|
||||||
},
|
|
||||||
|
|
||||||
bannerLogo: '',
|
bannerLogo: '',
|
||||||
bannerHero: ''
|
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 ─────────────────────────────────────────────────────
|
// ── Skin → Theme ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fromSkin(
|
export function fromSkin(
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export interface ApprovalReq {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConfirmReq {
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
danger?: boolean
|
||||||
|
detail?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClarifyReq {
|
export interface ClarifyReq {
|
||||||
choices: string[] | null
|
choices: string[] | null
|
||||||
question: string
|
question: string
|
||||||
|
|||||||
Reference in New Issue
Block a user