From a627981a652c4502425c9689432aef073a9784ec Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sun, 24 May 2026 00:12:55 -0500 Subject: [PATCH] fix(tui): stop slash dropdown from chopping last char of /goal (#31311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent bugs caused the slash-command autocomplete to render `/goal` as `/goa` (and `/gquota` as `/gquot` for that matter) in the TUI: 1. `tui_gateway/server.py` was forwarding `c.display` from prompt_toolkit's `Completion` straight into the JSON-RPC payload. prompt_toolkit normalizes `display=` into `FormattedText` (a `list` subclass), so the wire format became `[["", "/goal"]]` instead of the `string` that `CompletionItem.display` in the TUI declares. `meta` already went through `to_plain_text` — `display` did not. 2. The dropdown row in `appOverlays.tsx` used `flexDirection="row"` with the display `` and the (very long) meta `` as siblings. When the meta overflows the row width, Ink/Yoga shrinks the *first* column by one cell, lopping the trailing character off the command name. `/goal` triggers it reliably because its meta string is the longest of any built-in command (description + embedded `[text | pause | resume | clear | status]` usage hint). Wrapping the display column in `` keeps it at its natural width and lets the meta wrap or truncate instead. --- tests/test_tui_gateway_server.py | 20 ++++++++++++++++++++ tui_gateway/server.py | 7 ++++++- ui-tui/src/components/appOverlays.tsx | 13 +++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2824bd85916..3328110b2be 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1619,6 +1619,26 @@ def test_complete_slash_includes_provider_alias(): assert any(item["text"] == "provider" for item in resp["result"]["items"]) +def test_complete_slash_returns_plain_string_fields(): + # prompt_toolkit hands us FormattedText (a list subclass) for + # display/display_meta; the TUI's CompletionItem contract is plain + # strings, and shipping the raw list trips Ink's row layout into + # 1-char truncation of the next column (/goal → /goa). + resp = server.handle_request( + {"id": "1", "method": "complete.slash", "params": {"text": "/g"}} + ) + + items = resp["result"]["items"] + goal = next((it for it in items if it["text"] == "goal"), None) + assert goal is not None + assert isinstance(goal["display"], str), goal["display"] + assert isinstance(goal["meta"], str), goal["meta"] + assert goal["display"] == "/goal" + for item in items: + assert isinstance(item["display"], str), item + assert isinstance(item["meta"], str), item + + def test_complete_slash_includes_tui_details_command(): resp = server.handle_request( {"id": "1", "method": "complete.slash", "params": {"text": "/det"}} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 82107b7c05b..15dbf19ddeb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5382,7 +5382,12 @@ def _(rid, params: dict) -> dict: items = [ { "text": c.text, - "display": c.display or c.text, + # prompt_toolkit gives us FormattedText (a list of (style, + # text) tuples) for display/display_meta. Serialize both as + # plain strings — the TUI's CompletionItem.display contract + # is a string, and sending the raw list trips Ink's row + # layout into 1-char truncation of the next column. + "display": to_plain_text(c.display) if c.display else c.text, "meta": to_plain_text(c.display_meta) if c.display_meta else "", } for c in completer.get_completions(doc, None) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index c12624a4bf8..7fd14563a99 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -187,10 +187,15 @@ export function FloatingOverlays({ key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`} width="100%" > - - {' '} - {item.display} - + {/* flexShrink=0 — when meta overflows the row, Ink/Yoga + otherwise shaves the last char off the display column + (e.g. /goal renders as /goa). */} + + + {' '} + {item.display} + + {item.meta ? (