Skip to content

Commit f383674

Browse files
committed
Truncate picker lines to terminal width to prevent wrapping
1 parent 45bb2fb commit f383674

File tree

4 files changed

+168
-4
lines changed

4 files changed

+168
-4
lines changed

hdi

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,59 @@ _term_width() {
895895
_DASH_POOL="────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
896896
_DOUBLE_DASH_POOL="════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════"
897897

898+
# Truncate an ANSI-formatted string to max visible columns, appending …
899+
# when cut. ANSI escape codes (CSI SGR) don't count toward width, and codes
900+
# that appear after the cut are preserved so that RESET / background-reset
901+
# still fire on the output line.
902+
# Sets _TRUNC.
903+
_TRUNC=""
904+
_truncate_visible() {
905+
local max=$1 str=$2
906+
907+
# Fast path: no escape sequences
908+
if [[ "$str" != *$'\x1b'* ]]; then
909+
if (( ${#str} <= max )); then
910+
_TRUNC="$str"
911+
elif (( max < 1 )); then
912+
_TRUNC=""
913+
else
914+
_TRUNC="${str:0:max - 1}"
915+
fi
916+
return
917+
fi
918+
919+
# Strip ANSI to get visible length (single parameter expansion, no subshell)
920+
local plain="${str//$'\x1b'\[[0-9;]*[a-zA-Z]/}"
921+
if (( ${#plain} <= max )); then
922+
_TRUNC="$str"
923+
return
924+
fi
925+
# Truncation needed: keep first (max-1) visible chars with their interleaved
926+
# escapes, append …, then append any escapes after the cut so that RESET
927+
# (or other terminating codes) still take effect.
928+
local prefix="" trailing="" keep=$((max - 1))
929+
local visible=0 i=0 in_esc=0 ch
930+
local len=${#str}
931+
while (( i < len )); do
932+
ch="${str:$i:1}"
933+
if (( in_esc )); then
934+
if (( visible < keep )); then prefix+="$ch"
935+
else trailing+="$ch"; fi
936+
[[ "$ch" == [a-zA-Z] ]] && in_esc=0
937+
elif [[ "$ch" == $'\x1b' ]]; then
938+
in_esc=1
939+
if (( visible < keep )); then prefix+="$ch"
940+
else trailing+="$ch"; fi
941+
elif (( visible < keep )); then
942+
prefix+="$ch"
943+
((visible++))
944+
fi
945+
((i++))
946+
done
947+
948+
_TRUNC="${prefix}${trailing}"
949+
}
950+
898951
# Format a section header with trailing dashes (sets _SH, no subshell)
899952
# _RENDER_WIDTH defaults to _MAX_CONTENT_WIDTH; draw_picker caps it to terminal width
900953
_SH=""
@@ -1043,8 +1096,17 @@ draw_picker() {
10431096
local n_items=${#DISPLAY_LINES[@]}
10441097
local buf=""
10451098

1046-
# Helper: append a line to buf with end-of-line clear
1047-
_line() { buf+="$1${EL}"$'\n'; (( count += 1 )); }
1099+
# Helper: append a line to buf with end-of-line clear.
1100+
# Truncates to term_w visible cols so long lines don't wrap — wrapping would
1101+
# make physical rows exceed PICKER_LINES and break cursor-up redraws.
1102+
_line() {
1103+
local _s="$1"
1104+
if (( ${#_s} > term_w )); then
1105+
_truncate_visible "$term_w" "$_s"
1106+
_s="$_TRUNC"
1107+
fi
1108+
buf+="${_s}${EL}"$'\n'; (( count += 1 ))
1109+
}
10481110
# Helper: append a blank line (just clear + newline)
10491111
_blank() { buf+="${EL}"$'\n'; (( count += 1 )); }
10501112

src/picker.sh

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,59 @@ _term_width() {
3232
_DASH_POOL="────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
3333
_DOUBLE_DASH_POOL="════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════"
3434

35+
# Truncate an ANSI-formatted string to max visible columns, appending …
36+
# when cut. ANSI escape codes (CSI SGR) don't count toward width, and codes
37+
# that appear after the cut are preserved so that RESET / background-reset
38+
# still fire on the output line.
39+
# Sets _TRUNC.
40+
_TRUNC=""
41+
_truncate_visible() {
42+
local max=$1 str=$2
43+
44+
# Fast path: no escape sequences
45+
if [[ "$str" != *$'\x1b'* ]]; then
46+
if (( ${#str} <= max )); then
47+
_TRUNC="$str"
48+
elif (( max < 1 )); then
49+
_TRUNC=""
50+
else
51+
_TRUNC="${str:0:max - 1}"
52+
fi
53+
return
54+
fi
55+
56+
# Strip ANSI to get visible length (single parameter expansion, no subshell)
57+
local plain="${str//$'\x1b'\[[0-9;]*[a-zA-Z]/}"
58+
if (( ${#plain} <= max )); then
59+
_TRUNC="$str"
60+
return
61+
fi
62+
# Truncation needed: keep first (max-1) visible chars with their interleaved
63+
# escapes, append …, then append any escapes after the cut so that RESET
64+
# (or other terminating codes) still take effect.
65+
local prefix="" trailing="" keep=$((max - 1))
66+
local visible=0 i=0 in_esc=0 ch
67+
local len=${#str}
68+
while (( i < len )); do
69+
ch="${str:$i:1}"
70+
if (( in_esc )); then
71+
if (( visible < keep )); then prefix+="$ch"
72+
else trailing+="$ch"; fi
73+
[[ "$ch" == [a-zA-Z] ]] && in_esc=0
74+
elif [[ "$ch" == $'\x1b' ]]; then
75+
in_esc=1
76+
if (( visible < keep )); then prefix+="$ch"
77+
else trailing+="$ch"; fi
78+
elif (( visible < keep )); then
79+
prefix+="$ch"
80+
((visible++))
81+
fi
82+
((i++))
83+
done
84+
85+
_TRUNC="${prefix}${trailing}"
86+
}
87+
3588
# Format a section header with trailing dashes (sets _SH, no subshell)
3689
# _RENDER_WIDTH defaults to _MAX_CONTENT_WIDTH; draw_picker caps it to terminal width
3790
_SH=""
@@ -180,8 +233,17 @@ draw_picker() {
180233
local n_items=${#DISPLAY_LINES[@]}
181234
local buf=""
182235

183-
# Helper: append a line to buf with end-of-line clear
184-
_line() { buf+="$1${EL}"$'\n'; (( count += 1 )); }
236+
# Helper: append a line to buf with end-of-line clear.
237+
# Truncates to term_w visible cols so long lines don't wrap — wrapping would
238+
# make physical rows exceed PICKER_LINES and break cursor-up redraws.
239+
_line() {
240+
local _s="$1"
241+
if (( ${#_s} > term_w )); then
242+
_truncate_visible "$term_w" "$_s"
243+
_s="$_TRUNC"
244+
fi
245+
buf+="${_s}${EL}"$'\n'; (( count += 1 ))
246+
}
185247
# Helper: append a blank line (just clear + newline)
186248
_blank() { buf+="${EL}"$'\n'; (( count += 1 )); }
187249

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# long-command
2+
3+
## Setup
4+
5+
```bash
6+
npm install
7+
```
8+
9+
## Deploy
10+
11+
```bash
12+
dokku config:set myapp API_KEY=sk_live_abcdef1234567890 DATABASE_URL=postgres://user:password@host.example.com:5432/database REDIS_URL=redis://cache.internal.example.com:6379/0 LOG_LEVEL=info FEATURE_FLAG_ONE=true
13+
```

test/hdi.bats

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,33 @@ else:
935935
[[ "$output" != *"f files"* ]]
936936
}
937937

938+
# ── Interactive: line width ──────────────────────────────────────────────────
939+
940+
@test "interactive: long command lines are truncated to terminal width" {
941+
# Regression: lines wider than the terminal wrap, making physical rows
942+
# exceed PICKER_LINES. Cursor-up on redraw then leaves the old header
943+
# visible, leaking a "[hdi] project" line into scrollback per keypress.
944+
_HDI_BENCH_PICKER=1 LINES=24 COLUMNS=80 run "$HDI" "$FIXTURES/long-command"
945+
[ "$status" -eq 0 ]
946+
947+
# Each rendered line's visible width (ANSI stripped) must be <= COLUMNS.
948+
# The dokku line in the fixture is ~200 chars — far wider than 80.
949+
local max=0 len line stripped
950+
while IFS= read -r line; do
951+
# Strip CSI SGR escape sequences
952+
stripped=$(printf '%s' "$line" | sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g')
953+
# Also strip erase-to-end-of-line (the ${EL} marker appended by _line)
954+
stripped=$(printf '%s' "$stripped" | sed $'s/\x1b\\[K//g')
955+
len=$(printf '%s' "$stripped" | wc -m | tr -d ' ')
956+
(( len > max )) && max=$len
957+
done <<< "$output"
958+
959+
(( max <= 80 )) || { echo "max line width was $max, expected <= 80"; return 1; }
960+
961+
# The long command should be visibly truncated with a horizontal ellipsis.
962+
[[ "$output" == *""* ]]
963+
}
964+
938965
# ── Tilde fences ────────────────────────────────────────────────────────────
939966

940967
@test "tilde fences: extracts commands from ~~~ blocks" {

0 commit comments

Comments
 (0)