From 5a940180b492c6cae93cea35ba2c47cb28eb7d86 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:44:31 -0500 Subject: [PATCH] fix(tui): show the invocation for a skill send, not the skill body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry a display string through the submit path so the transcript renders what the user typed while the agent still receives the expanded skill. Drops the '⚡ loading skill' line — the invocation bubble says it. --- .../src/__tests__/createSlashHandler.test.ts | 13 +++++++++---- ui-tui/src/app/createSlashHandler.ts | 19 +++++++++++++++---- ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/submissionCore.ts | 13 +++++++++++-- ui-tui/src/app/useSubmission.ts | 5 +++-- ui-tui/src/gatewayTypes.ts | 4 ++-- ui-tui/src/lib/rpc.ts | 7 +++++-- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index d00646ac900..b5949362add 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -810,8 +810,10 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) - it('falls through to command.dispatch for skill commands and sends the message', async () => { - const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step' + it('falls through to command.dispatch for skill commands, sending the body but showing the invocation', async () => { + const skillMessage = + '[IMPORTANT: The user has invoked the "hermes-agent-dev" skill, indicating they want you to follow its instructions.\n' + + 'The full skill content is loaded below.]\n\nUse this skill to do X.\n\n## Steps\n1. First step' const ctx = buildCtx({ gateway: { @@ -836,9 +838,12 @@ describe('createSlashHandler', () => { const h = createSlashHandler(ctx) expect(h('/hermes-agent-dev')).toBe(true) await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev') + expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage, true, '/hermes-agent-dev') }) - expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) + // The expanded skill body is model-facing: no transcript line may carry it. + for (const [line] of ctx.transcript.sys.mock.calls) { + expect(line).not.toContain('Use this skill to do X') + } }) it('handles command.dispatch payloads returned directly by slash.exec', async () => { diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index dc798e842c6..9a50f77b617 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,3 +1,5 @@ +import { dispatchDisplayText } from '@hermes/shared/skill-scaffold' + import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' @@ -104,10 +106,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return void handler(`/${d.target}${argTail}`) } - if (d.type === 'skill') { - sys(`⚡ loading skill: ${d.name}`) + // A skill/bundle dispatch's `message` is the expanded skill body — + // model-facing scaffolding. The transcript shows the invocation instead; + // an ordinary send has no projection and goes through unchanged. + const sendDispatch = (display: string | undefined, message: string) => { + const shown = dispatchDisplayText(display, message) - return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) + return shown ? send(message, true, shown) : send(message) + } + + if (d.type === 'skill') { + return d.message?.trim() + ? sendDispatch(d.display, d.message) + : sys(`/${parsed.name}: skill payload missing message`) } if (d.type === 'send') { @@ -115,7 +126,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sys(d.notice) } - return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + return d.message?.trim() ? sendDispatch(d.display, d.message) : sys(`/${parsed.name}: empty message`) } if (d.type === 'prefill') { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d82fa1a90ed..1a446f69e15 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -526,7 +526,7 @@ export interface SlashHandlerContext { transcript: { page: (text: string, title?: string) => void panel: (title: string, sections: PanelSection[]) => void - send: (text: string) => void + send: (text: string, showUserMessage?: boolean, displayText?: string) => void setHistoryItems: StateSetter sys: (text: string) => void trimLastExchange: (items: Msg[]) => Msg[] diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 534ef6c8f03..98b416c44d9 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -42,7 +42,16 @@ export function markSubmitting(): void { // Submit a ready prompt (already resolved to be neither a slash command nor a // shell escape, with a live session). Pulled out of useSubmission so the // synchronous-busy invariant above is unit-testable without React test infra. -export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessage = true): void { +// +// `displayOverride` is what the transcript shows when it differs from what the +// agent receives — a `/skill` invocation expands into the whole skill body, and +// that scaffolding is model-facing only. +export function submitPrompt( + text: string, + deps: SubmitPromptDeps, + showUserMessage = true, + displayOverride?: string +): void { const sid = getUiState().sid if (!sid) { @@ -63,7 +72,7 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa deps.setLastUserMsg(text) if (show) { - deps.appendMessage({ role: 'user', text: displayText }) + deps.appendMessage({ role: 'user', text: displayOverride || displayText }) } patchUiState({ busy: true, status: 'running…' }) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index a70b1fd7390..0ced5f0b8a2 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -67,7 +67,7 @@ export function useSubmission(opts: UseSubmissionOptions) { }, [composerState.input, composerState.inputBuf]) const send = useCallback( - (text: string, showUserMessage = true) => { + (text: string, showUserMessage = true, displayText?: string) => { const expand = expandSnips(composerState.pasteSnips) submitPrompt( @@ -80,7 +80,8 @@ export function useSubmission(opts: UseSubmissionOptions) { setLastUserMsg, sys }, - showUserMessage + showUserMessage, + displayText ) }, [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 63219a1ab71..41c5063295e 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -70,8 +70,8 @@ export type { export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } - | { message?: string; name: string; type: 'skill' } - | { message: string; notice?: string; type: 'send' } + | { display?: string; message?: string; name: string; type: 'skill' } + | { display?: string; message: string; notice?: string; type: 'send' } | { message: string; notice?: string; type: 'prefill' } // ── Config ─────────────────────────────────────────────────────────── diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index fda9694ddeb..f54fb166f77 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -22,15 +22,18 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul return { type: 'alias', target: o.target } } + const str = (value: unknown) => (typeof value === 'string' ? value : undefined) + if (t === 'skill' && typeof o.name === 'string') { - return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } + return { type: 'skill', name: o.name, message: str(o.message), display: str(o.display) } } if (t === 'send' && typeof o.message === 'string') { return { type: 'send', message: o.message, - notice: typeof o.notice === 'string' ? o.notice : undefined + notice: str(o.notice), + display: str(o.display) } }