Approval prompt truncation and visibility issues
NousResearch/hermes-agent · cluster 18376 · apps/desktop
Data as of
7 open items. Fix merged in #34808. Check it covers your case.
The short answer
- Known?
- Yes. 17 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #34808. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #34808, #44691. That is separate from the fix event. - Still open
- 5 issues and 2 PRs.
Next step: read #34808, then check whether your report is one of the 5 still open.
Read #34808Pick up a Lab taskThese issues and fixes concern command text being truncated or clipped in approval prompts across CLI, TUI, desktop, and Telegram interfaces, making it difficult or impossible to review the full command before approving. Work spans bug reports, UX concerns, and fixes addressing wrapping, multiline display, and off-screen clipping of approval options.
Open issues5
- Issue#38581TUI approval prompt truncates multi-line execute_code payloads with no in-prompt full-text review
- Issue#38582TUI approval prompt uses ambiguous “full text above” copy for hidden approval payload lines
- Issue#38583TUI approval flow lacks classic CLI-style full command review affordance
- Issue#61249[Feature]: Desktop approval bar truncates the approval description to one line — no way to review a multi-line change/diff before approving
- Issue#62359[Security/UX]: Telegram approvals remain actionable when command preview is truncated at 3,800 characters
Open PRs2
Fixes on record
-
#34808 fix(cli): clarify panel clips choices off-screen on short terminals
files:
cli.py,website/docs/user-guide/features/code-execution.md -
#44691 fix(cli,tui): wrap long approval commands instead of truncating
files:
cli.py,tests/cli/test_cli_approval_ui.py,ui-tui/src/components/appOverlays.tsx,ui-tui/src/components/prompts.tsxShow the diff
-
cli.py@@ -9629,7 +9629,7 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: show_full = state.get("show_full", False) title = "⚠️ Dangerous Command" - cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...' + cmd_display = command choice_labels = { "once": "Allow once", "session": "Allow for this session", @@ -9653,6 +9653,11 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: # Pre-wrap the mandatory content — command + choices must always render. cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width) + if not show_full and "view" in choices and len(cmd_wrapped) > 4: + cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text( + "… (choose Show full command)", + inner_text_width, + ) # (choice_index, wrapped_line) so we can re-apply selected styling below choice_wrapped: list[tuple[int, str]] = [] @@ -9702,7 +9707,10 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped)) if len(cmd_wrapped) > max_cmd_rows: keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1 - cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"] + cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text( + "… (command truncated — use /logs or /debug for full text)", + inner_text_width, + ) # Allocate any remaining rows to description. The extra -1 in full mode # accounts for the blank separator between choices and description. -
tests/cli/test_cli_approval_ui.py@@ -154,7 +154,33 @@ def test_approval_display_places_title_inside_box_not_border(self): assert "Dangerous Command" not in lines[0] assert any("Dangerous Command" in line for line in lines[1:3]) assert "Show full command" in rendered - assert "githubcli-archive-keyring.gpg" not in rendered + assert "githubcli-archive-" in rendered + assert "keyring.gpg" in rendered + assert "status=progress" in rendered + + def test_approval_display_wraps_preview_hint_on_narrow_terminal(self): + cli = _make_cli_stub() + cli._approval_state = { + "command": "sudo " + ("very-long-command-segment-" * 8), + "description": "shell command via -c/-lc flag", + "choices": ["once", "session", "always", "deny", "view"], + "selected": 0, + "response_queue": queue.Queue(), + } + + import shutil as _shutil + + with patch("cli.shutil.get_terminal_size", + return_value=_shutil.os.terminal_size((30, 24))): + fragments = cli._get_approval_display_fragments() + + rendered = "".join(text for _style, text in fragments) + lines = rendered.splitlines() + border_width = len(lines[0]) + + assert "Show full" in rendered + assert "command)" in rendered + assert all(len(line) == border_width for line in lines) def test_approval_display_shows_full_command_after_view(self): cli = _make_cli_stub() -
ui-tui/src/components/appOverlays.tsx@@ -30,7 +30,7 @@ export function PromptZone({ if (overlay.approval) { return ( <Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}> - <ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={theme} /> + <ApprovalPrompt cols={cols} onChoice={onApprovalChoice} req={overlay.approval} t={theme} /> </Box> ) } -
ui-tui/src/components/prompts.tsx@@ -1,4 +1,4 @@ -import { Box, Text, useInput } from '@hermes/ink' +import { Box, Text, useInput, wrapAnsi } from '@hermes/ink' import { useState } from 'react' import { isMac } from '../lib/platform.js' @@ -66,7 +66,7 @@ export function approvalAction( return { kind: 'noop' } } -export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { +export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(0) const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS @@ -80,7 +80,11 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { } }) - const rawLines = req.command.split('\n') + // Wrap long single-line commands to the panel width instead of clipping the + // tail (mirrors the CLI approval panel fix — the full command must be + // reviewable before approving). Border + paddingX + inner padding ≈ 8 cols. + const innerWidth = Math.max(20, cols - 8) + const rawLines = req.command.split('\n').flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\n')) const shown = rawLines.slice(0, CMD_PREVIEW_LINES) const overflow = rawLines.length - shown.length @@ -264,6 +268,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp } interface ApprovalPromptProps { + cols?: number onChoice: (s: string) => void req: ApprovalReq t: Theme
-
-
#44864 feat(desktop): expand the full command inline from the approval bar
files:
apps/desktop/src/components/assistant-ui/tool-approval.test.tsx,apps/desktop/src/components/assistant-ui/tool-approval.tsx,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.tsShow the diff
-
apps/desktop/src/components/assistant-ui/tool-approval.test.tsx@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => { expect($approvalRequest.get()).toBeNull() }) + it('reveals the full command inline when the Command toggle is clicked', () => { + const longCommand = 'python -c "' + 'x'.repeat(400) + '"' + setRequest(longCommand) + render(<PendingToolApproval part={part('terminal')} />) + + // Collapsed by default: the full command is not in the DOM yet. + expect(screen.queryByText(longCommand)).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /Command/ })) + + expect(screen.getByText(longCommand)).toBeTruthy() + }) + it('sends choice "deny" on Reject', async () => { const request = mockGateway() setRequest() -
apps/desktop/src/components/assistant-ui/tool-approval.tsx@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { ChevronDown, Loader2 } from '@/lib/icons' +import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts' @@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { // "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so // it goes through a confirm step rather than firing straight from the menu. const [confirmAlways, setConfirmAlways] = useState(false) + // The pending tool row only shows a single truncated line of the command, and + // a pending row can't be expanded (no result yet), so the full command was + // previously only reachable via the "Always allow" modal. Let the user reveal + // it inline instead — "expand, Run" (2 clicks) rather than the modal dance. + const [showCommand, setShowCommand] = useState(false) const busy = submitting !== null // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". const allowPermanent = request.allowPermanent !== false + const hasCommand = request.command.trim().length > 0 const respond = useCallback( async (choice: ApprovalChoice) => { @@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { }, [confirmAlways, respond]) return ( - <div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline"> - <div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary"> + <div className="mt-1 ps-5" data-slot="tool-approval-inline"> + <div className="flex items-center gap-2.5"> + <div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary"> + <Button + className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary" + disabled={busy} + onClick={() => void respond('once')} + size="xs" + variant="ghost" + > + {submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run} + {submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>} + </Button> + <span aria-hidden className="w-px self-stretch bg-primary/20" /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label={copy.moreOptions} + className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary" + disabled={busy} + size="xs" + variant="ghost" + > + <ChevronDown className="size-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="min-w-44"> + <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem> + {allowPermanent && ( + <DropdownMenuItem + onSelect={() => { + // Defer one tick so the menu fully unmounts before the dialog + // mounts — otherwise Radix's focus-return races the dialog and + // dismisses it via onInteractOutside. + setTimeout(() => setConfirmAlways(true), 0) + }} + > + {copy.alwaysAllowMenu} + </DropdownMenuItem> + )} + <DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive"> + {copy.reject} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + <Button - className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary" + className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground" disabled={busy} - onClick={() => void respond('once')} + onClick={() => void respond('deny')} size="xs" variant="ghost" > - {submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run} - {submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>} + {submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject} + {submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>} </Button> - <span aria-hidden className="w-px self-stretch bg-primary/20" /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label={copy.moreOptions} - className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary" - disabled={busy} - size="xs" - variant="ghost" - > - <ChevronDown className="size-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="min-w-44"> - <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem> - {allowPermanent && ( - <DropdownMenuItem - onSelect={() => { - // Defer one tick so the menu fully unmounts before the dialog - // mounts — otherwise Radix's focus-return races the dialog and - // dismisses it via onInteractOutside. - setTimeout(() => setConfirmAlways(true), 0) - }} - > - {copy.alwaysAllowMenu} - </DropdownMenuItem> - )} - <DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive"> - {copy.reject} - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + + {hasCommand && ( + <Button + aria-expanded={showCommand} + className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground" + onClick={() => setShowCommand(value => !value)} + size="xs" + variant="ghost" + > + {copy.command} + <ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} /> + </Button> + )} </div> - <Button - className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground" - disabled={busy} - onClick={() => void respond('deny')} - size="xs" - variant="ghost" - > - {submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject} - {submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>} - </Button> + {showCommand && hasCommand && ( + <pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground"> + {request.command.trim()} + </pre> + )} <Dialog onOpenChange={setConfirmAlways} open={confirmAlways}> <DialogContent className="max-w-md"> <DialogHeader> <DialogTitle>{copy.alwaysTitle}</DialogTitle> - <DialogDescription> - {copy.alwaysDescription(request.description)} - </DialogDescription> + <DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription> </DialogHeader> {request.command.trim() && ( -
apps/desktop/src/i18n/en.ts@@ -1665,6 +1665,7 @@ export const en: Translations = { gatewayDisconnected: 'Hermes gateway is not connected', sendFailed: 'Could not send approval response', run: 'Run', + command: 'Command', moreOptions: 'More approval options', allowSession: 'Allow this session', alwaysAllowMenu: 'Always allow…', -
apps/desktop/src/i18n/ja.ts@@ -1798,6 +1798,7 @@ export const ja = defineLocale({ gatewayDisconnected: 'Hermes ゲートウェイが接続されていません', sendFailed: '承認応答を送信できませんでした', run: '実行', + command: 'コマンド', moreOptions: 'その他の承認オプション', allowSession: 'このセッションで許可', alwaysAllowMenu: '常に許可…', -
apps/desktop/src/i18n/types.ts@@ -1325,6 +1325,7 @@ export interface Translations { gatewayDisconnected: string sendFailed: string run: string + command: string moreOptions: string allowSession: string alwaysAllowMenu: string -
apps/desktop/src/i18n/zh-hant.ts@@ -1759,6 +1759,7 @@ export const zhHant = defineLocale({ gatewayDisconnected: 'Hermes 閘道未連線', sendFailed: '無法傳送核准回應', run: '執行', + command: '指令', moreOptions: '更多核准選項', allowSession: '允許本工作階段', alwaysAllowMenu: '一律允許…', -
apps/desktop/src/i18n/zh.ts@@ -1844,6 +1844,7 @@ export const zh: Translations = { gatewayDisconnected: 'Hermes 网关未连接', sendFailed: '无法发送审批响应', run: '运行', + command: '命令', moreOptions: '更多审批选项', allowSession: '允许本会话', alwaysAllowMenu: '始终允许…',
-
4 PRs closed without merging
From the close comments: duplicate 2 · done elsewhere 1 · merged via another change 1
-
#18384 fix(cli): wrap long approval commands in prompt · merged via another change →
#44691 -
#44948 fix(desktop): show full command in approval dialog instead of truncating (#44888) · duplicate →
#44864,#44888,#44910,#44977 -
#44977 fix(desktop): show full command in approval prompt · done elsewhere →
5d6c16e,266b5a1,#44864 -
#45089 fix(desktop): long shell commands truncated in approval prompt with no way to view full command (#44888) · duplicate →
#44864,#44888
Closed issues · 3
- Closed#18376[Bug]: Approval overlay command display truncated to one line — no text wrapping at terminal width (regression of #1553)
- Closed#34645[Bug]: TUI approval & clarify dialogs render incompletely — options invisible/unselectable on macOS TUI 审批/选择弹窗渲染不全
- Closed#44888Desktop: Long shell commands are truncated in the approval prompt with no way to view the full command
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab