Merge remote-tracking branch 'origin/main' into bb/yolo-palette

# Conflicts:
#	apps/desktop/src/app/command-palette/index.tsx
This commit is contained in:
Brooklyn Nicholson 2026-07-30 03:31:40 -05:00
commit 640056704c
46 changed files with 1533 additions and 410 deletions

View file

@ -895,6 +895,17 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
// a frozen stage, and users cancel a healthy update. Force line-by-line
// output instead.
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
// We hold the update-in-progress marker for this whole run, and the
// `hermes update` child claims that SAME lock (hermes_cli/update_lock.py).
// Name our pid so the child recognizes the live holder as its own
// orchestrator and runs under our claim — without this every GUI update
// refuses its parent's marker with exit 2 ("Hermes is still running")
// and no number of retries can ever succeed. Keep the variable name in
// sync with HANDOFF_PID_ENV in hermes_cli/update_lock.py.
envs.push((
"HERMES_UPDATE_HANDOFF_PID".to_string(),
OsString::from(std::process::id().to_string()),
));
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
@ -1218,6 +1229,17 @@ mod tests {
);
}
#[test]
fn update_child_env_names_our_pid_for_the_lock_handoff() {
let envs = update_child_env(Path::new("/x/hermes-agent"));
assert!(
envs.iter().any(|(k, v)| k == "HERMES_UPDATE_HANDOFF_PID"
&& v.to_str() == Some(std::process::id().to_string().as_str())),
"the hermes update child claims the same marker we hold; without our pid \
it refuses its own parent's lock and every GUI update dead-ends on exit 2"
);
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");

View file

@ -5155,6 +5155,20 @@ function sendClosePreviewRequested() {
webContents.send('hermes:close-preview-requested')
}
function sendOpenFolderRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
const webContents = mainWindow.webContents
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.send('hermes:open-folder-requested')
}
// Tell the renderer the machine just woke. Sleep silently drops the
// renderer's WebSocket to the local backend; the renderer reconnects on this
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
@ -5272,6 +5286,10 @@ function buildApplicationMenu() {
// a menu accelerator would fight the rebind panel and (on macOS) be
// swallowed before the renderer sees it. Here purely for discoverability.
{ click: () => createInstanceWindow(), label: 'New Window' },
// Same no-accelerator rationale: ⌘O is the rebindable renderer keybind
// (workspace.openFolder). Clicking runs the same open-folder-as-project
// flow through the renderer.
{ click: () => sendOpenFolderRequested(), label: 'Open Folder…' },
{ type: 'separator' },
IS_MAC
? {

View file

@ -212,6 +212,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onOpenFolderRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-folder-requested', listener)
return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener)
},
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)

View file

@ -0,0 +1,146 @@
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
//
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
//
// Reports, per round, the time from the keydown the app actually receives to:
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
// means: the overlay owes you a frame immediately)
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
// plus any long tasks in the window, so a slow open is attributable.
import { CDP, sleep } from './perf/lib/cdp.mjs'
const args = process.argv.slice(2)
const flag = name => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const port = Number(flag('port') ?? 9222)
const rounds = Number(flag('rounds') ?? 8)
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
const INSTALL = `
(() => {
if (window.__CMDK__) window.__CMDK__.stop()
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
// Time from the keydown the APP receives — excludes CDP transport, so the
// number is what a user's finger actually experiences.
const onKey = e => {
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
state.t0 = performance.now()
state.armed = false
}
}
window.addEventListener('keydown', onKey, true)
const obs = new MutationObserver(() => {
if (state.t0 === null) return
if (state.frame === null && document.querySelector('[cmdk-input]')) {
state.frame = performance.now() - state.t0
}
const n = document.querySelectorAll('[cmdk-item]').length
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
})
obs.observe(document.body, { childList: true, subtree: true })
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
})
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
window.__CMDK__ = {
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
read: () => ({
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
rows: state.rows,
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
}),
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
}
return true
})()
`
// Settle: frame painted AND rows stopped growing for two frames.
const WAIT = `
new Promise(resolve => {
let stable = 0
let last = -1
const started = performance.now()
const tick = () => {
const r = window.__CMDK__.read()
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
if (++stable >= 2) { resolve(r); return }
} else { stable = 0 }
last = r.rows
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
`
const key = async type =>
cdp.send('Input.dispatchKeyEvent', {
type,
key: 'k',
code: 'KeyK',
windowsVirtualKeyCode: 75,
nativeVirtualKeyCode: 75,
modifiers: 4
})
const esc = async () => {
for (const type of ['keyDown', 'keyUp']) {
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(400)
}
await cdp.eval(INSTALL)
await esc()
const samples = []
for (let i = 0; i < rounds; i++) {
await sleep(250)
await cdp.eval('window.__CMDK__.arm()')
await key('rawKeyDown')
await key('keyUp')
const r = await cdp.eval(WAIT)
samples.push(r)
console.log(`round ${i}:`, r)
await esc()
}
await cdp.eval('window.__CMDK__.stop()')
const stat = k => {
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
if (!v.length) return null
return {
min: v[0],
median: v[Math.floor(v.length / 2)],
max: v[v.length - 1]
}
}
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
console.log('keydown → rows painted (ms):', stat('rows_ms'))
console.log('long-task time in window (ms):', stat('longtask_ms'))
cdp.close()

View file

@ -121,7 +121,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@ -265,7 +265,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
renderComposerContents(editor, text)
renderComposerContents(editor, text, { trailingCommitted: true })
}
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {

View file

@ -1043,9 +1043,7 @@ export function ChatBar({
<div
className={cn(
'z-30 flex flex-col',
poppedOut
? 'fixed max-w-[calc(100vw-1.5rem)]'
: 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
poppedOut ? 'fixed max-w-[calc(100vw-1.5rem)]' : 'absolute bottom-0 left-1/2 max-w-full -translate-x-1/2'
)}
data-popped-out={poppedOut ? '' : undefined}
data-slot="composer-dock"
@ -1131,126 +1129,126 @@ export function ChatBar({
}}
ref={composerRef}
>
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
)}
{/* Drag region: covers the transparent grab margin around the surface.
{isHelpHint && <HelpHint />}
{trigger && !argStageEmpty && (
<ComposerTriggerPopover
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}
loading={triggerLoading}
onHover={setTriggerActive}
onPick={replaceTriggerWithChip}
/>
)}
{!poppedOut && (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
)}
{/* Drag region: covers the transparent grab margin around the surface.
The surface sits on top (z-4) so only the exposed ring receives this
element's hover/cursor grab cursor + a diagonal hatch (/////)
appear when you hover the draggable margin, never over the input.
The hatch pattern + opacity ladder live in styles.css. */}
{popoutAllowed && (
<div
aria-hidden
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={handleComposerToggle}
/>
)}
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-surface"
ref={composerSurfaceRef}
>
{popoutAllowed && (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={handleComposerToggle}
/>
)}
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
COMPOSER_DROP_FADE_CLASS,
dragActive && COMPOSER_DROP_ACTIVE_CLASS
)}
data-slot="composer-fade"
data-slot="composer-surface"
ref={composerSurfaceRef}
>
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
{t.composer.editingQueuedInComposer}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('cancel')}
type="button"
variant="ghost"
>
{t.common.cancel}
</Button>
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('save')}
type="button"
>
{t.common.save}
</Button>
</div>
</div>
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
composerFill,
composerSurfaceGlass
)}
/>
<CodingStatusRow
onBranchOff={handleBranchOff}
onConvertBranch={handleConvertBranch}
onListBranches={handleListBranches}
onOpen={toggleReview}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
/>
<div
className={cn(
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
{/* Contribution seams: banners above, a row below, inline
additions beside the "+" menu and before the controls.
All four render nothing until something contributes. */}
<ContribSlot area={COMPOSER_AREAS.top} />
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
{t.composer.editingQueuedInComposer}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('cancel')}
type="button"
variant="ghost"
>
{t.common.cancel}
</Button>
<Button
className="h-6 rounded-md px-2 text-[0.68rem]"
onClick={() => exitQueuedEdit('save')}
type="button"
>
{t.common.save}
</Button>
</div>
</div>
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
<div
className={cn(
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
<ContribSlot area={COMPOSER_AREAS.leading} />
</div>
<div className="min-w-0 [grid-area:input]">{input}</div>
<div className="flex items-center justify-end gap-(--composer-control-gap) [grid-area:controls]">
<ContribSlot area={COMPOSER_AREAS.actions} />
{controls}
</div>
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
<ContribSlot area={COMPOSER_AREAS.bottom} />
</div>
</div>
</div>
</ComposerPrimitive.Root>
{/* Underside: chrome-free strip BELOW the composer. Outside the root
for the same reason as the micro actions it must not fall inside

View file

@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
editor.remove()
})
// A directive typed by hand chips; the same directive pasted has to chip too,
// or copy/pasting a prompt silently drops every command in it.
it('chips a pasted slash command, including one that ends the paste', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
// Committed pills carry the trailing space the typed path appends, so a
// later full re-render doesn't read the token as half-typed.
expect(composerPlainText(editor)).toBe('/some-skill ')
editor.remove()
})
it('chips a skill named mid-paste alongside a ref', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
editor.remove()
})
it('leaves a pasted path alone — /usr/local is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
editor.remove()
})
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('foo/some-skill')
editor.remove()
})
it('chips a command pasted right after an existing chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
editor.remove()
})
})
describe('replaceBeforeCaret', () => {

View file

@ -16,22 +16,13 @@ import {
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
/** A committed leading slash command: `/name` followed by whitespace. The
* whitespace requirement is what separates a committed command (chips always
* serialize with their auto-inserted trailing space) from one still being
* typed, which must stay editable text. */
const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
export function escapeHtml(value: string) {
@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
})
}
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
let cursor = 0
/** Every span of `text` that renders as a chip, in source order. */
function chipSpans(text: string, options: SlashCommandScanOptions) {
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
appendTextWithBreaks(target, text.slice(cursor, index))
target.append(refChipElement(match[1] || 'file', match[2] || ''))
cursor = index + match[0].length
const refs = Array.from(text.matchAll(REF_RE)).map(match => {
const start = match.index ?? 0
return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
})
const commands = slashCommandMatches(text, options).map(match => ({
end: match.end,
node: () => slashChipElement(match.command, match.kind),
start: match.start
}))
return [...refs, ...commands].sort((a, b) => a.start - b.start)
}
/** Build the chip/text DOM for `text`. Directives hydrate back to their pills
* `@kind:value` refs and `/command` invocations both so text that arrives
* whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
* same chips the typed path would have committed. */
export function appendComposerContents(
target: DocumentFragment | HTMLElement,
text: string,
options: SlashCommandScanOptions = {}
) {
let cursor = 0
for (const span of chipSpans(text, options)) {
// A `@` ref wins an overlap: a command token can't contain an `@`, so the
// only way spans collide is a slash inside a quoted ref value
// (`` @url:`a /clean` ``), which belongs to that value.
if (span.start < cursor) {
continue
}
appendTextWithBreaks(target, text.slice(cursor, span.start))
target.append(span.node())
cursor = span.end
}
appendTextWithBreaks(target, text.slice(cursor))
}
export function renderComposerContents(target: HTMLElement, text: string) {
export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
target.replaceChildren()
// A leading `/command` hydrates back to its pill — parity with REF_RE for
// `@` refs, so a full re-render from serialized text (draft restore, undo,
// the trigger commit fallback) doesn't demote a committed command chip to
// plain text. Only commands with NO argument stage qualify (skills, quick
// commands, no-arg built-ins): their committed pill is exactly the bare
// `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship
// it`, `/personality alice`) stay text — their tail may be prose that was
// never committed. The trailing whitespace is load-bearing too: a committed
// pill always serializes with its auto-inserted space, while a half-typed
// `/wor` must stay editable text.
const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0]
if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) {
target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill'))
text = text.slice(command.length)
}
appendComposerContents(target, text)
// Defaults to live editing, where a token ending the text is still being
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
appendComposerContents(target, text, options)
}
/** Caret range when the selection lives inside `editor`; else null. */
@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) {
return { range, selection }
}
/** Insert text at the caret (replacing any selection), with any `@kind:value`
* directives in it landing as chips. Pastes use this instead of
* `execCommand('insertText')` Chromium's editing pipeline is ~O(n²) on large
* multiline blobs. */
/** Serialized text from the editor's start up to (`container`, `offset`).
*
* Chips are ATOMIC here: each contributes an object-replacement placeholder
* rather than leaking its label text, and a <br> contributes a newline. That
* makes a chip edge read as a token boundary, which is what both trigger
* detection and directive recognition need. */
export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
const probe = document.createRange()
probe.selectNodeContents(editor)
probe.setEnd(container, offset)
const scratch = document.createElement('div')
scratch.append(probe.cloneContents())
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
chip.replaceWith('\uFFFC')
}
for (const br of scratch.querySelectorAll('br')) {
br.replaceWith('\n')
}
return scratch.textContent ?? ''
}
/** True when the insertion point starts a token the editor's start, or after
* whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
* command; `foo ` + the same paste is. */
function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
// No caret means the insert lands at the end, so the question is about the
// editor's last character either way.
const before = range
? serializeTextBefore(editor, range.startContainer, range.startOffset)
: serializeTextBefore(editor, editor, editor.childNodes.length)
const last = before.slice(-1)
return !last || /[\s\uFFFC]/.test(last)
}
/** Insert text at the caret (replacing any selection), with any directives in
* it landing as chips. Pastes use this instead of `execCommand('insertText')`
* Chromium's editing pipeline is ~O(n²) on large multiline blobs.
*
* The text arrives whole rather than typed, so a `/command` ending it is
* complete rather than half-written and chips like the rest. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendComposerContents(fragment, text)
// Before measuring the boundary — a replaced selection puts the insertion
// point where the selection started, not where it ended.
if (hit) {
hit.range.deleteContents()
}
appendComposerContents(fragment, text, {
boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
trailingCommitted: true
})
// A slash pill ending the insert gets the trailing space the typed commit
// path appends, or the next full re-render reads it as a half-typed token
// and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
fragment.append(document.createTextNode(' '))
}
const tail = fragment.lastChild
if (hit) {
hit.range.deleteContents()
hit.range.insertNode(fragment)
} else {
editor.append(fragment)

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { slashCommandMatches } from './slash-refs'
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
describe('slashCommandMatches', () => {
it('recognizes a leading command and a skill named mid-prose', () => {
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
'skill:/some-skill',
'skill:/other-skill'
])
})
it('leaves a path alone — /usr/local/bin is not a command', () => {
expect(commands('see /usr/local/bin ')).toEqual([])
})
it('holds a trailing token as still-typed unless the text is inert', () => {
expect(commands('/some-skill')).toEqual([])
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
})
it('leaves an arg-taking command as text — its tail may be prose', () => {
expect(commands('/goal ship the redesign')).toEqual([])
})
it('leaves a command with no desktop surface as text', () => {
expect(commands('/exit now')).toEqual([])
})
it('offers a built-in only as an invocation, never mid-message', () => {
// Mirrors what the popover offers: `/new` acts on the app, so it means
// nothing dropped into a sentence, while a skill reads as "handle this
// part with X".
expect(commands('/new ')).toEqual(['command:/new'])
expect(commands('start over with /new ')).toEqual([])
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
})
it('disqualifies a leading token when the text lands mid-word', () => {
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
})
})

View file

@ -0,0 +1,105 @@
/**
* Slash-command recognition for text the composer did not watch being typed
* a paste, a restored draft, an undo step, a rebuilt line.
*
* The typed path chips a command as it's picked or accepted, so the composer
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
* directive-text). Text that arrives whole never passed through that path, so
* it needs the same commands recognized in place on exactly the terms the
* typed path would have used, or hydration invents pills the popover would
* never have committed.
*/
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
// A command token starts a word and doesn't continue into a path: `/usr/local`
// is a path, not a `/usr` command. Same shape the sent message uses to decide
// what renders as a pill, so the composer and the transcript agree.
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
export interface SlashCommandMatch {
/** The command with its leading slash, e.g. `/clean`. */
command: string
end: number
kind: SlashChipKind
start: number
}
export interface SlashCommandScanOptions {
/**
* Whether the text is preceded by a token boundary. False when it's being
* inserted mid-word (a paste landing against existing characters), which
* disqualifies a token at index 0 `foo/clean` is not a command. It also
* makes that token mid-message rather than an invocation.
*/
boundaryBefore?: boolean
/**
* Whether a token ending the text counts as committed. True for inert text
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
* is the whole command. False while editing live, where a trailing `/wor` is
* a half-typed query the popover owns and must leave editable.
*/
trailingCommitted?: boolean
}
/**
* Only commands with NO argument stage chip: their committed pill is exactly
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
* (`/goal ship it`) stay text their tail may be prose. Commands with no
* desktop surface at all (`/exit`, `/config`) stay text too.
*/
function chippableKind(command: string): SlashChipKind | null {
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
return null
}
return resolveDesktopCommand(command) ? 'command' : 'skill'
}
/** Every `/command` in `text` that should render as a pill, in source order. */
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
const { boundaryBefore = true, trailingCommitted = false } = options
if (!text.includes('/')) {
return []
}
const matches: SlashCommandMatch[] = []
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
const start = match.index ?? 0
const command = match[0]
const end = start + command.length
const after = text[end]
// A committed pill always carries its auto-inserted trailing space, which
// is what separates it from a token still being typed.
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
continue
}
// Only the FIRST token can be an invocation, and only when the text lands
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
const invocation = start === 0
if (invocation && !boundaryBefore) {
continue
}
const kind = chippableKind(command)
// Later tokens are references dropped into prose, where the popover offers
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
// never would.
if (kind && (invocation || kind === 'skill')) {
matches.push({ command, end, kind, start })
}
}
return matches
}

View file

@ -1,6 +1,8 @@
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { $reactionsEnabled } from '@/store/reactions-enabled'
import { serializeTextBefore } from './rich-editor'
export interface TriggerState {
/** True for a `/` typed mid-message — an inline skill/command reference in
* prose rather than a command invocation. Arg completion doesn't apply. */
@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
const scratch = document.createElement('div')
scratch.append(before.cloneContents())
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
chip.replaceWith('\uFFFC')
}
for (const br of scratch.querySelectorAll('br')) {
br.replaceWith('\n')
}
return scratch.textContent ?? ''
return serializeTextBefore(editor, range.startContainer, range.startOffset)
}
export function detectTrigger(textBefore: string): TriggerState | null {

View file

@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'
/** The muted slot beside a section label (loading glyph, status hint). */
export function SidebarSectionMeta({ children }: { children: React.ReactNode }) {
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
return <span className="shrink-0 text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
}
// ── Row geometry (session row is canonical — everything composes these) ─────

View file

@ -135,7 +135,7 @@ export function SidebarCronJobsSection({
<SidebarGroup className="shrink-0 p-0 pb-1">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>

View file

@ -24,7 +24,9 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
const tail = label.slice(label.length - tailLen)
return (
<span className="flex min-w-0" title={title}>
// overflow-hidden: the pinned tail is shrink-0, so at extreme narrow widths
// it must clip inside the label rather than push the trailing icons out.
<span className="flex min-w-0 overflow-hidden" title={title}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>

View file

@ -64,7 +64,9 @@ function SidebarSectionHeader({
<div className="group/section flex shrink-0 items-center justify-between gap-1 pb-1 pt-1.5">
{collapsible ? (
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
// min-w-0 lets the label truncate at narrow sidebar widths instead of
// pushing the header's trailing action icons out of view.
className="group/section-label flex w-fit min-w-0 items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
@ -75,7 +77,7 @@ function SidebarSectionHeader({
/>
</button>
) : (
<div className="flex w-fit items-center gap-1 leading-none">{labelBody}</div>
<div className="flex w-fit min-w-0 items-center gap-1 leading-none">{labelBody}</div>
)}
{action}
</div>

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
@ -14,6 +14,7 @@ import {
HUD_TEXT
} from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { KbdCombo } from '@/components/ui/kbd'
@ -68,7 +69,7 @@ import {
} from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
import {
@ -111,6 +112,8 @@ interface PaletteItem {
action?: string
/** Renders a trailing check: this row IS the current setting (theme, mode). */
active?: boolean
/** Static trailing combo hint for a modifier-variant select (e.g. `mod+enter`). */
comboHint?: string
/** Short note beside the label — state the row acts on (a version, a count). */
detail?: string
/** `state` when the row will change what `detail` says (a toggle's on/off). */
@ -121,6 +124,8 @@ interface PaletteItem {
keepOpen?: boolean
keywords?: string[]
label: string
/** Label shown while ⌘/⌃ is held — previews the modifier-variant action. */
modLabel?: string
/**
* When set, /-select (or -Enter) opens a new tab and -select pops a
* window matching sidebar session rows. Plain select stays in-place.
@ -240,21 +245,92 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
// theme lists under both Light and Dark). The id suffix disambiguates.
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
const EMPTY_GROUPS: PaletteGroup[] = []
// Backstop only. The palette normally retires on the content's real
// `animationend`, so the CSS owns the close duration; this just guarantees the
// body can't stay mounted forever somewhere animations never run (jsdom,
// `animation: none`). Deliberately longer than any plausible exit so it never
// races the real signal and truncates the fade.
const EXIT_FALLBACK_MS = 1000
/**
* The palette's row list, split out so an OPENING palette paints before it
* renders rows. This component mounts with the portal, so `useDeferredValue`'s
* initial value applies per open: the first commit is the frame + input
* (instant), and the several-hundred-row list arrives in an interruptible
* follow-up render. Opening K must never wait on building the list.
*/
const PaletteGroups = memo(function PaletteGroups({
bindings,
groups,
modHeld,
noResultsLabel,
onSelectItem,
onSelectMods,
search
}: {
bindings: Record<string, string[]>
groups: PaletteGroup[]
modHeld: boolean
noResultsLabel: string
onSelectItem: (item: PaletteItem) => void
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
search: string
}) {
const deferred = useDeferredValue(groups, EMPTY_GROUPS)
// While the rows are still catching up, an empty list means "not rendered
// yet", not "nothing matched" — don't flash the empty state on open.
const pending = deferred !== groups
return (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{deferred.length === 0 && !pending && (
<div className="py-6 text-center text-sm text-muted-foreground">{noResultsLabel}</div>
)}
{deferred.map((group, index) => (
<CommandGroup className={HUD_HEADING} heading={group.heading} key={group.heading ?? `palette-group-${index}`}>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
modHeld={modHeld}
onSelectItem={onSelectItem}
onSelectMods={onSelectMods}
search={search}
/>
))}
</CommandGroup>
))}
</>
)
})
const PaletteRow = memo(function PaletteRow({
bindings,
item,
modHeld,
onSelectMods,
onSelectItem,
search
}: {
bindings: Record<string, string[]>
item: PaletteItem
modHeld: boolean
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
onSelectItem: (item: PaletteItem) => void
search: string
}) {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
// The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
// so every downstream `ml-auto` fallback below keeps working unchanged.
const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
// While ⌘/⌃ is held, a row with a modifier variant previews it: the label
// swaps to the variant's copy so Enter reads as what it will actually do.
const modPreview = modHeld && Boolean(item.modLabel)
return (
<CommandItem
@ -265,15 +341,21 @@ const PaletteRow = memo(function PaletteRow({
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{/* Same per-term split as scoreItem's AND matcher, so the emphasis
shows exactly which words earned the row its rank. */}
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
<span className={cn('truncate', modPreview && 'text-muted-foreground/80')}>
{modPreview ? (
item.modLabel
) : (
/* Same per-term split as scoreItem's AND matcher, so the emphasis
shows exactly which words earned the row its rank. */
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
)}
</span>
{item.detail && (
<span className={cn(HUD_NOTE, HUD_NOTE_VARIANT[item.detailVariant ?? 'muted'])}>{item.detail}</span>
)}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{combo && (
<KbdCombo className={cn('ml-auto', modPreview ? 'opacity-90' : 'opacity-55')} combo={combo} size="sm" />
)}
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
</CommandItem>
@ -284,6 +366,12 @@ const PaletteRow = memo(function PaletteRow({
// "Go to session id" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
// A typed/pasted folder path: absolute (`/…`) or a Windows drive (`C:\…`).
// Deliberately NOT `~/…`: the upsert's membership check (projectIdForCwd)
// compares literal strings against the tree's absolute paths, so an unexpanded
// home path would always miss and double-create.
const FOLDER_PATH_RE = /^(\/|[A-Za-z]:[/\\]).+/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@ -365,12 +453,72 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
/**
* K is an overlay that is stateful to itself: pressing it must open a frame
* immediately, and must not be held up by whatever else the shell is doing. So
* the mounted cost of a CLOSED palette is one store subscription and nothing
* else.
*
* Everything expensive a dozen store subscriptions (connection, update
* status/apply, keybinds, worktrees, projects, theme, i18n), three server
* queries, and the group builders that assemble a few hundred rows lives in
* `CommandPaletteBody`, which only exists while the palette is on screen.
* Before this split those hooks ran on every render of the always-mounted
* component: an in-flight update rewrote `$updateApply` per progress line and
* rebuilt the entire row set each time, for a surface nobody could see.
*
* `mounted` lags `open` by the close animation rather than tracking it exactly.
* Unmounting the body the instant `open` flips false would rip the content out
* of the tree before Radix could play `data-[state=closed]`, so the overlay
* would vanish instead of closing. The body reports its own exit via
* `onExited` (the content's real `animationend`), so nothing here has to know
* how long that animation is the CSS owns the duration.
*
* The `openCount` key remounts the body per open, which is what lets local
* search/sub-page state reset without a close effect.
*/
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const [mounted, setMounted] = useState(open)
const [openCount, setOpenCount] = useState(0)
const retire = useCallback(() => {
// Only retire the body if the palette is still closed — a reopen mid-fade
// must not unmount the fresh instance.
if (!$commandPaletteOpen.get()) {
setMounted(false)
}
}, [])
useEffect(() => {
if (open) {
setOpenCount(count => count + 1)
setMounted(true)
return
}
// Safety net for environments where the exit animation never runs (jsdom,
// `animation: none`), so the body can't be stranded mounted. The real
// unmount is `onExited` below; whichever fires first wins.
const timer = setTimeout(retire, EXIT_FALLBACK_MS)
return () => clearTimeout(timer)
}, [open, retire])
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
{mounted && <CommandPaletteBody key={openCount} onExited={retire} />}
</DialogPrimitive.Root>
)
}
function CommandPaletteBody({ onExited }: { onExited: () => void }) {
const { t } = useI18n()
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const worktrees = useStore($repoWorktrees)
const projectTree = useStore($projectTree)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
@ -386,10 +534,6 @@ export function CommandPalette() {
const clientApply = useStore($updateApply)
const backendStatus = useStore($backendUpdateStatus)
const backendApply = useStore($backendUpdateApply)
// Running a keepOpen row (a toggle) changes state the rows themselves report,
// so the groups have to rebuild without the palette closing. A counter keeps
// that generic — the palette never learns which stores its contributions read.
const [selectTick, setSelectTick] = useState(0)
const updateVersionLabel = useMemo(() => {
const backend = connection?.mode === 'remote'
@ -425,24 +569,44 @@ export function CommandPalette() {
}
}
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
// Live ⌘/⌃-held state while the palette is open: rows with a modifier
// variant (projects) preview it by swapping their label. Window-level
// listeners because focus sits in the search input; blur clears so a
// ⌘-Tab away doesn't strand the preview on.
const [modHeld, setModHeld] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setModHeld(event.metaKey || event.ctrlKey)
const clear = () => setModHeld(false)
window.addEventListener('keydown', sync, { capture: true })
window.addEventListener('keyup', sync, { capture: true })
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync, { capture: true })
window.removeEventListener('keyup', sync, { capture: true })
window.removeEventListener('blur', clear)
}
}, [])
// Server-backed sources for the type-to-search groups. This component only
// exists while the palette is open, so the queries are inherently lazy — no
// `enabled` gate needed. react-query handles caching/dedup/staleness, so a
// reopen paints from cache and revalidates in the background.
const configQuery = useQuery({
queryKey: ['command-palette', 'config'],
queryFn: getHermesConfigRecord,
enabled: open
queryFn: getHermesConfigRecord
})
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
queryFn: () => listAllProfileSessions(200, 1, 'exclude')
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
queryFn: () => listAllProfileSessions(200, 0, 'only')
})
const mcpServers = useMemo(() => {
@ -456,21 +620,16 @@ export function CommandPalette() {
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
}
}, [open])
// Search/sub-page are local to a mount, and this component remounts per open
// (keyed by open count), so each open starts clean without a reset effect.
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
if (pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
}, [pendingPage])
const go = useCallback((path: string) => () => navigateToWorkspacePage(navigate, path), [navigate])
@ -505,6 +664,11 @@ export function CommandPalette() {
[t.settings.fieldLabels]
)
// Running a keepOpen row (a toggle) changes state the rows themselves report,
// so the groups have to rebuild without the palette closing. A counter keeps
// that generic — the palette never learns which stores its contributions read.
const [selectTick, setSelectTick] = useState(0)
const contributedItems = usePaletteContributions()
// The active repo's worktrees → "new conversation in <branch>". This is the
@ -538,32 +702,38 @@ export function CommandPalette() {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
const cc = t.commandCenter
// Registry-contributed rows (core features + plugins) — one group, omitted
// while nothing contributes.
const commandGroup: PaletteGroup[] =
contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
// Read on open and after every select (the deps below), so a
// row that reports state can't show the state it just left.
detail: item.detail?.(),
detailVariant: item.detailVariant,
icon: item.icon ?? Zap,
id: item.key,
keepOpen: item.keepOpen,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: []
// Projects are the primary way the desktop scopes work, so they're jumpable
// from the palette. Plain select is a pure scope switch (sidebar enters the
// project — never spends main); ⌘-Enter / ⌘-click also starts a new session
// at the project root (stacked as a tab when main holds a chat), previewed
// by the label swap while ⌘ is held. Rows carry the project's own codicon,
// matching the sidebar. The pinned "Open folder…" row is the ⌘O upsert.
const projectGroup: PaletteGroup = {
heading: cc.projects,
items: [
{
action: 'workspace.openFolder',
icon: codiconIcon('folder-opened'),
id: 'project-open-folder',
keywords: ['open', 'folder', 'directory', 'project', 'add', 'import', 'workspace'],
label: cc.openFolder,
run: () => void openFolderAsProject()
},
...projectTree.map(project => ({
comboHint: 'mod+enter',
icon: codiconIcon(project.icon || (project.isNoProject ? 'home' : 'folder-library')),
id: `project-${project.id}`,
keywords: ['project', 'workspace', 'go to', project.label, ...(project.path ? [project.path] : [])],
label: project.label,
modLabel: cc.newSessionInProject(project.label),
runWithEvent: (event?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) =>
goToProject(project.id, { newSession: Boolean(event?.metaKey || event?.ctrlKey) })
}))
]
}
// Group order is the tiebreaker rankGroups falls back on (stable sort), and
// exact ties are the common case — "yolo" hits both "Toggle YOLO" and a
// exact ties are the common case — "yolo" hits both "Toggle yolo" and a
// worktree named bb/yolo-* as a whole word. So this order IS the priority:
// where you're going, then what you can do, then what you can configure.
return [
@ -646,7 +816,29 @@ export function CommandPalette() {
}
]
},
...commandGroup,
projectGroup,
// Registry-contributed rows (core features + plugins) — one group,
// omitted while nothing contributes.
...(contributedItems.length > 0
? [
{
heading: cc.commands,
items: contributedItems.map(item => ({
action: item.action,
// Read on mount and after every select (the deps below), so a
// row that reports state can't show the state it just left.
detail: item.detail?.(),
detailVariant: item.detailVariant,
icon: item.icon ?? Zap,
id: item.key,
keepOpen: item.keepOpen,
keywords: item.keywords,
label: item.label,
run: item.run
}))
}
]
: []),
{
heading: cc.commandCenter,
items: [
@ -744,11 +936,11 @@ export function CommandPalette() {
]
}
]
// `open` and `selectTick` are deliberate re-read triggers, not values: rows
// report live state through `detail()`, so the groups must rebuild when the
// palette opens and after each select — eslint only sees unused deps.
// `selectTick` is a deliberate re-read trigger, not a value: rows report
// live state through `detail()`, so the groups must rebuild after a select
// that kept the palette open — eslint only sees an unused dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contributedItems, go, open, selectTick, settingsSectionLabel, t, updateVersionLabel])
}, [contributedItems, go, projectTree, selectTick, settingsSectionLabel, t, updateVersionLabel])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@ -778,6 +970,23 @@ export function CommandPalette() {
})
}
// Paste/type an absolute folder path → open it as a project directly (the
// ⌘O upsert without the native picker). Same reflex as the raw-session-id
// row above.
if (FOLDER_PATH_RE.test(directId)) {
result.push({
items: [
{
icon: codiconIcon('folder-opened'),
id: `open-folder-${directId}`,
keywords: ['open', 'folder', 'project', directId],
label: t.commandCenter.openFolderAt(directId),
run: () => void openFolderAsProject(directId)
}
]
})
}
// Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only
// lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should
// jump to the exact tab (matches the "not just the top lvl" ask).
@ -1048,101 +1257,92 @@ export function CommandPalette() {
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
// The close animation finishing is what retires this whole subtree —
// the CSS owns the duration, not a hardcoded timer. Guarded on the
// content itself (descendants animate too) and on the closed state, so
// an OPEN animation never unmounts the palette we just opened.
onAnimationEnd={event => {
if (event.target === event.currentTarget && event.currentTarget.dataset.state === 'closed') {
onExited()
}
}}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop shouldFilter={false}>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>{t.commandCenter.back}</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<PaletteGroups
bindings={bindings}
groups={visibleGroups}
modHeld={modHeld}
noResultsLabel={t.commandCenter.noResults}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
)}
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
// Capture modifiers before cmdk's Enter fires onSelect (which
// swipes the inviting MouseEvent and hands us nothing).
noteSelectMods(event)
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
return
}
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage
onGenerate={() => {
closeCommandPalette()
openPetGenerate()
}}
search={search}
/>
) : page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
(keyed to its internal filter count) would never fire. */}
{visibleGroups.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
))}
</CommandGroup>
))}
</>
)}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}

View file

@ -5,6 +5,7 @@ import { openSession } from '@/app/open-session'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { $activeGatewayProfile } from '@/store/profile'
import { openFolderAsProject } from '@/store/projects'
import {
$sessions,
getRememberedRoute,
@ -187,6 +188,13 @@ export function useDesktopIntegrations({
return () => unsubscribe?.()
}, [navigate])
// File > Open Folder… — same open-folder-as-project upsert as the ⌘O keybind.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onOpenFolderRequested?.(() => void openFolderAsProject())
return () => unsubscribe?.()
}, [])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
if (isSecondaryWindow()) {

View file

@ -528,7 +528,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
lastStartWorkTokenRef.current = startWorkSessionRequest.token
startSessionInWorkspace(startWorkSessionRequest.path)
startSessionInWorkspace(startWorkSessionRequest.path, { openTab: startWorkSessionRequest.openTab })
if (startWorkSessionRequest.draft) {
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })

View file

@ -34,7 +34,7 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { requestNewWorktree } from '@/store/projects'
import { openFolderAsProject, requestNewWorktree } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { setModelPickerOpen } from '@/store/session'
import { reopenLastClosedTile } from '@/store/session-states'
@ -174,6 +174,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Only meaningful inside a git repo — a no-op otherwise (the key falls
// through instead of silently doing nothing).
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
// ⌘O: native folder picker → open the folder as a project (upsert) with a
// fresh session anchored there.
'workspace.openFolder': () => void openFolderAsProject(),
// Narrow-viewport reveal is handled inside the store toggles now.
'view.toggleSidebar': toggleSidebarOpen,

View file

@ -100,16 +100,8 @@ const _chatMessageFieldsExhaustive: {
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
} = {}
const COMPARED_FIELDS = [
'id',
'role',
'pending',
'error',
'hidden',
'branchGroupId',
'interim',
'reactions'
] as const
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim', 'reactions'] as const
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
// Compile-time check: every ChatMessagePart discriminant must be handled by
@ -193,10 +185,7 @@ export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMess
return (
aList.length === bList.length &&
aList.every(
(reaction, index) =>
reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
)
aList.every((reaction, index) => reaction.emoji === bList[index].emoji && reaction.author === bList[index].author)
)
}

View file

@ -173,11 +173,13 @@ export function KeybindSettings() {
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
return (
<button
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
className="group/kbd-cat flex w-fit min-w-0 items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
onClick={onToggle}
type="button"
>
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
<span className="min-w-0 truncate text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
{label}
</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
open={open}

View file

@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
import {
buildGroups,
firstVisibleGroupIndex,
LIVE_TAIL_MIN_GROUPS,
LIVE_TAIL_PARTS,
liveTailStart,
type MessageGroup
} from './list'
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
// selector in list.tsx).
@ -81,32 +88,79 @@ describe('firstVisibleGroupIndex', () => {
})
})
describe('isVirtualizedGroup', () => {
it('never virtualizes the newest turns (the live tail)', () => {
const count = 20
describe('liveTailStart', () => {
const group = (id: string, weight: number): MessageGroup => ({ id, index: 0, kind: 'standalone', weight })
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
}
it('keeps the newest turns rendered until the parts budget is spent', () => {
// 10 turns x 10 parts. A 40-part tail covers the newest 4-5 turns.
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 10))
const start = liveTailStart(groups)
expect(start).toBeGreaterThan(0)
expect(start).toBeLessThan(groups.length)
// Everything from `start` onward is the live tail...
const tailParts = groups.slice(start).reduce((sum, g) => sum + g.weight, 0)
expect(tailParts).toBeGreaterThan(LIVE_TAIL_PARTS)
// ...and dropping its oldest member puts it back under budget, i.e. the
// tail is minimal rather than sprawling.
const withoutOldest = groups.slice(start + 1).reduce((sum, g) => sum + g.weight, 0)
expect(withoutOldest).toBeLessThanOrEqual(LIVE_TAIL_PARTS)
})
it('virtualizes older turns that sit before the live tail', () => {
const count = 20
it('virtualizes the old bulk of a long agent transcript', () => {
// The regression this guards: heavy tool turns. A turn-count tail (6) left
// NOTHING virtualized on transcripts like this, so every Radix overlay open
// paid a whole-document style recalc.
const groups = Array.from({ length: 40 }, (_, i) => group(`g${i}`, 120))
expect(isVirtualizedGroup(0, count)).toBe(true)
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
// Only the min-group floor stays rendered; the other 38 turns skip.
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('never virtualizes below the min-group floor, however heavy the turns', () => {
const groups = Array.from({ length: 5 }, (_, i) => group(`g${i}`, 10_000))
expect(liveTailStart(groups)).toBe(groups.length - LIVE_TAIL_MIN_GROUPS)
})
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
const count = LIVE_TAIL_GROUPS
const groups = [group('a', 5), group('b', 5), group('c', 5)]
for (let i = 0; i < count; i++) {
expect(isVirtualizedGroup(i, count)).toBe(false)
expect(liveTailStart(groups)).toBe(0)
})
it('handles an empty transcript', () => {
expect(liveTailStart([])).toBe(0)
})
it('honors a custom budget', () => {
const groups = Array.from({ length: 10 }, (_, i) => group(`g${i}`, 1))
// A 3-part budget would keep 4 turns, but the max-groups ceiling is not hit
// here, so the parts budget wins.
expect(liveTailStart(groups, 3)).toBe(6)
})
it('never renders more than the old turn-count tail did, on any shape', () => {
// Guards the one way a parts budget can regress: a long transcript of tiny
// turns, where walking back 40 parts reaches further than 6 turns would.
const shapes = [
Array.from({ length: 40 }, () => 4), // long chat, tiny turns
Array.from({ length: 40 }, () => 1), // pathological: 1-part turns
Array.from({ length: 12 }, () => 6),
[80, 120, 60, 150, 90, 200, 70], // real agent tile
[30, 45]
]
for (const weights of shapes) {
const groups = weights.map((weight, i) => group(`g${i}`, weight))
const rendered = (start: number) => weights.slice(start).reduce((a, b) => a + b, 0)
const oldStart = Math.max(0, groups.length - 6)
expect(rendered(liveTailStart(groups))).toBeLessThanOrEqual(rendered(oldStart))
}
})
it('honors a custom tail size', () => {
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
})
})

View file

@ -130,16 +130,63 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
// stick-to-bottom lock drifts and the view creeps up over older turns — the
// "long session eventually shows old responses" glitch.
//
// Keep the newest N turns always-rendered so a turn is only ever virtualized
// Keep the newest turns always-rendered so a turn is only ever virtualized
// once its layout has settled at its final size (remembered == real → skipping
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
// recalc win on long transcripts is preserved (that scales with the hundreds of
// old turns, not this small live tail).
export const LIVE_TAIL_GROUPS = 6
// recalc win on long transcripts is preserved.
//
// The tail is budgeted in PARTS, not turns, because that is what the cost
// actually scales with — the same currency as RENDER_BUDGET / FIRST_PAINT_BUDGET.
// A turn-count tail silently defeats itself on agent transcripts: one tool-heavy
// turn is 50-200 parts, so a 6-TURN tail exempted the entire visible transcript
// and nothing virtualized at all. Measured on a 5-tile window (7/3/5/3/2 groups
// per tile): zero content-visibility containers were active, and every Radix
// overlay open paid the full ~610ms whole-document recalc that #66470 fixed.
//
// 40 parts ≈ the 1-2 turns a viewport shows after scroll-to-bottom (the same
// reasoning as FIRST_PAINT_BUDGET=20, doubled so a turn that grows mid-stream
// doesn't fall out of the tail as it settles).
export const LIVE_TAIL_PARTS = 40
// Floor: always exempt at least this many turns regardless of weight, so a
// transcript of very heavy turns still keeps the streaming one unvirtualized.
export const LIVE_TAIL_MIN_GROUPS = 2
// Ceiling: never exempt more than this many turns, however light they are. On a
// long transcript of tiny turns a parts-only budget would walk back further
// than the old turn-count tail did and virtualize LESS — this keeps the new
// policy a strict improvement on every shape.
export const LIVE_TAIL_MAX_GROUPS = 6
/** True when a visible group is old enough to virtualize (outside the live tail). */
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
return indexInVisible < visibleCount - liveTail
/**
* Index of the newest group that still virtualizes everything at or after it
* is the live tail and stays rendered. Walks newest-first accumulating parts,
* so the tail covers a viewport's worth of content rather than a fixed number
* of turns, clamped to [MIN, MAX] turns. Computed once per render, not per row.
*/
export function liveTailStart(
groups: readonly MessageGroup[],
tailParts = LIVE_TAIL_PARTS,
minGroups = LIVE_TAIL_MIN_GROUPS,
maxGroups = LIVE_TAIL_MAX_GROUPS
): number {
let parts = 0
let start = groups.length
for (let i = groups.length - 1; i >= 0; i--) {
parts += groups[i]?.weight ?? 1
start = i
if (parts > tailParts) {
break
}
}
// Clamp the tail to [minGroups, maxGroups] turns: the floor keeps the live
// turn rendered when turns are huge, the ceiling stops a tail of tiny turns
// from sprawling past what the old turn-count policy rendered.
const floor = Math.max(0, groups.length - minGroups)
const ceiling = Math.max(0, groups.length - maxGroups)
return Math.min(floor, Math.max(ceiling, start))
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
@ -278,6 +325,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
// Where the always-rendered live tail begins. Derived from the WEIGHTED
// groups (parts, not turns) so the tail is a viewport's worth of content —
// see liveTailStart. Computed once here rather than per row.
const tailStart = useMemo(
() => liveTailStart(hiddenCount > 0 ? weightedGroups.slice(hiddenCount) : weightedGroups),
[weightedGroups, hiddenCount]
)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
@ -436,12 +492,11 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
// turns. See liveTailStart.
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
indexInVisible < tailStart && '[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
@ -461,7 +516,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
</MessageRenderBoundary>
</div>
)),
[visibleGroups, components, structuralSignature]
[visibleGroups, components, structuralSignature, tailStart]
)
return (

View file

@ -117,10 +117,7 @@ export const ReactionPicker: FC<{
// Opt this one surface out of the shared popover glass: emoji hover
// tints at 15% alpha are unreadable over blurred transcript text.
// Overriding the local surface var keeps the arrow matched for free.
className={cn(
'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
!expanded && 'flex gap-0.5'
)}
className={cn('w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]', !expanded && 'flex gap-0.5')}
onCloseAutoFocus={event => event.preventDefault()}
side="top"
>

View file

@ -168,7 +168,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@ -187,7 +187,11 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
editor &&
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
) {
renderComposerContents(editor, draft)
// Inert by construction — this repaints on mount or when the editor
// isn't the one being typed into. A message opened for edit is finished
// text, so a `/command` ending it is committed and chips, matching how
// the transcript rendered that same message a moment ago.
renderComposerContents(editor, draft, { trailingCommitted: true })
if (document.activeElement === editor) {
placeCaretEnd(editor)

View file

@ -45,8 +45,8 @@ import {
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
splitTreeZone,
setTreeGroupMinimized
setTreeGroupMinimized,
splitTreeZone
} from '../store'
import { type DoubleTapContext, startPaneDrag } from './drag-session'

View file

@ -11,7 +11,7 @@ interface DisclosureCaretProps extends Omit<CodiconProps, 'name'> {
export function DisclosureCaret({ className, open, size = '0.75rem', ...props }: DisclosureCaretProps) {
return (
<Codicon
className={cn('transition-transform duration-150', open && 'rotate-90', className)}
className={cn('shrink-0 transition-transform duration-150', open && 'rotate-90', className)}
name="chevron-right"
size={size}
{...props}

View file

@ -241,6 +241,7 @@ declare global {
write: (id: string, data: string) => Promise<boolean>
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenFolderRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void

View file

@ -220,6 +220,7 @@ export const ar = defineLocale({
'session.focusSearch': 'البحث في الجلسات',
'session.togglePin': 'تثبيت / إلغاء تثبيت الجلسة الحالية',
'workspace.newWorktree': 'worktree جديد',
'workspace.openFolder': 'فتح مجلد كمشروع',
'composer.focus': 'التركيز على المحرّر',
'composer.modelPicker': 'فتح منتقي النموذج',
'composer.voice': 'بدء / إيقاف المحادثة الصوتية',

View file

@ -251,6 +251,7 @@ export const en: Translations = {
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'workspace.newWorktree': 'New worktree',
'workspace.openFolder': 'Open folder as project',
'composer.focus': 'Focus composer',
'composer.modelPicker': 'Open model picker',
'composer.voice': 'Start / stop voice conversation',
@ -1145,6 +1146,10 @@ export const en: Translations = {
goTo: 'Go to',
goToSession: 'Go to session',
branches: 'Branches',
projects: 'Projects',
openFolder: 'Open folder as project…',
openFolderAt: path => `Open folder as project — ${path}`,
newSessionInProject: project => `New session in ${project}`,
commands: 'Commands',
startInBranch: branch => `New conversation in ${branch}`,
commandCenter: 'Command Center',

View file

@ -1008,6 +1008,10 @@ export interface Translations {
goTo: string
goToSession: string
branches: string
projects: string
openFolder: string
openFolderAt: (path: string) => string
newSessionInProject: (project: string) => string
commands: string
startInBranch: (branch: string) => string
commandCenter: string

View file

@ -246,6 +246,7 @@ export const zh: Translations = {
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'workspace.newWorktree': '新建工作树',
'workspace.openFolder': '打开文件夹为项目',
'composer.focus': '聚焦输入框',
'composer.modelPicker': '打开模型选择器',
'composer.voice': '开始 / 停止语音对话',
@ -1342,6 +1343,10 @@ export const zh: Translations = {
goTo: '前往',
goToSession: '前往会话',
branches: '分支',
projects: '项目',
openFolder: '打开文件夹为项目…',
openFolderAt: path => `打开文件夹为项目 — ${path}`,
newSessionInProject: project => `${project} 中新建会话`,
commands: '命令',
startInBranch: branch => `${branch} 中开始新对话`,
commandCenter: '命令中心',

View file

@ -88,6 +88,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'session.togglePin', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
// ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
// workspace::Open). Picks a folder and opens it as a project (upsert:
// enters the owning project when one exists, else creates one), landing on
// a fresh session anchored there.
{ id: 'workspace.openFolder', category: 'session', defaults: ['mod+o'] },
// ── Navigation ───────────────────────────────────────────────────────────
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },

View file

@ -161,6 +161,36 @@ export function exitProjectScope(): void {
$projectScope.set(ALL_PROJECTS)
}
// A project's working root: its primary folder, else the first repo that has
// one. Empty for the path-less Home bucket. (The sidebar's `projectTreeCwd` is
// the same rule over the same tree — this is the store-side copy so the store
// doesn't reach into the sidebar's React module.)
const projectRootCwd = (project: SidebarProjectTree | undefined): string =>
(project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
// ⌘K "go to project": flip the sidebar into grouped mode and enter the project
// — a pure scope switch, same as clicking the overview row (never spends main).
// With `newSession` (⌘-select / ⌘-Enter) it also lands on a fresh session draft
// anchored at the project root — stacked as a tab when main already holds a
// chat (palette opens are opens-from-nowhere). A path-less project (the Home
// bucket) gets a plain detached draft.
export function goToProject(id: string, options?: { newSession?: boolean }): void {
setSidebarAgentsGrouped(true)
enterProject(id)
if (!options?.newSession) {
return
}
const cwd = projectRootCwd($projectTree.get().find(node => node.id === id))
if (cwd) {
requestStartWorkSession(cwd, undefined, { openTab: true })
} else {
requestFreshSession()
}
}
// The cwd a NEW chat should start in. The "active project" is just an atom
// ($projectScope) — so when you're inside a project, a new session (cmd-n, the
// trunk "+") starts at that project's root (its primary repo = the default-branch
@ -177,8 +207,7 @@ export function resolveNewSessionCwd(): string {
}
if (scope !== ALL_PROJECTS) {
const project = $projectTree.get().find(node => node.id === scope)
const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
const cwd = projectRootCwd($projectTree.get().find(node => node.id === scope))
if (cwd) {
return cwd
@ -997,6 +1026,8 @@ export async function switchBranchInRepo(repoPath: string, branch: string): Prom
// effect even if the path repeats.
export interface StartWorkSessionRequest {
draft?: string
/** Stack the fresh session as a tab when main already holds a chat (palette/⌘O opens-from-nowhere). */
openTab?: boolean
path: string
token: number
}
@ -1016,7 +1047,7 @@ export function requestNewWorktree(): void {
let startWorkToken = 0
export function requestStartWorkSession(path: string, draft?: string): void {
export function requestStartWorkSession(path: string, draft?: string, options?: { openTab?: boolean }): void {
const target = path.trim()
if (!target) {
@ -1024,7 +1055,12 @@ export function requestStartWorkSession(path: string, draft?: string): void {
}
startWorkToken += 1
$startWorkSessionRequest.set({ draft: draft?.trim() || undefined, path: target, token: startWorkToken })
$startWorkSessionRequest.set({
draft: draft?.trim() || undefined,
openTab: options?.openTab || undefined,
path: target,
token: startWorkToken
})
}
export async function removeWorktreePath(
@ -1068,3 +1104,48 @@ export async function pickProjectFolder(): Promise<null | string> {
return dir || null
}
// ⌘O / palette "Open folder…": open a folder AS a project, upserting. A folder
// already covered by a project (explicit or auto) just enters it; anything else
// becomes a new project named after the folder. Either way the sidebar scopes
// to the project and a fresh session draft lands anchored at the folder — the
// one-keystroke version of new project → enter → new session. Like goToProject,
// this is an open-from-nowhere: an occupied main gets a stacked tab, not stolen.
export async function openFolderAsProject(dir?: string): Promise<void> {
const target = (dir ?? (await pickProjectFolder()) ?? '').trim()
if (!target) {
return
}
// Refresh first so the membership check runs against live truth — a repo
// cloned since the last scan should enter its auto project, not double-create.
await refreshProjectTree()
const existing = projectIdForCwd(target)
if (existing) {
setSidebarAgentsGrouped(true)
enterProject(existing)
} else {
const name =
target
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.pop() || target
try {
const created = await createProject({ name, folders: [target], primaryPath: target, use: true })
if (created) {
enterProject(created.id)
}
} catch (err) {
// Stale backend (no projects.* RPC) or a failed write: still open the
// folder as a plain workspace session below — the project row can wait.
notify({ kind: 'warning', message: err instanceof Error ? err.message : String(err) })
}
}
requestStartWorkSession(target, undefined, { openTab: true })
}

View file

@ -62,10 +62,7 @@ export async function toggleMessageReaction(
const gateway = activeGateway()
if (!sessionId || !gateway) {
notifyError(
new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
'Could not react'
)
notifyError(new Error(!sessionId ? 'No active session' : 'Gateway not connected'), 'Could not react')
return
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { group, split } from '@/components/pane-shell/tree/model'
import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model'
import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $selectedStoredSessionId } from '@/store/session'
import type { SessionTile } from '@/store/session-states'
@ -138,3 +138,92 @@ describe('blankDraftTile', () => {
expect(blankDraftTile([], {})).toBeNull()
})
})
// ⌘⇧T used to only restore `$sessionTiles`. Adoption inserts silently
// (activate:false), so the tab came back behind the still-fronted workspace.
// Real path: register, adopt, focus — same as paneMirror + reopen.
describe('reopenLastClosedTile focuses the restored tab', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
const session = await import('@/store/session')
const states = await import('@/store/session-states')
registry.register({
area: 'panes',
data: { placement: 'main', uncloseable: true },
id: 'workspace',
render: () => null,
title: 'chat'
})
// panes ← $sessionTiles (paneMirror stub). Adoption is synchronous on
// register, so openSessionTile + focusOpenSession works the same tick.
const registered = new Map<string, () => void>()
const syncTiles = () => {
const wanted = new Set(states.$sessionTiles.get().map(t => t.storedSessionId))
for (const id of wanted) {
if (registered.has(id)) {
continue
}
registered.set(
id,
registry.register({
area: 'panes',
data: { dock: { pane: 'workspace', pos: 'center' }, placement: 'main' },
id: tilePane(id),
render: () => null,
title: id
})
)
}
for (const [id, dispose] of registered) {
if (!wanted.has(id)) {
dispose()
registered.delete(id)
tree.removeTreePane(tilePane(id))
}
}
}
states.$sessionTiles.listen(syncTiles)
tree.watchContributedPanes()
session.$selectedStoredSessionId.set('primary')
tree.declareDefaultTree(model.group(['workspace'], { active: 'workspace', id: 'grp-main' }))
states.openSessionTile('closed', 'center', 'workspace')
states.focusOpenSession('closed')
tree.noteActiveTreeGroup('grp-main')
expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
return { states, tree }
}
it('fronts the restored tab after ⌘⇧T', async () => {
const { states, tree } = await setup()
states.closeSessionTile('closed')
expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(false)
expect(findGroupOfPane(tree.$layoutTree.get()!, 'workspace')?.active).toBe('workspace')
states.reopenLastClosedTile()
expect(states.$sessionTiles.get().some(t => t.storedSessionId === 'closed')).toBe(true)
expect(findGroupOfPane(tree.$layoutTree.get()!, tilePane('closed'))?.active).toBe(tilePane('closed'))
expect(tree.$activeTreeGroup.get()).toBe('grp-main')
})
})

View file

@ -702,8 +702,10 @@ export function discardSessionTile(storedSessionId: string) {
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
}
/** T reopen the most recently closed tab where it was. Skips ids that are
* live again (reopened, or now the primary). */
/** T reopen the most recently closed tab where it was, then focus it.
* Adoption alone is silent (won't steal the active tab), so restore has to
* front the pane explicitly. Skips ids that are live again (reopened / now
* the primary). */
export function reopenLastClosedTile(): void {
const stack = closedStack()
@ -716,6 +718,7 @@ export function reopenLastClosedTile(): void {
if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
focusOpenSession(storedSessionId)
return
}

View file

@ -18239,11 +18239,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
(voice_mode == "all")
or (voice_mode == "voice_only" and is_voice_input)
# ``voice.auto_tts`` is synced into the adapter on gateway startup.
# Treat it as "voice accompanies text replies" unless a chat was
# explicitly turned off. The base adapter's own auto-TTS path only
# covers voice-input replies, so final text replies need the runner
# path here.
or (voice_mode != "off" and adapter_auto_tts)
# It is the fallback only when the chat has no explicit mode;
# otherwise the chat-level all/voice_only/off choice takes precedence.
or (voice_mode is None and adapter_auto_tts)
)
if not should:
logger.debug(

View file

@ -27,6 +27,15 @@ A marker only counts as a live update when its pid is alive AND it is younger
than :data:`UPDATE_MARKER_MAX_AGE_MS` mirroring ``readLiveUpdateMarker`` so a
crashed updater self-heals instead of wedging every future update. A stale
marker is removed on read by whoever notices it first.
One layering wrinkle: the Tauri updater holds this marker for its WHOLE run and
then spawns ``hermes update`` as a child stage. Without a handoff the child
sees its own parent's live marker and refuses — the GUI update deadlocks
against itself on every attempt ("Hermes is still running", retry forever).
The updater therefore exports :data:`HANDOFF_PID_ENV` naming its own pid, and
``acquire`` treats a live holder matching that pid as the lock we are already
running under. The env var alone grants nothing: the pid must also be the
live marker owner, so a stale or forged value cannot bypass the lock.
"""
from __future__ import annotations
@ -47,6 +56,13 @@ UPDATE_MARKER_MAX_AGE_SECONDS = 20 * 60
MARKER_NAME = ".hermes-update-in-progress"
# Set by an orchestrating updater (the Tauri `hermes-setup --update` flow) to
# its own pid before spawning `hermes update` as a child stage. The parent
# holds the marker for its whole run, so without this the child refuses its
# own parent's lock and the GUI update can never complete. See update_child_env
# in apps/bootstrap-installer/src-tauri/src/update.rs — keep the name in sync.
HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID"
# Exit code meaning "another updater/instance owns this install right now".
# Already the de-facto contract: the Windows shim + venv-holder guards in
# _cmd_update_impl exit 2, and the Tauri updater matches on it
@ -95,6 +111,22 @@ def _pid_alive(pid: int) -> bool:
return False
def _handoff_pid() -> int | None:
"""Pid of the orchestrating updater that spawned us, if any.
Read from :data:`HANDOFF_PID_ENV`. Malformed values count as absent
a broken handoff must fall back to the normal refusal, never crash.
"""
raw = os.environ.get(HANDOFF_PID_ENV, "").strip()
if not raw:
return None
try:
pid = int(raw)
except ValueError:
return None
return pid if pid > 0 else None
@dataclass(frozen=True)
class UpdateHolder:
"""A confirmed-live update currently holding the lock."""
@ -168,9 +200,17 @@ class UpdateLock:
self.holder: UpdateHolder | None = None
def acquire(self) -> bool:
"""Claim the lock. Returns False (and sets ``holder``) if it's taken."""
"""Claim the lock. Returns False (and sets ``holder``) if it's taken.
A live holder whose pid matches :data:`HANDOFF_PID_ENV` is our own
orchestrating parent (the Tauri updater spawning `hermes update` as a
stage): we run under ITS claim rather than refusing or re-writing the
marker, and ``release`` leaves the parent's marker untouched.
"""
existing = read_live_update(path=self.path)
if existing is not None:
if existing.pid == _handoff_pid():
return True
self.holder = existing
return False
try:

View file

@ -73,6 +73,25 @@ class TestAutoVoiceReplyFormat:
voice_event, "hello", [], already_sent=True
) is True
def test_should_send_voice_reply_voice_only_still_requires_voice_input(self):
"""Explicit voice_only must not widen to text input (#73508 regression).
Persisted voice_only mode is synced into the adapter as an explicit
auto-TTS opt-in, so adapter_auto_tts is True for this chat. The
chat-level mode stays authoritative: text input gets no voice reply,
voice input still does.
"""
runner = _make_runner()
runner._voice_mode["telegram:123"] = "voice_only"
adapter = _make_adapter(Platform.TELEGRAM)
adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
runner.adapters[Platform.TELEGRAM] = adapter
event = _make_event(Platform.TELEGRAM, chat_id="123")
assert runner._should_send_voice_reply(event, "hello", []) is False
voice_event = _make_event(Platform.TELEGRAM, chat_id="123", message_type=MessageType.VOICE)
assert runner._should_send_voice_reply(voice_event, "hello", [], already_sent=True) is True
def _make_runner() -> GatewayRunner:
with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):

View file

@ -22,6 +22,7 @@ import time
import pytest
from hermes_cli.update_lock import (
HANDOFF_PID_ENV,
UPDATE_MARKER_MAX_AGE_SECONDS,
UpdateLock,
describe_holder,
@ -175,3 +176,51 @@ def test_unwritable_marker_location_does_not_block_the_update(tmp_path):
assert lock.acquire() is True
assert lock.acquired is False, "nothing was written, so there is nothing to release"
class TestHandoffFromOrchestratingUpdater:
"""The Tauri updater holds the marker, then spawns ``hermes update``.
The regression: the child saw its own parent's live marker and exited 2,
so every GUI update failed with "Hermes is still running" and retrying
just re-ran the same self-deadlock. The parent names its pid in
HANDOFF_PID_ENV; a live holder matching it is our own orchestrator.
"""
def test_child_runs_under_the_parents_live_claim(self, marker, monkeypatch):
# Stand in for the parent updater with our own (live) pid.
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
lock = UpdateLock(path=marker)
assert lock.acquire() is True
assert lock.acquired is False, "the parent's claim is not ours to own"
lock.release()
assert marker.exists(), "the parent still needs its marker after our stage ends"
assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()
def test_handoff_pid_that_is_not_the_live_holder_grants_nothing(self, marker, monkeypatch):
"""The env var alone must not bypass the lock."""
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid() + 1))
lock = UpdateLock(path=marker)
assert lock.acquire() is False
assert lock.holder is not None
@pytest.mark.parametrize("value", ["", "not-a-pid", "-1", "0"], ids=["empty", "garbage", "negative", "zero"])
def test_malformed_handoff_values_fall_back_to_refusal(self, marker, monkeypatch, value):
marker.write_text(f"{os.getpid()}\n{int(time.time())}\n", encoding="utf-8")
monkeypatch.setenv(HANDOFF_PID_ENV, value)
assert UpdateLock(path=marker).acquire() is False
def test_handoff_env_with_no_marker_claims_normally(self, marker, monkeypatch):
"""A handoff pid must not stop us writing our own claim when unlocked."""
monkeypatch.setenv(HANDOFF_PID_ENV, str(os.getpid()))
lock = UpdateLock(path=marker)
assert lock.acquire() is True
assert lock.acquired is True
assert int(marker.read_text(encoding="utf-8").splitlines()[0]) == os.getpid()

View file

@ -29,6 +29,7 @@ export { default as useStdin } from './src/ink/hooks/use-stdin.ts'
export { useTabStatus } from './src/ink/hooks/use-tab-status.ts'
export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts'
export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts'
export type { TerminalTitlePair } from './src/ink/hooks/use-terminal-title.ts'
export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts'
export { default as measureElement } from './src/ink/measure-element.ts'
export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts'

View file

@ -21,6 +21,7 @@ export { default as useStdin } from './ink/hooks/use-stdin.js'
export { useTabStatus } from './ink/hooks/use-tab-status.js'
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export type { TerminalTitlePair } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
export { default as measureElement } from './ink/measure-element.js'
export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'

View file

@ -7,15 +7,20 @@ import { TerminalWriteContext } from '../useTerminalNotification.js'
/**
* Declaratively set the terminal tab/window title.
*
* Pass a string to set the title. ANSI escape sequences are stripped
* automatically so callers don't need to know about terminal encoding.
* Pass a single string to set both the tab and window title (OSC 0).
* Pass `{ tab, window }` to set them independently: the short `tab` string
* goes to OSC 1 (icon/tab label) and the longer `window` string goes to
* OSC 2 (window title bar). This matters for terminals like Apple
* Terminal.app whose narrow background tabs truncate the title from the
* left a single long OSC 0 string leaves only the tail visible, while a
* separate short OSC 1 keeps the session name readable.
*
* Pass `null` to opt out the hook becomes a no-op and leaves the
* terminal title untouched.
*
* On Windows, uses `process.title` (classic conhost doesn't support OSC).
* Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout.
*/
export function useTerminalTitle(title: string | null): void {
export function useTerminalTitle(title: string | TerminalTitlePair | null): void {
const writeRaw = useContext(TerminalWriteContext)
useEffect(() => {
@ -23,12 +28,37 @@ export function useTerminalTitle(title: string | null): void {
return
}
const clean = stripAnsi(title)
if (process.platform === 'win32') {
const clean = stripAnsi(typeof title === 'string' ? title : (title.window ?? title.tab ?? ''))
process.title = clean
} else {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean))
return
}
if (typeof title === 'string') {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, stripAnsi(title)))
return
}
// Separate tab (OSC 1) and window (OSC 2) titles so narrow tab bars
// show the short session name instead of a truncated tail.
const tab = stripAnsi(title.tab ?? '')
const window = stripAnsi(title.window ?? '')
if (tab && window) {
writeRaw(osc(OSC.SET_ICON, tab) + osc(OSC.SET_TITLE, window))
} else if (window) {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, window))
} else if (tab) {
writeRaw(osc(OSC.SET_TITLE_AND_ICON, tab))
}
}, [title, writeRaw])
}
export interface TerminalTitlePair {
/** Short title for the tab/icon label (OSC 1). */
tab?: string
/** Full title for the window title bar (OSC 2). */
window?: string
}

View file

@ -626,7 +626,12 @@ export function useMainApp(gw: GatewayClient) {
const tabCwd = ui.info?.cwd
useTerminalTitle(
model ? composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '') : 'Hermes'
model
? {
tab: composeTabTitle(marker, ui.sessionTitle, '', ''),
window: composeTabTitle(marker, ui.sessionTitle, model, tabCwd ? shortCwd(tabCwd, 24) : '')
}
: 'Hermes'
)
useEffect(() => {

View file

@ -167,7 +167,11 @@ declare module '@hermes/ink' {
readonly write: (data: string) => boolean
}
export function useTerminalFocus(): boolean
export function useTerminalTitle(title: string | null): void
export function useTerminalTitle(title: string | TerminalTitlePair | null): void
export interface TerminalTitlePair {
tab?: string
window?: string
}
export function useDeclaredCursor(args: {
readonly line: number
readonly column: number