Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([

const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)

const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
const PICKER_OWNED_COMMANDS = new Set(['/model'])

const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class CommandDef:
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration",
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
args_hint="[model] [--provider name] [--global] [--refresh]"),
Comment thread
OutThisLife marked this conversation as resolved.
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
args_hint="[auto|codex_app_server]"),
Expand Down
13 changes: 11 additions & 2 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1666,12 +1666,21 @@ def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypa
assert resp["result"]["provider"] == "bedrock"


def test_complete_slash_includes_provider_alias():
def test_complete_slash_drops_removed_provider_alias():
# `/provider` was folded into a single `/model` command, so autocomplete
# must no longer offer the dead alias...
resp = server.handle_request(
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
)

assert any(item["text"] == "provider" for item in resp["result"]["items"])
assert not any(item["text"] == "provider" for item in resp["result"]["items"])

# ...while `/model` stays the canonical command.
resp_model = server.handle_request(
{"id": "2", "method": "complete.slash", "params": {"text": "/mod"}}
)

assert any(item["text"] == "model" for item in resp_model["result"]["items"])


def test_complete_slash_returns_plain_string_fields():
Expand Down
67 changes: 57 additions & 10 deletions ui-tui/src/__tests__/activeSessionSwitcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import { describe, expect, it } from 'vitest'

import { DEFAULT_THEME } from '../theme.js'
import type { SessionActiveItem } from '../gatewayTypes.js'
import {
activeSessionCountLabel,
canTypeOrchestratorPrompt,
currentSessionSelectionIndex,
orchestratorContextHint,
orchestratorContextHintSegments,
orchestratorGlobalHotkeyHint,
orchestratorGlobalHotkeyHintSegments,
orchestratorHintSegmentColor,
clampOrchestratorSelection,
closeFallbackAfterClose,
currentSessionSelectionIndex,
draftModelArgFromPickerValue,
draftModelDisplayLabel,
fixedSessionColumnStyle,
draftTitleFromPrompt,
fixedSessionColumnStyle,
isNewSessionRow,
newSessionMarkerColor,
newSessionRowIndex,
orchestratorContextHint,
orchestratorContextHintSegments,
orchestratorGlobalHotkeyHint,
orchestratorGlobalHotkeyHintSegments,
orchestratorHintSegmentColor,
orchestratorRowClickAction,
orchestratorVisibleRowIndexes,
selectedSessionRowStyle
relativeSessionAge,
resumableHistory,
selectedSessionRowStyle,
sessionRowKindAt,
sessionsCountLabel
} from '../components/activeSessionSwitcher.js'
import type { SessionActiveItem } from '../gatewayTypes.js'
import type { SessionListItem } from '../gatewayTypes.js'
import { DEFAULT_THEME } from '../theme.js'

describe('session orchestrator helpers', () => {
it('labels live sessions compactly for tight overlays', () => {
Expand Down Expand Up @@ -155,3 +160,45 @@ describe('session orchestrator helpers', () => {
)
})
})

describe('unified Sessions overlay helpers', () => {
it('orders rows as [new][live…][history…]', () => {
// 2 live sessions, any number of history rows after them.
expect(sessionRowKindAt(0, 2)).toBe('new')
expect(sessionRowKindAt(1, 2)).toBe('live')
expect(sessionRowKindAt(2, 2)).toBe('live')
expect(sessionRowKindAt(3, 2)).toBe('history')
expect(sessionRowKindAt(9, 2)).toBe('history')
// No live sessions: row 0 is new, everything after is history.
expect(sessionRowKindAt(0, 0)).toBe('new')
expect(sessionRowKindAt(1, 0)).toBe('history')
})

it('drops already-live sessions from the resumable history (dedupe by id)', () => {
const history = [
{ id: 'a', message_count: 1, preview: '', started_at: 0, title: 'A' },
{ id: 'b', message_count: 2, preview: '', started_at: 0, title: 'B' },
{ id: 'c', message_count: 3, preview: '', started_at: 0, title: 'C' }
] satisfies SessionListItem[]

const live = [{ id: 'b', status: 'idle' }] satisfies SessionActiveItem[]

expect(resumableHistory(history, live).map(h => h.id)).toEqual(['a', 'c'])
expect(resumableHistory(history, []).map(h => h.id)).toEqual(['a', 'b', 'c'])
})

it('labels live + resumable counts compactly', () => {
expect(sessionsCountLabel(0, 0)).toBe('0 live · 0 resumable')
expect(sessionsCountLabel(2, 7)).toBe('2 live · 7 resumable')
})

it('renders relative session age, blank when unknown', () => {
const nowSec = Math.floor(Date.now() / 1000)

expect(relativeSessionAge(nowSec)).toBe('today')
expect(relativeSessionAge(nowSec - 36 * 3600)).toBe('yesterday')
expect(relativeSessionAge(nowSec - 3 * 86400)).toBe('3d ago')
expect(relativeSessionAge(undefined)).toBe('')
expect(relativeSessionAge(0)).toBe('')
})
})
30 changes: 27 additions & 3 deletions ui-tui/src/__tests__/createSlashHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ describe('createSlashHandler', () => {
resetUiState()
})

it('opens the resume picker locally', () => {
it('opens the unified sessions overlay for /resume', () => {
const ctx = buildCtx()

expect(createSlashHandler(ctx)('/resume')).toBe(true)
expect(getOverlayState().picker).toBe(true)
expect(getOverlayState().sessions).toBe(true)
})

it('opens the live session switcher locally even when the current session is busy', () => {
it('resumes a prior session by id when /resume has an argument', () => {
const ctx = buildCtx()

expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-old')
expect(getOverlayState().sessions).toBe(false)
})

it('opens the unified sessions overlay locally even when the current session is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx()

Expand All @@ -28,6 +36,22 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})

it('blocks immediate resume-by-id while a turn is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx({ session: { ...buildSession(), guardBusySessionSwitch: vi.fn(() => true) } })

expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
expect(ctx.session.guardBusySessionSwitch).toHaveBeenCalled()
expect(ctx.session.resumeById).not.toHaveBeenCalled()
})

it('treats /session (singular) as an alias of the sessions overlay', () => {
const ctx = buildCtx()

expect(createSlashHandler(ctx)('/session')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
})

it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()

Expand Down
3 changes: 1 addition & 2 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export interface OverlayState {
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
picker: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
Expand Down Expand Up @@ -385,7 +384,7 @@ export interface AppOverlaysProps {
onModelSelect: (value: string) => void
onNewLiveSession: () => void
onNewPromptSession: (prompt: string, modelArg?: string) => void
onPickerSelect: (sessionId: string) => void
onResumeSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
Expand Down
8 changes: 3 additions & 5 deletions ui-tui/src/app/overlayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({
confirm: null,
modelPicker: false,
pager: null,
picker: false,
secret: null,
sessions: false,
skillsHub: false,
Expand All @@ -21,8 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())

export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || sessions || skillsHub || sudo)
({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo)
)

export const getOverlayState = () => $overlayState.get()
Expand All @@ -36,7 +35,7 @@ export const resetOverlayState = () => $overlayState.set(buildOverlayState())
/**
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
* / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model
* picker, skills hub, session picker. Those are opened deliberately and
* picker, skills hub, sessions overlay. Those are opened deliberately and
* shouldn't vanish when a turn ends. Called from turnController.idle() on
* every turn completion / interrupt; the old "reset everything" behaviour
* silently closed /agents the moment delegation finished.
Expand All @@ -47,7 +46,6 @@ export const resetFlowOverlays = () =>
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub
})
12 changes: 0 additions & 12 deletions ui-tui/src/app/slash/commands/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,18 +204,6 @@ export const coreCommands: SlashCommand[] = [
}
},

{
help: 'resume a prior session',
name: 'resume',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}

arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true })
}
},

{
help: 'set or show current session title',
name: 'title',
Expand Down
22 changes: 19 additions & 3 deletions ui-tui/src/app/slash/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,30 @@ export const sessionCommands: SlashCommand[] = [
},

{
aliases: ['switch'],
help: 'switch between live TUI sessions',
aliases: ['switch', 'session', 'resume'],
help: 'browse, switch, or resume sessions',
name: 'sessions',
run: (arg, ctx) => {
if (arg.trim().toLowerCase() === 'new') {
const trimmed = arg.trim()

// A new *live* session keeps the current one running in the background
// (it doesn't close it), so fanning out while busy is allowed — that's
// the whole point of multiple live sessions.
if (trimmed.toLowerCase() === 'new') {
return ctx.session.newLiveSession()
}

// `/resume <id|title>` (and `/sessions <id>`) load a cold session and
// CLOSE the current one, so guard it while a turn is in-flight to avoid
// corrupting streaming/busy state. Bare opens the overlay to browse.
if (trimmed) {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}

return ctx.session.resumeById(trimmed)
}

patchOverlayState({ sessions: true })
}
},
Expand Down
8 changes: 4 additions & 4 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ skillsHub: false })
}

if (overlay.picker) {
return patchOverlayState({ picker: false })
if (overlay.sessions) {
return patchOverlayState({ sessions: false })
}

if (overlay.agents) {
Expand Down Expand Up @@ -341,8 +341,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {

if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.picker) {
patchOverlayState({ picker: false })
} else if (key.escape && overlay.sessions) {
patchOverlayState({ sessions: false })
}

// When a prompt overlay is up and the user pressed a scroll key, fall
Expand Down
13 changes: 12 additions & 1 deletion ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,17 @@ export function useMainApp(gw: GatewayClient) {
newLiveSession: () => session.newLiveSession(),
newPromptSession,
onModelSelect,
resumeById: session.resumeById,
// Resuming a cold session from the overlay CLOSES the current one, so it
// must respect the busy guard just like the `/resume` slash path.
// (Switching between live sessions and `+ new` keep the current session
// running, so those stay unguarded — that's the orchestrator's purpose.)
resumeById: (id: string) => {
if (session.guardBusySessionSwitch('switch sessions')) {
return
}

session.resumeById(id)
},
setStickyPrompt
}),
[
Expand All @@ -1001,6 +1011,7 @@ export function useMainApp(gw: GatewayClient) {
newPromptSession,
onModelSelect,
session.activateLiveSession,
session.guardBusySessionSwitch,
session.newLiveSession,
session.resumeById
]
Expand Down
2 changes: 1 addition & 1 deletion ui-tui/src/app/useSessionLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {

const resumeById = useCallback(
(id: string) => {
patchOverlayState({ picker: false })
patchOverlayState({ sessions: false })
patchUiState({ status: 'resuming…' })

rpc<SetupStatusResponse>('setup.status', {}).then(setup => {
Expand Down
Loading
Loading