@zalom/plastic 2.0.0-alpha.14 → 2.0.0-alpha.15
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/hooks/message-display +24 -0
- package/package.json +1 -1
- package/scripts/lib/hook_replay.rb +83 -0
- package/scripts/lib/message_display.rb +116 -10
- package/scripts/lib/report_screen.rb +19 -2
package/hooks/message-display
CHANGED
|
@@ -107,4 +107,28 @@ fi
|
|
|
107
107
|
|
|
108
108
|
[ "$handoff" = 1 ] || exit 0
|
|
109
109
|
|
|
110
|
+
# 331a1 (D1): chunk 0 is about to hand off to Ruby, which takes on the
|
|
111
|
+
# order of 150 ms to boot before it ever writes SCREEN or NOSCREEN. Claude
|
|
112
|
+
# Code fires the per-chunk hook PROCESSES CONCURRENTLY (see the header
|
|
113
|
+
# comment above), so a later chunk landing inside that window used to find
|
|
114
|
+
# no message directory at all, fail this script's own cheap shape test,
|
|
115
|
+
# and pass through as raw Markdown - measured at 15 to 21 chunks of every
|
|
116
|
+
# screen, against a stream-realistic 5 ms stagger. Staking a PENDING
|
|
117
|
+
# marker here, the instant the handoff gate above passes, closes that
|
|
118
|
+
# window down to microseconds: a later chunk's own "does the directory
|
|
119
|
+
# exist" check (below, unchanged) now finds it almost at once, and
|
|
120
|
+
# MessageDisplay polls for the real decision instead of judging the
|
|
121
|
+
# chunk's own shape. mkdir is the one non-builtin on this whole path - it
|
|
122
|
+
# forks, unlike everything else in this file - but it runs ONLY here,
|
|
123
|
+
# after the handoff gate, so the ordinary (non-candidate) chunk that is
|
|
124
|
+
# the overwhelming common case still forks nothing (matrix L8). A failed
|
|
125
|
+
# mkdir or printf (an unwritable tmp root, a race with another process) is
|
|
126
|
+
# silently swallowed: it must never change this script's own exit status
|
|
127
|
+
# or skip the Ruby handoff below - Ruby's own polling already fails open
|
|
128
|
+
# (D5) when no decision ever appears.
|
|
129
|
+
if [ "$is_index_zero" = 1 ]; then
|
|
130
|
+
mkdir -p "$MSGDIR" 2>/dev/null
|
|
131
|
+
printf '' > "$MSGDIR/PENDING" 2>/dev/null
|
|
132
|
+
fi
|
|
133
|
+
|
|
110
134
|
printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
|
package/package.json
CHANGED
|
@@ -56,6 +56,89 @@ module HookReplay
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
|
|
60
|
+
# way `replay` does, but fires every chunk in its OWN thread, staggered by
|
|
61
|
+
# `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
|
|
62
|
+
# than run sequentially. This is what reproduces the decision-race defect
|
|
63
|
+
# 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
|
|
64
|
+
# in production, and `replay`'s strictly-sequential default never puts two
|
|
65
|
+
# chunks in flight at once, so it could never have reproduced the race in
|
|
66
|
+
# the first place.
|
|
67
|
+
#
|
|
68
|
+
# `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
|
|
69
|
+
# at once": firing all 335 chunks of the live session capture with no
|
|
70
|
+
# stagger at all takes about 8 s of wall clock on this 8-core machine,
|
|
71
|
+
# because EACH chunk boots its own real Ruby process and the completions
|
|
72
|
+
# cluster at the tail once every core is saturated - a load real streaming
|
|
73
|
+
# never produces (a real stream delivers a chunk every few tens of
|
|
74
|
+
# milliseconds, one at a time). One Ruby process per streamed chunk is the
|
|
75
|
+
# actual throughput ceiling here, not something this method works around.
|
|
76
|
+
#
|
|
77
|
+
# `replay`'s own signature and sequential default are UNCHANGED by this
|
|
78
|
+
# method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
|
|
79
|
+
# must keep working exactly as it does today) - this is a sibling method
|
|
80
|
+
# in the same module, never a replacement.
|
|
81
|
+
#
|
|
82
|
+
# Returns the SAME result shape `replay` returns (one Hash per chunk, keys
|
|
83
|
+
# index/exitstatus/stdout/stderr/final), ordered by index regardless of
|
|
84
|
+
# the order the threads actually finish in.
|
|
85
|
+
def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
|
|
86
|
+
message_id: "replay", env: {}, gap_ms: 5, jitter: true)
|
|
87
|
+
chunks = text.scan(/.{1,#{chunk}}/m)
|
|
88
|
+
chunks = [""] if chunks.empty?
|
|
89
|
+
full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
|
|
90
|
+
gap = gap_ms / 1000.0
|
|
91
|
+
|
|
92
|
+
results = Array.new(chunks.length)
|
|
93
|
+
threads = chunks.each_with_index.map do |delta, i|
|
|
94
|
+
payload = {
|
|
95
|
+
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
96
|
+
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
97
|
+
"hook_event_name" => "MessageDisplay",
|
|
98
|
+
}
|
|
99
|
+
Thread.new do
|
|
100
|
+
begin
|
|
101
|
+
delay = i * gap
|
|
102
|
+
delay += (rand * gap / 2.0) if jitter
|
|
103
|
+
sleep(delay)
|
|
104
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
105
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
# A raise inside a thread body is invisible until join, and an
|
|
108
|
+
# unrescued one aborts `threads.each(&:join)` at the first dead
|
|
109
|
+
# thread: every later thread is then never joined and outlives the
|
|
110
|
+
# call, racing whatever the caller does next (typically removing
|
|
111
|
+
# the very tmp root those threads are still writing under). Report
|
|
112
|
+
# the failure as this chunk's own result instead, so the array is
|
|
113
|
+
# always complete, every thread is always joined, and a replay
|
|
114
|
+
# tells its caller what went wrong rather than throwing at it.
|
|
115
|
+
results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
|
|
116
|
+
final: payload["final"] }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
threads.each { |thread| thread.join }
|
|
121
|
+
results
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Indices of the chunks that reached the terminal as raw Markdown: a
|
|
125
|
+
# non-final chunk that emitted nothing at all, after the engaging chunk.
|
|
126
|
+
# A chunk "passed through" when its stdout is empty; a chunk was
|
|
127
|
+
# "blanked" (correctly buffered, not shown raw) when its stdout contains
|
|
128
|
+
# `"displayContent":""`. Only chunks with an index greater than the
|
|
129
|
+
# engaging chunk's own index count - the engaging chunk is the first one
|
|
130
|
+
# (at or after `start_index`) whose stdout is non-empty, and chunks
|
|
131
|
+
# before it already reached the terminal live, verbatim, through the
|
|
132
|
+
# ordinary passthrough path (they were never candidates for buffering at
|
|
133
|
+
# all, so an empty stdout from one of them is not this defect).
|
|
134
|
+
def passthrough_indices(outs, start_index: 0)
|
|
135
|
+
engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
|
|
136
|
+
return [] unless engaging
|
|
137
|
+
|
|
138
|
+
outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
|
|
139
|
+
.map { |o| o[:index] }
|
|
140
|
+
end
|
|
141
|
+
|
|
59
142
|
def run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
60
143
|
return capture(hook_path, payload, full_env) unless timeout
|
|
61
144
|
|
|
@@ -60,6 +60,35 @@ require_relative "screen_paint"
|
|
|
60
60
|
# (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
|
|
61
61
|
# fence right after the painted region in `finalize`. Neither ever reaches
|
|
62
62
|
# back into an earlier, already-displayed chunk.
|
|
63
|
+
#
|
|
64
|
+
# Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
|
|
65
|
+
# names the concurrency; what it did not close is chunk 0's own boot time.
|
|
66
|
+
# Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
|
|
67
|
+
# writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
|
|
68
|
+
# dozen or more later chunks to be judged with nothing on disk at all, so
|
|
69
|
+
# every one of them fell back to the cheap shape test and, being ordinary
|
|
70
|
+
# non-table prose, passed straight through plain. The bash launcher (hooks/
|
|
71
|
+
# message-display) now stakes a PENDING file with builtins the instant
|
|
72
|
+
# chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
|
|
73
|
+
# message directory within microseconds instead of after Ruby's own boot.
|
|
74
|
+
# While PENDING exists, a later chunk polls for the real decision WHATEVER
|
|
75
|
+
# ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
|
|
76
|
+
# a decision is certainly coming, and the cheap shape gate exists only for
|
|
77
|
+
# the "nothing at all exists yet, is a wait even worth paying for" case,
|
|
78
|
+
# which no longer applies once something IS on disk. A PENDING whose mtime
|
|
79
|
+
# is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
|
|
80
|
+
# fail open): chunk 0 must have died or hung, and waiting out a whole budget
|
|
81
|
+
# for a decision that is provably not coming would only delay every chunk
|
|
82
|
+
# behind it. That staleness check runs ONCE, before any polling, since a
|
|
83
|
+
# file's mtime never changes while this process looks at it. The poll
|
|
84
|
+
# budget itself scales with the chunk's own index (`budget_ms`, D3): base
|
|
85
|
+
# wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
|
|
86
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
87
|
+
# reproduced this) is allowed to wait for a decision that is certainly on
|
|
88
|
+
# its way, while chunk 1 of an ordinary short message still fails open
|
|
89
|
+
# quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
|
|
90
|
+
# (`write_screen`/`write_noscreen`), on both paths, so "a decision already
|
|
91
|
+
# exists" and "PENDING is still there" are never both true for long.
|
|
63
92
|
class MessageDisplay
|
|
64
93
|
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
65
94
|
# opener engages, with NO intent-id resolution (the roster and delay
|
|
@@ -76,8 +105,13 @@ class MessageDisplay
|
|
|
76
105
|
BUFFER_MAX_AGE_SECONDS = 3600
|
|
77
106
|
SCREEN_FILE = "SCREEN"
|
|
78
107
|
NOSCREEN_FILE = "NOSCREEN"
|
|
108
|
+
# 331a1 (D1): staked by the bash launcher, with builtins, the instant
|
|
109
|
+
# chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
|
|
110
|
+
# NOSCREEN (D2), never read by this class as a decision in its own right.
|
|
111
|
+
PENDING_FILE = "PENDING"
|
|
79
112
|
|
|
80
113
|
def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
|
|
114
|
+
index_wait_ms: 20, max_wait_ms: 2000,
|
|
81
115
|
sleeper: ->(seconds) { sleep(seconds) })
|
|
82
116
|
@tmp_root = tmp_root
|
|
83
117
|
@plastic_home = plastic_home
|
|
@@ -85,6 +119,8 @@ class MessageDisplay
|
|
|
85
119
|
@now = now
|
|
86
120
|
@wait_ms = wait_ms
|
|
87
121
|
@poll_ms = poll_ms
|
|
122
|
+
@index_wait_ms = index_wait_ms
|
|
123
|
+
@max_wait_ms = max_wait_ms
|
|
88
124
|
@sleeper = sleeper
|
|
89
125
|
end
|
|
90
126
|
|
|
@@ -132,6 +168,13 @@ class MessageDisplay
|
|
|
132
168
|
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
|
|
133
169
|
end
|
|
134
170
|
|
|
171
|
+
# 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
|
|
172
|
+
# class must agree, byte for byte, on where PENDING lives - the same
|
|
173
|
+
# contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
|
|
174
|
+
def self.pending_path(tmp_root:, session_id:, message_id:)
|
|
175
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
|
|
176
|
+
end
|
|
177
|
+
|
|
135
178
|
private
|
|
136
179
|
|
|
137
180
|
# Chunk 0 decides, synchronously, before anything else touches this
|
|
@@ -159,7 +202,7 @@ class MessageDisplay
|
|
|
159
202
|
split = split_at_opener(delta)
|
|
160
203
|
return engage(dir, index, split, final) if split
|
|
161
204
|
|
|
162
|
-
decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
|
|
205
|
+
decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
|
|
163
206
|
|
|
164
207
|
return nil unless decision == :screen
|
|
165
208
|
|
|
@@ -212,17 +255,34 @@ class MessageDisplay
|
|
|
212
255
|
lines[0...-1].join
|
|
213
256
|
end
|
|
214
257
|
|
|
215
|
-
# Checks for an existing decision first (free)
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
258
|
+
# Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
|
|
259
|
+
# whole fix: when PENDING exists, a decision is certainly coming, so this
|
|
260
|
+
# chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
|
|
261
|
+
# is never even consulted on this branch - unless PENDING is already
|
|
262
|
+
# stale (older than this chunk's own budget), which reads as NOSCREEN at
|
|
263
|
+
# once, fail open, without ever polling. Only when there is no PENDING
|
|
264
|
+
# AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
|
|
265
|
+
# or this replay never wrote one) does today's original behavior apply:
|
|
266
|
+
# the cheap shape test gates whether a bounded poll is worth paying for.
|
|
267
|
+
# `gate_delta: nil` (the final chunk) skips that shape test entirely and
|
|
268
|
+
# always polls.
|
|
269
|
+
def wait_for_decision(dir, gate_delta:, index:)
|
|
220
270
|
decision = read_decision_now(dir)
|
|
221
271
|
return decision if decision
|
|
222
272
|
|
|
273
|
+
if pending?(dir)
|
|
274
|
+
return :noscreen if pending_stale?(dir, index)
|
|
275
|
+
|
|
276
|
+
return poll_for_decision(dir, index)
|
|
277
|
+
end
|
|
278
|
+
|
|
223
279
|
return :timeout if gate_delta && !maybe_screen?(gate_delta)
|
|
224
280
|
|
|
225
|
-
|
|
281
|
+
poll_for_decision(dir, index)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def poll_for_decision(dir, index)
|
|
285
|
+
max_polls_for_budget(index).times do
|
|
226
286
|
@sleeper.call(@poll_ms / 1000.0)
|
|
227
287
|
decision = read_decision_now(dir)
|
|
228
288
|
return decision if decision
|
|
@@ -238,6 +298,24 @@ class MessageDisplay
|
|
|
238
298
|
nil
|
|
239
299
|
end
|
|
240
300
|
|
|
301
|
+
def pending?(dir)
|
|
302
|
+
File.exist?(File.join(dir, PENDING_FILE))
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Checked ONCE, before any polling - a file's mtime never changes while
|
|
306
|
+
# this process is looking at it, so re-checking inside the poll loop
|
|
307
|
+
# would only ever repeat the same answer. Any error reading the mtime
|
|
308
|
+
# (a race where PENDING vanished between `pending?` and here, most
|
|
309
|
+
# likely because the real decision just landed) is NOT staleness: it
|
|
310
|
+
# falls through to the ordinary poll, which will pick up that decision
|
|
311
|
+
# on its very next read.
|
|
312
|
+
def pending_stale?(dir, index)
|
|
313
|
+
age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
|
|
314
|
+
age_ms > budget_ms(index)
|
|
315
|
+
rescue StandardError
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
|
|
241
319
|
# 331a (M5a): the start index crosses process boundaries through SCREEN's
|
|
242
320
|
# own content, never in-memory state - the final chunk is routinely a
|
|
243
321
|
# SEPARATE process from the one that engaged. An empty or missing file
|
|
@@ -288,21 +366,34 @@ class MessageDisplay
|
|
|
288
366
|
end
|
|
289
367
|
end
|
|
290
368
|
|
|
369
|
+
# 331a1 (D3): index-scaled too, same as the decision poll - the final
|
|
370
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
371
|
+
# reproduced this) must be allowed to wait long enough for the earlier
|
|
372
|
+
# chunk files to land, not just the base wait_ms an ordinary short
|
|
373
|
+
# message gets by with.
|
|
291
374
|
def wait_for_chunk_files(dir, start_index, index)
|
|
292
375
|
return if index <= start_index
|
|
293
376
|
|
|
294
377
|
needed = (start_index...index).map(&:to_s)
|
|
295
|
-
max_polls_for_budget.times do
|
|
378
|
+
max_polls_for_budget(index).times do
|
|
296
379
|
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
297
380
|
|
|
298
381
|
@sleeper.call(@poll_ms / 1000.0)
|
|
299
382
|
end
|
|
300
383
|
end
|
|
301
384
|
|
|
302
|
-
|
|
385
|
+
# 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
|
|
386
|
+
# max_wait_ms - a chunk deep into a long streamed message is certainly
|
|
387
|
+
# going to see its decision eventually, so it is allowed to wait longer
|
|
388
|
+
# than chunk 1 of an ordinary short message.
|
|
389
|
+
def budget_ms(index)
|
|
390
|
+
[@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def max_polls_for_budget(index)
|
|
303
394
|
return 0 unless @poll_ms.to_f.positive?
|
|
304
395
|
|
|
305
|
-
(
|
|
396
|
+
(budget_ms(index) / @poll_ms.to_f).ceil
|
|
306
397
|
end
|
|
307
398
|
|
|
308
399
|
# Whatever chunk files exist FROM THE START INDEX onward, in index order,
|
|
@@ -363,12 +454,18 @@ class MessageDisplay
|
|
|
363
454
|
# separate process, in production) knows where to start waiting and
|
|
364
455
|
# splicing, and so `finalize_final` never touches chunks that were passed
|
|
365
456
|
# through untouched before engagement.
|
|
457
|
+
# 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
|
|
458
|
+
# chunk turns out to be the one that engages.
|
|
366
459
|
def write_screen(dir, index)
|
|
367
460
|
atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
|
|
461
|
+
remove_pending(dir)
|
|
368
462
|
end
|
|
369
463
|
|
|
464
|
+
# 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
|
|
465
|
+
# never finds both PENDING and NOSCREEN and has to choose between them.
|
|
370
466
|
def write_noscreen(dir)
|
|
371
467
|
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
468
|
+
remove_pending(dir)
|
|
372
469
|
end
|
|
373
470
|
|
|
374
471
|
# 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
|
|
@@ -378,6 +475,15 @@ class MessageDisplay
|
|
|
378
475
|
FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
|
|
379
476
|
end
|
|
380
477
|
|
|
478
|
+
# 331a1 (D2): removal must never raise - a decision was already written
|
|
479
|
+
# successfully by the time this runs, and a stray filesystem error here
|
|
480
|
+
# must never turn a successful decision into an unhandled exception.
|
|
481
|
+
def remove_pending(dir)
|
|
482
|
+
FileUtils.rm_f(File.join(dir, PENDING_FILE))
|
|
483
|
+
rescue StandardError
|
|
484
|
+
nil
|
|
485
|
+
end
|
|
486
|
+
|
|
381
487
|
def atomic_write(path, content)
|
|
382
488
|
FileUtils.mkdir_p(File.dirname(path))
|
|
383
489
|
tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
|
|
@@ -197,9 +197,25 @@ module ReportScreen
|
|
|
197
197
|
budget = limit - (4 + 3 * (ncols - 1)) - overage
|
|
198
198
|
widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
|
|
199
199
|
|
|
200
|
+
# 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
|
|
201
|
+
# whenever its OWN unfitted input already fits the bound - rebuilding it from the
|
|
202
|
+
# shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
|
|
203
|
+
# than any data row in the first place, landing it as the only row the backstop ever
|
|
204
|
+
# cut (or, when the rebuilt form happened to still fit, wider than its own "---"
|
|
205
|
+
# input, which D1 forbids just as much). Only when even the unmodified input cannot
|
|
206
|
+
# fit does the old rebuild-and-backstop path apply - the bound wins there, which is
|
|
207
|
+
# exactly what test_fit_screen_backstops_an_unshrinkable_row and
|
|
208
|
+
# test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
|
|
209
|
+
# reaching this branch is classified as a separator by the same regex, so it gets
|
|
210
|
+
# the identical pass-through rule.
|
|
200
211
|
fitted_rows = raw_rows.each_with_index.map do |cells, ri|
|
|
201
212
|
if is_sep[ri]
|
|
202
|
-
|
|
213
|
+
original = rows[ri]
|
|
214
|
+
if ScreenPaint.display_columns(original) <= limit
|
|
215
|
+
original
|
|
216
|
+
else
|
|
217
|
+
"| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
|
|
218
|
+
end
|
|
203
219
|
else
|
|
204
220
|
rendered = cells.each_with_index.map do |c, ci|
|
|
205
221
|
next c.to_s.strip if ci >= ncols
|
|
@@ -215,7 +231,8 @@ module ReportScreen
|
|
|
215
231
|
# and the assembled row can still be over the limit; truncate the whole row on a word
|
|
216
232
|
# boundary rather than let it survive past 115 - a data table's separator row included
|
|
217
233
|
# (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
|
|
218
|
-
# separator, which always passes through untouched (W2).
|
|
234
|
+
# separator, which always passes through untouched (W2). A separator already passed
|
|
235
|
+
# through byte-identical above never trips this (it already fits by construction).
|
|
219
236
|
fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
|
|
220
237
|
|
|
221
238
|
"#{fitted_rows.join("\n")}\n"
|