codebyplan 1.10.3 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +534 -209
- package/package.json +1 -1
- package/templates/hooks/README.md +58 -16
- package/templates/hooks/cbp-statusline.mjs +385 -0
- package/templates/hooks/cbp-statusline.py +331 -0
- package/templates/hooks/cbp-statusline.sh +138 -82
- package/templates/hooks/cbp-subagent-statusline.mjs +200 -0
- package/templates/hooks/cbp-subagent-statusline.py +183 -0
- package/templates/hooks/cbp-subagent-statusline.sh +87 -39
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via the cbp-subagent-statusline.sh dispatcher)
|
|
3
|
+
# Claude Code Subagent Status Line — python renderer. Byte-identical output to the
|
|
4
|
+
# bash renderer in cbp-subagent-statusline.sh and the node renderer in
|
|
5
|
+
# cbp-subagent-statusline.mjs. Output protocol: one {"id":...,"content":...} JSON
|
|
6
|
+
# line per task. See the .sh file for the full option/env/seam contract.
|
|
7
|
+
# Never throws to stdout.
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
raw_input = sys.stdin.read()
|
|
18
|
+
try:
|
|
19
|
+
data = json.loads(raw_input)
|
|
20
|
+
except Exception:
|
|
21
|
+
data = {}
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
data = {}
|
|
24
|
+
|
|
25
|
+
root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
|
|
26
|
+
if not root:
|
|
27
|
+
root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
columns = math.trunc(float(data.get("columns") or 0))
|
|
31
|
+
except (TypeError, ValueError):
|
|
32
|
+
columns = 0
|
|
33
|
+
tasks = data.get("tasks")
|
|
34
|
+
if not isinstance(tasks, list):
|
|
35
|
+
tasks = []
|
|
36
|
+
|
|
37
|
+
# ---- Config: no_color from statusline.json -------------------------------
|
|
38
|
+
cfg_no_color = False
|
|
39
|
+
try:
|
|
40
|
+
with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
|
|
41
|
+
parsed = json.load(fh)
|
|
42
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("no_color"), bool):
|
|
43
|
+
cfg_no_color = parsed["no_color"]
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
no_color = (
|
|
48
|
+
(os.environ.get("NO_COLOR") not in (None, ""))
|
|
49
|
+
or os.environ.get("CBP_SUBAGENT_STATUSLINE_NO_COLOR") == "1"
|
|
50
|
+
or cfg_no_color is True
|
|
51
|
+
)
|
|
52
|
+
if no_color:
|
|
53
|
+
RST = DIM = BOLD = GREEN = RED = BLUE = ""
|
|
54
|
+
else:
|
|
55
|
+
RST = "\x1b[0m"
|
|
56
|
+
DIM = "\x1b[2m"
|
|
57
|
+
BOLD = "\x1b[1m"
|
|
58
|
+
GREEN = "\x1b[32m"
|
|
59
|
+
RED = "\x1b[31m"
|
|
60
|
+
BLUE = "\x1b[34m"
|
|
61
|
+
|
|
62
|
+
def fmt_k(val):
|
|
63
|
+
v = float(val)
|
|
64
|
+
if v >= 1000000:
|
|
65
|
+
t = math.floor((v + 50000) / 100000)
|
|
66
|
+
return "%d.%dM" % (t // 10, t % 10)
|
|
67
|
+
elif v >= 1000:
|
|
68
|
+
t = math.floor((v + 50) / 100)
|
|
69
|
+
return "%d.%dK" % (t // 10, t % 10)
|
|
70
|
+
return str(int(v))
|
|
71
|
+
|
|
72
|
+
def cbp_now():
|
|
73
|
+
nv = os.environ.get("CBP_STATUSLINE_NOW")
|
|
74
|
+
if nv not in (None, ""):
|
|
75
|
+
return math.trunc(float(nv))
|
|
76
|
+
return math.floor(time.time())
|
|
77
|
+
|
|
78
|
+
def fmt_age_secs(start):
|
|
79
|
+
delta = cbp_now() - math.trunc(float(start))
|
|
80
|
+
if delta < 0:
|
|
81
|
+
delta = 0
|
|
82
|
+
if delta >= 3600:
|
|
83
|
+
return "%dh%dm" % (delta // 3600, (delta % 3600) // 60)
|
|
84
|
+
if delta >= 60:
|
|
85
|
+
return "%dm%ds" % (delta // 60, delta % 60)
|
|
86
|
+
return "%ds" % delta
|
|
87
|
+
|
|
88
|
+
def is_ascii_letter(b):
|
|
89
|
+
return (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A)
|
|
90
|
+
|
|
91
|
+
def truncate(rendered):
|
|
92
|
+
# ANSI-aware byte-based truncation — mirrors the bash byte walk exactly.
|
|
93
|
+
if not (columns > 0):
|
|
94
|
+
return rendered
|
|
95
|
+
buf = rendered.encode("utf-8")
|
|
96
|
+
raw_len = len(buf)
|
|
97
|
+
target = columns - 3
|
|
98
|
+
if target < 0:
|
|
99
|
+
target = 0
|
|
100
|
+
vis = 0
|
|
101
|
+
i = 0
|
|
102
|
+
in_esc = False
|
|
103
|
+
while i < raw_len and vis < target:
|
|
104
|
+
b = buf[i]
|
|
105
|
+
if in_esc:
|
|
106
|
+
if is_ascii_letter(b):
|
|
107
|
+
in_esc = False
|
|
108
|
+
elif b == 0x1B:
|
|
109
|
+
in_esc = True
|
|
110
|
+
else:
|
|
111
|
+
vis += 1
|
|
112
|
+
i += 1
|
|
113
|
+
while i < raw_len:
|
|
114
|
+
b = buf[i]
|
|
115
|
+
if in_esc:
|
|
116
|
+
if is_ascii_letter(b):
|
|
117
|
+
in_esc = False
|
|
118
|
+
i += 1
|
|
119
|
+
elif b == 0x1B:
|
|
120
|
+
in_esc = True
|
|
121
|
+
i += 1
|
|
122
|
+
else:
|
|
123
|
+
break
|
|
124
|
+
if i < raw_len:
|
|
125
|
+
head = buf[:i]
|
|
126
|
+
tail = (RST + "...").encode("utf-8")
|
|
127
|
+
return (head + tail).decode("utf-8", errors="replace")
|
|
128
|
+
return rendered
|
|
129
|
+
|
|
130
|
+
out = []
|
|
131
|
+
for task in tasks:
|
|
132
|
+
if not isinstance(task, dict):
|
|
133
|
+
continue
|
|
134
|
+
t_id = "" if task.get("id") is None else str(task.get("id"))
|
|
135
|
+
if t_id == "":
|
|
136
|
+
continue
|
|
137
|
+
t_name = "" if task.get("name") is None else str(task.get("name"))
|
|
138
|
+
t_type = "" if task.get("type") is None else str(task.get("type"))
|
|
139
|
+
t_status = "" if task.get("status") is None else str(task.get("status"))
|
|
140
|
+
t_desc = "" if task.get("description") is None else str(task.get("description"))
|
|
141
|
+
try:
|
|
142
|
+
t_start = math.trunc(float(task.get("startTime") or 0))
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
t_start = 0
|
|
145
|
+
try:
|
|
146
|
+
t_tokens = math.trunc(float(task.get("tokenCount") or 0))
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
t_tokens = 0
|
|
149
|
+
|
|
150
|
+
if t_status == "running":
|
|
151
|
+
icon = "%s●%s" % (GREEN, RST)
|
|
152
|
+
elif t_status == "pending":
|
|
153
|
+
icon = "%s○%s" % (DIM, RST)
|
|
154
|
+
elif t_status == "completed":
|
|
155
|
+
icon = "%s✓%s" % (BLUE, RST)
|
|
156
|
+
elif t_status == "failed":
|
|
157
|
+
icon = "%s✗%s" % (RED, RST)
|
|
158
|
+
else:
|
|
159
|
+
icon = "%s•%s" % (DIM, RST)
|
|
160
|
+
|
|
161
|
+
row = "%s %s%s%s" % (icon, BOLD, t_name, RST)
|
|
162
|
+
if t_type:
|
|
163
|
+
row += " %s(%s)%s" % (DIM, t_type, RST)
|
|
164
|
+
if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION") != "1" and t_desc:
|
|
165
|
+
row += " %s—%s %s" % (DIM, RST, t_desc)
|
|
166
|
+
if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS") != "1" and t_tokens > 0:
|
|
167
|
+
row += " %s·%s %stokens:%s%s" % (DIM, RST, DIM, RST, fmt_k(t_tokens))
|
|
168
|
+
if t_start > 0:
|
|
169
|
+
row += " %s·%s %sage:%s%s" % (DIM, RST, DIM, RST, fmt_age_secs(t_start))
|
|
170
|
+
|
|
171
|
+
rendered = truncate(row)
|
|
172
|
+
out.append(
|
|
173
|
+
'{"id":%s,"content":%s}'
|
|
174
|
+
% (json.dumps(t_id, ensure_ascii=False), json.dumps(rendered, ensure_ascii=False))
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
sys.stdout.write(("\n".join(out) + "\n") if out else "")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
main()
|
|
182
|
+
except Exception:
|
|
183
|
+
sys.exit(0)
|
|
@@ -1,22 +1,65 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Portability: bash 3.2+ (macOS default /bin/bash). UTF-8 multibyte chars in
|
|
3
3
|
# user-controlled fields are byte-iterated under 3.2 (over-counts visible width
|
|
4
|
-
# for non-ASCII content); acceptable safe-direction approximation
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# for non-ASCII content); acceptable safe-direction approximation — and the node
|
|
5
|
+
# (.mjs) / python (.py) renderers iterate the SAME UTF-8 bytes so all three agree.
|
|
6
|
+
# @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via settings.json subagentStatusLine.command)
|
|
7
|
+
# Claude Code Subagent Status Line — multi-runtime dispatcher + bash renderer.
|
|
8
|
+
# One JSON line per active subagent. Output protocol per upstream docs:
|
|
8
9
|
# {"id": "<task_id>", "content": "<rendered_body>"}
|
|
9
10
|
# - Omitted tasks keep their default Claude Code rendering
|
|
10
11
|
# - Empty content strings hide the row
|
|
11
12
|
# NOT a PreToolUse/PostToolUse/Notification hook — do NOT register in hooks[].
|
|
12
13
|
#
|
|
13
|
-
#
|
|
14
|
+
# RENDERER SELECTION (per-device, gitignored .codebyplan/statusline.local.json):
|
|
15
|
+
# { "renderer": "bash" | "node" | "python" } — default bash, falls back to bash
|
|
16
|
+
# when the chosen runtime is unavailable. Set via `codebyplan statusline …`.
|
|
17
|
+
#
|
|
18
|
+
# CONFIG: honours `no_color` from the committed .codebyplan/statusline.json. The six
|
|
19
|
+
# main-statusline line toggles do NOT apply to subagent rows.
|
|
20
|
+
#
|
|
21
|
+
# ENV-VAR OVERRIDES (env > config > default)
|
|
14
22
|
# CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION=1 omit DESCRIPTION segment
|
|
15
23
|
# CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS=1 omit tokens:N segment
|
|
16
24
|
# CBP_SUBAGENT_STATUSLINE_NO_COLOR=1 strip all ANSI codes (also honoured by $NO_COLOR)
|
|
25
|
+
#
|
|
26
|
+
# TEST SEAMS (no effect in normal use)
|
|
27
|
+
# CBP_STATUSLINE_ROOT=<dir> override the .codebyplan/ lookup root (default: repo root)
|
|
28
|
+
# CBP_STATUSLINE_NOW=<epoch> override "now" for age rendering (determinism)
|
|
29
|
+
|
|
30
|
+
# ============================================================
|
|
31
|
+
# DISPATCHER — resolve root + renderer, delegate when possible
|
|
32
|
+
# ============================================================
|
|
33
|
+
CBP_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || CBP_HOOK_DIR="."
|
|
34
|
+
CBP_ROOT="${CBP_STATUSLINE_ROOT:-}"
|
|
35
|
+
if [ -z "$CBP_ROOT" ]; then
|
|
36
|
+
CBP_ROOT="$(cd "$CBP_HOOK_DIR/../.." && pwd 2>/dev/null)" || CBP_ROOT="$PWD"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
CBP_RENDERER="bash"
|
|
40
|
+
CBP_LOCAL_CFG="$CBP_ROOT/.codebyplan/statusline.local.json"
|
|
41
|
+
if [ -f "$CBP_LOCAL_CFG" ] && command -v jq >/dev/null 2>&1; then
|
|
42
|
+
_r="$(jq -r '.renderer // empty' "$CBP_LOCAL_CFG" 2>/dev/null)"
|
|
43
|
+
case "$_r" in bash|node|python) CBP_RENDERER="$_r" ;; esac
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
if [ "$CBP_RENDERER" = "node" ] && command -v node >/dev/null 2>&1 \
|
|
47
|
+
&& [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs" ]; then
|
|
48
|
+
CBP_STATUSLINE_ROOT="$CBP_ROOT" exec node "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs"
|
|
49
|
+
fi
|
|
50
|
+
if [ "$CBP_RENDERER" = "python" ] && command -v python3 >/dev/null 2>&1 \
|
|
51
|
+
&& [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.py" ]; then
|
|
52
|
+
CBP_STATUSLINE_ROOT="$CBP_ROOT" exec python3 "$CBP_HOOK_DIR/cbp-subagent-statusline.py"
|
|
53
|
+
fi
|
|
54
|
+
# Fall through → inline bash renderer (default + universal fallback).
|
|
17
55
|
|
|
18
56
|
INPUT=$(cat)
|
|
19
57
|
|
|
58
|
+
# Byte-deterministic string ops: force the C locale so the truncation walk below
|
|
59
|
+
# counts UTF-8 BYTES (locale-independent), matching the .mjs/.py byte iteration.
|
|
60
|
+
# Output bytes are unaffected (multibyte content passes through verbatim).
|
|
61
|
+
export LC_ALL=C
|
|
62
|
+
|
|
20
63
|
# One jq pass for base fields (cwd, session_id, columns)
|
|
21
64
|
eval "$(echo "$INPUT" | jq -r '
|
|
22
65
|
@sh "CWD=\(.cwd // "")",
|
|
@@ -24,9 +67,17 @@ eval "$(echo "$INPUT" | jq -r '
|
|
|
24
67
|
@sh "COLUMNS=\(.columns // 0)"
|
|
25
68
|
')"
|
|
26
69
|
|
|
27
|
-
# ----
|
|
70
|
+
# ---- Config: no_color from .codebyplan/statusline.json -----------------------
|
|
71
|
+
CFG_NO_COLOR=false
|
|
72
|
+
CBP_CFG="$CBP_ROOT/.codebyplan/statusline.json"
|
|
73
|
+
if [ -f "$CBP_CFG" ] && command -v jq >/dev/null 2>&1; then
|
|
74
|
+
# `== true` (NOT jq `//`): only an explicit true enables no_color.
|
|
75
|
+
eval "$(jq -r '"CFG_NO_COLOR=\(.no_color == true)"' "$CBP_CFG" 2>/dev/null)"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# ---- Colour setup (env > config) ---------------------------------------------
|
|
28
79
|
# Only the colours referenced by the render block below are defined here.
|
|
29
|
-
if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ]; then
|
|
80
|
+
if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ] || [ "$CFG_NO_COLOR" = "true" ]; then
|
|
30
81
|
RST=''; DIM=''; BOLD=''; GREEN=''; RED=''; BLUE=''
|
|
31
82
|
else
|
|
32
83
|
RST='\033[0m'
|
|
@@ -37,25 +88,29 @@ else
|
|
|
37
88
|
BLUE='\033[34m'
|
|
38
89
|
fi
|
|
39
90
|
|
|
40
|
-
# ---- Token/size formatter K / M
|
|
91
|
+
# ---- Token/size formatter K / M — integer round-half-up (cross-runtime) ------
|
|
41
92
|
fmt_k() {
|
|
42
93
|
local val=$1
|
|
43
94
|
if [ "$val" -ge 1000000 ] 2>/dev/null; then
|
|
44
|
-
|
|
95
|
+
local t=$(( (val + 50000) / 100000 ))
|
|
96
|
+
printf "%d.%dM" $(( t / 10 )) $(( t % 10 ))
|
|
45
97
|
elif [ "$val" -ge 1000 ] 2>/dev/null; then
|
|
46
|
-
|
|
98
|
+
local t=$(( (val + 50) / 100 ))
|
|
99
|
+
printf "%d.%dK" $(( t / 10 )) $(( t % 10 ))
|
|
47
100
|
else
|
|
48
101
|
printf "%d" "$val"
|
|
49
102
|
fi
|
|
50
103
|
}
|
|
51
104
|
|
|
52
|
-
# ----
|
|
53
|
-
|
|
54
|
-
|
|
105
|
+
# ---- "now" with test seam ----------------------------------------------------
|
|
106
|
+
cbp_now() {
|
|
107
|
+
if [ -n "${CBP_STATUSLINE_NOW:-}" ]; then printf '%s' "$CBP_STATUSLINE_NOW"; else date +%s; fi
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" ------------------
|
|
55
111
|
fmt_age_secs() {
|
|
56
112
|
local start=$1
|
|
57
|
-
local now
|
|
58
|
-
now=$(date +%s)
|
|
113
|
+
local now; now=$(cbp_now)
|
|
59
114
|
local delta=$(( now - start ))
|
|
60
115
|
[ "$delta" -lt 0 ] && delta=0
|
|
61
116
|
if [ "$delta" -ge 3600 ]; then
|
|
@@ -67,18 +122,16 @@ fmt_age_secs() {
|
|
|
67
122
|
fi
|
|
68
123
|
}
|
|
69
124
|
|
|
70
|
-
# ---- Strip ANSI codes for visible-length measurement (column truncation) ----
|
|
71
|
-
strip_ansi() {
|
|
72
|
-
printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'
|
|
73
|
-
}
|
|
74
|
-
|
|
75
125
|
# ---- JSON-string-escape a bash variable (embed in {"id":..., "content":...}) -
|
|
76
126
|
json_escape() {
|
|
77
127
|
printf '%s' "$1" | jq -Rs '.'
|
|
78
128
|
}
|
|
79
129
|
|
|
80
|
-
# ---- Per-task render: one jq
|
|
81
|
-
#
|
|
130
|
+
# ---- Per-task render: one jq US-delimited emission, looped in bash ----------
|
|
131
|
+
# Join with the Unit Separator (U+001F, non-whitespace) rather than @tsv: a tab is
|
|
132
|
+
# IFS-whitespace, so `IFS=$'\t' read` collapses consecutive tabs and an EMPTY field
|
|
133
|
+
# (e.g. blank description) would shift every later field. US is non-whitespace, so
|
|
134
|
+
# empty fields are preserved — matching the .mjs/.py object iteration.
|
|
82
135
|
echo "$INPUT" | jq -r '
|
|
83
136
|
.tasks // [] | .[] | [
|
|
84
137
|
.id // "",
|
|
@@ -88,8 +141,8 @@ echo "$INPUT" | jq -r '
|
|
|
88
141
|
.description // "",
|
|
89
142
|
(.startTime // 0 | tostring),
|
|
90
143
|
(.tokenCount // 0 | tostring)
|
|
91
|
-
] |
|
|
92
|
-
' | while IFS=$'\
|
|
144
|
+
] | join("\u001f")
|
|
145
|
+
' | while IFS=$'\037' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
|
|
93
146
|
# Skip blank rows (empty tasks[] → no output)
|
|
94
147
|
[ -z "$T_ID" ] && continue
|
|
95
148
|
|
|
@@ -123,14 +176,12 @@ echo "$INPUT" | jq -r '
|
|
|
123
176
|
# Apply printf to interpret escape sequences, then truncate to $COLUMNS
|
|
124
177
|
RENDERED=$(printf '%b' "$ROW")
|
|
125
178
|
if [ "$COLUMNS" -gt 0 ] 2>/dev/null; then
|
|
126
|
-
# ANSI-aware iterative truncation: walk RENDERED
|
|
127
|
-
# \033[...letter escape sequences as zero-width and ordinary
|
|
179
|
+
# ANSI-aware iterative truncation: walk RENDERED byte-by-byte treating
|
|
180
|
+
# \033[...letter escape sequences as zero-width and ordinary bytes as
|
|
128
181
|
# one-width. Stop when the visible counter reaches COLUMNS - 3, then
|
|
129
|
-
# append RST + ellipsis.
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
# multi-byte UTF-8 in user-controlled descriptions counts as multiple
|
|
133
|
-
# visible units — acceptable approximation for the ASCII-dominant case.
|
|
182
|
+
# append RST + ellipsis. Byte-based slicing means non-ASCII multi-byte
|
|
183
|
+
# UTF-8 counts as multiple visible units — the .mjs/.py renderers iterate
|
|
184
|
+
# the same UTF-8 bytes, so all three stay byte-identical.
|
|
134
185
|
TARGET=$(( COLUMNS - 3 ))
|
|
135
186
|
[ "$TARGET" -lt 0 ] && TARGET=0
|
|
136
187
|
RAW_LEN=${#RENDERED}
|
|
@@ -150,12 +201,11 @@ echo "$INPUT" | jq -r '
|
|
|
150
201
|
fi
|
|
151
202
|
i=$(( i + 1 ))
|
|
152
203
|
done
|
|
153
|
-
# Post-loop drain: every rendered row ends with a trailing ${RST}
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
# or end-of-string.
|
|
204
|
+
# Post-loop drain: every rendered row ends with a trailing ${RST}. Without
|
|
205
|
+
# this drain a row whose visible content fits within COLUMNS still has
|
|
206
|
+
# `i < RAW_LEN` (pointing inside the trailing reset), spuriously triggering
|
|
207
|
+
# truncation. Walk past any in-progress escape and any further escapes until
|
|
208
|
+
# a real visible byte or end-of-string.
|
|
159
209
|
while [ $i -lt $RAW_LEN ]; do
|
|
160
210
|
ch="${RENDERED:$i:1}"
|
|
161
211
|
if [ "$in_esc" = "1" ]; then
|
|
@@ -171,8 +221,6 @@ echo "$INPUT" | jq -r '
|
|
|
171
221
|
fi
|
|
172
222
|
done
|
|
173
223
|
if [ $i -lt $RAW_LEN ]; then
|
|
174
|
-
# printf '%b' converts the literal-text RST (`\033[0m`) into an actual
|
|
175
|
-
# ESC byte sequence, matching the byte semantics of RENDERED's prefix.
|
|
176
224
|
RENDERED="${RENDERED:0:$i}$(printf '%b' "$RST")..."
|
|
177
225
|
fi
|
|
178
226
|
fi
|