chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-01 19:53:41 -05:00
parent d5d7b5c6dc
commit e00297782d
19 changed files with 782 additions and 153 deletions
@@ -0,0 +1,47 @@
import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
import { COMPLETION_DRAWER_CLASS } from './constants'
export const COMPLETION_DRAWER_ROW_CLASS =
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors hover:bg-accent/70 data-highlighted:bg-accent'
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
char,
children
}: {
adapter: Unstable_TriggerAdapter
ariaLabel: string
char: string
children: ReactNode
}) {
return (
<ComposerPrimitive.Unstable_TriggerPopover
adapter={adapter}
aria-label={ariaLabel}
char={char}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
>
{children}
</ComposerPrimitive.Unstable_TriggerPopover>
)
}
export function CompletionDrawerEmpty({
children,
title
}: {
children?: ReactNode
title: string
}) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<p>{title}</p>
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
</div>
)
}
@@ -1,4 +1,3 @@
import type { Unstable_IconComponent } from '@assistant-ui/react'
import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react'
import type { CSSProperties } from 'react'
@@ -28,15 +27,8 @@ export const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
file: FileText
}
export const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
file: FileText,
folder: FolderOpen,
image: ImageIcon,
url: Link
}
export const DIRECTIVE_POPOVER_CLASS =
'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),26rem)] max-h-[min(24rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/60 bg-popover/95 p-1.5 text-popover-foreground shadow-2xl backdrop-blur-md ring-1 ring-black/5'
export const COMPLETION_DRAWER_CLASS =
'absolute inset-x-0 bottom-[calc(100%-0.0rem)] z-50 max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain rounded-t-(--composer-active-radius) rounded-b-none border-x border-t border-b-0 border-ring/45 bg-popover/96 p-1.5 pb-3 text-popover-foreground shadow-[0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)] backdrop-blur-md'
export const PROMPT_SNIPPETS = [
{
@@ -1,103 +1,56 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import {
ComposerPrimitive,
type Unstable_IconComponent,
type Unstable_MentionCategory,
type Unstable_MentionDirective
} from '@assistant-ui/react'
import { FileText } from 'lucide-react'
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
import { DIRECTIVE_POPOVER_CLASS } from './constants'
import type { ContextSuggestion } from './types'
import {
ComposerCompletionDrawer,
CompletionDrawerEmpty,
COMPLETION_DRAWER_ROW_CLASS
} from './completion-drawer'
export function DirectivePopover({
adapter,
directive,
fallbackIcon: Fallback,
iconMap
loading = false
}: {
adapter: Unstable_TriggerAdapter
directive: Unstable_MentionDirective
fallbackIcon: Unstable_IconComponent
iconMap: Record<string, Unstable_IconComponent>
loading?: boolean
}) {
return (
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Reference suggestions" char="@">
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{items => (
<div className="grid gap-0.5">
<div className="px-2 pb-1 pt-0.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/80">
Reference a file
</div>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-foreground">
<p>No file suggestions yet.</p>
<p className="mt-1 text-xs text-muted-foreground/80">
Keep typing to filter, or click <span className="font-medium text-foreground/80">+</span> to attach
files, folders, or a URL.
</p>
</div>
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
<span className="font-mono text-foreground/80">@./src</span>.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const Icon = directiveIcon(item, iconMap, Fallback)
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent/70 data-highlighted:bg-accent"
index={index}
item={item}
key={`${item.type}:${item.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground/80" />
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium text-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-[0.72rem] text-muted-foreground/85">{item.description}</span>
)}
</span>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
})
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)
)}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</ComposerCompletionDrawer>
)
}
export function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
const items: Unstable_TriggerItem[] = []
function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) {
const metadata = item.metadata as { display?: string; meta?: string } | undefined
const display = metadata?.display || item.label
const description = metadata?.meta || item.description
for (const s of suggestions ?? []) {
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
if (!match) {
continue
}
const [, type, id] = match
items.push({
id,
type,
label: s.display || id,
description: s.meta,
metadata: { icon: type }
})
}
return [{ id: 'context', label: 'References', items }]
}
function directiveIcon(
item: Unstable_TriggerItem,
iconMap: Record<string, Unstable_IconComponent>,
fallback: Unstable_IconComponent
): Unstable_IconComponent {
const meta = item.metadata as Record<string, unknown> | undefined
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
return iconMap[key] ?? iconMap[item.type] ?? fallback ?? FileText
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className={COMPLETION_DRAWER_ROW_CLASS}
index={index}
item={item}
>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
}
@@ -0,0 +1,67 @@
import { COMPLETION_DRAWER_CLASS } from './constants'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
['/clear', 'start a new session'],
['/resume', 'resume a prior session'],
['/details', 'control transcript detail level'],
['/copy', 'copy selection or last assistant message'],
['/quit', 'exit hermes']
]
const HOTKEYS: [string, string][] = [
['@', 'reference files, folders, urls, git'],
['/', 'slash command palette'],
['?', 'this quick help (delete to dismiss)'],
['Enter', 'send · Shift+Enter for newline'],
['Cmd/Ctrl+K', 'send next queued turn'],
['Cmd/Ctrl+L', 'redraw'],
['Esc', 'close popover · cancel run'],
['↑ / ↓', 'cycle popover / history']
]
export function HelpHint() {
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title="Common commands">
{COMMON_COMMANDS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} mono />
))}
</Section>
<Section title="Hotkeys">
{HOTKEYS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} />
))}
</Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
</p>
</div>
)
}
function Section({ children, title }: { children: React.ReactNode; title: string }) {
return (
<div className="grid gap-0.5 pt-0.5">
<p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75">
{title}
</p>
{children}
</div>
)
}
function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) {
return (
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
<span
className={mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'}
>
{keyLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}
@@ -0,0 +1,116 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
interface AtItemMetadata extends Record<string, string> {
icon: string
display: string
meta: string
/** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */
rawText: string
/** Just the value portion (after `@kind:`), or empty for simple refs. */
insertId: string
}
function textValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */
function classify(entry: CompletionEntry): {
type: string
insertId: string
display: string
meta: string
} {
const match = KIND_RE.exec(entry.text)
if (match) {
const [, kind, rest] = match
return {
type: kind,
insertId: rest,
display: textValue(entry.display, rest || `@${kind}:`),
meta: textValue(entry.meta)
}
}
return {
type: 'simple',
insertId: entry.text,
display: textValue(entry.display, entry.text),
meta: textValue(entry.meta)
}
}
/** Live `@` completions backed by the gateway's `complete.path` RPC. */
export function useAtCompletions(options: {
gateway: HermesGateway | null
sessionId: string | null
cwd: string | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { gateway, sessionId, cwd } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
if (!gateway) {
return { items: [], query }
}
const word = `@${query}`
const params: Record<string, unknown> = { word }
if (sessionId) {
params.session_id = sessionId
}
if (cwd) {
params.cwd = cwd
}
try {
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
return { items: result.items ?? [], query }
} catch {
return { items: [], query }
}
},
[gateway, sessionId, cwd]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const classified = classify(entry)
const metadata: AtItemMetadata = {
icon: classified.type,
display: classified.display,
meta: classified.meta,
rawText: entry.text,
insertId: classified.insertId
}
return {
// Unique id keyed on the gateway's full `text` so two entries that share
// a basename (e.g. multiple `index.ts`) don't collide in keyboard nav.
id: `${entry.text}|${index}`,
type: classified.type,
label: classified.display,
...(classified.meta ? { description: classified.meta } : {}),
metadata
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
}
/** Re-export `classify` for use by the formatter (insertion side). */
export { classify }
@@ -0,0 +1,115 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
}
export interface CompletionPayload {
items: CompletionEntry[]
query: string
}
/**
* Drives an assistant-ui `Unstable_TriggerAdapter` from an async RPC call.
*
* Mirrors the TUI's `useCompletion` flow: each query change schedules a
* debounced fetch (default 60ms) and the adapter synchronously returns the
* most recent items while the user keeps typing. When the fetch resolves we
* store the new items + the query they belong to, which causes a re-render
* with a fresh adapter instance — `Unstable_TriggerPopover` then re-runs its
* `search()` and shows the latest results.
*/
export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
fetcher: (query: string) => Promise<CompletionPayload>
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { enabled, debounceMs = 60, fetcher, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
query: '\u0000',
items: []
})
const [loading, setLoading] = useState(false)
const tokenRef = useRef(0)
const timerRef = useRef<number | null>(null)
const pendingQueryRef = useRef<string | null>(null)
const cancelTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
useEffect(() => () => cancelTimer(), [cancelTimer])
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
return
}
if (pendingQueryRef.current === query) {
return
}
pendingQueryRef.current = query
cancelTimer()
const token = ++tokenRef.current
setLoading(true)
timerRef.current = window.setTimeout(() => {
timerRef.current = null
fetcher(query)
.then(payload => {
if (token !== tokenRef.current) {
return
}
setState({
query: payload.query,
items: payload.items.map((entry, index) => toItem(entry, index))
})
})
.catch(() => {
if (token !== tokenRef.current) {
return
}
setState({ query, items: [] })
})
.finally(() => {
if (token === tokenRef.current) {
setLoading(false)
}
})
}, debounceMs)
},
[cancelTimer, debounceMs, enabled, fetcher, toItem]
)
const adapter = useMemo<Unstable_TriggerAdapter>(
() => ({
categories: () => [],
categoryItems: () => [],
search: (query: string) => {
if (query !== state.query) {
scheduleFetch(query)
}
return state.items
}
}),
[scheduleFetch, state]
)
return { adapter, loading }
}
@@ -0,0 +1,107 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const PICKER_OWNED = new Set(['/model', '/provider', 'model', 'provider'])
interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
}
interface CommandsCatalogResponse {
pairs?: [string, string][]
}
function textValue(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : ''))
.join('')
.trim()
}
return fallback
}
function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: {
gateway: HermesGateway | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { gateway } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
if (!gateway) {
return { items: [], query }
}
const text = `/${query}`
// Model/provider have a dedicated picker; suppress slash completions for them once typed.
if (text.startsWith('/model') || text.startsWith('/provider')) {
return { items: [], query }
}
try {
if (!query) {
const catalog = await gateway.request<CommandsCatalogResponse>('commands.catalog')
const items = (catalog.pairs ?? [])
.map(([command, meta]) => ({
text: command,
display: command,
meta
}))
.filter(item => !PICKER_OWNED.has(item.text))
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
const items = (result.items ?? []).filter(item => !PICKER_OWNED.has(item.text))
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const command = commandText(entry.text)
const display = textValue(entry.display, commandText(entry.text))
const meta = textValue(entry.meta)
const metadata: SlashItemMetadata = {
command,
display,
meta
}
return {
id: `${entry.text}|${index}`,
type: 'slash',
label: display.startsWith('/') ? display.slice(1) : display,
...(meta ? { description: meta } : {}),
metadata
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
}
@@ -85,13 +85,11 @@ export function useVoiceConversation({
}
const appendSpeechText = (text: string) => {
const cleaned = text
if (!cleaned) {
if (!text) {
return
}
speechBufferRef.current = `${speechBufferRef.current} ${cleaned}`.trim()
speechBufferRef.current = `${speechBufferRef.current} ${text}`.trim()
}
const takeSpeechChunk = (force = false): string | null => {
+35 -21
View File
@@ -1,8 +1,7 @@
import { ComposerPrimitive, unstable_useMentionAdapter, useAui, useAuiState } from '@assistant-ui/react'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import { FileText } from 'lucide-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { type ClipboardEvent, type CSSProperties, useEffect, useRef, useState } from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { chatMessageText } from '@/lib/chat-messages'
@@ -17,7 +16,6 @@ import {
ASK_PLACEHOLDERS,
COMPOSER_BACKDROP_STYLE,
DEFAULT_MAX_RECORDING_SECONDS,
DIRECTIVE_ICONS,
EDGE_NEWLINES_RE,
EXPAND_HEIGHT_PX,
NARROW_VIEWPORT,
@@ -26,10 +24,14 @@ import {
} from './constants'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { buildMentionCategories, DirectivePopover } from './directive-popover'
import { DirectivePopover } from './directive-popover'
import { HelpHint } from './help-hint'
import { useAtCompletions } from './hooks/use-at-completions'
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { SlashPopover } from './slash-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
@@ -40,9 +42,12 @@ function trimPastedEdgeNewlines(text: string): string {
export function ChatBar({
busy,
cwd,
disabled,
focusKey,
gateway,
maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS,
sessionId,
state,
onCancel,
onAddUrl,
@@ -76,19 +81,13 @@ export function ChatBar({
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
)
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
const mention = unstable_useMentionAdapter({
categories: mentionCategories,
includeModelContextTools: false,
formatter: hermesDirectiveFormatter,
iconMap: DIRECTIVE_ICONS,
fallbackIcon: FileText
})
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
const stacked = expanded || stack
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const showHelpHint = draft === '?'
const glassTweaks = useComposerGlassTweaks()
@@ -344,20 +343,27 @@ export function ChatBar({
return (
<>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<DirectivePopover
adapter={mention.adapter}
directive={mention.directive}
fallbackIcon={mention.fallbackIcon ?? FileText}
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
/>
<ComposerPrimitive.Root
className={cn(SHELL, 'group/composer pb-8 pt-2')}
data-slot="composer-root"
style={
{
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
onSubmit={e => {
e.preventDefault()
submitDraft()
}}
ref={composerRef}
>
{showHelpHint && <HelpHint />}
<DirectivePopover
adapter={at.adapter}
directive={{ formatter: hermesDirectiveFormatter }}
loading={at.loading}
/>
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
<div
className="pointer-events-none absolute inset-x-0 bottom-0 top-0"
style={{ background: glassTweaks.fadeBackground }}
@@ -375,6 +381,7 @@ export function ChatBar({
ref={glassShellRef}
style={
{
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
@@ -403,7 +410,14 @@ export function ChatBar({
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
style={{ ...COMPOSER_BACKDROP_STYLE, borderRadius: `${glassTweaks.liquid.cornerRadius}px` }}
data-slot="composer-surface"
style={
{
...COMPOSER_BACKDROP_STYLE,
'--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`,
borderRadius: `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
@@ -0,0 +1,63 @@
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import {
ComposerCompletionDrawer,
CompletionDrawerEmpty,
COMPLETION_DRAWER_ROW_CLASS
} from './completion-drawer'
const slashFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined
const command = typeof metadata?.command === 'string' ? metadata.command : null
if (command) {
return command
}
return `/${item.label}`
},
parse() {
return []
}
}
export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) {
return (
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Slash command suggestions" char="/">
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{items => (
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
Try <span className="font-mono text-foreground/80">/help</span> for the full list.
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined
const display = meta?.display ?? meta?.command ?? `/${item.label}`
const description = meta?.meta || item.description
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem
className={COMPLETION_DRAWER_ROW_CLASS}
index={index}
item={item}
key={item.id}
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
)
})
)}
</div>
)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerCompletionDrawer>
)
}
@@ -28,6 +28,9 @@ export interface ChatBarProps {
focusKey?: string | null
maxRecordingSeconds?: number
state: ChatBarState
gateway?: import('@/hermes').HermesGateway | null
sessionId?: string | null
cwd?: string | null
onCancel: () => void
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onAddUrl?: (url: string) => void
@@ -1,5 +1,7 @@
import { type AudioDataProvider, AudioWave, useCustomAudio } from '@audiowave/react'
import { useStore } from '@nanostores/react'
import { Loader2, Mic, Volume2, VolumeX } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -8,6 +10,15 @@ import { $voicePlayback } from '@/store/voice-playback'
import type { VoiceActivityState } from './types'
type BrowserAudioContext = typeof AudioContext
interface ElementAnalyser {
analyser: AnalyserNode
context: AudioContext
}
const elementAnalysers = new WeakMap<HTMLAudioElement, ElementAnalyser>()
function formatElapsed(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
@@ -40,21 +51,80 @@ function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
)
}
function PlaybackBars() {
const bars = [820, 940, 760, 880, 700, 980, 790]
function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) {
const provider = useMemo<AudioDataProvider>(
() => ({
onAudioData(callback) {
if (!audioElement) {
return () => undefined
}
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return () => undefined
}
let entry = elementAnalysers.get(audioElement)
if (!entry || entry.context.state === 'closed') {
const context = new AudioContextCtor()
const source = context.createMediaElementSource(audioElement)
const analyser = context.createAnalyser()
analyser.fftSize = 256
analyser.smoothingTimeConstant = 0.72
source.connect(analyser)
analyser.connect(context.destination)
entry = { analyser, context }
elementAnalysers.set(audioElement, entry)
}
void entry.context.resume()
const data = new Uint8Array(entry.analyser.fftSize)
let raf = 0
const tick = () => {
entry.analyser.getByteTimeDomainData(data)
callback(new Uint8Array(data))
raf = window.requestAnimationFrame(tick)
}
tick()
return () => window.cancelAnimationFrame(raf)
}
}),
[audioElement]
)
const { source } = useCustomAudio({
provider,
status: audioElement ? 'active' : 'idle'
})
return (
<div aria-hidden="true" className="flex h-4 items-center gap-0.75">
{bars.map((duration, index) => (
<span
className="voice-wave-bar h-full w-0.5 rounded-full bg-current"
key={index}
style={{
animationDelay: `${index * -110}ms`,
animationDuration: `${duration}ms`
}}
<div aria-hidden="true" className="h-4 w-22 overflow-hidden rounded-full">
{source ? (
<AudioWave
amplitudeMode="adaptive"
animateCurrentPick
backgroundColor="transparent"
barColor="rgb(37 99 235)"
barWidth={2}
gain={1.8}
gap={1}
height={16}
onlyActive
rounded={2}
secondaryBarColor="transparent"
source={source}
speed={2}
width="100%"
/>
))}
) : null}
</div>
)
}
@@ -129,7 +199,7 @@ export function VoicePlaybackActivity() {
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
{!preparing && <PlaybackBars />}
{!preparing && <PlaybackWaveform audioElement={playback.audioElement} />}
</div>
<Button
+6 -1
View File
@@ -24,6 +24,7 @@ import {
$awaitingResponse,
$busy,
$contextSuggestions,
$currentCwd,
$currentModel,
$currentProvider,
$freshDraftReady,
@@ -108,6 +109,7 @@ export function ChatView({
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
@@ -225,7 +227,7 @@ export function ChatView({
return (
<>
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
<div className="relative flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
@@ -264,8 +266,10 @@ export function ChatView({
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
@@ -277,6 +281,7 @@ export function ChatView({
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
@@ -48,6 +48,37 @@ interface PromptActionsOptions {
) => ClientSessionState
}
interface CommandsCatalogResponse {
categories?: Array<{ name: string; pairs: [string, string][] }>
pairs?: [string, string][]
skill_count?: number
warning?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
const sections = catalog.categories?.length
? catalog.categories
: [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
.map(section => {
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
return [`${section.name}:`, ...rows].join('\n')
})
.join('\n\n')
const tail = [
catalog.skill_count ? `${catalog.skill_count} skill commands available.` : '',
catalog.warning ? `warning: ${catalog.warning}` : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No commands available.', tail].filter(Boolean).join('\n\n')
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
@@ -193,6 +224,18 @@ export function usePromptActions({
return
}
if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway<CommandsCatalogResponse>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sessionId,
@@ -84,6 +84,31 @@ export function formatRefValue(value: string): string {
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
serialize(item: Unstable_TriggerItem): string {
const metadata = item.metadata as { rawText?: unknown; insertId?: unknown } | undefined
const rawText = typeof metadata?.rawText === 'string' ? metadata.rawText : null
const insertId = typeof metadata?.insertId === 'string' ? metadata.insertId : null
// Live-completion items carry the gateway's original `text` field via metadata.
if (rawText) {
// Palette starters (`@file:` with empty value) — insert verbatim so the
// user can keep typing the path inline.
if (rawText.endsWith(':') && !insertId) {
return rawText
}
// Simple references like `@diff` / `@staged`.
if (!insertId) {
return rawText
}
// Typed references with a value — quote when needed.
const kindMatch = rawText.match(/^@([^:]+):/)
const kind = kindMatch?.[1] ?? item.type
return `@${kind}:${formatRefValue(insertId)}`
}
// Fallback for legacy callers that pass raw `id` strings.
if (item.id === `${item.type}:`) {
return `@${item.id}`
}
@@ -64,7 +64,7 @@ export function NotificationStack() {
return (
<div
aria-label="Notifications"
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 flex-col gap-2"
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
<NotificationItem notification={latest} />
+8 -2
View File
@@ -11,8 +11,13 @@ import { sanitizeTextForSpeech } from './speech-text'
let currentAudio: HTMLAudioElement | null = null
let sequence = 0
function currentState(status: VoicePlaybackState['status'], options?: VoicePlaybackOptions): VoicePlaybackState {
function currentState(
status: VoicePlaybackState['status'],
options?: VoicePlaybackOptions,
audioElement: HTMLAudioElement | null = null
): VoicePlaybackState {
return {
audioElement,
messageId: options?.messageId ?? null,
sequence,
source: options?.source ?? null,
@@ -35,6 +40,7 @@ export function stopVoicePlayback() {
}
setVoicePlaybackState({
audioElement: null,
messageId: null,
sequence,
source: null,
@@ -65,7 +71,7 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions
const audio = new Audio(response.data_url)
currentAudio = audio
setVoicePlaybackState(currentState('speaking', options))
setVoicePlaybackState(currentState('speaking', options, audio))
await new Promise<void>((resolve, reject) => {
audio.addEventListener('ended', () => resolve(), { once: true })
+2
View File
@@ -4,6 +4,7 @@ export type VoicePlaybackSource = 'read-aloud' | 'voice-conversation'
export type VoicePlaybackStatus = 'idle' | 'preparing' | 'speaking'
export interface VoicePlaybackState {
audioElement: HTMLAudioElement | null
messageId: string | null
sequence: number
source: VoicePlaybackSource | null
@@ -11,6 +12,7 @@ export interface VoicePlaybackState {
}
export const $voicePlayback = atom<VoicePlaybackState>({
audioElement: null,
messageId: null,
sequence: 0,
source: null,
+26 -23
View File
@@ -184,29 +184,6 @@ button {
-webkit-app-region: no-drag;
}
@keyframes voice-wave {
0%,
100% {
opacity: 0.45;
transform: scaleY(0.28);
}
35% {
opacity: 0.95;
transform: scaleY(1);
}
62% {
opacity: 0.7;
transform: scaleY(0.52);
}
}
.voice-wave-bar {
animation: voice-wave 860ms ease-in-out infinite;
transform-origin: center;
}
.composer-liquid-shell-wrap {
pointer-events: none;
border-radius: var(--composer-glass-radius, 20px);
@@ -282,6 +259,32 @@ button {
box-shadow: none !important;
}
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
[data-slot='composer-surface'] {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
border-top-color: transparent;
box-shadow:
0 0.0625rem 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-ink) 6%, transparent);
}
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
[data-glass-frame='true'] {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
[data-slot='composer-root']:has([data-slot='composer-completion-drawer'][data-state='open'])
[data-slot='composer-completion-drawer'] {
margin-bottom: -0.5rem;
border-bottom: 0;
box-shadow:
0 -0.0625rem 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
0 -1rem 2.25rem -1.75rem color-mix(in srgb, var(--dt-foreground) 34%, transparent),
0 -0.3125rem 0.875rem -0.6875rem color-mix(in srgb, var(--dt-foreground) 22%, transparent);
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: var(--composer-glass-radius, 20px) !important;
}