@zalom/plastic 2.0.0-alpha.4 → 2.0.0-alpha.6

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.
@@ -18,6 +18,18 @@ require_relative "session_ledger"
18
18
  module DoctorSessionLedger
19
19
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
20
 
21
+ # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
+ # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
+ # session start never ran at all (a `-p` print session, a resumed
24
+ # background job, an unregistered SessionStart hook), not a session that
25
+ # ran and then never closed. No choice of session id ever creates a
26
+ # pointer for that class, so it would otherwise stay invisible for the
27
+ # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
+ # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
+ # between its own start and its first pointer write is normal and must not
30
+ # be flagged, but that window is seconds, not hours.
31
+ NO_POINTER_TTL_SECONDS = 5 * 60
32
+
21
33
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
22
34
  # else that file's mtime, else the directory's mtime.
23
35
  def heartbeat_age(dir, now)
@@ -37,6 +49,7 @@ module DoctorSessionLedger
37
49
 
38
50
  store_dir = File.join(plastic_home, "store")
39
51
  orphans = orphaned_session_dirs(store_dir, now)
52
+ no_pointer = no_pointer_session_dirs(store_dir, now)
40
53
  shape = day_ledger_shape_problems(store_dir)
41
54
 
42
55
  checks = []
@@ -54,6 +67,20 @@ module DoctorSessionLedger
54
67
  "session is gone: a live session rewrites its heartbeat on every " \
55
68
  "prompt and edit, so only a listed directory may be removed, by hand.")
56
69
  end
70
+ checks << if no_pointer.empty?
71
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
+ message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
+ "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
+ else
75
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
+ message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
+ "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
+ "(session start never ran for this session)",
79
+ details: no_pointer, fixable: true,
80
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
+ "session never started: no `current` pointer means session start never " \
82
+ "ran for it, so this is not a live session missing a checklist entry.")
83
+ end
57
84
  checks << if shape.empty?
58
85
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
59
86
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -86,6 +113,31 @@ module DoctorSessionLedger
86
113
  [] # unreadable .tmp/: nothing to report, never a crash
87
114
  end
88
115
 
116
+ # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
+ # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
+ # that one is age-only and blind to whether a pointer exists at all, so a
119
+ # young no-pointer dir (a session between its start and its first pointer
120
+ # write) must not appear here even though it may well appear there once it
121
+ # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
+ # and a dir can legitimately show up in neither, either, or both.
123
+ def no_pointer_session_dirs(store_dir, now)
124
+ tmp_root = SessionLedger.tmp_root(store_dir)
125
+ return [] unless File.directory?(tmp_root)
126
+
127
+ Dir.children(tmp_root).sort.filter_map do |name|
128
+ dir = File.join(tmp_root, name)
129
+ next unless File.directory?(dir)
130
+ next if File.exist?(File.join(dir, "current"))
131
+
132
+ age = heartbeat_age(dir, now)
133
+ next if age <= NO_POINTER_TTL_SECONDS
134
+
135
+ "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
+ end
137
+ rescue SystemCallError
138
+ [] # unreadable .tmp/: nothing to report, never a crash
139
+ end
140
+
89
141
  def day_ledger_shape_problems(store_dir)
90
142
  root = SessionLedger.sessions_root(store_dir)
91
143
  return [] unless File.directory?(root)
@@ -54,6 +54,18 @@ module HookRegistry
54
54
  { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
55
55
  ] },
56
56
  ],
57
+ # Intent 316a (D6): Claude Code only, no Codex projection — MessageDisplay
58
+ # is not one of CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never
59
+ # picks it up; codex_hook_names stays exactly what it was (pinned by
60
+ # test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
61
+ # chunk of every assistant message (D11); the launcher (hooks/message-
62
+ # display) decides with shell builtins and forks nothing on the common
63
+ # case, execing Ruby only for a candidate message.
64
+ "MessageDisplay" => [
65
+ { "matcher" => "", "hooks" => [
66
+ { "name" => "message-display", "status" => "" },
67
+ ] },
68
+ ],
57
69
  }
58
70
  end
59
71
 
@@ -76,8 +76,25 @@ class InstallerCore
76
76
  @agents = agents
77
77
  end
78
78
 
79
+ # Reads the package version from `package.json` when present (the source
80
+ # tree, and any dev checkout), falling back to the bare `VERSION` file the
81
+ # installer copies to `~/.plastic` (row B, spec 315b): an installed system
82
+ # carries VERSION but never package.json, so `rollback.rb` and `update.rb`
83
+ # constructing an InstallerCore with `package_root: ~/.plastic` crashed with
84
+ # a raw Errno::ENOENT before this fallback existed. Raises a named error,
85
+ # not a bare ENOENT, when neither file exists, so the caller is not
86
+ # misdirected toward the wrong missing file.
79
87
  def read_package_version(root)
80
- File.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
88
+ package_json = File.join(root, "package.json")
89
+ version_file = File.join(root, "VERSION")
90
+
91
+ if File.exist?(package_json)
92
+ JSON.parse(File.read(package_json))["version"]
93
+ elsif File.exist?(version_file)
94
+ File.read(version_file).strip
95
+ else
96
+ raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
97
+ end
81
98
  end
82
99
 
83
100
  # --- Channel derivation (the channel is encoded in the version string) ---
@@ -426,6 +443,14 @@ class InstallerCore
426
443
  "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
427
444
  "scripts/intent-screen" => "scripts/intent-screen",
428
445
  "scripts/hook-savepoint" => "scripts/hook-savepoint",
446
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
447
+ # (see hook_files above), so the two lib files a require_relative
448
+ # between themselves are unguarded there — these three literal
449
+ # entries are their only protection (test/install_sync_test.rb:23-29
450
+ # greps installer_core.rb's own source text for "scripts/<name>").
451
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
452
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
453
+ "scripts/hook-message-display" => "scripts/hook-message-display",
429
454
  }
430
455
  end
431
456
 
@@ -1,9 +1,17 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
  # IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
4
- # record: the intent file, the tier's INDEX.md, savepoint.md, and checklist.md.
4
+ # record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
5
5
  # Every number on the screen comes from here so the session never writes one by
6
6
  # eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
7
+ #
8
+ # Intent 316a fixed three defects the field code inherited into both the plain
9
+ # renderer and the ANSI renderer (scripts/lib/intent_screen_ansi.rb): the
10
+ # Insight row dumping a multi-clause remainder into the note column, an empty
11
+ # "What this means" heading rendering bold with nothing under it, and step
12
+ # text cut mid-sentence. `step_text`, `insight_fields` and `next_fields` are
13
+ # public so the ANSI renderer reuses the exact same trims (D3) rather than
14
+ # re-deriving them and drifting.
7
15
  module IntentScreen
8
16
  BAR_WIDTH = 20
9
17
  ON = "█"
@@ -11,10 +19,22 @@ module IntentScreen
11
19
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
12
20
  SECTIONS = %w[Active Future Completed Abandoned].freeze
13
21
  ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
14
- STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·]\s*/i
22
+ # Em dash and en dash added (intent 316a O1e): a checklist item written
23
+ # "S1 — text" (the em dash every checklist this intent writes, and the one a
24
+ # reviewer reads, uses) kept its prefix under the old character class and
25
+ # rendered "S1 [ open ] S1 — text" on screen.
26
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*[-:·—–]\s*/i
15
27
  INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
16
28
  SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
17
29
 
30
+ # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
31
+ # trim: a clause trim on step text destroys a pinned `OPEN:` row
32
+ # (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
33
+ INSIGHT_VALUE_MAX = 72
34
+ INSIGHT_NOTE_MAX = 96
35
+ NEXT_VALUE_MAX = 72
36
+ STEP_TEXT_MAX = 110
37
+
18
38
  # Where a resume lands, from the ledger's last line (the boarding matrix).
19
39
  def self.landing_stage(stage, milestone)
20
40
  case stage
@@ -54,8 +74,6 @@ module IntentScreen
54
74
  fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
55
75
  fields.merge!(insight_fields(intent_text))
56
76
  fields["steps.rows"] = steps_rows(items)
57
- fields["meaning"] = ""
58
- fields["close"] = ""
59
77
 
60
78
  out = template.dup
61
79
  fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
@@ -171,7 +189,11 @@ module IntentScreen
171
189
  "progress.note" => note }
172
190
  end
173
191
 
174
- def self.next_fields(items, status, checklist_present:)
192
+ # `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
193
+ # table's pipe-escaping; the ANSI renderer, which never emits a table,
194
+ # passes `escape_pipes: false` to get the raw value instead of a literal
195
+ # `\|`. Named to not shadow the module's own `escape` method.
196
+ def self.next_fields(items, status, checklist_present:, escape_pipes: true)
175
197
  return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
176
198
  return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
177
199
 
@@ -179,43 +201,83 @@ module IntentScreen
179
201
  return { "next" => "", "next.note" => "all steps done" } unless idx
180
202
 
181
203
  head, = split_first_clause(items[idx][:text])
182
- { "next" => "S#{idx + 1} · #{escape(head)}", "next.note" => "first open step" }
204
+ head = truncate_words(head, NEXT_VALUE_MAX)
205
+ head = escape(head) if escape_pipes
206
+ { "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
183
207
  end
184
208
 
185
209
  def self.steps_rows(items)
186
210
  return "| | | no steps yet |" if items.empty?
187
211
 
188
212
  items.each_with_index.map do |item, i|
189
- "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(item[:text])} |"
213
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
190
214
  end.join("\n")
191
215
  end
192
216
 
217
+ # Public (intent 316a O1c) so the ANSI renderer trims step text identically:
218
+ # word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
219
+ def self.step_text(text)
220
+ truncate_words(text, STEP_TEXT_MAX)
221
+ end
222
+
193
223
  def self.escape(text)
194
224
  text.gsub("|", "\\|")
195
225
  end
196
226
 
197
227
  # --- ## Insights ----------------------------------------------------------------
198
228
 
199
- def self.insight_fields(intent_text)
229
+ def self.insight_fields(intent_text, escape_pipes: true)
200
230
  section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
201
231
  entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
202
232
  return { "insight" => "none yet", "insight.note" => "" } unless entry
203
233
 
204
234
  ts, text = entry[1], entry[2].strip
205
235
  head, tail = split_first_clause(text)
236
+ value = truncate_words(head, INSIGHT_VALUE_MAX)
237
+ tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
206
238
  note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
207
- { "insight" => escape(head), "insight.note" => escape(note) }
239
+ if escape_pipes
240
+ { "insight" => escape(value), "insight.note" => escape(note) }
241
+ else
242
+ { "insight" => value, "insight.note" => note }
243
+ end
208
244
  end
209
245
 
246
+ # First clause of `text`, and at most one following clause as the tail.
247
+ # Anything past the second clause is discarded (intent 316a O1a): the old
248
+ # behavior dumped the ENTIRE remainder into the note (an 800-character
249
+ # real-world tail starting mid-list). Boundary is a `.` or `;` immediately
250
+ # followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
251
+ # are never mistaken for clause ends.
210
252
  def self.split_first_clause(text)
211
- m = text.match(/\A(.+?)[.;](\s+.*|\z)/m)
212
- head = m ? m[1] : text
213
- tail = m ? m[2].to_s.strip : ""
214
- if head.length > 60
215
- cut = head[0, 60].rindex(" ") || 60
216
- tail = "#{head[cut..].strip} #{tail}".strip
217
- head = head[0, cut].strip
218
- end
253
+ head, rest = clause_and_rest(text)
254
+ return [head, ""] unless rest
255
+
256
+ second, more = clause_and_rest(rest)
257
+ tail = more ? second : rest
219
258
  [head, tail]
220
259
  end
260
+
261
+ # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
262
+ # the remainder means either no boundary exists at all, or the boundary
263
+ # sits at the absolute end of `text` (a single trailing clause with nothing
264
+ # after it) — both cases where there is no SECOND clause to fold in.
265
+ def self.clause_and_rest(text)
266
+ m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
267
+ return [text, nil] unless m
268
+
269
+ remainder = m[2].to_s.strip
270
+ remainder.empty? ? [m[1], nil] : [m[1], remainder]
271
+ end
272
+
273
+ # Word-boundary truncation with a trailing "…" when cut, never mid-word and
274
+ # never a clause trim (intent 316a D3).
275
+ def self.truncate_words(text, max)
276
+ return text if text.length <= max
277
+ return "…" if max <= 1
278
+
279
+ cut = text[0, max - 1].rindex(" ")
280
+ cut = max - 1 if cut.nil? || cut.zero?
281
+ "#{text[0, cut].rstrip}…"
282
+ end
221
283
  end
@@ -0,0 +1,190 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_screen"
5
+
6
+ # IntentScreenAnsi (intent 316a, O2) - renders one intent screen with raw
7
+ # truecolor ANSI escapes, productionizing 318's mockup--render.rb. Standard
8
+ # library only. Calls the SAME public IntentScreen.* field methods
9
+ # scripts/intent-screen calls (store_fields, index_fields, savepoint_fields,
10
+ # checklist_items, progress_fields, next_fields, insight_fields,
11
+ # items_present?, fallback_name, step_text) and re-derives nothing, so every
12
+ # field the ANSI block prints is the identical value the plain screen prints
13
+ # (D3), just carried through a different layout.
14
+ #
15
+ # `color:` is a constructor/call argument, never an environment read (D18):
16
+ # the plain path (`color: false`) is one call away and testable without
17
+ # touching NO_COLOR or a TTY. No ENV, no Dir.pwd, no Dir.home.
18
+ module IntentScreenAnsi
19
+ ESC = "\e"
20
+ RESET = "#{ESC}[0m".freeze
21
+ BOLD = "#{ESC}[1m".freeze
22
+ TEAL = "#{ESC}[38;2;45;212;191m".freeze
23
+ AMBER = "#{ESC}[38;2;245;158;11m".freeze
24
+ GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
25
+ MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
26
+ NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
27
+
28
+ BAR_CELLS = 24
29
+ EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
30
+
31
+ DEFAULT_WIDTH = 100
32
+
33
+ ELLIPSIS = "…"
34
+
35
+ def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH)
36
+ base = File.basename(intent_dir)
37
+ id = base.split("--", 2).first
38
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
39
+
40
+ fields = {}
41
+ fields.merge!(IntentScreen.store_fields(store_root))
42
+ status, title = IntentScreen.index_fields(store_root, id)
43
+ fields["status"] = status
44
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
45
+ fields["id"] = id
46
+ fields["name"] = title || IntentScreen.fallback_name(intent_text)
47
+ fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
48
+ items = IntentScreen.checklist_items(intent_dir)
49
+ fields.merge!(IntentScreen.progress_fields(items))
50
+ fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
51
+ fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
52
+ fields.transform_values! { |v| clean(v) }
53
+
54
+ done_n = fields["progress.done"].to_i
55
+ total_n = fields["progress.total"].to_i
56
+
57
+ out = +""
58
+ out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
59
+ out << "\n\n"
60
+
61
+ # The 4th column marks a row whose value is already a finished, pre-fit
62
+ # string (the Progress bar, built above from styled glyphs plus a count)
63
+ # rather than raw field text still needing `fit_plain`. Naming that
64
+ # explicitly here reads better than testing the value for a leading ESC
65
+ # byte further down, which is really just asking "is this the Progress
66
+ # row?" through a type check.
67
+ field_rows = [
68
+ ["Store", fields["store"], fields["store.note"], false],
69
+ ["Status", fields["status"], fields["status.note"], false],
70
+ ["Stage", fields["stage"], fields["stage.note"], false],
71
+ ["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
72
+ ["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
73
+ ["Next", fields["next"], fields["next.note"], false],
74
+ ["Insight", fields["insight"], fields["insight.note"], false],
75
+ ]
76
+ key_width = field_rows.map { |k, _, _, _| k.length }.max
77
+ prefix_width = key_width + 4 # " " + key.ljust + " "
78
+
79
+ field_rows.each do |key, value, note, prebuilt|
80
+ value_budget = [width - prefix_width, 0].max
81
+ value_text = prebuilt ? value : fit_plain(value, value_budget)
82
+ out << " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}\n"
83
+ next if note.to_s.empty?
84
+
85
+ note_budget = [width - prefix_width, 0].max
86
+ indent = " " * prefix_width
87
+ out << "#{indent}#{fit(note, note_budget) { |t| styled(t, color, MIDGREY) }}\n"
88
+ end
89
+
90
+ out << "\n"
91
+ out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
92
+ out << "\n\n"
93
+
94
+ if items.empty?
95
+ out << " no steps yet\n"
96
+ else
97
+ # Padded to the widest label (matrix B2): at 10+ steps "S10" is one
98
+ # column wider than "S1..S9", and without padding every badge past S9
99
+ # drifts out of column with the rows above it.
100
+ label_width = "S#{items.size}".length
101
+ items.each_with_index do |item, i|
102
+ num = "S#{i + 1}".ljust(label_width)
103
+ badge = status_cell(item[:done], color)
104
+ prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
105
+ text_budget = [width - prefix_plain.length, 0].max
106
+ text = fit_plain(clean(IntentScreen.step_text(item[:text])), text_budget)
107
+ out << " #{num} [#{badge}] #{text}\n"
108
+ end
109
+ end
110
+
111
+ out
112
+ end
113
+
114
+ # --- markdown-noise stripping (intent 316a, S1 answer 5 / matrix 19b) ------
115
+ #
116
+ # `displayContent` is still Markdown-processed by Claude Code even inside a
117
+ # raw ANSI block (a live capture showed backticks silently stripped from
118
+ # step text). Strip backticks and neutralise `*`/`_` runs from every value
119
+ # before it reaches the block, so nothing is left for that pass to act on.
120
+ # Single underscores are left alone: they are common inside ordinary words
121
+ # (`intent_screen.rb`) and GFM does not treat an intraword underscore as
122
+ # emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
123
+ def self.clean(text)
124
+ text.to_s.delete("`*").gsub(/_{2,}/, "")
125
+ end
126
+
127
+ # --- width cap (D15, matrix 18) --------------------------------------------
128
+
129
+ # Truncates `text` (already markdown-clean) to `max` visible columns with a
130
+ # trailing ellipsis when cut, then yields the truncated plain text to the
131
+ # block for coloring. Coloring never adds visible width.
132
+ def self.fit(text, max)
133
+ plain = fit_plain(text, max)
134
+ block_given? ? yield(plain) : plain
135
+ end
136
+
137
+ def self.fit_plain(text, max)
138
+ return "" if max <= 0
139
+ return text if text.length <= max
140
+ return ELLIPSIS[0, max] if max <= 1
141
+
142
+ "#{text[0, max - 1]}#{ELLIPSIS}"
143
+ end
144
+
145
+ # --- palette ----------------------------------------------------------------
146
+
147
+ def self.styled(text, color, *codes)
148
+ return text unless color
149
+
150
+ "#{codes.join}#{text}#{RESET}"
151
+ end
152
+
153
+ def self.status_cell(done, color)
154
+ label = done ? " done " : " open "
155
+ return label unless color
156
+
157
+ hue = done ? TEAL : AMBER
158
+ "#{hue}#{BOLD}#{label}#{RESET}"
159
+ end
160
+
161
+ # `.dup` matters, not just style (318's own note, carried forward): these
162
+ # constants are built via string interpolation, which frozen_string_literal
163
+ # does NOT freeze automatically — only static literals get that. `.freeze`
164
+ # above makes them immutable, but `bar << ...` below still needs its OWN
165
+ # mutable copy or it would raise (or, without the freeze, silently corrupt
166
+ # the shared constant for every later call in the same process — matrix 16).
167
+ def self.render_bar(done, total, color)
168
+ ratio = total.zero? ? 0.0 : done.to_f / total
169
+
170
+ unless color
171
+ on = total.zero? ? 0 : (done * BAR_CELLS) / total
172
+ return ("#" * on) + ("." * (BAR_CELLS - on))
173
+ end
174
+
175
+ units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
176
+ full, rem = units.divmod(8)
177
+ full = [full, BAR_CELLS].min
178
+
179
+ bar = TEAL.dup
180
+ bar << ("█" * full)
181
+ if full < BAR_CELLS && rem.positive?
182
+ bar << EIGHTHS[rem]
183
+ full += 1
184
+ end
185
+ track = BAR_CELLS - full
186
+ bar << GRAPHITE_BG << (" " * track) if track.positive?
187
+ bar << RESET
188
+ bar
189
+ end
190
+ end