mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Compare commits
4 Commits
7603126c86
...
036dd14425
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
036dd14425 | ||
|
|
1e499a7136 | ||
|
|
e58308c680 | ||
|
|
6147a867cd |
@@ -30,12 +30,12 @@ record.tsx ─┐
|
||||
workflows/<name>.json
|
||||
│ served at /api/workflow/<name>
|
||||
▼
|
||||
showroom.js │ xterm.js writes ANSI; DOM overlays target frame ids
|
||||
showroom.js │ xterm.js renders ANSI; DOM overlays target frame ids
|
||||
▼
|
||||
browser
|
||||
```
|
||||
|
||||
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (jsDelivr CDN) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
|
||||
`frame` actions embed ANSI from an Ink render; the browser feeds them into `@xterm/xterm` (CDN, cached) so the surface is the actual TUI. Captions, spotlights, highlights, and fades are DOM overlays anchored to frame `id`s.
|
||||
|
||||
## Timeline actions
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const renderPage = (initial: { name: string; workflow: unknown }, catalog
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hermes TUI Showroom</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css" />
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -48,6 +49,9 @@ export const renderPage = (initial: { name: string; workflow: unknown }, catalog
|
||||
window.__SHOWROOM_INITIAL__ = ${initialJson};
|
||||
window.__SHOWROOM_CATALOG__ = ${catalogJson};
|
||||
</script>
|
||||
<script type="importmap">
|
||||
{ "imports": { "@xterm/": "https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/" } }
|
||||
</script>
|
||||
<script type="module">${js}</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -143,7 +143,7 @@ const featureTour = async () => {
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'tool-trail',
|
||||
text: 'Real ui-tui MessageLine + Panel rendered to ANSI and replayed in xterm.js.',
|
||||
text: 'Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ ansi: assistantResult, at: 5400, id: 'assistant-result', type: 'frame' },
|
||||
@@ -436,6 +436,334 @@ const voiceMode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Static prompt mocks (no useInput, safe for snap()) ---
|
||||
|
||||
const ApprovalPromptStatic = ({
|
||||
command,
|
||||
description,
|
||||
selected = 0,
|
||||
theme
|
||||
}: {
|
||||
command: string
|
||||
description: string
|
||||
selected?: number
|
||||
theme: Theme
|
||||
}) => {
|
||||
const labels = ['Allow once', 'Allow this session', 'Always allow', 'Deny']
|
||||
const lines = command.split('\n').slice(0, 5)
|
||||
|
||||
return (
|
||||
<Box borderColor={theme.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.color.warn}>
|
||||
⚠ approval required · {description}
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{lines.map((line, i) => (
|
||||
<Text color={theme.color.cornsilk} key={i}>
|
||||
{line || ' '}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Text />
|
||||
|
||||
{labels.map((label, i) => (
|
||||
<Text key={label}>
|
||||
<Text bold={i === selected} color={i === selected ? theme.color.warn : theme.color.dim} inverse={i === selected}>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {label}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const ClarifyPromptStatic = ({
|
||||
choices,
|
||||
question,
|
||||
selected = 0,
|
||||
theme
|
||||
}: {
|
||||
choices: string[]
|
||||
question: string
|
||||
selected?: number
|
||||
theme: Theme
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
<Text color={theme.color.amber}>ask</Text>
|
||||
<Text color={theme.color.cornsilk}> {question}</Text>
|
||||
</Text>
|
||||
|
||||
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
||||
<Text key={i}>
|
||||
<Text bold={i === selected} color={i === selected ? theme.color.label : theme.color.dim} inverse={i === selected}>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {c}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>
|
||||
↑/↓ select · Enter confirm · 1-{choices.length + 1} quick pick · Esc cancel
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const ModelPickerStatic = ({
|
||||
currentModel,
|
||||
items,
|
||||
selected = 0,
|
||||
stage,
|
||||
theme
|
||||
}: {
|
||||
currentModel: string
|
||||
items: string[]
|
||||
selected?: number
|
||||
stage: 'model' | 'provider'
|
||||
theme: Theme
|
||||
}) => (
|
||||
<Box borderStyle="double" borderColor={theme.color.amber} flexDirection="column" paddingX={1} width={50}>
|
||||
<Text bold color={theme.color.amber} wrap="truncate-end">
|
||||
{stage === 'provider' ? 'Select Provider' : 'Select Model'}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.dim} wrap="truncate-end">
|
||||
{stage === 'provider' ? `Current model: ${currentModel}` : currentModel}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.label} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
|
||||
<Text color={theme.color.dim}>{' '}</Text>
|
||||
|
||||
{items.map((item, i) => (
|
||||
<Text
|
||||
bold={i === selected}
|
||||
color={i === selected ? theme.color.amber : theme.color.dim}
|
||||
inverse={i === selected}
|
||||
key={item}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{i === selected ? '▸ ' : ' '}
|
||||
{i + 1}. {item}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={theme.color.dim}>{' '}</Text>
|
||||
<Text color={theme.color.dim}>persist: session · g toggle</Text>
|
||||
<Text color={theme.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc/q cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const interactivePrompts = async () => {
|
||||
// User asks for something that triggers approval
|
||||
const userAsk = await snap(
|
||||
<Msg role="user" text="Run npm install express in the project root." />
|
||||
)
|
||||
|
||||
const assistantExplains = await snap(
|
||||
<Msg
|
||||
role="assistant"
|
||||
text="I'll install express. The package manager needs approval — here's the command."
|
||||
/>
|
||||
)
|
||||
|
||||
// Approval prompt
|
||||
const approval = await snap(
|
||||
<ApprovalPromptStatic
|
||||
command={'npm install express\nadded 58 packages in 3.2s\n\n+ express@5.1.0'}
|
||||
description="install dependency"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
// After approval, user asks something ambiguous
|
||||
const userClarify = await snap(
|
||||
<Msg role="user" text="Deploy this to staging." />
|
||||
)
|
||||
|
||||
const assistantAsks = await snap(
|
||||
<Msg role="assistant" text="Which environment should I target?" />
|
||||
)
|
||||
|
||||
// Clarify prompt
|
||||
const clarify = await snap(
|
||||
<ClarifyPromptStatic
|
||||
choices={['staging-us-east', 'staging-eu-west', 'staging-ap-south']}
|
||||
question="Which region?"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const confirmResult = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['target', 'staging-us-east'],
|
||||
['branch', 'main'],
|
||||
['preview', 'https://pr-128.railway.app']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="deployment queued"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
return {
|
||||
composer: 'deploy this to staging',
|
||||
timeline: [
|
||||
{ ansi: userAsk, at: 200, id: 'ask', type: 'frame' },
|
||||
{ ansi: assistantExplains, at: 1200, id: 'explain', type: 'frame' },
|
||||
{ ansi: approval, at: 2600, id: 'approval', type: 'frame' },
|
||||
{ at: 2900, duration: 1500, target: 'approval', type: 'spotlight' },
|
||||
{
|
||||
at: 3100,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'approval',
|
||||
text: 'Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 5400, duration: 400, text: '1', type: 'compose' },
|
||||
{ at: 5900, duration: 500, text: '', type: 'compose' },
|
||||
{ ansi: userClarify, at: 6600, id: 'clarify-ask', type: 'frame' },
|
||||
{ ansi: assistantAsks, at: 7600, id: 'clarify-reply', type: 'frame' },
|
||||
{ ansi: clarify, at: 8800, id: 'clarify', type: 'frame' },
|
||||
{ at: 9100, duration: 1500, target: 'clarify', type: 'spotlight' },
|
||||
{
|
||||
at: 9300,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'clarify',
|
||||
text: 'Clarify prompts handle ambiguous requests — numbered choices or free text.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 11600, duration: 400, text: '1', type: 'compose' },
|
||||
{ ansi: confirmResult, at: 12200, id: 'result', type: 'frame' },
|
||||
{ at: 12500, duration: 1300, target: 'result', type: 'highlight' }
|
||||
],
|
||||
title: 'Hermes TUI · Interactive Prompts',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const modelPicker = async () => {
|
||||
const userAsk = await snap(
|
||||
<Msg role="user" text="Switch to Claude." />
|
||||
)
|
||||
|
||||
const assistantReply = await snap(
|
||||
<Msg role="assistant" text="Opening the model picker — pick a provider first, then a model." />
|
||||
)
|
||||
|
||||
// Provider selection stage
|
||||
const providers = await snap(
|
||||
<ModelPickerStatic
|
||||
currentModel="gpt-5-codex"
|
||||
items={[
|
||||
'OpenAI · 8 models',
|
||||
'Anthropic · 6 models',
|
||||
'Google · 5 models',
|
||||
'OpenRouter · 42 models',
|
||||
'xAI · 3 models'
|
||||
]}
|
||||
selected={1}
|
||||
stage="provider"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
// Model selection stage
|
||||
const models = await snap(
|
||||
<ModelPickerStatic
|
||||
currentModel="Anthropic"
|
||||
items={[
|
||||
'claude-opus-4',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-3.7',
|
||||
'claude-haiku-3.5',
|
||||
'claude-sonnet-3.5'
|
||||
]}
|
||||
selected={1}
|
||||
stage="model"
|
||||
theme={t}
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
const result = await snap(
|
||||
<Panel
|
||||
sections={[
|
||||
{
|
||||
rows: [
|
||||
['from', 'gpt-5-codex'],
|
||||
['to', 'claude-sonnet-4'],
|
||||
['scope', 'this session']
|
||||
]
|
||||
}
|
||||
]}
|
||||
t={t}
|
||||
title="model switched"
|
||||
/>,
|
||||
180
|
||||
)
|
||||
|
||||
return {
|
||||
composer: '',
|
||||
timeline: [
|
||||
{ at: 200, duration: 500, text: '/model', type: 'compose' },
|
||||
{ ansi: userAsk, at: 900, id: 'ask', type: 'frame' },
|
||||
{ ansi: assistantReply, at: 1800, id: 'reply', type: 'frame' },
|
||||
{ ansi: providers, at: 3000, id: 'providers', type: 'frame' },
|
||||
{ at: 3300, duration: 1800, target: 'providers', type: 'spotlight' },
|
||||
{
|
||||
at: 3500,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'providers',
|
||||
text: 'Provider stage: pick from authenticated backends. Shows model count per provider.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 5600, duration: 300, text: '2', type: 'compose' },
|
||||
{ ansi: models, at: 6200, id: 'models', type: 'frame' },
|
||||
{ at: 6500, duration: 1800, target: 'models', type: 'spotlight' },
|
||||
{
|
||||
at: 6700,
|
||||
duration: 2000,
|
||||
position: 'right',
|
||||
target: 'models',
|
||||
text: 'Model stage: scrollable list with ▸ selection. Number keys for quick pick.',
|
||||
type: 'caption'
|
||||
},
|
||||
{ at: 9000, duration: 300, text: '2', type: 'compose' },
|
||||
{ ansi: result, at: 9600, id: 'result', type: 'frame' },
|
||||
{ at: 9900, duration: 1300, target: 'result', type: 'highlight' },
|
||||
{
|
||||
at: 10100,
|
||||
duration: 1700,
|
||||
position: 'right',
|
||||
target: 'result',
|
||||
text: 'Model swap mid-session. Transcript and cache stay intact.',
|
||||
type: 'caption'
|
||||
}
|
||||
],
|
||||
title: 'Hermes TUI · Model Picker',
|
||||
viewport: { cols: COLS, rows: ROWS }
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('recording workflows…')
|
||||
|
||||
@@ -447,6 +775,8 @@ const main = async () => {
|
||||
'subagent-trail.json',
|
||||
'slash-commands.json',
|
||||
'voice-mode.json',
|
||||
'interactive-prompts.json',
|
||||
'model-picker.json',
|
||||
'ink-frames.json'
|
||||
]) {
|
||||
try {
|
||||
@@ -460,6 +790,8 @@ const main = async () => {
|
||||
writeWorkflow('subagent-trail', await subagentTrail())
|
||||
writeWorkflow('slash-commands', await slashCommands())
|
||||
writeWorkflow('voice-mode', await voiceMode())
|
||||
writeWorkflow('interactive-prompts', await interactivePrompts())
|
||||
writeWorkflow('model-picker', await modelPicker())
|
||||
|
||||
console.log('done')
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
--label: #daa520;
|
||||
--bg: #0a0a0a;
|
||||
--bg-deep: #050505;
|
||||
--status-bg: #1a1a2e;
|
||||
--status-fg: #c0c0c0;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
@@ -26,7 +24,8 @@ body {
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(205, 127, 50, 0.12), transparent 36rem),
|
||||
radial-gradient(circle at 82% 14%, rgba(255, 215, 0, 0.05), transparent 30rem), var(--bg-deep);
|
||||
radial-gradient(circle at 82% 14%, rgba(255, 215, 0, 0.05), transparent 30rem),
|
||||
var(--bg-deep);
|
||||
}
|
||||
|
||||
#showroom {
|
||||
@@ -37,6 +36,8 @@ body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* --- Shell --- */
|
||||
|
||||
.showroom-shell {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -54,23 +55,7 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.showroom-picker {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(205, 127, 50, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 6px 30px 6px 14px;
|
||||
background: rgba(205, 127, 50, 0.06)
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23cd7f32' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
|
||||
no-repeat right 12px center / 10px;
|
||||
color: var(--cornsilk);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showroom-picker:focus {
|
||||
outline: 1px solid var(--bronze);
|
||||
}
|
||||
/* --- Stage --- */
|
||||
|
||||
.showroom-stage {
|
||||
position: relative;
|
||||
@@ -103,6 +88,8 @@ body {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* --- Status bar --- */
|
||||
|
||||
.showroom-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -127,6 +114,8 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Composer --- */
|
||||
|
||||
.showroom-composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -164,6 +153,8 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Body (DOM message mode) --- */
|
||||
|
||||
.showroom-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -172,16 +163,18 @@ body {
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
|
||||
.showroom-body.is-frame-mode {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
display: block;
|
||||
}
|
||||
/* --- xterm container (frame mode) --- */
|
||||
|
||||
.showroom-xterm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 300ms var(--ease-out);
|
||||
}
|
||||
|
||||
.showroom-xterm.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.showroom-xterm .xterm-viewport {
|
||||
@@ -189,16 +182,13 @@ body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* --- DOM-mode lines --- */
|
||||
|
||||
.showroom-line,
|
||||
.showroom-tool {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
animation: showroom-enter 320ms var(--ease-out) forwards;
|
||||
transition:
|
||||
opacity 420ms var(--ease-in-out),
|
||||
filter 420ms var(--ease-in-out),
|
||||
transform 420ms var(--ease-in-out),
|
||||
background 420ms var(--ease-in-out);
|
||||
}
|
||||
|
||||
@keyframes showroom-enter {
|
||||
@@ -237,6 +227,8 @@ body {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
/* --- Tool panel --- */
|
||||
|
||||
.showroom-tool {
|
||||
margin-left: 22px;
|
||||
border: 1px solid rgba(205, 127, 50, 0.32);
|
||||
@@ -268,12 +260,20 @@ body {
|
||||
color: var(--bronze);
|
||||
}
|
||||
|
||||
/* --- Highlight --- */
|
||||
|
||||
.is-highlighted {
|
||||
filter: brightness(1.4);
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
transform: translateX(3px);
|
||||
transition:
|
||||
filter 420ms var(--ease-in-out),
|
||||
background 420ms var(--ease-in-out),
|
||||
transform 420ms var(--ease-in-out);
|
||||
}
|
||||
|
||||
/* --- Overlays (captions, spotlights) --- */
|
||||
|
||||
.showroom-overlays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -316,6 +316,28 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* --- Picker --- */
|
||||
|
||||
.showroom-picker {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(205, 127, 50, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 6px 30px 6px 14px;
|
||||
background: rgba(205, 127, 50, 0.06)
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23cd7f32' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>")
|
||||
no-repeat right 12px center / 10px;
|
||||
color: var(--cornsilk);
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showroom-picker:focus {
|
||||
outline: 1px solid var(--bronze);
|
||||
}
|
||||
|
||||
/* --- Controls bar --- */
|
||||
|
||||
.showroom-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -371,6 +393,8 @@ body {
|
||||
color: var(--cornsilk);
|
||||
}
|
||||
|
||||
/* --- Progress --- */
|
||||
|
||||
.showroom-progress {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
|
||||
const initial = window.__SHOWROOM_INITIAL__
|
||||
const catalog = window.__SHOWROOM_CATALOG__ ?? []
|
||||
const root = document.getElementById('showroom')
|
||||
const SPEEDS = [0.5, 1, 2]
|
||||
const SCALES = [1, 2, 3, 4]
|
||||
const XTERM_VERSION = '6.0.0'
|
||||
|
||||
const role = {
|
||||
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
|
||||
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
|
||||
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
|
||||
user: { copy: '#daa520', glyph: '❯', tone: '#ffd700' }
|
||||
}
|
||||
|
||||
const escapeHtml = value =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
char => ({ '&': '&', '"': '"', "'": ''', '<': '<', '>': '>' })[char]
|
||||
)
|
||||
|
||||
const state = {
|
||||
body: null,
|
||||
composer: null,
|
||||
frameMode: false,
|
||||
frameTargets: new Map(),
|
||||
overlays: null,
|
||||
progressFill: null,
|
||||
@@ -130,12 +117,16 @@ const placeNear = (node, id, position = 'right') => {
|
||||
node.style.top = `${Math.max(12, top)}px`
|
||||
}
|
||||
|
||||
const message = action => {
|
||||
if (state.frameMode) {
|
||||
return
|
||||
}
|
||||
// --- Actions ---
|
||||
|
||||
const message = action => {
|
||||
const spec = {
|
||||
assistant: { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' },
|
||||
system: { copy: '#cc9b1f', glyph: '·', tone: '#cc9b1f' },
|
||||
tool: { copy: '#cc9b1f', glyph: '⚡', tone: '#cd7f32' },
|
||||
user: { copy: '#daa520', glyph: '❯', tone: '#ffd700' }
|
||||
}[action.role] ?? { copy: '#fff8dc', glyph: '┊', tone: '#cd7f32' }
|
||||
|
||||
const spec = role[action.role] ?? role.assistant
|
||||
const line = document.createElement('div')
|
||||
const glyph = document.createElement('span')
|
||||
const copy = document.createElement('div')
|
||||
@@ -156,10 +147,6 @@ const message = action => {
|
||||
}
|
||||
|
||||
const tool = action => {
|
||||
if (state.frameMode) {
|
||||
return
|
||||
}
|
||||
|
||||
const box = document.createElement('div')
|
||||
const title = document.createElement('div')
|
||||
const items = document.createElement('div')
|
||||
@@ -259,7 +246,7 @@ const clearTranscript = () => {
|
||||
state.overlays.textContent = ''
|
||||
state.frameTargets.clear()
|
||||
|
||||
if (state.frameMode && state.term) {
|
||||
if (state.term) {
|
||||
state.term.reset()
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
@@ -271,6 +258,8 @@ const clearTranscript = () => {
|
||||
|
||||
const ACTIONS = { caption, clear: clearTranscript, compose, fade, frame, highlight, message, spotlight, status, tool }
|
||||
|
||||
// --- Progress ---
|
||||
|
||||
const fmtTime = ms => {
|
||||
if (!Number.isFinite(ms)) {
|
||||
return '0.0s'
|
||||
@@ -295,24 +284,20 @@ const tickProgress = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureXtermStylesheet = () => {
|
||||
const id = 'xterm-css'
|
||||
// --- xterm ---
|
||||
|
||||
const initXterm = () => {
|
||||
const hasFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
|
||||
|
||||
if (!hasFrames) {
|
||||
state.term = null
|
||||
state.termContainer = null
|
||||
|
||||
if (document.getElementById(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.id = id
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/css/xterm.css`
|
||||
document.head.append(link)
|
||||
}
|
||||
|
||||
const initXterm = async () => {
|
||||
ensureXtermStylesheet()
|
||||
const mod = await import(`https://cdn.jsdelivr.net/npm/@xterm/xterm@${XTERM_VERSION}/+esm`)
|
||||
const { Terminal } = mod
|
||||
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
|
||||
state.termContainer = state.body.querySelector('.showroom-xterm')
|
||||
|
||||
state.term = new Terminal({
|
||||
cols: state.viewport.cols,
|
||||
@@ -349,8 +334,13 @@ const initXterm = async () => {
|
||||
|
||||
state.term.open(state.termContainer)
|
||||
state.term.write('\x1b[?25l')
|
||||
|
||||
// Fade in
|
||||
requestAnimationFrame(() => state.termContainer.classList.add('is-visible'))
|
||||
}
|
||||
|
||||
// --- Playback ---
|
||||
|
||||
const play = () => {
|
||||
clearTimers()
|
||||
clearTranscript()
|
||||
@@ -372,6 +362,8 @@ const play = () => {
|
||||
state.raf = requestAnimationFrame(tickProgress)
|
||||
}
|
||||
|
||||
// --- Controls ---
|
||||
|
||||
const setSpeed = next => {
|
||||
state.speed = next
|
||||
|
||||
@@ -420,6 +412,8 @@ const loadWorkflow = async name => {
|
||||
await rebuild()
|
||||
}
|
||||
|
||||
// --- DOM ---
|
||||
|
||||
const buildOptions = () => {
|
||||
if (!catalog.length) {
|
||||
return ''
|
||||
@@ -429,7 +423,7 @@ const buildOptions = () => {
|
||||
.map(({ name, title }) => {
|
||||
const selected = name === initial?.name ? ' selected' : ''
|
||||
|
||||
return `<option value="${escapeHtml(name)}"${selected}>${escapeHtml(title)}</option>`
|
||||
return `<option value="${name}"${selected}>${title}</option>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
@@ -444,12 +438,11 @@ const buildSegmented = (values, active) =>
|
||||
|
||||
const computeViewport = () => {
|
||||
const fromWorkflow = state.workflow.viewport ?? {}
|
||||
const usesFrames = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
|
||||
|
||||
return {
|
||||
cellWidth: usesFrames ? 9 : 8,
|
||||
cellWidth: 9,
|
||||
cols: 80,
|
||||
lineHeight: usesFrames ? 19 : 18,
|
||||
lineHeight: 19,
|
||||
rows: 24,
|
||||
scale: 2,
|
||||
...fromWorkflow
|
||||
@@ -458,7 +451,6 @@ const computeViewport = () => {
|
||||
|
||||
const renderShell = () => {
|
||||
state.viewport = computeViewport()
|
||||
state.frameMode = (state.workflow.timeline ?? []).some(a => a.type === 'frame')
|
||||
state.frameTargets.clear()
|
||||
|
||||
state.shell.style.setProperty('--cell-w', `${state.viewport.cellWidth}px`)
|
||||
@@ -475,13 +467,13 @@ const renderShell = () => {
|
||||
<span class="showroom-status-left"></span>
|
||||
<span class="showroom-status-right"></span>
|
||||
</div>
|
||||
<div class="showroom-body${state.frameMode ? ' is-frame-mode' : ''}"></div>
|
||||
<div class="showroom-body"></div>
|
||||
<div class="showroom-composer" data-target="composer"></div>
|
||||
</div>
|
||||
<div class="showroom-overlays"></div>
|
||||
</div>
|
||||
<footer class="showroom-controls">
|
||||
<button type="button" data-action="restart" title="restart (R)">↻</button>
|
||||
<button type="button" data-action="restart" title="restart (R)">↻</button>
|
||||
<span class="showroom-segmented" data-segment="scale">${buildSegmented(SCALES, state.scale)}</span>
|
||||
<span class="showroom-segmented" data-segment="speed">${buildSegmented(SPEEDS, state.speed)}</span>
|
||||
${catalog.length > 1 ? `<select class="showroom-picker" data-action="picker">${buildOptions()}</select>` : ''}
|
||||
@@ -517,24 +509,12 @@ const renderShell = () => {
|
||||
void loadWorkflow(event.target.value)
|
||||
})
|
||||
}
|
||||
|
||||
if (state.frameMode) {
|
||||
state.body.innerHTML = '<div class="showroom-xterm" data-target="terminal"></div>'
|
||||
state.termContainer = state.body.querySelector('.showroom-xterm')
|
||||
} else {
|
||||
state.term = null
|
||||
state.termContainer = null
|
||||
}
|
||||
}
|
||||
|
||||
const rebuild = async () => {
|
||||
renderShell()
|
||||
initXterm()
|
||||
setScale(state.workflow.viewport?.scale ?? fitScale())
|
||||
|
||||
if (state.frameMode) {
|
||||
await initXterm()
|
||||
}
|
||||
|
||||
play()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "tool-trail",
|
||||
"text": "Real ui-tui MessageLine + Panel rendered to ANSI and replayed in xterm.js.",
|
||||
"text": "Real ui-tui MessageLine + Panel rendered to ANSI and replayed via xterm.js.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
|
||||
104
ui-tui/.showroom/workflows/interactive-prompts.json
Normal file
104
ui-tui/.showroom/workflows/interactive-prompts.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"composer": "deploy this to staging",
|
||||
"timeline": [
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CRun\u001b[1Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[1Cin\u001b[1Cthe\u001b[1Cproject\u001b[1Croot.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 200,
|
||||
"id": "ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CI'll\u001b[1Cinstall\u001b[1Cexpress.\u001b[1CThe\u001b[1Cpackage\u001b[1Cmanager\u001b[1Cneeds\u001b[1Capproval\u001b[1C—\u001b[1Chere's\u001b[1Cthe\r\n\u001b[3Ccommand.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1200,
|
||||
"id": "explain",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔══════════════════════════════════════════════════════════════════════════════╗\r\n║\u001b[1C⚠\u001b[1Capproval\u001b[1Crequired\u001b[1C·\u001b[1Cinstall\u001b[1Cdependency\u001b[36C║\r\n║\u001b[2Cnpm\u001b[1Cinstall\u001b[1Cexpress\u001b[57C║\r\n║\u001b[2Cadded\u001b[1C58\u001b[1Cpackages\u001b[1Cin\u001b[1C3.2s\u001b[51C║\r\n║\u001b[78C║\r\n║\u001b[2C+\u001b[1Cexpress@5.1.0\u001b[61C║\r\n║\u001b[1C▸\u001b[1C1.\u001b[1CAllow\u001b[1Conce\u001b[62C║\r\n║\u001b[3C2.\u001b[1CAllow\u001b[1Cthis\u001b[1Csession\u001b[54C║\r\n║\u001b[3C3.\u001b[1CAlways\u001b[1Callow\u001b[60C║\r\n║\u001b[3C4.\u001b[1CDeny\u001b[68C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CCtrl+C\u001b[1Cdeny\u001b[20C║\r\n╚══════════════════════════════════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 2600,
|
||||
"id": "approval",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 2900,
|
||||
"duration": 1500,
|
||||
"target": "approval",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 3100,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "approval",
|
||||
"text": "Approval prompts gate dangerous commands. Four options: allow once, session, always, deny.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 5400,
|
||||
"duration": 400,
|
||||
"text": "1",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"at": 5900,
|
||||
"duration": 500,
|
||||
"text": "",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CDeploy\u001b[1Cthis\u001b[1Cto\u001b[1Cstaging.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6600,
|
||||
"id": "clarify-ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2CWhich\u001b[1Cenvironment\u001b[1Cshould\u001b[1CI\u001b[1Ctarget?\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 7600,
|
||||
"id": "clarify-reply",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026hask\u001b[1CWhich\u001b[1Cregion?\r\n▸\u001b[1C1.\u001b[1Cstaging-us-east\r\n\u001b[2C2.\u001b[1Cstaging-eu-west\r\n\u001b[2C3.\u001b[1Cstaging-ap-south\r\n\u001b[2C4.\u001b[1COther\u001b[1C(type\u001b[1Cyour\u001b[1Canswer)\r\n↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cconfirm\u001b[1C·\u001b[1C1-4\u001b[1Cquick\u001b[1Cpick\u001b[1C·\u001b[1CEsc\u001b[1Ccancel\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 8800,
|
||||
"id": "clarify",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 9100,
|
||||
"duration": 1500,
|
||||
"target": "clarify",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 9300,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "clarify",
|
||||
"text": "Clarify prompts handle ambiguous requests — numbered choices or free text.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 11600,
|
||||
"duration": 400,
|
||||
"text": "1",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[30Cdeployment\u001b[1Cqueued\u001b[31C│\r\n│\u001b[78C│\r\n│\u001b[2Ctarget\u001b[14Cstaging-us-east\u001b[41C│\r\n│\u001b[2Cbranch\u001b[14Cmain\u001b[52C│\r\n│\u001b[2Cpreview\u001b[13Chttps://pr-128.railway.app\u001b[30C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 12200,
|
||||
"id": "result",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 12500,
|
||||
"duration": 1300,
|
||||
"target": "result",
|
||||
"type": "highlight"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Interactive Prompts",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
100
ui-tui/.showroom/workflows/model-picker.json
Normal file
100
ui-tui/.showroom/workflows/model-picker.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"composer": "",
|
||||
"timeline": [
|
||||
{
|
||||
"at": 200,
|
||||
"duration": 500,
|
||||
"text": "/model",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h\r\n❯\u001b[2CSwitch\u001b[1Cto\u001b[1CClaude.\r\n\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 900,
|
||||
"id": "ask",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h┊\u001b[2COpening\u001b[1Cthe\u001b[1Cmodel\u001b[1Cpicker\u001b[1C—\u001b[1Cpick\u001b[1Ca\u001b[1Cprovider\u001b[1Cfirst,\u001b[1Cthen\u001b[1Ca\u001b[1Cmodel.\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 1800,
|
||||
"id": "reply",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CProvider\u001b[32C║\r\n║\u001b[1CCurrent\u001b[1Cmodel:\u001b[1Cgpt-5-codex\u001b[21C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1COpenAI\u001b[1C·\u001b[1C8\u001b[1Cmodels\u001b[25C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1CAnthropic\u001b[1C·\u001b[1C6\u001b[1Cmodels\u001b[22C║\r\n║\u001b[3C3.\u001b[1CGoogle\u001b[1C·\u001b[1C5\u001b[1Cmodels\u001b[25C║\r\n║\u001b[3C4.\u001b[1COpenRouter\u001b[1C·\u001b[1C42\u001b[1Cmodels\u001b[20C║\r\n║\u001b[3C5.\u001b[1CxAI\u001b[1C·\u001b[1C3\u001b[1Cmodels\u001b[28C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 3000,
|
||||
"id": "providers",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 3300,
|
||||
"duration": 1800,
|
||||
"target": "providers",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 3500,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "providers",
|
||||
"text": "Provider stage: pick from authenticated backends. Shows model count per provider.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 5600,
|
||||
"duration": 300,
|
||||
"text": "2",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╔════════════════════════════════════════════════╗\r\n║\u001b[1CSelect\u001b[1CModel\u001b[35C║\r\n║\u001b[1CAnthropic\u001b[38C║\r\n║\u001b[48C║\r\n║\u001b[48C║\r\n║\u001b[3C1.\u001b[1Cclaude-opus-4\u001b[29C║\r\n║\u001b[1C▸\u001b[1C2.\u001b[1Cclaude-sonnet-4\u001b[27C║\r\n║\u001b[3C3.\u001b[1Cclaude-sonnet-3.7\u001b[25C║\r\n║\u001b[3C4.\u001b[1Cclaude-haiku-3.5\u001b[26C║\r\n║\u001b[3C5.\u001b[1Cclaude-sonnet-3.5\u001b[25C║\r\n║\u001b[48C║\r\n║\u001b[1Cpersist:\u001b[1Csession\u001b[1C·\u001b[1Cg\u001b[1Ctoggle\u001b[20C║\r\n║\u001b[1C↑/↓\u001b[1Cselect\u001b[1C·\u001b[1CEnter\u001b[1Cchoose\u001b[1C·\u001b[1C1-9,0\u001b[1Cquick\u001b[1C·\u001b[6C║\r\n║\u001b[1CEsc/q\u001b[1Ccancel\u001b[35C║\r\n╚════════════════════════════════════════════════╝\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 6200,
|
||||
"id": "models",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 6500,
|
||||
"duration": 1800,
|
||||
"target": "models",
|
||||
"type": "spotlight"
|
||||
},
|
||||
{
|
||||
"at": 6700,
|
||||
"duration": 2000,
|
||||
"position": "right",
|
||||
"target": "models",
|
||||
"text": "Model stage: scrollable list with ▸ selection. Number keys for quick pick.",
|
||||
"type": "caption"
|
||||
},
|
||||
{
|
||||
"at": 9000,
|
||||
"duration": 300,
|
||||
"text": "2",
|
||||
"type": "compose"
|
||||
},
|
||||
{
|
||||
"ansi": "\u001b[?25l\u001b[?2026h╭──────────────────────────────────────────────────────────────────────────────╮\r\n│\u001b[78C│\r\n│\u001b[32Cmodel\u001b[1Cswitched\u001b[32C│\r\n│\u001b[78C│\r\n│\u001b[2Cfrom\u001b[16Cgpt-5-codex\u001b[45C│\r\n│\u001b[2Cto\u001b[18Cclaude-sonnet-4\u001b[41C│\r\n│\u001b[2Cscope\u001b[15Cthis\u001b[1Csession\u001b[44C│\r\n│\u001b[78C│\r\n╰──────────────────────────────────────────────────────────────────────────────╯\r\n\u001b[?2026l\u001b[?2026h\u001b[?25h\u001b[?2026l\u001b[?25h",
|
||||
"at": 9600,
|
||||
"id": "result",
|
||||
"type": "frame"
|
||||
},
|
||||
{
|
||||
"at": 9900,
|
||||
"duration": 1300,
|
||||
"target": "result",
|
||||
"type": "highlight"
|
||||
},
|
||||
{
|
||||
"at": 10100,
|
||||
"duration": 1700,
|
||||
"position": "right",
|
||||
"target": "result",
|
||||
"text": "Model swap mid-session. Transcript and cache stay intact.",
|
||||
"type": "caption"
|
||||
}
|
||||
],
|
||||
"title": "Hermes TUI · Model Picker",
|
||||
"viewport": {
|
||||
"cols": 80,
|
||||
"rows": 16
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user