opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
@@ -3,9 +3,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
@@ -32,9 +31,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const isUploading = attachment.uploadState === 'uploading'
|
||||
const hasUploadError = attachment.uploadState === 'error'
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
@@ -62,15 +59,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
// We already hold the image bytes (the card thumbnail) — render those
|
||||
// directly so a screenshot/clipboard image previews even when its only
|
||||
// on-disk copy is a transient path the renderer can't re-read.
|
||||
const withBytes =
|
||||
attachment.kind === 'image' && attachment.previewUrl
|
||||
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
|
||||
: preview
|
||||
|
||||
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
@@ -80,51 +69,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-busy={isUploading || undefined}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className={cn(
|
||||
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
|
||||
hasUploadError
|
||||
? 'border-destructive/45 hover:border-destructive/60'
|
||||
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
|
||||
)}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
)}
|
||||
{isUploading && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
|
||||
</span>
|
||||
)}
|
||||
{hasUploadError && (
|
||||
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
|
||||
<AlertCircle className="size-3.5 text-destructive" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.62rem] leading-3.5',
|
||||
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||
)}
|
||||
>
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,25 +3,32 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
||||
'hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
ariaLabel,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
@@ -63,7 +62,6 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -75,9 +73,9 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={steerLabel}>
|
||||
<Tip label={c.steer}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
aria-label={c.steer}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// No global setupFiles registers auto-cleanup, so unmount between tests —
|
||||
// otherwise a second render() leaks the first editor and getByTestId('editor')
|
||||
// matches multiple nodes.
|
||||
afterEach(cleanup)
|
||||
|
||||
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
|
||||
// branch + submitDraft), driven through REAL DOM keydown events on a
|
||||
// contentEditable.
|
||||
//
|
||||
// Regression repro for #39630: pressing Enter right after typing (fast typing /
|
||||
// IME) did nothing. The composer state (`draft` from useAuiState) and its
|
||||
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
|
||||
// read empty state and either dropped the message, drained a queued prompt
|
||||
// instead of sending, or (while busy) refused to queue. The fix reads the live
|
||||
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
|
||||
// submitDraft — so the just-typed text always wins.
|
||||
//
|
||||
// We model the race deterministically the way the IME repro does: mutate the
|
||||
// editor's textContent WITHOUT firing an input event, so the React `draft`
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
onCancel,
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
onCancel: () => void
|
||||
onDrain: () => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const draftRef = useRef('')
|
||||
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
|
||||
// it lags the DOM until React re-renders (the source of the bug).
|
||||
const [draft, setDraft] = useState('')
|
||||
const attachments: unknown[] = []
|
||||
|
||||
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
|
||||
|
||||
const setText = (next: string) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
setDraft(domText)
|
||||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (busy) {
|
||||
if (payloadPresent) {
|
||||
onQueue(text)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
} else if (!payloadPresent && queued.length > 0) {
|
||||
onDrain()
|
||||
} else if (payloadPresent) {
|
||||
onSubmit(text)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||
onDrain()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (busy && !hasLivePayload) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
}
|
||||
}
|
||||
|
||||
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
|
||||
// the assertions prove the handler never relies on it.
|
||||
void draft
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onInput={event => setText(composerPlainText(event.currentTarget))}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
|
||||
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Fast typing: the DOM has the text but NO input event fired, so `draft`
|
||||
// state is still empty (the exact stale-state race).
|
||||
await act(async () => {
|
||||
editor.textContent = 'hello world'
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
|
||||
const onQueue = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = 'urgent follow-up'
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onQueue = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
|
||||
const onDrain = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,6 @@ export interface CompletionEntry {
|
||||
text: string
|
||||
display?: unknown
|
||||
meta?: unknown
|
||||
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
|
||||
* header whenever this changes between consecutive items, so the fetcher must
|
||||
* emit entries already grouped contiguously. */
|
||||
group?: string
|
||||
/** Optional completion-action id. When set, picking the item runs that action
|
||||
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
|
||||
action?: string
|
||||
}
|
||||
|
||||
export interface CompletionPayload {
|
||||
|
||||
@@ -2,17 +2,12 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSkinSlashCompletions,
|
||||
desktopSlashDescription,
|
||||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
@@ -21,10 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
group: string
|
||||
rawText: string
|
||||
/** Completion-action id; empty for ordinary insert-a-chip completions. */
|
||||
action: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
@@ -46,21 +38,12 @@ function commandText(value: string): string {
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
/** How many recent sessions to surface inline before the "Browse all…" entry. */
|
||||
const SESSION_INLINE_LIMIT = 7
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
|
||||
* come from here, not the backend (whose skin list is CLI/TUI-only). */
|
||||
skinThemes?: DesktopThemeCommandOption[]
|
||||
activeSkin?: string
|
||||
}): {
|
||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
loading: boolean
|
||||
} {
|
||||
const { gateway, skinThemes, activeSkin } = options
|
||||
const { gateway } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
@@ -71,136 +54,34 @@ export function useSlashCompletions(options: {
|
||||
|
||||
const text = `/${query}`
|
||||
|
||||
// The desktop owns /skin entirely (client-side theme context). Surface its
|
||||
// theme list inside this single popover instead of a bespoke one, and skip
|
||||
// the backend skin completions (which describe CLI/TUI skins that don't
|
||||
// apply here). Matches once we're past `/skin ` into the arg stage.
|
||||
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
|
||||
|
||||
if (skinArg && skinThemes) {
|
||||
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
|
||||
text: entry.text,
|
||||
display: entry.display,
|
||||
meta: entry.meta,
|
||||
group: 'Themes'
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
// /resume (and its aliases) completes recent sessions inline — the same
|
||||
// client-side list the picker overlay shows — instead of the backend
|
||||
// (whose /resume opens an interactive TUI picker we can't render here).
|
||||
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||
|
||||
if (sessionArg) {
|
||||
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
? $sessions.get().filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
: $sessions.get()
|
||||
).slice(0, SESSION_INLINE_LIMIT)
|
||||
|
||||
const items: CompletionEntry[] = matches.map(session => ({
|
||||
text: `/resume ${session.id}`,
|
||||
display: sessionTitle(session),
|
||||
meta: (session.preview ?? '').trim(),
|
||||
group: 'Sessions'
|
||||
}))
|
||||
|
||||
// Trailing "more" affordance (Cursor-style): picking it opens the full
|
||||
// session picker overlay directly. `text` stays a bare `/resume` so that
|
||||
// submitting it (Enter) still opens the overlay if the action is skipped.
|
||||
items.push({
|
||||
text: '/resume',
|
||||
display: 'Browse all sessions…',
|
||||
meta: '',
|
||||
group: 'Sessions',
|
||||
action: 'session-picker'
|
||||
})
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
group: section.name || undefined,
|
||||
meta
|
||||
}))
|
||||
)
|
||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||
'complete.slash',
|
||||
{ text }
|
||||
)
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||
// the full `/personality alice` token instead of stranding `/alice`.
|
||||
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||
const isArgCompletion = replaceFrom > 1
|
||||
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||
|
||||
const decorated = (result.items ?? [])
|
||||
.map(item => {
|
||||
if (!isArgCompletion) {
|
||||
return item
|
||||
}
|
||||
|
||||
const argText = typeof item.text === 'string' ? item.text : ''
|
||||
|
||||
return { ...item, text: `${prefix}${argText}` }
|
||||
})
|
||||
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
// Arg suggestions (e.g. `/handoff <platform>`) live under one
|
||||
// header; otherwise split skills out from built-in commands.
|
||||
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
|
||||
// Arg items carry their own meta (the personality/toolset/platform
|
||||
// blurb). Only command rows get the registry description — looking
|
||||
// one up for `/personality none` would clobber it with the parent
|
||||
// command's text.
|
||||
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
}))
|
||||
|
||||
// Keep each group contiguous so headers render once: Commands before
|
||||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway, skinThemes, activeSkin]
|
||||
[gateway]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
@@ -212,8 +93,6 @@ export function useSlashCompletions(options: {
|
||||
command,
|
||||
display,
|
||||
meta,
|
||||
group: textValue(entry.group),
|
||||
action: textValue(entry.action),
|
||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||
// direct-insertion path instead of the legacy @type:id fallback.
|
||||
// Without this, the item.id (which includes a "|index" suffix for
|
||||
|
||||
@@ -13,25 +13,17 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
@@ -48,11 +40,10 @@ import {
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
@@ -73,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRefs,
|
||||
droppedFileInlineRef,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from './inline-refs'
|
||||
@@ -83,9 +74,9 @@ import {
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -104,30 +95,6 @@ const COMPOSER_FADE_BACKGROUND =
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
/** Completion items can carry an `action` (set in use-slash-completions) that
|
||||
* runs a side effect on pick instead of inserting a chip — e.g. the session
|
||||
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
|
||||
* items are a registry row, not a composer branch. */
|
||||
const COMPLETION_ACTIONS: Record<string, () => void> = {
|
||||
'session-picker': () => setSessionPickerOpen(true)
|
||||
}
|
||||
|
||||
/** Map a picked `/` completion to its pill accent. Driven by the completion
|
||||
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
|
||||
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
const group = (item.metadata as { group?: unknown } | undefined)?.group
|
||||
|
||||
if (group === 'Skills') {
|
||||
return 'skill'
|
||||
}
|
||||
|
||||
if (group === 'Themes') {
|
||||
return 'theme'
|
||||
}
|
||||
|
||||
return 'command'
|
||||
}
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -137,10 +104,6 @@ interface QueueEditState {
|
||||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// Quiet period after the last keystroke before persisting the draft;
|
||||
// unmount/pagehide flushes bypass it.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -182,9 +145,6 @@ export function ChatBar({
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -196,17 +156,14 @@ export function ChatBar({
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const queueEditRef = useRef(queueEdit)
|
||||
queueEditRef.current = queueEdit
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
const stacked = expanded || narrow || tight
|
||||
const trimmedDraft = draft.trim()
|
||||
@@ -214,12 +171,10 @@ export function ChatBar({
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -507,6 +462,12 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
@@ -659,50 +620,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
|
||||
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
|
||||
|
||||
if (runAction) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, prefix)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
closeTrigger()
|
||||
runAction()
|
||||
requestMainFocus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
// No pill while expanding — the bare command stays plain text until an arg
|
||||
// is picked, at which point a single pill is emitted for the full command.
|
||||
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||
const keepTriggerOpen = starter || expandsToArgs
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestMainFocus()
|
||||
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
@@ -712,20 +639,7 @@ export function ChatBar({
|
||||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
if (slashKind) {
|
||||
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
|
||||
// the platform) land here because the caret sits past a contenteditable
|
||||
// chip. Rebuild the prefix and re-emit a single pill for the full command.
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
renderComposerContents(editor, `${prefix}${text}`)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
@@ -736,13 +650,8 @@ export function ChatBar({
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
const chip = slashKind
|
||||
? slashChipElement(serialized, slashKind)
|
||||
: directive
|
||||
? refChipElement(directive[1], directive[2])
|
||||
: null
|
||||
|
||||
if (chip) {
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
@@ -905,16 +814,7 @@ export function ChatBar({
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
// Decide from the DOM, not React state. `hasComposerPayload` is derived
|
||||
// from the AUI composer state, which lags the latest keystroke by a
|
||||
// render, so on fast typing / IME the just-typed text isn't in state yet.
|
||||
// Without the live read, a real message typed while prompts are queued
|
||||
// would drain the queue instead of sending. submitDraft() re-syncs and
|
||||
// sends the live editor text.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
return
|
||||
@@ -922,10 +822,7 @@ export function ChatBar({
|
||||
|
||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||
// Gate on the live DOM payload (not the render-lagged composer state) so a
|
||||
// message typed fast / via IME while busy still reaches submitDraft() and
|
||||
// gets queued instead of being mistaken for an empty Enter.
|
||||
if (busy && !hasLivePayload) {
|
||||
if (busy && !hasComposerPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1022,25 +919,24 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// In-app drags (project tree / gutter) are workspace-relative paths the
|
||||
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
|
||||
// drops are absolute local paths a remote gateway can't read (and images
|
||||
// need byte upload for vision), so route them through the upload pipeline.
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
const refs = droppedFileInlineRefs(inAppRefs, cwd)
|
||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (refs.length && insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (osDrops.length) {
|
||||
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
@@ -1060,7 +956,11 @@ export function ChatBar({
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (!candidates.length) {
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (!refs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1068,27 +968,9 @@ export function ChatBar({
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
// Dropping straight onto the text box used to inline-ref *every* file —
|
||||
// including OS/Finder drops, whose absolute local path a remote gateway
|
||||
// can't read and whose image bytes never reached vision. Split by origin:
|
||||
// in-app drags stay inline refs; OS drops go through the upload pipeline.
|
||||
// (When no upload handler is wired, fall back to inline refs for all.)
|
||||
const attach = onAttachDroppedItems
|
||||
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
|
||||
|
||||
if (refs.length && insertInlineRefs(refs)) {
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (attach && osDrops.length) {
|
||||
void Promise.resolve(attach(osDrops)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
requestMainFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
@@ -1113,69 +995,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
// on enter. Keyed writes are idempotent, so no skip-sentinel.
|
||||
useEffect(() => {
|
||||
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||
loadIntoComposer(text, attachments)
|
||||
|
||||
return () => {
|
||||
const editing = queueEditRef.current
|
||||
|
||||
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||
} else if (!isBrowsingHistory(sessionId)) {
|
||||
stashAt(activeQueueSessionKey)
|
||||
}
|
||||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced stash into the active scope. Skipped while browsing history or
|
||||
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||
useEffect(() => {
|
||||
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce window would drop trailing keystrokes without this.
|
||||
useEffect(() => {
|
||||
const flushPendingDraftPersist = () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(pending.scope, pending.text)
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flushPendingDraftPersist)
|
||||
flushPendingDraftPersist()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
@@ -1378,61 +1197,21 @@ export function ChatBar({
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
useEffect(() => {
|
||||
if (!queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey) {
|
||||
if (editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
setQueueEdit(null)
|
||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
const submittedScope = activeQueueSessionKeyRef.current
|
||||
const submittedAttachments = attachments ?? []
|
||||
|
||||
const restore = () => {
|
||||
loadIntoComposer(text, submittedAttachments)
|
||||
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||
}
|
||||
|
||||
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
// not have synced yet — reading state here drops the message (Enter looks
|
||||
// like it does nothing; typing a trailing space only "fixes" it because the
|
||||
// extra input event forces a state sync). draftRef is updated on every
|
||||
// input event; refresh it from the editor once more to also cover an
|
||||
// in-flight keystroke that hasn't fired its input event yet.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
aui.composer().setText(domText)
|
||||
}
|
||||
}
|
||||
|
||||
const text = draftRef.current
|
||||
const payloadPresent = text.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (queueEdit) {
|
||||
exitQueuedEdit('save')
|
||||
} else if (busy) {
|
||||
@@ -1443,11 +1222,12 @@ export function ChatBar({
|
||||
// busy guard for commands that genuinely need an idle session (skill
|
||||
// /send directives). Queuing them would make every slash command wait
|
||||
// for the current turn to finish, which is how the TUI never behaves.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (payloadPresent) {
|
||||
void onSubmit(submitted)
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
@@ -1455,15 +1235,15 @@ export function ChatBar({
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
} else if (payloadPresent) {
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
void onSubmit(submitted, { attachments })
|
||||
}
|
||||
|
||||
focusInput()
|
||||
@@ -1630,7 +1410,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
@@ -1649,15 +1429,7 @@ export function ChatBar({
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
)
|
||||
@@ -1696,6 +1468,7 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
|
||||
@@ -83,12 +83,6 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
|
||||
* dropping any that carry no path. */
|
||||
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
|
||||
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
if (!refs.length) {
|
||||
return null
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
DIRECTIVE_CHIP_CLASS,
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
formatRefValue
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
@@ -80,24 +77,6 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
||||
return chip
|
||||
}
|
||||
|
||||
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||
* it back to the exact text that gets submitted. */
|
||||
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||
const chip = document.createElement('span')
|
||||
const text = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.dataset.refText = command
|
||||
chip.dataset.slashKind = kind
|
||||
chip.className = slashChipClass(kind)
|
||||
text.className = 'truncate'
|
||||
text.textContent = label || command
|
||||
chip.append(slashIconElement(kind), text)
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
||||
const lines = text.split('\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface SkinSlashPopoverProps {
|
||||
draft: string
|
||||
onSelect: (command: string) => void
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
role="listbox"
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<button
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
key={item.text}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(item.text)
|
||||
}}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,33 +22,6 @@ describe('detectTrigger', () => {
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the slash trigger live while typing args', () => {
|
||||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat file-style paths as slash triggers', () => {
|
||||
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||
})
|
||||
|
||||
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
|
||||
@@ -6,13 +6,7 @@ export interface TriggerState {
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||
// paths like `src/foo/bar`.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
@@ -103,17 +97,11 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (slash) {
|
||||
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
||||
@@ -34,17 +34,9 @@ describe('ComposerTriggerPopover i18n', () => {
|
||||
})
|
||||
|
||||
it('renders localized loading copy for slash commands', () => {
|
||||
renderPopover('/', true)
|
||||
const { container } = renderPopover('/', true)
|
||||
|
||||
// While loading the popover shows only the spinner + loading copy — the
|
||||
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
|
||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the slash empty-state hint when not loading', () => {
|
||||
const { container } = renderPopover('/')
|
||||
|
||||
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||
expect(container.textContent).toContain('/help')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -9,6 +7,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
@@ -24,7 +23,11 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
url: 'globe'
|
||||
}
|
||||
|
||||
function atIcon(item: Unstable_TriggerItem) {
|
||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
||||
if (kind === '/') {
|
||||
return 'terminal'
|
||||
}
|
||||
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
@@ -39,18 +42,6 @@ function atIcon(item: Unstable_TriggerItem) {
|
||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
|
||||
interface RowMeta {
|
||||
display?: string
|
||||
group?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const ROW_BASE_CLASS = [
|
||||
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
@@ -72,9 +63,6 @@ export function ComposerTriggerPopover({
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
const isSlash = kind === '/'
|
||||
|
||||
let lastGroup: string | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,94 +73,41 @@ export function ComposerTriggerPopover({
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
)
|
||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as RowMeta | undefined
|
||||
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
const group = meta?.group?.trim()
|
||||
const showHeader = isSlash && Boolean(group) && group !== lastGroup
|
||||
const isFirstHeader = lastGroup === undefined
|
||||
lastGroup = group || lastGroup
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
<button
|
||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
<button
|
||||
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
{isSlash ? (
|
||||
<>
|
||||
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||
long command names / descriptions stay readable without a
|
||||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Fragment>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user