{"repo":"NousResearch/hermes-agent","public_id":"18376","display_label":"Approval prompt truncation and visibility issues","summary":"These 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.","subsystem":"apps/desktop","counts":{"members":17,"open_issues":5,"open_issues_distinct":5,"open_prs":2,"closed_issues":3,"merged_prs":3,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34808},"resolution":{"state":"partially_resolved","by":"pr-34808,pr-44691"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T12:08:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.09794117647058823},"fix_edges":[{"issue_number":18376,"pr_number":44691,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34645,"pr_number":34808,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":44888,"pr_number":44864,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":18376,"title":"[Bug]: Approval overlay command display truncated to one line — no text wrapping at terminal width (regression of #1553)","url":null,"state":"closed"},{"kind":"issue","number":34645,"title":"[Bug]:  TUI approval & clarify dialogs render incompletely — options invisible/unselectable on macOS TUI 审批/选择弹窗渲染不全","url":null,"state":"closed"},{"kind":"issue","number":38581,"title":"TUI approval prompt truncates multi-line execute_code payloads with no in-prompt full-text review","url":null,"state":"open"},{"kind":"issue","number":38582,"title":"TUI approval prompt uses ambiguous “full text above” copy for hidden approval payload lines","url":null,"state":"open"},{"kind":"issue","number":38583,"title":"TUI approval flow lacks classic CLI-style full command review affordance","url":null,"state":"open"},{"kind":"issue","number":44888,"title":"Desktop: Long shell commands are truncated in the approval prompt with no way to view the full command","url":null,"state":"closed"},{"kind":"issue","number":61249,"title":"[Feature]: Desktop approval bar truncates the approval description to one line — no way to review a multi-line change/diff before approving","url":null,"state":"open"},{"kind":"issue","number":62359,"title":"[Security/UX]: Telegram approvals remain actionable when command preview is truncated at 3,800 characters","url":null,"state":"open"},{"kind":"pr","number":18384,"title":"fix(cli): wrap long approval commands in prompt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44691","number":44691}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":34808,"title":"fix(cli): clarify panel clips choices off-screen on short terminals","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","website/docs/user-guide/features/code-execution.md"]},{"kind":"pr","number":44691,"title":"fix(cli,tui): wrap long approval commands instead of truncating","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_approval_ui.py","ui-tui/src/components/appOverlays.tsx","ui-tui/src/components/prompts.tsx"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -9629,7 +9629,7 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:\n         show_full = state.get(\"show_full\", False)\n \n         title = \"⚠️  Dangerous Command\"\n-        cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'\n+        cmd_display = command\n         choice_labels = {\n             \"once\": \"Allow once\",\n             \"session\": \"Allow for this session\",","@@ -9653,6 +9653,11 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:\n \n         # Pre-wrap the mandatory content — command + choices must always render.\n         cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)\n+        if not show_full and \"view\" in choices and len(cmd_wrapped) > 4:\n+            cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(\n+                \"… (choose Show full command)\",\n+                inner_text_width,\n+            )\n \n         # (choice_index, wrapped_line) so we can re-apply selected styling below\n         choice_wrapped: list[tuple[int, str]] = []","@@ -9702,7 +9707,10 @@ def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:\n         max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))\n         if len(cmd_wrapped) > max_cmd_rows:\n             keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1\n-            cmd_wrapped = cmd_wrapped[:keep] + [\"… (command truncated — use /logs or /debug for full text)\"]\n+            cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(\n+                \"… (command truncated — use /logs or /debug for full text)\",\n+                inner_text_width,\n+            )\n \n         # Allocate any remaining rows to description. The extra -1 in full mode\n         # accounts for the blank separator between choices and description."]},{"file":"tests/cli/test_cli_approval_ui.py","hunks":["@@ -154,7 +154,33 @@ def test_approval_display_places_title_inside_box_not_border(self):\n         assert \"Dangerous Command\" not in lines[0]\n         assert any(\"Dangerous Command\" in line for line in lines[1:3])\n         assert \"Show full command\" in rendered\n-        assert \"githubcli-archive-keyring.gpg\" not in rendered\n+        assert \"githubcli-archive-\" in rendered\n+        assert \"keyring.gpg\" in rendered\n+        assert \"status=progress\" in rendered\n+\n+    def test_approval_display_wraps_preview_hint_on_narrow_terminal(self):\n+        cli = _make_cli_stub()\n+        cli._approval_state = {\n+            \"command\": \"sudo \" + (\"very-long-command-segment-\" * 8),\n+            \"description\": \"shell command via -c/-lc flag\",\n+            \"choices\": [\"once\", \"session\", \"always\", \"deny\", \"view\"],\n+            \"selected\": 0,\n+            \"response_queue\": queue.Queue(),\n+        }\n+\n+        import shutil as _shutil\n+\n+        with patch(\"cli.shutil.get_terminal_size\",\n+                   return_value=_shutil.os.terminal_size((30, 24))):\n+            fragments = cli._get_approval_display_fragments()\n+\n+        rendered = \"\".join(text for _style, text in fragments)\n+        lines = rendered.splitlines()\n+        border_width = len(lines[0])\n+\n+        assert \"Show full\" in rendered\n+        assert \"command)\" in rendered\n+        assert all(len(line) == border_width for line in lines)\n \n     def test_approval_display_shows_full_command_after_view(self):\n         cli = _make_cli_stub()"]},{"file":"ui-tui/src/components/appOverlays.tsx","hunks":["@@ -30,7 +30,7 @@ export function PromptZone({\n   if (overlay.approval) {\n     return (\n       <Box flexDirection=\"column\" flexShrink={0} paddingX={1} paddingY={1}>\n-        <ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={theme} />\n+        <ApprovalPrompt cols={cols} onChoice={onApprovalChoice} req={overlay.approval} t={theme} />\n       </Box>\n     )\n   }"]},{"file":"ui-tui/src/components/prompts.tsx","hunks":["@@ -1,4 +1,4 @@\n-import { Box, Text, useInput } from '@hermes/ink'\n+import { Box, Text, useInput, wrapAnsi } from '@hermes/ink'\n import { useState } from 'react'\n \n import { isMac } from '../lib/platform.js'","@@ -66,7 +66,7 @@ export function approvalAction(\n   return { kind: 'noop' }\n }\n \n-export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {\n+export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) {\n   const [sel, setSel] = useState(0)\n   const opts = req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS\n ","@@ -80,7 +80,11 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {\n     }\n   })\n \n-  const rawLines = req.command.split('\\n')\n+  // Wrap long single-line commands to the panel width instead of clipping the\n+  // tail (mirrors the CLI approval panel fix — the full command must be\n+  // reviewable before approving). Border + paddingX + inner padding ≈ 8 cols.\n+  const innerWidth = Math.max(20, cols - 8)\n+  const rawLines = req.command.split('\\n').flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\\n'))\n   const shown = rawLines.slice(0, CMD_PREVIEW_LINES)\n   const overflow = rawLines.length - shown.length\n ","@@ -264,6 +268,7 @@ export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProp\n }\n \n interface ApprovalPromptProps {\n+  cols?: number\n   onChoice: (s: string) => void\n   req: ApprovalReq\n   t: Theme\n"]}]},{"kind":"pr","number":44864,"title":"feat(desktop): expand the full command inline from the approval bar","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.ts"],"fix_hunks":[{"file":"apps/desktop/src/components/assistant-ui/tool-approval.test.tsx","hunks":["@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {\n     expect($approvalRequest.get()).toBeNull()\n   })\n \n+  it('reveals the full command inline when the Command toggle is clicked', () => {\n+    const longCommand = 'python -c \"' + 'x'.repeat(400) + '\"'\n+    setRequest(longCommand)\n+    render(<PendingToolApproval part={part('terminal')} />)\n+\n+    // Collapsed by default: the full command is not in the DOM yet.\n+    expect(screen.queryByText(longCommand)).toBeNull()\n+\n+    fireEvent.click(screen.getByRole('button', { name: /Command/ }))\n+\n+    expect(screen.getByText(longCommand)).toBeTruthy()\n+  })\n+\n   it('sends choice \"deny\" on Reject', async () => {\n     const request = mockGateway()\n     setRequest()"]},{"file":"apps/desktop/src/components/assistant-ui/tool-approval.tsx","hunks":["@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge\n import { useI18n } from '@/i18n'\n import { triggerHaptic } from '@/lib/haptics'\n import { ChevronDown, Loader2 } from '@/lib/icons'\n+import { cn } from '@/lib/utils'\n import { $gateway } from '@/store/gateway'\n import { notifyError } from '@/store/notifications'\n import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'","@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {\n   // \"Always allow\" persists the pattern to ~/.hermes/config.yaml permanently, so\n   // it goes through a confirm step rather than firing straight from the menu.\n   const [confirmAlways, setConfirmAlways] = useState(false)\n+  // The pending tool row only shows a single truncated line of the command, and\n+  // a pending row can't be expanded (no result yet), so the full command was\n+  // previously only reachable via the \"Always allow\" modal. Let the user reveal\n+  // it inline instead — \"expand, Run\" (2 clicks) rather than the modal dance.\n+  const [showCommand, setShowCommand] = useState(false)\n   const busy = submitting !== null\n   // false when the backend won't honor a permanent allow (tirith warning) → hide \"Always allow\".\n   const allowPermanent = request.allowPermanent !== false\n+  const hasCommand = request.command.trim().length > 0\n \n   const respond = useCallback(\n     async (choice: ApprovalChoice) => {","@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {\n   }, [confirmAlways, respond])\n \n   return (\n-    <div className=\"mt-1 flex items-center gap-2.5 ps-5\" data-slot=\"tool-approval-inline\">\n-      <div className=\"inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary\">\n+    <div className=\"mt-1 ps-5\" data-slot=\"tool-approval-inline\">\n+      <div className=\"flex items-center gap-2.5\">\n+        <div className=\"inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary\">\n+          <Button\n+            className=\"h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary\"\n+            disabled={busy}\n+            onClick={() => void respond('once')}\n+            size=\"xs\"\n+            variant=\"ghost\"\n+          >\n+            {submitting === 'once' ? <Loader2 className=\"size-3 animate-spin\" /> : copy.run}\n+            {submitting !== 'once' && <span className=\"text-[0.625rem] text-primary/60\">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}\n+          </Button>\n+          <span aria-hidden className=\"w-px self-stretch bg-primary/20\" />\n+          <DropdownMenu>\n+            <DropdownMenuTrigger asChild>\n+              <Button\n+                aria-label={copy.moreOptions}\n+                className=\"h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary\"\n+                disabled={busy}\n+                size=\"xs\"\n+                variant=\"ghost\"\n+              >\n+                <ChevronDown className=\"size-3\" />\n+              </Button>\n+            </DropdownMenuTrigger>\n+            <DropdownMenuContent align=\"start\" className=\"min-w-44\">\n+              <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>\n+              {allowPermanent && (\n+                <DropdownMenuItem\n+                  onSelect={() => {\n+                    // Defer one tick so the menu fully unmounts before the dialog\n+                    // mounts — otherwise Radix's focus-return races the dialog and\n+                    // dismisses it via onInteractOutside.\n+                    setTimeout(() => setConfirmAlways(true), 0)\n+                  }}\n+                >\n+                  {copy.alwaysAllowMenu}\n+                </DropdownMenuItem>\n+              )}\n+              <DropdownMenuItem onSelect={() => void respond('deny')} variant=\"destructive\">\n+                {copy.reject}\n+              </DropdownMenuItem>\n+            </DropdownMenuContent>\n+          </DropdownMenu>\n+        </div>\n+\n         <Button\n-          className=\"h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary\"\n+          className=\"h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground\"\n           disabled={busy}\n-          onClick={() => void respond('once')}\n+          onClick={() => void respond('deny')}\n           size=\"xs\"\n           variant=\"ghost\"\n         >\n-          {submitting === 'once' ? <Loader2 className=\"size-3 animate-spin\" /> : copy.run}\n-          {submitting !== 'once' && <span className=\"text-[0.625rem] text-primary/60\">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}\n+          {submitting === 'deny' ? <Loader2 className=\"size-3 animate-spin\" /> : copy.reject}\n+          {submitting !== 'deny' && <span className=\"text-[0.625rem] opacity-55\">Esc</span>}\n         </Button>\n-        <span aria-hidden className=\"w-px self-stretch bg-primary/20\" />\n-        <DropdownMenu>\n-          <DropdownMenuTrigger asChild>\n-            <Button\n-              aria-label={copy.moreOptions}\n-              className=\"h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary\"\n-              disabled={busy}\n-              size=\"xs\"\n-              variant=\"ghost\"\n-            >\n-              <ChevronDown className=\"size-3\" />\n-            </Button>\n-          </DropdownMenuTrigger>\n-          <DropdownMenuContent align=\"start\" className=\"min-w-44\">\n-            <DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>\n-            {allowPermanent && (\n-              <DropdownMenuItem\n-                onSelect={() => {\n-                  // Defer one tick so the menu fully unmounts before the dialog\n-                  // mounts — otherwise Radix's focus-return races the dialog and\n-                  // dismisses it via onInteractOutside.\n-                  setTimeout(() => setConfirmAlways(true), 0)\n-                }}\n-              >\n-                {copy.alwaysAllowMenu}\n-              </DropdownMenuItem>\n-            )}\n-            <DropdownMenuItem onSelect={() => void respond('deny')} variant=\"destructive\">\n-              {copy.reject}\n-            </DropdownMenuItem>\n-          </DropdownMenuContent>\n-        </DropdownMenu>\n+\n+        {hasCommand && (\n+          <Button\n+            aria-expanded={showCommand}\n+            className=\"h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground\"\n+            onClick={() => setShowCommand(value => !value)}\n+            size=\"xs\"\n+            variant=\"ghost\"\n+          >\n+            {copy.command}\n+            <ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />\n+          </Button>\n+        )}\n       </div>\n \n-      <Button\n-        className=\"h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground\"\n-        disabled={busy}\n-        onClick={() => void respond('deny')}\n-        size=\"xs\"\n-        variant=\"ghost\"\n-      >\n-        {submitting === 'deny' ? <Loader2 className=\"size-3 animate-spin\" /> : copy.reject}\n-        {submitting !== 'deny' && <span className=\"text-[0.625rem] opacity-55\">Esc</span>}\n-      </Button>\n+      {showCommand && hasCommand && (\n+        <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\">\n+          {request.command.trim()}\n+        </pre>\n+      )}\n \n       <Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>\n         <DialogContent className=\"max-w-md\">\n           <DialogHeader>\n             <DialogTitle>{copy.alwaysTitle}</DialogTitle>\n-            <DialogDescription>\n-              {copy.alwaysDescription(request.description)}\n-            </DialogDescription>\n+            <DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>\n           </DialogHeader>\n \n           {request.command.trim() && ("]},{"file":"apps/desktop/src/i18n/en.ts","hunks":["@@ -1665,6 +1665,7 @@ export const en: Translations = {\n       gatewayDisconnected: 'Hermes gateway is not connected',\n       sendFailed: 'Could not send approval response',\n       run: 'Run',\n+      command: 'Command',\n       moreOptions: 'More approval options',\n       allowSession: 'Allow this session',\n       alwaysAllowMenu: 'Always allow…',"]},{"file":"apps/desktop/src/i18n/ja.ts","hunks":["@@ -1798,6 +1798,7 @@ export const ja = defineLocale({\n       gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',\n       sendFailed: '承認応答を送信できませんでした',\n       run: '実行',\n+      command: 'コマンド',\n       moreOptions: 'その他の承認オプション',\n       allowSession: 'このセッションで許可',\n       alwaysAllowMenu: '常に許可…',"]},{"file":"apps/desktop/src/i18n/types.ts","hunks":["@@ -1325,6 +1325,7 @@ export interface Translations {\n       gatewayDisconnected: string\n       sendFailed: string\n       run: string\n+      command: string\n       moreOptions: string\n       allowSession: string\n       alwaysAllowMenu: string"]},{"file":"apps/desktop/src/i18n/zh-hant.ts","hunks":["@@ -1759,6 +1759,7 @@ export const zhHant = defineLocale({\n       gatewayDisconnected: 'Hermes 閘道未連線',\n       sendFailed: '無法傳送核准回應',\n       run: '執行',\n+      command: '指令',\n       moreOptions: '更多核准選項',\n       allowSession: '允許本工作階段',\n       alwaysAllowMenu: '一律允許…',"]},{"file":"apps/desktop/src/i18n/zh.ts","hunks":["@@ -1844,6 +1844,7 @@ export const zh: Translations = {\n       gatewayDisconnected: 'Hermes 网关未连接',\n       sendFailed: '无法发送审批响应',\n       run: '运行',\n+      command: '命令',\n       moreOptions: '更多审批选项',\n       allowSession: '允许本会话',\n       alwaysAllowMenu: '始终允许…',\n"]}]},{"kind":"pr","number":44948,"title":"fix(desktop): show full command in approval dialog instead of truncating (#44888)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44864","number":44864},{"kind":"pr","value":"#44888","number":44888},{"kind":"pr","value":"#44864","number":44864},{"kind":"pr","value":"#44910","number":44910},{"kind":"pr","value":"#44977","number":44977}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":44977,"title":"fix(desktop): show full command in approval prompt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5d6c16e97237ca08778291a11695faff9b2e5963","number":0},{"kind":"commit","value":"266b5a19f128799d2c604a965872608902836ceb","number":0},{"kind":"pr","value":"#44864","number":44864}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45089,"title":"fix(desktop): long shell commands truncated in approval prompt with no way to view full command (#44888)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44864","number":44864},{"kind":"pr","value":"#44888","number":44888}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62092,"title":"fix(desktop): preserve multiline approval descriptions","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62376,"title":"fix(telegram): hide approval buttons when command preview truncated","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}