mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
fix(tui): close slash parity gaps with CLI (#20339)
* fix(tui): close slash parity gaps with CLI Route unsupported /skills subcommands through slash.exec, support /new <name> titles, and handle /redraw natively so TUI behavior matches classic CLI. Also filter gateway-only commands out of the TUI catalog while keeping /status discoverable. * fix(tui): run remaining CLI parity paths natively Forward chat launch flags into the TUI runtime and handle live-session status and skill reloads in the gateway process so TUI state no longer depends on the slash worker's stale CLI instance. * fix(tui): block stale snapshot restores Prevent snapshot restore from running through the isolated slash worker because it mutates disk state without refreshing the live TUI agent. * chore: uptick * fix(tui): guard async session title updates Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging.
This commit is contained in:
parent
acca3ec3af
commit
794f48766c
14 changed files with 1266 additions and 284 deletions
|
|
@ -18,6 +18,27 @@ describe('createSlashHandler', () => {
|
|||
expect(getOverlayState().picker).toBe(true)
|
||||
})
|
||||
|
||||
it('handles /redraw locally without slash worker fallback', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
|
||||
})
|
||||
|
||||
it('routes /status to live session.status instead of slash worker', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/status')).toBe(true)
|
||||
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps typed /model switches session-scoped by default', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
|
||||
|
|
@ -157,12 +178,49 @@ describe('createSlashHandler', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('shows usage for an unknown /skills subcommand', () => {
|
||||
it('delegates non-native /skills subcommands to slash.exec', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills zzz')
|
||||
createSlashHandler(ctx)('/skills check')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'skills check',
|
||||
session_id: null
|
||||
})
|
||||
})
|
||||
|
||||
it('passes /new <title> through to the session lifecycle', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/new sprint planning')
|
||||
getOverlayState().confirm?.onConfirm()
|
||||
|
||||
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads skills in the live gateway and refreshes the catalog', async () => {
|
||||
const rpc = vi.fn((method: string) => {
|
||||
if (method === 'skills.reload') {
|
||||
return Promise.resolve({ output: '42 skill(s) available' })
|
||||
}
|
||||
if (method === 'commands.catalog') {
|
||||
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
createSlashHandler(ctx)('/reload-skills')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
|
||||
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
|
||||
)
|
||||
})
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Regressions from Copilot review on #19835: /voice output + frontend
|
||||
|
|
@ -192,9 +250,7 @@ describe('createSlashHandler', () => {
|
|||
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
|
||||
})
|
||||
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ch: 'r', mod: 'alt' })
|
||||
)
|
||||
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
|
||||
})
|
||||
|
||||
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
|
||||
|
|
@ -447,17 +503,17 @@ describe('createSlashHandler', () => {
|
|||
local: {
|
||||
catalog: {
|
||||
canon: {
|
||||
'/status': '/status',
|
||||
'/statusbar': '/statusbar'
|
||||
'/profile': '/profile',
|
||||
'/plugins': '/plugins'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(createSlashHandler(ctx)('/status')).toBe(true)
|
||||
expect(createSlashHandler(ctx)('/profile')).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'status',
|
||||
command: 'profile',
|
||||
session_id: null
|
||||
})
|
||||
})
|
||||
|
|
@ -675,7 +731,8 @@ const buildLocal = () => ({
|
|||
catalog: null,
|
||||
getHistoryItems: vi.fn(() => []),
|
||||
getLastUserMsg: vi.fn(() => ''),
|
||||
maybeWarn: vi.fn()
|
||||
maybeWarn: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
})
|
||||
|
||||
const buildSession = () => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
|
||||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
|
||||
import type {
|
||||
CommandsCatalogResponse,
|
||||
ConfigFullResponse,
|
||||
|
|
@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||
let startupPromptSubmitted = false
|
||||
|
||||
// Inject the disk-save callback into turnController so recordMessageComplete
|
||||
// can fire-and-forget a persist without having to plumb a gateway ref around.
|
||||
|
|
@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}, ms)
|
||||
}
|
||||
|
||||
const scheduleStartupPrompt = () => {
|
||||
if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) {
|
||||
return
|
||||
}
|
||||
|
||||
startupPromptSubmitted = true
|
||||
setTimeout(async () => {
|
||||
let sid = getUiState().sid
|
||||
|
||||
for (let i = 0; !sid && i < 40; i += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
sid = getUiState().sid
|
||||
}
|
||||
|
||||
if (!sid) {
|
||||
return sys('startup query skipped: no active session')
|
||||
}
|
||||
|
||||
if (STARTUP_IMAGE) {
|
||||
try {
|
||||
await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid })
|
||||
} catch (e) {
|
||||
sys(`startup image attach failed: ${rpcErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Terminal statuses are never overwritten by late-arriving live events —
|
||||
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
||||
// `failed` or `interrupted` terminal state (Copilot review #14045).
|
||||
|
|
@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (STARTUP_RESUME_ID) {
|
||||
patchUiState({ status: 'resuming…' })
|
||||
resumeById(STARTUP_RESUME_ID)
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (!cfg?.config?.display?.tui_auto_resume_recent) {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (target) {
|
||||
patchUiState({ status: 'resuming most recent…' })
|
||||
resumeById(target)
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export interface InputHandlerActions {
|
|||
die: () => void
|
||||
dispatchSubmission: (full: string) => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ export interface GatewayEventHandlerContext {
|
|||
session: {
|
||||
STARTUP_RESUME_ID: string
|
||||
colsRef: MutableRefObject<number>
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
resetSession: () => void
|
||||
resumeById: (id: string) => void
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
|
|
@ -272,12 +272,13 @@ export interface SlashHandlerContext {
|
|||
getHistoryItems: () => Msg[]
|
||||
getLastUserMsg: () => string
|
||||
maybeWarn: (value: unknown) => void
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
}
|
||||
session: {
|
||||
closeSession: (targetSid?: null | string) => Promise<unknown>
|
||||
die: () => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
resetVisibleHistory: (info?: null | SessionInfo) => void
|
||||
resumeById: (id: string) => void
|
||||
setSessionStartedAt: StateSetter<number>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { forceRedraw } from '@hermes/ink'
|
||||
|
||||
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
|
||||
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
||||
import { HOTKEYS } from '../../../content/hotkeys.js'
|
||||
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
|
||||
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
||||
import type {
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
SessionSaveResponse,
|
||||
SessionStatusResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SessionUndoResponse
|
||||
|
|
@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
|
|||
aliases: ['new'],
|
||||
help: 'start a new session',
|
||||
name: 'clear',
|
||||
run: (_arg, ctx, cmd) => {
|
||||
run: (arg, ctx, cmd) => {
|
||||
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = cmd.startsWith('/new')
|
||||
const requestedTitle = isNew ? arg.trim() : ''
|
||||
|
||||
const commit = () => {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
ctx.session.newSession(isNew ? 'new session started' : undefined)
|
||||
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
|
||||
}
|
||||
|
||||
if (NO_CONFIRM_DESTRUCTIVE) {
|
||||
|
|
@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'force a full UI repaint',
|
||||
name: 'redraw',
|
||||
run: (_arg, ctx) => {
|
||||
forceRedraw(process.stdout)
|
||||
ctx.transcript.sys('ui redrawn')
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'show live session info',
|
||||
name: 'status',
|
||||
run: (_arg, ctx) => {
|
||||
if (!ctx.sid) {
|
||||
return ctx.transcript.sys('no active session')
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
|
||||
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'resume a prior session',
|
||||
name: 'resume',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
BrowserManageResponse,
|
||||
CommandsCatalogResponse,
|
||||
DelegationPauseResponse,
|
||||
ProcessStopResponse,
|
||||
ReloadEnvResponse,
|
||||
|
|
@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
|
|||
total_pages?: number
|
||||
}
|
||||
|
||||
interface SkillsReloadResponse {
|
||||
output?: string
|
||||
}
|
||||
|
||||
export const opsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'stop background processes',
|
||||
|
|
@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['reload_skills'],
|
||||
help: 're-scan installed skills in the live TUI gateway',
|
||||
name: 'reload-skills',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway
|
||||
.rpc<SkillsReloadResponse>('skills.reload', {})
|
||||
.then(
|
||||
ctx.guarded<SkillsReloadResponse>(r => {
|
||||
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
|
||||
ctx.gateway
|
||||
.rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(
|
||||
ctx.guarded<CommandsCatalogResponse>(catalog => {
|
||||
if (!catalog?.pairs) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.local.setCatalog({
|
||||
canon: (catalog.canon ?? {}) as Record<string, string>,
|
||||
categories: catalog.categories ?? [],
|
||||
pairs: catalog.pairs as [string, string][],
|
||||
skillCount: (catalog.skill_count ?? 0) as number,
|
||||
sub: (catalog.sub ?? {}) as Record<string, string[]>
|
||||
})
|
||||
})
|
||||
)
|
||||
.catch(() => {})
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'browse, inspect, install skills',
|
||||
name: 'skills',
|
||||
run: (arg, ctx) => {
|
||||
run: (arg, ctx, cmd) => {
|
||||
const text = arg.trim()
|
||||
|
||||
if (!text) {
|
||||
|
|
@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
|
|||
const query = rest.join(' ').trim()
|
||||
const { rpc } = ctx.gateway
|
||||
const { panel, sys } = ctx.transcript
|
||||
const runViaSlashWorker = () => {
|
||||
ctx.gateway.gw
|
||||
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||
.then(r => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = r?.output || '/skills: no output'
|
||||
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
|
||||
|
||||
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
|
||||
})
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
|
||||
|
|
@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
|
|||
return
|
||||
}
|
||||
|
||||
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
|
||||
runViaSlashWorker()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -16,8 +16,8 @@ import type {
|
|||
} from '../gatewayTypes.js'
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { composerPromptWidth } from '../lib/inputMetrics.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
|
|
@ -631,7 +631,8 @@ export function useMainApp(gw: GatewayClient) {
|
|||
catalog,
|
||||
getHistoryItems: () => historyItemsRef.current,
|
||||
getLastUserMsg: () => lastUserMsgRef.current,
|
||||
maybeWarn
|
||||
maybeWarn,
|
||||
setCatalog
|
||||
},
|
||||
session: {
|
||||
closeSession: session.closeSession,
|
||||
|
|
@ -723,9 +724,12 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const anyPanelVisible = SECTION_NAMES.some(
|
||||
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
)
|
||||
const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const thinkingPanelVisible =
|
||||
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const toolsPanelVisible =
|
||||
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const activityPanelVisible =
|
||||
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
const showProgressArea = useTurnSelector(state =>
|
||||
anyPanelVisible
|
||||
|
|
@ -738,7 +742,9 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const hasTrailTools = Boolean(segment.tools?.length)
|
||||
|
||||
if (segment.kind === 'trail' && !segment.text) {
|
||||
return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
return (
|
||||
(thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'
|
|||
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import { evictInkCaches } from '@hermes/ink'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
SessionCloseResponse,
|
||||
SessionCreateResponse,
|
||||
SessionResumeResponse,
|
||||
SessionTitleResponse,
|
||||
SetupStatusResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
|
@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
)
|
||||
|
||||
const newSession = useCallback(
|
||||
async (msg?: string) => {
|
||||
async (msg?: string, title?: string) => {
|
||||
const setup = await rpc<SetupStatusResponse>('setup.status', {})
|
||||
|
||||
if (setup?.provider_configured === false) {
|
||||
|
|
@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
}
|
||||
|
||||
const info = r.info ?? null
|
||||
const requestedTitle = title?.trim() ?? ''
|
||||
|
||||
resetSession()
|
||||
setSessionStartedAt(Date.now())
|
||||
|
|
@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
if (msg) {
|
||||
sys(msg)
|
||||
}
|
||||
|
||||
if (requestedTitle) {
|
||||
rpc<SessionTitleResponse>('session.title', {
|
||||
session_id: r.session_id,
|
||||
title: requestedTitle
|
||||
})
|
||||
.then(result => {
|
||||
if (!result || getUiState().sid !== r.session_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTitle = (result.title ?? requestedTitle).trim()
|
||||
const suffix = result.pending ? ' (queued while session initializes)' : ''
|
||||
sys(`session title set: ${nextTitle}${suffix}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getUiState().sid !== r.session_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
sys(`warning: failed to set session title: ${message}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
|
||||
|
||||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
|
||||
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
|
||||
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
|
||||
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ export interface SessionUsageResponse {
|
|||
total?: number
|
||||
}
|
||||
|
||||
export interface SessionStatusResponse {
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface SessionCompressResponse {
|
||||
after_messages?: number
|
||||
after_tokens?: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue