@litfamily/litgrok 1.0.24 → 1.0.26

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.
Files changed (120) hide show
  1. package/.grok/hooks/auto-handoff.mjs +1 -1
  2. package/.grok/hooks/lit-status-line.mjs +414 -269
  3. package/.grok/hooks/plan-progress.mjs +11 -2
  4. package/.grok/hooks/record-passive-event.mjs +20 -9
  5. package/.grok/hooks/workflow-stage.mjs +48 -13
  6. package/.grok/rules/00-litgrok.md +21 -12
  7. package/.grok/skills/autoconference/SKILL.md +3 -1
  8. package/.grok/skills/autoresearch/SKILL.md +3 -1
  9. package/.grok/skills/browser-drive/SKILL.md +3 -1
  10. package/.grok/skills/browser-drive/scripts/capability-probe.mjs +2 -2
  11. package/.grok/skills/comment-checker/SKILL.md +3 -1
  12. package/.grok/skills/debugging/SKILL.md +3 -1
  13. package/.grok/skills/deep-interview/SKILL.md +4 -1
  14. package/.grok/skills/frontend-ui-ux/scripts/probe.mjs +3 -2
  15. package/.grok/skills/lit-brainstorm/SKILL.md +162 -0
  16. package/.grok/skills/lit-brainstorm/references/converge-and-score.md +85 -0
  17. package/.grok/skills/lit-brainstorm/references/handoff-and-notes.md +66 -0
  18. package/.grok/skills/lit-brainstorm/references/idea-lane-packet.md +58 -0
  19. package/.grok/skills/lit-brainstorm/references/idea-lenses.md +60 -0
  20. package/.grok/skills/lit-diagram-drawer/scripts/diagram-metrics.mjs +1 -1
  21. package/.grok/skills/lit-diagram-drawer/scripts/export.mjs +6 -6
  22. package/.grok/skills/lit-diagram-drawer/scripts/renderer-status.mjs +3 -3
  23. package/.grok/skills/lit-docx/SKILL.md +3 -1
  24. package/.grok/skills/lit-docx/scripts/convert_md_to_pdf.py +7 -4
  25. package/.grok/skills/lit-docx/scripts/docx_gate.py +5 -2
  26. package/.grok/skills/lit-docx/scripts/pdf_fonts.py +63 -0
  27. package/.grok/skills/lit-docx/scripts/run.mjs +3 -5
  28. package/.grok/skills/lit-docx/templates/latex/acs/template.tex +5 -0
  29. package/.grok/skills/lit-docx/templates/latex/elsevier/template.tex +5 -0
  30. package/.grok/skills/lit-docx/templates/latex/ieee/template.tex +5 -0
  31. package/.grok/skills/lit-docx/templates/latex/korean-generic/template.tex +5 -0
  32. package/.grok/skills/lit-docx/templates/latex/nature/template.tex +5 -0
  33. package/.grok/skills/lit-handoff/SKILL.md +3 -1
  34. package/.grok/skills/lit-humanizer/scripts/extract.mjs +6 -3
  35. package/.grok/skills/lit-humanizer/scripts/test-fixtures.mjs +3 -1
  36. package/.grok/skills/lit-plan/SKILL.md +2 -0
  37. package/.grok/skills/lit-pptx/SKILL.md +3 -1
  38. package/.grok/skills/lit-pptx/scripts/deps.cjs +18 -8
  39. package/.grok/skills/lit-pptx/scripts/proc.cjs +192 -0
  40. package/.grok/skills/lit-pptx/scripts/run.mjs +3 -5
  41. package/.grok/skills/lit-recap/SKILL.md +3 -1
  42. package/.grok/skills/lit-typographic-motion/SKILL.md +9 -6
  43. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  44. package/.grok/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  45. package/.grok/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  46. package/.grok/skills/lit-typographic-motion/fonts/pins.json +15 -0
  47. package/.grok/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  48. package/.grok/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +36 -0
  49. package/.grok/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  50. package/.grok/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +24 -0
  51. package/.grok/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  52. package/.grok/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +25 -0
  53. package/.grok/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  54. package/.grok/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  55. package/.grok/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +67 -0
  56. package/.grok/skills/lit-typographic-motion/recipes/orbit-loop/index.html +119 -0
  57. package/.grok/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  58. package/.grok/skills/lit-typographic-motion/recipes/painted-light/index.html +192 -0
  59. package/.grok/skills/lit-typographic-motion/recipes/weight-tide/expected.json +67 -0
  60. package/.grok/skills/lit-typographic-motion/recipes/weight-tide/index.html +85 -0
  61. package/.grok/skills/lit-typographic-motion/references/cover.md +380 -0
  62. package/.grok/skills/lit-typographic-motion/references/craft-loop.md +4 -0
  63. package/.grok/skills/lit-typographic-motion/references/runtime.md +14 -3
  64. package/.grok/skills/lit-typographic-motion/references/stage.md +2 -1
  65. package/.grok/skills/lit-typographic-motion/references/treatment.md +5 -0
  66. package/.grok/skills/lit-typographic-motion/requirements-audio.txt +3 -3
  67. package/.grok/skills/lit-typographic-motion/scripts/lib/cache.mjs +1 -1
  68. package/.grok/skills/lit-typographic-motion/scripts/lib/chrome.mjs +14 -4
  69. package/.grok/skills/lit-typographic-motion/scripts/lib/constants.mjs +39 -0
  70. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-assets.mjs +88 -0
  71. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-capture.mjs +144 -0
  72. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-gate.mjs +312 -0
  73. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-image.mjs +111 -0
  74. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-pool.mjs +62 -0
  75. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-sheet.mjs +73 -0
  76. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-treatment.mjs +104 -0
  77. package/.grok/skills/lit-typographic-motion/scripts/lib/cover-webp.mjs +134 -0
  78. package/.grok/skills/lit-typographic-motion/scripts/lib/cover.mjs +347 -0
  79. package/.grok/skills/lit-typographic-motion/scripts/lib/encode.mjs +1 -1
  80. package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +2 -2
  81. package/.grok/skills/lit-typographic-motion/scripts/lib/look.mjs +99 -7
  82. package/.grok/skills/lit-typographic-motion/scripts/lib/render.mjs +8 -11
  83. package/.grok/skills/lit-typographic-motion/scripts/lib/sound.mjs +1 -1
  84. package/.grok/skills/lit-typographic-motion/scripts/lib/stage.mjs +3 -1
  85. package/.grok/skills/lit-typographic-motion/scripts/lib/treatment.mjs +1 -0
  86. package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +45 -3
  87. package/.grok/skills/lit-typographic-motion/scripts/prewarm.mjs +35 -22
  88. package/.grok/skills/lit-typographic-motion/scripts/stage/cover-init.js +44 -0
  89. package/.grok/skills/lit-typographic-motion/scripts/stage/loop-kit.js +96 -0
  90. package/.grok/skills/litgoal/SKILL.md +14 -2
  91. package/.grok/skills/litgrok/SKILL.md +30 -7
  92. package/.grok/skills/litresearch/SKILL.md +3 -1
  93. package/.grok/skills/litwork/SKILL.md +12 -1
  94. package/.grok/skills/lsp/SKILL.md +3 -1
  95. package/.grok/skills/lsp-setup/SKILL.md +3 -1
  96. package/.grok/skills/lsp-setup/scripts/lsp-server-table.mjs +3 -3
  97. package/.grok/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
  98. package/.grok/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  99. package/.grok/skills/refactor/SKILL.md +3 -1
  100. package/.grok/skills/review-work/SKILL.md +20 -26
  101. package/.grok/skills/rules/SKILL.md +3 -1
  102. package/.grok/skills/start-work/SKILL.md +1 -1
  103. package/.grok/skills/structural-search/SKILL.md +3 -1
  104. package/.grok/skills/wikify/SKILL.md +2 -0
  105. package/.grok-plugin/plugin.json +2 -1
  106. package/CHANGELOG.md +18 -0
  107. package/README.md +12 -11
  108. package/README_ko-KR.md +12 -11
  109. package/bin/litgrok.mjs +7 -2
  110. package/bin/status-line-config.mjs +26 -14
  111. package/docs/assets/cover-motion-still.webp +0 -0
  112. package/docs/assets/cover-motion.webp +0 -0
  113. package/docs/assets/cover.webp +0 -0
  114. package/docs/assets/readme/README.md +5 -5
  115. package/docs/assets/readme/badge-version.svg +1 -1
  116. package/docs/assets/skills/lit-brainstorm.webp +0 -0
  117. package/docs/reference.md +18 -8
  118. package/docs/reference_ko-KR.md +19 -8
  119. package/package.json +3 -2
  120. package/plugin.json +1 -1
@@ -9,12 +9,19 @@ import { readHudRecordForCwd, writeContextRecord } from './litgrok-hud-state.mjs
9
9
  import { STAGES, readWorkflowState } from './workflow-stage.mjs';
10
10
 
11
11
  const MAX_INPUT_BYTES = 64 * 1024;
12
- const GRADIENT_STOPS = [[255, 99, 55], [255, 45, 149], [0, 229, 255]];
13
- const BRAND_COLOR = [255, 99, 55];
12
+ // The family look: ONE LitGrok accent, flame orange (255 99 55, LitGrok's tick in the v7 armory), for all
13
+ // ordinary text and separators. Colour means more only in the context bands (yellow at 70%, red at 90%),
14
+ // the pink 🔥 LIT chip, the current-stage chip, a warning, a test verdict and a finished turn.
15
+ const ROLES = {
16
+ accent: [255, 99, 55], dim: [138, 138, 138], empty: [68, 68, 68], rail: [70, 80, 92],
17
+ yellow: [215, 175, 0], red: [215, 95, 95], green: [95, 175, 95], ink: [11, 15, 20], pink: [255, 45, 149],
18
+ };
19
+ // The family mark: L, I and T on pink, violet and cyan.
20
+ const BRAND_STOPS = [[255, 45, 149], [128, 137, 202], [0, 229, 255]];
14
21
  // Grok cuts each line at 1024 characters, escapes included.
15
22
  const MAX_LINE_CHARACTERS = 1024;
16
23
  const RECORD_SCHEMA = 'litgrok.hook-event/v1';
17
- // The ribbon reads only the end of the session ledger, enough for a few hundred records.
24
+ // The card reads only the end of the session ledger, enough for a few hundred records.
18
25
  const LEDGER_TAIL_BYTES = 128 * 1024;
19
26
  const COMPLETE_SHOWN_MS = 20 * 1000;
20
27
  const HANDOFF_NEAR_POINTS = 10;
@@ -22,25 +29,28 @@ const CLOCK_SKEW_MS = 5 * 1000;
22
29
  // A turn the ledger has not heard from for this long is no longer shown as running.
23
30
  const LEDGER_TURN_QUIET_MS = 10 * 60 * 1000;
24
31
  const TOOL_EVENTS = new Set(['PostToolUse', 'PostToolUseFailure']);
25
- // [background, foreground] of each pill.
26
- const PILLS = {
27
- brand: [[255, 45, 149], [18, 6, 12]],
28
- running: [[10, 166, 194], [0, 19, 24]],
29
- complete: [[29, 77, 54], [184, 245, 207]],
30
- warning: [[90, 58, 8], [255, 210, 138]],
31
- };
32
- const TEXT = { dim: [139, 148, 158], amber: [255, 175, 60], green: [107, 219, 143] };
33
- const GLYPHS = {
34
- basic: { running: '●', complete: '✓', warning: '⚠', timer: '', tools: (n) => `${n} tool${n === 1 ? '' : 's'}`, agents: (n) => `${n} agent${n === 1 ? '' : 's'}` },
35
- nerd: { running: '\uf111', complete: '\uf00c', warning: '\uf071', timer: '\uf017 ', tools: (n) => `\uf0ad ${n}`, agents: (n) => `\uf0c0 ${n}` },
36
- };
37
32
  const ANSI_16 = [[0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
38
33
  [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]];
39
- // The workflow row's rail runs orange to pink to a softer cyan than the label.
40
- const RAIL_STOPS = [[255, 99, 55], [255, 45, 149], [95, 215, 255]];
41
- // The moving head is the label's cyan: a white cell would vanish on a light terminal.
42
- const RAIL = { ink: [11, 15, 20], faint: [72, 79, 88], head: [0, 229, 255], red: [255, 95, 95] };
43
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
34
+ const BRAILLE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
35
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
36
+ // Every symbol the card draws, per glyph set. `ascii` is for terminals and fonts without box, braille or emoji.
37
+ const GLYPHS = {
38
+ basic: {
39
+ fire: '🔥', tick: '✓', cross: '✗', dot: '·', lit: '━', unlit: '─', run: '●', warn: '⚠', elbow: '└', next: '▸', sep: '│',
40
+ full: '▮', empty: '▯', rail: '▎', ellipsis: '…', spinner: BRAILLE, bar: ['█', '░', ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']],
41
+ timer: '', tools: (n) => plural(n, 'tool'), agents: (n) => plural(n, 'agent'), git: 'git ',
42
+ },
43
+ nerd: {
44
+ fire: '🔥', tick: '', cross: '', dot: '·', lit: '━', unlit: '─', run: '', warn: '', elbow: '└', next: '▸', sep: '│',
45
+ full: '▮', empty: '▯', rail: '▎', ellipsis: '…', spinner: BRAILLE, bar: ['█', '░', ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']],
46
+ timer: ' ', tools: (n) => ` ${n}`, agents: (n) => ` ${n}`, git: ' ',
47
+ },
48
+ ascii: {
49
+ fire: '', tick: '+', cross: 'x', dot: '-', lit: '=', unlit: '-', run: '*', warn: '!', elbow: '`', next: '>', sep: '|',
50
+ full: '#', empty: '-', rail: '|', ellipsis: '~', spinner: ['|', '/', '-', '\\'], bar: ['#', '-', ['']],
51
+ timer: '', tools: (n) => plural(n, 'tool'), agents: (n) => plural(n, 'agent'), git: 'git ',
52
+ },
53
+ };
44
54
  // Grok's own tool names, shortened to the words people know them by. Any other name shows as sent.
45
55
  const TOOL_LABELS = {
46
56
  run_terminal_command: 'Bash', read_file: 'Read', search_replace: 'Edit', write: 'Write', grep: 'Grep', list_dir: 'List',
@@ -95,50 +105,27 @@ function windowSize(value) {
95
105
  return `${Math.max(1, Math.round(value / 1000))}k`;
96
106
  }
97
107
 
98
- function contextBar(percent) {
99
- const exact = (percent * 3) / 100;
100
- const filled = Math.floor(exact);
101
- const partialBlocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
102
- const partial = filled < 3 && exact > filled ? Math.max(1, Math.min(7, Math.round((exact - filled) * 8))) : 0;
103
- return `${'█'.repeat(filled)}${partialBlocks[partial]}${'░'.repeat(3 - filled - (partial > 0 ? 1 : 0))}`;
104
- }
105
-
106
- // A percent Grok did not send shows as ?, never as a guess.
107
- function contextText(contextWindow) {
108
- const size = windowSize(contextWindow?.context_window_size);
109
- const value = contextWindow?.used_percentage;
110
- if (typeof value !== 'number' || !Number.isFinite(value)) return size ? `ctx ?/${size}` : 'ctx ?';
111
- const percent = Math.round(Math.max(0, Math.min(100, value)));
112
- return `ctx [${contextBar(percent)}] ${percent}%${size ? `/${size}` : ''}`;
108
+ function finitePercent(value) {
109
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(Math.max(0, Math.min(100, value))) : null;
113
110
  }
114
111
 
115
112
  function cellWidth(character) {
116
- if (/\p{Mark}/u.test(character) || character === '\u200d') return 0;
117
- return /[\u1100-\u115f\u2329\u232a\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u.test(character) ? 2 : 1;
113
+ if (/\p{Mark}/u.test(character) || character === '‍') return 0;
114
+ return /[ᄀ-ᅟ〈〉⺀-꓏가-힣豈-﫿︐-︙︰-﹯＀-⦆¢-₩\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u.test(character) ? 2 : 1;
118
115
  }
119
116
 
120
117
  function width(text) {
121
118
  return [...text].reduce((total, character) => total + cellWidth(character), 0);
122
119
  }
123
120
 
124
- function clipCells(text, maximum) {
121
+ function clipCells(text, maximum, ellipsis = '…') {
122
+ if (width(text) <= maximum) return text;
125
123
  let result = '';
126
- let used = 0;
127
- let truncated = false;
128
124
  for (const character of text) {
129
- const cells = cellWidth(character);
130
- if (used + cells > maximum || result.length + character.length > 78) {
131
- truncated = true;
132
- break;
133
- }
125
+ if (width(result) + cellWidth(character) + width(ellipsis) > maximum) break;
134
126
  result += character;
135
- used += cells;
136
- }
137
- if (truncated && maximum > 0) {
138
- while (result && (width(result) + 1 > maximum || result.length >= 78)) result = [...result].slice(0, -1).join('');
139
- result += '…';
140
127
  }
141
- return result;
128
+ return maximum >= width(ellipsis) ? `${result.trimEnd()}${ellipsis}` : '';
142
129
  }
143
130
 
144
131
  function distance(left, right) {
@@ -166,37 +153,49 @@ function sgr(rgb, depth, background = false) {
166
153
  return `\u001b[${background ? 48 : 38};2;${rgb.join(';')}m`;
167
154
  }
168
155
 
169
- function gradientColor(position, stops = GRADIENT_STOPS) {
170
- const stop = position <= 0.5 ? 0 : 1;
171
- const fraction = position <= 0.5 ? position * 2 : (position - 0.5) * 2;
172
- return stops[stop].map((value, index) => Math.round(value + (stops[stop + 1][index] - value) * fraction));
173
- }
174
-
175
- // Bold is set once and each letter only changes the colour, so the longest label stays far below the cut.
176
- function paintLabel(text, depth) {
177
- const characters = [...text];
178
- return `\u001b[1m${characters.map((character, index) => {
179
- if (character === ' ') return character;
180
- const position = characters.length === 1 ? 0 : index / (characters.length - 1);
181
- return `${sgr(gradientColor(position), depth)}${character}`;
182
- }).join('')}\u001b[0m`;
183
- }
184
-
185
- function formatRow({ discipline, model, effort, context, version }, depth, maxCells) {
186
- const brand = `[🔥LIT${version ? ` v${version}` : ''}]`;
187
- const suffix = `${effort ? ` [${effort}]` : ''} │ ${context}`;
188
- let ignition = '';
189
- let ignitionText = '';
190
- if (discipline) {
191
- // The skill name gives way after the model, and the model keeps at least its ellipsis.
192
- const room = maxCells - width(`${brand} 🔥 LIT IGNITED · 🔥 | ${suffix}`) - 1;
193
- const maxDisciplineCells = Math.max(1, Math.min(discipline === 'lit-scientific-visualization' ? 32 : 24, room));
194
- const label = `LIT IGNITED · ${clipCells(discipline, maxDisciplineCells)}`;
195
- ignitionText = ` 🔥 ${label} 🔥`;
196
- ignition = ` 🔥 ${depth ? paintLabel(label, depth) : label} 🔥`;
197
- }
198
- const modelText = clipCells(model, Math.max(1, maxCells - width(`${brand}${ignitionText} | ${suffix}`)));
199
- return `${depth ? `\u001b[1m${sgr(BRAND_COLOR, depth)}${brand}\u001b[0m` : brand}${ignition} | ${modelText}${suffix}`;
156
+ // A painter for one colour depth; depth 0 returns the text untouched.
157
+ function painter(depth) {
158
+ return {
159
+ depth,
160
+ fg: (text, role, bold = false) => (depth && text ? `${bold ? '\u001b[1m' : ''}${sgr(ROLES[role], depth)}${text}\u001b[0m` : text),
161
+ };
162
+ }
163
+
164
+ // A filled chip on the pink (the brand) or the accent (the current stage) with dark text and a space of
165
+ // padding each side; a Nerd Font gives it rounded caps, and without colour it falls back to brackets.
166
+ function chip(text, background, paint, nerd, open = '(', close = ')') {
167
+ if (!paint.depth) return { plain: `${open}${text}${close}`, painted: `${open}${text}${close}` };
168
+ const fill = `${sgr(ROLES[background], paint.depth, true)}${sgr(ROLES.ink, paint.depth)}\u001b[1m`;
169
+ if (!nerd) return { plain: ` ${text} `, painted: `${fill} ${text} \u001b[0m` };
170
+ const cap = (glyph) => `${sgr(ROLES[background], paint.depth)}${glyph}\u001b[0m`;
171
+ return { plain: `${text}`, painted: `${cap('')}${fill}${text}\u001b[0m${cap('')}` };
172
+ }
173
+
174
+ // Below 70% a gauge keeps the accent; 70% turns it yellow and 90% red, as the family gauges do.
175
+ function bandOf(percent) {
176
+ return percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'accent';
177
+ }
178
+
179
+ function blockBar(percent, cells, glyphs) {
180
+ const [full, empty, partials] = glyphs.bar;
181
+ const exact = (percent * cells) / 100;
182
+ const filled = Math.floor(exact);
183
+ const partial = partials.length > 1 && filled < cells && exact > filled ? Math.max(1, Math.min(partials.length - 1, Math.round((exact - filled) * partials.length))) : 0;
184
+ return { filled: `${full.repeat(filled)}${partials[partial]}`, empty: empty.repeat(cells - filled - (partial > 0 ? 1 : 0)) };
185
+ }
186
+
187
+ function duration(ms) {
188
+ const seconds = Math.floor(ms / 1000);
189
+ if (seconds < 60) return `${seconds}s`;
190
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
191
+ return `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}m`;
192
+ }
193
+
194
+ function clock(ms) {
195
+ const seconds = Math.floor(Math.max(0, ms) / 1000);
196
+ const minutes = Math.floor(seconds / 60);
197
+ const rest = String(seconds % 60).padStart(2, '0');
198
+ return minutes < 60 ? `${minutes}:${rest}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${rest}`;
200
199
  }
201
200
 
202
201
  // Automatic handoff needs Grok's context percent, which only the status line receives. Nothing is
@@ -313,25 +312,15 @@ function turnActivity(records, promptId) {
313
312
  };
314
313
  }
315
314
 
316
- function duration(ms) {
317
- const seconds = Math.floor(ms / 1000);
318
- if (seconds < 60) return `${seconds}s`;
319
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`;
320
- return `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}m`;
321
- }
322
-
323
315
  // Returns [long, short] warning text, or null. A plan-gate hold comes first, then a handoff request,
324
316
  // then a context at or near the automatic-handoff percent.
325
- function warningText(status, activity) {
317
+ function warningText(status, activity, handoff) {
326
318
  if (activity?.planGate) return ['plan gate: save plans/<slug>.md', 'plan gate'];
327
319
  if (activity?.handoffAsked) return ['handoff requested', 'handoff'];
328
- const used = status?.context_window?.used_percentage;
329
- if (typeof used !== 'number' || !Number.isFinite(used)) return null;
330
- const settings = resolveAutoHandoff({ env: process.env, dirs: statusDirs(status) });
331
- if (!settings.active) return null;
332
- const percent = Math.round(Math.max(0, Math.min(100, used)));
333
- if (percent >= settings.percent) return [`ctx ${percent}% ≥ handoff at ${settings.percent}%`, `ctx ${percent}%≥${settings.percent}%`];
334
- if (percent >= settings.percent - HANDOFF_NEAR_POINTS) return [`ctx ${percent}% → handoff at ${settings.percent}%`, `ctx ${percent}%→${settings.percent}%`];
320
+ const percent = finitePercent(status?.context_window?.used_percentage);
321
+ if (percent === null || !handoff.active) return null;
322
+ if (percent >= handoff.percent) return [`ctx ${percent}% ≥ handoff at ${handoff.percent}%`, `ctx ${percent}%≥${handoff.percent}%`];
323
+ if (percent >= handoff.percent - HANDOFF_NEAR_POINTS) return [`ctx ${percent}% → handoff at ${handoff.percent}%`, `ctx ${percent}%→${handoff.percent}%`];
335
324
  return null;
336
325
  }
337
326
 
@@ -350,208 +339,351 @@ function turnState(status, activity, now) {
350
339
  return { running, startedAt: running ? activity.startedAt : null };
351
340
  }
352
341
 
353
- function bandState(status, now, activity, turn) {
354
- const running = turn.running;
355
- const warning = warningText(status, activity);
356
- let timer = null;
357
- let state = null;
358
- if (running) {
359
- state = 'running';
360
- if (turn.startedAt !== null) timer = duration(Math.max(0, now - turn.startedAt));
361
- } else if (activity?.endedAt && !activity.failed && now - activity.endedAt <= COMPLETE_SHOWN_MS && activity.endedAt <= now + CLOCK_SKEW_MS) {
362
- state = 'complete';
363
- timer = duration(Math.max(0, activity.endedAt - activity.startedAt));
342
+ // running, complete (for twenty seconds after a clean stop) or idle, with the turn's timer.
343
+ function phaseOf(now, activity, turn) {
344
+ if (turn.running) return { phase: 'running', timer: turn.startedAt === null ? null : duration(Math.max(0, now - turn.startedAt)) };
345
+ if (activity?.endedAt && !activity.failed && now - activity.endedAt <= COMPLETE_SHOWN_MS && activity.endedAt <= now + CLOCK_SKEW_MS) {
346
+ return { phase: 'complete', timer: duration(Math.max(0, activity.endedAt - activity.startedAt)) };
347
+ }
348
+ return { phase: 'idle', timer: null };
349
+ }
350
+
351
+ /**
352
+ * A piece of a line. `paint` colours the text it is given. A narrow row works down from the highest
353
+ * `drop` or `clip` number (0 never): a drop removes the segment, a clip shortens it through
354
+ * `shrink(cells)`, which returns the text at that width or '' when it cannot get that small. `lead` (and
355
+ * its coloured form `leadPainted`) is what stands before it, instead of the line's separator.
356
+ */
357
+ const segment = (text, paint = (value) => value, { drop = 0, clip = 0, shrink = null, lead = null, leadPainted = null, minCells = 1 } = {}) => ({
358
+ text, paint, drop, clip, shrink, lead, leadPainted, minCells,
359
+ });
360
+
361
+ // Lays one line out of segments; `right` segments sit flush with the row's right edge.
362
+ function layoutLine({ left, right = [], separator, rail, maxCells, ellipsis }) {
363
+ const items = [...left.map((item) => ({ ...item, side: 'left' })), ...right.map((item) => ({ ...item, side: 'right' }))];
364
+ for (;;) {
365
+ const join = (side, painted) => items.filter((item) => item.side === side).map((item, index) => {
366
+ // A segment that comes straight after the first one may take its own lead (`|` after the mark).
367
+ const own = index === 1 && item.firstLead ? item.firstLead : item;
368
+ const lead = index === 0 ? '' : own.lead ?? separator.plain;
369
+ const paintedLead = index === 0 ? '' : own.leadPainted ?? own.lead ?? separator.painted;
370
+ return painted ? `${paintedLead}${item.paint(item.text)}` : `${lead}${item.text}`;
371
+ }).join('');
372
+ const leftPlain = `${rail.plain}${join('left', false)}`;
373
+ const rightPlain = join('right', false);
374
+ const bare = `${rail.painted}${join('left', true)}${join('right', true)}`;
375
+ // The padding that pushes the right side to the edge counts toward Grok's cut as well.
376
+ const gap = rightPlain ? Math.max(2, Math.min(maxCells - width(leftPlain) - width(rightPlain), MAX_LINE_CHARACTERS - bare.length)) : 0;
377
+ const total = width(leftPlain) + gap + width(rightPlain);
378
+ const painted = `${rail.painted}${join('left', true)}${' '.repeat(gap)}${join('right', true)}`;
379
+ if (total <= maxCells && painted.length <= MAX_LINE_CHARACTERS) return painted;
380
+ // The highest drop or clip number goes first; a clipped segment can still be dropped later.
381
+ const steps = items.flatMap((item) => [...(item.drop > 0 ? [{ item, at: item.drop, drop: true }] : []), ...(item.clip > 0 ? [{ item, at: item.clip, drop: false }] : [])]);
382
+ if (steps.length === 0) {
383
+ // Nothing left to drop or shorten: the last segment goes, and the first is kept to the end.
384
+ if (items.length > 1) items.pop();
385
+ else return `${rail.painted}${clipCells(items[0]?.text ?? '', Math.max(0, maxCells - width(rail.plain)), ellipsis)}`;
386
+ continue;
387
+ }
388
+ const step = steps.reduce((best, candidate) => (candidate.at > best.at || (candidate.at === best.at && !candidate.drop) ? candidate : best));
389
+ const target = step.item;
390
+ if (step.drop) {
391
+ items.splice(items.indexOf(target), 1);
392
+ continue;
393
+ }
394
+ // A segment that cannot get small enough shrinks as far as it goes, and the next step follows.
395
+ const shrink = (cells) => (target.shrink ? target.shrink(cells) : clipCells(target.text, cells, ellipsis));
396
+ if (total > maxCells) {
397
+ const shorter = shrink(Math.max(target.minCells, width(target.text) - (total - maxCells)));
398
+ if (shorter && width(shorter) < width(target.text)) target.text = shorter;
399
+ } else {
400
+ // Escapes count toward Grok's cut too, so a row can be too long while it fits its columns: the
401
+ // segment shrinks until its painted form is short enough.
402
+ const excess = painted.length - MAX_LINE_CHARACTERS;
403
+ const length = target.paint(target.text).length;
404
+ for (let cells = width(target.text) - 1; cells >= target.minCells; cells -= 1) {
405
+ const shorter = shrink(cells);
406
+ if (shorter && (length - target.paint(shorter).length >= excess || cells === target.minCells)) {
407
+ target.text = shorter;
408
+ break;
409
+ }
410
+ }
411
+ }
412
+ target.clip = 0;
364
413
  }
365
- if (state === null && warning === null) return null;
366
- return { state, timer, warning, activity: running || state === 'complete' ? activity : null };
367
414
  }
368
415
 
369
- function pill(text, kind, depth, nerd) {
370
- if (!depth) return { plain: `(${text})`, painted: `(${text})` };
371
- const [background, foreground] = PILLS[kind];
372
- if (nerd) {
373
- const cap = (glyph) => `${sgr(background, depth)}${glyph}\u001b[0m`;
374
- return { plain: `${text}`, painted: `${cap('')}${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m${text}\u001b[0m${cap('')}` };
416
+ // The figures in the family order: model [effort] │ ctx [█▎░] n%/size, then the automatic handoff percent,
417
+ // the session's cost and the Git branch, each left out when Grok did not send it.
418
+ function figureSegments(status, { glyphs, paint, handoff }) {
419
+ const model = plainText(status?.model?.display_name, plainText(status?.model?.id, 'grok'));
420
+ const effort = effortLevel(status?.effort?.level);
421
+ const percent = finitePercent(status?.context_window?.used_percentage);
422
+ const size = windowSize(status?.context_window?.context_window_size);
423
+ const accent = (text) => paint.fg(text, 'accent');
424
+ // A narrow row clips the model name and keeps the effort beside it; the narrowest give it up for the gauge.
425
+ const tail = effort ? ` [${effort}]` : '';
426
+ const segments = [segment(`${model}${tail}`, accent, {
427
+ drop: 0.5, clip: 2, minCells: width(tail) + 1, shrink: (cells) => `${clipCells(model, Math.max(1, cells - width(tail)), glyphs.ellipsis)}${tail}`,
428
+ })];
429
+ if (percent === null) {
430
+ segments.push(segment(`ctx ?${size ? `/${size}` : ''}`, accent));
431
+ } else {
432
+ const band = bandOf(percent);
433
+ const bar = blockBar(percent, 3, glyphs);
434
+ const figure = `${percent}%${size ? `/${size}` : ''}`;
435
+ segments.push(segment(`ctx [${bar.filled}${bar.empty}] ${figure}`, () => (
436
+ `${accent('ctx [')}${paint.fg(bar.filled, band)}${paint.fg(bar.empty, 'empty')}${accent(']')} ${paint.fg(figure, band)}`)));
375
437
  }
376
- return { plain: ` ${text} `, painted: `${sgr(background, depth, true)}${sgr(foreground, depth)}\u001b[1m ${text} \u001b[0m` };
377
- }
378
-
379
- function renderBand(discipline, band, depth, nerd, { warningIndex = 0, tools = true, agents = true, timer = true, warning = true, brand = 'full', skillCells = Infinity }) {
380
- const glyphs = nerd ? GLYPHS.nerd : GLYPHS.basic;
381
- const kind = band.warning ? 'warning' : band.state === 'complete' ? 'complete' : 'running';
382
- const pills = [pill(brand === 'full' ? '🔥 LIT' : '🔥', 'brand', depth, nerd), pill(`${glyphs[kind]} ${clipCells(discipline, skillCells)}`, kind, depth, nerd)];
383
- const items = [];
384
- const paint = (text, rgb, bold = false) => (depth ? `${bold ? '\u001b[1m' : ''}${sgr(rgb, depth)}${text}\u001b[0m` : text);
385
- if (band.warning && warning) items.push({ plain: band.warning[warningIndex], painted: paint(band.warning[warningIndex], TEXT.amber, true) });
386
- if (band.state === 'complete') items.push({ plain: 'complete', painted: paint('complete', TEXT.green, true) });
387
- if (band.timer && timer) items.push({ plain: `${glyphs.timer}${band.timer}`, painted: paint(`${glyphs.timer}${band.timer}`, TEXT.dim) });
388
- if (band.activity && tools) items.push({ plain: glyphs.tools(band.activity.tools), painted: paint(glyphs.tools(band.activity.tools), TEXT.dim) });
389
- if (band.activity && agents && band.state === 'running' && band.activity.agents > 0) {
390
- items.push({ plain: glyphs.agents(band.activity.agents), painted: paint(glyphs.agents(band.activity.agents), TEXT.dim) });
438
+ if (handoff.active) segments.push(segment(`handoff@${handoff.percent}%`, accent, { drop: 4 }));
439
+ const cost = status?.cost?.total_cost_usd;
440
+ // Grok's own row hides a cost under half a cent so it never shows a misleading $0.00.
441
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost >= 0.005) {
442
+ segments.push(segment(`$${cost < 100 ? cost.toFixed(2) : Math.round(cost)}`, accent, { drop: 3 }));
391
443
  }
392
- const separator = { plain: ' · ', painted: paint(' · ', TEXT.dim) };
393
- const join = (key) => [pills.map((part) => part[key]).join(' '), items.map((part) => part[key]).join(separator[key])].filter(Boolean).join(' ');
394
- return { plain: join('plain'), painted: join('painted') };
395
- }
396
-
397
- // Narrow rows give up tool calls first, then subagents, the timer, the long warning, the warning itself
398
- // and the brand's name, and last of all clip the skill name.
399
- function formatBand(discipline, band, depth, nerd, maxCells) {
400
- const tiers = [
401
- {},
402
- { tools: false },
403
- { tools: false, agents: false },
404
- { tools: false, agents: false, timer: false },
405
- { tools: false, agents: false, timer: false, warningIndex: 1 },
406
- { tools: false, agents: false, timer: false, warningIndex: 1, brand: 'compact' },
407
- { tools: false, agents: false, timer: false, warning: false, brand: 'compact' },
408
- ];
409
- for (const tier of tiers) {
410
- const row = renderBand(discipline, band, depth, nerd, tier);
411
- if (width(row.plain) <= maxCells && row.painted.length <= MAX_LINE_CHARACTERS) return row.painted;
444
+ const branch = plainText(status?.workspace?.branch, '');
445
+ if (branch) {
446
+ const worktree = plainText(status?.workspace?.git_worktree, '');
447
+ segments.push(segment(`${glyphs.git}${clipCells(branch, 32, glyphs.ellipsis)}${worktree ? ` ${glyphs.dot} ${clipCells(worktree, 20, glyphs.ellipsis)}` : ''}`, accent, { drop: 5 }));
412
448
  }
413
- const last = tiers.at(-1);
414
- const room = maxCells - width(renderBand('', band, depth, nerd, { ...last, skillCells: 0 }).plain);
415
- return renderBand(discipline, band, depth, nerd, { ...last, skillCells: Math.max(1, room) }).painted;
449
+ return segments;
416
450
  }
417
451
 
418
- function clock(ms) {
419
- const seconds = Math.floor(Math.max(0, ms) / 1000);
420
- const minutes = Math.floor(seconds / 60);
421
- const rest = String(seconds % 60).padStart(2, '0');
422
- return minutes < 60 ? `${minutes}:${rest}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${rest}`;
452
+ function brandText(version, glyphs) {
453
+ return `[${glyphs.fire}LIT${version ? ` v${version}` : ''}]`;
454
+ }
455
+
456
+ // The family mark: bold, the fire and the brackets in the terminal's own colour, L, I and T on the
457
+ // gradient, the version in its last stop. 256 colours make it bold magenta and 16 colours bold cyan.
458
+ function brandSegment(version, glyphs, paint, options = {}) {
459
+ const paintBrand = (text) => {
460
+ if (!paint.depth) return text;
461
+ if (paint.depth === 256) return `\u001b[0m\u001b[1m\u001b[38;5;201m${text}\u001b[0m`;
462
+ if (paint.depth === 16) return `\u001b[0m\u001b[1m\u001b[36m${text}\u001b[0m`;
463
+ const at = text.indexOf('LIT');
464
+ const letters = [...'LIT'].map((letter, index) => `${sgr(BRAND_STOPS[index], 24)}${letter}`).join('');
465
+ return `\u001b[0m\u001b[1m${text.slice(0, at)}${letters}${sgr(BRAND_STOPS[2], 24)}${text.slice(at + 3)}\u001b[0m`;
466
+ };
467
+ // A narrow row lets the version go before anything else on the mark.
468
+ return segment(brandText(version, glyphs), paintBrand, { ...options, shrink: () => brandText('', glyphs) });
469
+ }
470
+
471
+ // The discipline keeps to 24 cells (32 for the longest skill name) so the line stays far below Grok's
472
+ // 1024-character cut.
473
+ function disciplineName(name, glyphs, cells = Infinity) {
474
+ return clipCells(name, Math.min(cells, name === 'lit-scientific-visualization' ? 32 : 24), glyphs.ellipsis);
475
+ }
476
+
477
+ // The ignition mark of the one-line row: the pink 🔥 LIT chip, then IGNITED · <discipline> 🔥 in the accent.
478
+ // Without colour it reads 🔥 LIT IGNITED · <discipline> 🔥. A narrow row clips the discipline, never the frame.
479
+ function ignitionSegment(discipline, glyphs, paint, nerd, options = {}) {
480
+ const label = glyphs.fire ? `${glyphs.fire} LIT` : 'LIT';
481
+ const brand = chip(label, 'pink', paint, nerd, '', '');
482
+ const build = (name) => `${brand.plain} IGNITED ${glyphs.dot} ${name}${glyphs.fire ? ` ${glyphs.fire}` : ''}`;
483
+ const frame = width(build(''));
484
+ return segment(build(disciplineName(discipline, glyphs)), (text) => `${brand.painted}${paint.fg(text.slice(brand.plain.length), 'accent', true)}`, {
485
+ ...options, minCells: frame + 1, shrink: (cells) => build(disciplineName(discipline, glyphs, Math.max(1, cells - frame))),
486
+ });
487
+ }
488
+
489
+ // Joins the figures to a line: `|` after the mark, `│` between the rest, all in the accent.
490
+ function figureLeads(figures, glyphs, paint, afterMark) {
491
+ return figures.map((item, index) => {
492
+ const lead = ` ${glyphs.sep} `;
493
+ return { ...item, lead, leadPainted: paint.fg(lead, 'accent'), firstLead: afterMark ? { lead: ' | ', leadPainted: paint.fg(' | ', 'accent') } : null };
494
+ });
495
+ }
496
+
497
+ // One row: the mark, model [effort] │ ctx, the ignition mark while a discipline is active, then the
498
+ // handoff percent, cost and branch. It is the whole HUD between workflows and in LITGROK_HUD_LINES=1
499
+ // mode. A narrow row gives up the branch, the handoff percent, the cost and the version, then clips the
500
+ // model name and last the discipline.
501
+ function compactRow({ status, discipline, version, glyphs, paint, nerd, handoff, maxCells }) {
502
+ const [model, context, ...extras] = figureSegments(status, { glyphs, paint, handoff });
503
+ const figures = [model, context, ...(discipline ? [ignitionSegment(discipline, glyphs, paint, nerd, { clip: 1 })] : []), ...extras];
504
+ const left = [brandSegment(version, glyphs, paint, { clip: 3 }), ...figureLeads(figures, glyphs, paint, true)];
505
+ return layoutLine({ left, separator: { plain: ' ', painted: ' ' }, rail: { plain: '', painted: '' }, maxCells, ellipsis: glyphs.ellipsis });
423
506
  }
424
507
 
425
- // The current stage's pill: its name, the plan's checked tasks while working, and a tick once the
508
+ // The current stage's label: its name, the plan's checked tasks while working, and a tick once the
426
509
  // stage's turn has ended (for work, only once every task is checked).
427
- function stageLabel(workflow) {
428
- if (workflow.stage === 'done') return 'DONE ✓';
510
+ function stageLabel(workflow, glyphs) {
511
+ if (workflow.stage === 'done') return `done ${glyphs.tick}`;
429
512
  const plan = workflow.plan;
430
513
  const counter = workflow.stage === 'work' && plan?.total > 0 ? ` ${plan.done}/${plan.total}` : '';
431
514
  const finished = workflow.ended && (workflow.stage !== 'work' || (plan?.total > 0 && plan.done === plan.total));
432
- return `${workflow.stage.toUpperCase()}${counter}${finished ? ' ✓' : ''}`;
515
+ return `${workflow.stage}${counter}${finished ? ` ${glyphs.tick}` : ''}`;
433
516
  }
434
517
 
435
- // The breadcrumb as { text, role, position, rail } pieces. Stage k of n sits at k/(n-1) along the
436
- // orange-pink-cyan gradient and each rail cell between two stages takes its own step in between.
437
- // Rails up to the current stage are lit. A stage before it shows a tick when the session went through
438
- // it; interview shows only when it happened.
439
- function breadcrumbPieces(workflow, { rail, compact }) {
518
+ // The stage map as the family card draws it: interview ✓ ━━ plan ✓ ━━ [work 3/7] ━━ review · ── done ·.
519
+ // Stages behind are dim with a tick when the session went through them, the current stage is a chip on the
520
+ // accent, the stages ahead are dim with a dot. Rails into a passed or the current stage are accent `━`, the
521
+ // rail leaving the current stage is a grey `━` and the rails further on are grey `─`. Interview shows only
522
+ // when it happened.
523
+ function mapPieces(workflow, { rail, compact }, glyphs) {
440
524
  const shown = STAGES.filter((stage) => stage !== 'interview' || workflow.visited.includes('interview') || workflow.stage === 'interview');
441
525
  const current = shown.indexOf(workflow.stage);
442
- const last = Math.max(1, shown.length - 1);
443
526
  const pieces = [];
444
527
  shown.forEach((stage, index) => {
445
528
  if (index > 0) {
446
529
  if (!compact) pieces.push({ text: ' ', role: 'space' });
447
530
  for (let cell = 0; cell < rail; cell += 1) {
448
- pieces.push({ text: index <= current ? '━' : '─', role: index <= current ? 'lit' : 'dim', position: (index - 1 + (cell + 1) / (rail + 1)) / last, rail: [index, cell] });
531
+ const role = index <= current ? 'lit' : index === current + 1 ? 'leaving' : 'ahead';
532
+ pieces.push({ text: role === 'ahead' ? glyphs.unlit : glyphs.lit, role, rail: [index, cell] });
449
533
  }
450
534
  if (!compact) pieces.push({ text: ' ', role: 'space' });
451
535
  }
452
- const position = index / last;
453
- if (index === current) pieces.push({ text: stageLabel(workflow), role: 'pill', position });
454
- else if (index > current) pieces.push({ text: compact ? '·' : `${stage} ·`, role: 'future' });
455
- else if (workflow.visited.includes(stage)) pieces.push({ text: compact ? '✓' : `${stage} ✓`, role: 'past', position });
456
- else pieces.push({ text: compact ? '-' : stage, role: 'skipped' });
536
+ if (index === current) pieces.push({ text: stageLabel(workflow, glyphs), role: 'current' });
537
+ else if (index > current) pieces.push({ text: compact ? glyphs.dot : `${stage} ${glyphs.dot}`, role: 'dim' });
538
+ else if (workflow.visited.includes(stage)) pieces.push({ text: compact ? glyphs.tick : `${stage} ${glyphs.tick}`, role: 'dim' });
539
+ else pieces.push({ text: compact ? glyphs.unlit : stage, role: 'dim' });
457
540
  });
458
541
  return { pieces, current };
459
542
  }
460
543
 
461
- // While a turn runs, one bright cell rides the rail into the current stage and moves on at each
462
- // redraw (Grok redraws about every 300 ms during a turn).
463
- function renderBreadcrumb(workflow, tier, depth, running, now) {
464
- const { pieces, current } = breadcrumbPieces(workflow, tier);
465
- const plain = pieces.map(({ text, role }) => (role === 'pill' ? `[${text}]` : text)).join('');
466
- if (!depth) return { plain, painted: plain };
544
+ // While a turn runs, one pink cell rides the rail into the current stage and moves on at each redraw
545
+ // (Grok redraws about every 300 ms during a turn).
546
+ function renderMap(workflow, tier, { paint, glyphs, nerd, running, now }) {
547
+ const { pieces, current } = mapPieces(workflow, tier, glyphs);
548
+ const pill = (text) => chip(text, 'accent', paint, nerd, '[', ']');
549
+ const plain = pieces.map(({ text, role }) => (role === 'current' ? pill(text).plain : text)).join('');
550
+ if (!paint.depth) return { plain, painted: plain };
467
551
  const head = running ? [current > 0 ? current : 1, Math.floor(now / 300) % tier.rail] : null;
468
- const painted = pieces.map(({ text, role, position, rail }) => {
552
+ const painted = pieces.map(({ text, role, rail }) => {
469
553
  if (role === 'space') return text;
470
- if (role === 'future' || role === 'skipped') return `${sgr(TEXT.dim, depth)}${text}`;
471
- const rgb = gradientColor(position, RAIL_STOPS);
472
- if (role === 'pill') return `${sgr(rgb, depth, true)}${sgr(RAIL.ink, depth)}\u001b[1m ${text} \u001b[0m`;
473
- if (role === 'past') return `${sgr(rgb, depth)}${text}`;
474
- if (head && rail[0] === head[0] && rail[1] === head[1]) return `${sgr(RAIL.head, depth)}${text}`;
475
- return `${sgr(role === 'lit' ? rgb : RAIL.faint, depth)}${text}`;
554
+ if (role === 'current') return pill(text).painted;
555
+ if (role === 'dim') return paint.fg(text, 'dim');
556
+ if (head && rail[0] === head[0] && rail[1] === head[1]) return paint.fg(text, 'pink', true);
557
+ return paint.fg(text, role === 'lit' ? 'accent' : 'rail');
476
558
  }).join('');
477
- return { plain, painted: `${painted}\u001b[0m` };
559
+ return { plain, painted };
560
+ }
561
+
562
+ // Narrow rows shorten the rails to two cells and one, then drop the stage names, and last of all keep
563
+ // only the current stage.
564
+ function mapLine(workflow, context, rail, maxCells) {
565
+ const room = maxCells - width(rail.plain);
566
+ for (const tier of [{ rail: 4 }, { rail: 2 }, { rail: 1 }, { rail: 1, compact: true }]) {
567
+ const map = renderMap(workflow, tier, context);
568
+ if (width(map.plain) <= room && map.painted.length + rail.painted.length <= MAX_LINE_CHARACTERS) return `${rail.painted}${map.painted}`;
569
+ }
570
+ const label = clipCells(stageLabel(workflow, context.glyphs), Math.max(1, room - 2), context.glyphs.ellipsis);
571
+ return `${rail.painted}${chip(label, 'accent', context.paint, context.nerd, '[', ']').painted}`;
478
572
  }
479
573
 
480
574
  function toolLabel(name) {
481
575
  return Object.hasOwn(TOOL_LABELS, name) ? TOOL_LABELS[name] : clipCells(plainText(name, 'tool'), 20);
482
576
  }
483
577
 
484
- function renderWorkflow(workflow, live, running, depth, now, tier) {
485
- const paint = (text, rgb) => (depth ? `${sgr(rgb, depth)}${text}\u001b[0m` : text);
486
- const parts = [];
487
- if (workflow?.stage) parts.push(renderBreadcrumb(workflow, tier, depth, running, now));
488
- if (live && tier.live) {
489
- const spinner = SPINNER[Math.floor(now / 100) % SPINNER.length];
490
- const text = tier.live === 'full' ? `${spinner} ${toolLabel(live.name)} · ${clock(now - live.startedAt)}` : `${spinner} ${clock(now - live.startedAt)}`;
491
- parts.push({ plain: text, painted: paint(text, TEXT.amber) });
578
+ // The card's left edge: one accent bar per line, so the lines read as one card.
579
+ function railFor(paint, glyphs) {
580
+ if (!paint.depth) return { plain: '', painted: '' };
581
+ return { plain: `${glyphs.rail} `, painted: `${paint.fg(glyphs.rail, 'accent')} ` };
582
+ }
583
+
584
+ // The card's title: the mark, the pink 🔥 LIT chip and the discipline, then at the right edge the turn's
585
+ // tool calls and subagents beside its state, as the family card's corner reads (● 1m42s running,
586
+ // ✓ complete, ⚠ a warning, idle).
587
+ function titleLine({ discipline, version, glyphs, paint, nerd, phase, warning, activity, maxCells, rail }) {
588
+ const brand = chip(glyphs.fire ? `${glyphs.fire} LIT` : 'LIT', 'pink', paint, nerd);
589
+ const name = discipline ?? 'workflow';
590
+ const left = [
591
+ brandSegment(version, glyphs, paint, { clip: 3 }),
592
+ { ...segment(brand.plain, () => brand.painted), lead: ' | ', leadPainted: paint.fg(' | ', 'accent') },
593
+ segment(`${glyphs.dot} ${disciplineName(name, glyphs)}`, (text) => `${paint.fg(glyphs.dot, 'dim')} ${paint.fg(text.slice(glyphs.dot.length + 1), discipline ? 'accent' : 'dim', Boolean(discipline))}`, {
594
+ clip: 0.5, minCells: width(glyphs.dot) + 2, shrink: (cells) => `${glyphs.dot} ${disciplineName(name, glyphs, Math.max(1, cells - width(glyphs.dot) - 1))}`,
595
+ }),
596
+ ];
597
+ const right = [];
598
+ if (activity && phase.phase !== 'idle') {
599
+ const parts = activity.tools > 0 ? [glyphs.tools(activity.tools)] : [];
600
+ if (phase.phase === 'running' && activity.agents > 0) parts.push(glyphs.agents(activity.agents));
601
+ if (parts.length > 0) right.push(segment(parts.join(` ${glyphs.dot} `), (text) => paint.fg(text, 'dim'), { drop: 4 }));
492
602
  }
493
- if (workflow?.test && tier.tests) {
603
+ if (warning) right.push(segment(`${glyphs.warn} ${warning[1]}`, (text) => paint.fg(text, 'yellow', true), { drop: 1 }));
604
+ if (phase.phase === 'running') right.push(segment(`${glyphs.run} ${phase.timer ? `${glyphs.timer}${phase.timer}` : 'running'}`, (text) => paint.fg(text, 'accent', true), { drop: 2 }));
605
+ else if (phase.phase === 'complete') right.push(segment(`${glyphs.tick} complete ${phase.timer}`, (text) => paint.fg(text, 'green', true), { drop: 2 }));
606
+ else if (!warning) right.push(segment('idle', (text) => paint.fg(text, 'dim'), { drop: 2 }));
607
+ return layoutLine({ left, right, separator: { plain: ' ', painted: ' ' }, rail, maxCells, ellipsis: glyphs.ellipsis });
608
+ }
609
+
610
+ // The detail line: checked plan tasks with the next one, the last test verdict and the long form of a
611
+ // warning on the left; the tool in flight with its clock on the right.
612
+ function detailLine({ workflow, live, warning, glyphs, paint, now, maxCells, rail }) {
613
+ const left = [];
614
+ const plan = workflow?.plan;
615
+ if (plan?.total > 0) {
616
+ const cells = Math.min(plan.total, 10);
617
+ const filled = Math.round((plan.done / plan.total) * cells);
618
+ const bar = `${glyphs.full.repeat(filled)}${glyphs.empty.repeat(cells - filled)}`;
619
+ left.push(segment(`task ${plan.done}/${plan.total} ${bar}`, () => `${paint.fg('task ', 'dim')}${paint.fg(`${plan.done}/${plan.total}`, 'accent', true)} ${paint.fg(glyphs.full.repeat(filled), 'accent')}${paint.fg(glyphs.empty.repeat(cells - filled), 'empty')}`));
620
+ if (typeof plan.next === 'string' && plan.next && plan.done < plan.total) {
621
+ const lead = `next ${glyphs.next} `;
622
+ const title = plainText(plan.next, '');
623
+ left.push(segment(`${lead}${title}`, (text) => `${paint.fg(lead, 'dim')}${paint.fg(text.slice(lead.length), 'accent')}`, {
624
+ drop: 5, clip: 6, minCells: width(lead) + 8, shrink: (cells) => `${lead}${clipCells(title, cells - width(lead), glyphs.ellipsis)}`,
625
+ }));
626
+ }
627
+ }
628
+ if (workflow?.test) {
494
629
  const pass = workflow.test.verdict === 'pass';
495
- const text = `tests ${pass ? '✓' : '✗'}${workflow.test.count === undefined ? '' : ` ${workflow.test.count}`}`;
496
- parts.push({ plain: text, painted: paint(text, pass ? TEXT.green : RAIL.red) });
630
+ left.push(segment(`tests ${pass ? glyphs.tick : glyphs.cross}${workflow.test.count === undefined ? '' : ` ${workflow.test.count}`}`, (text) => paint.fg(text, pass ? 'green' : 'red', true), { drop: 3 }));
497
631
  }
498
- return { plain: parts.map((part) => part.plain).join(' '), painted: parts.map((part) => part.painted).join(' ') };
499
- }
500
-
501
- // Narrow rows shorten the rails, then drop the test badge, the tool's name, the tool and the stage
502
- // names, and last of all keep only the current stage.
503
- function formatWorkflow(workflow, live, running, depth, now, maxCells) {
504
- const tiers = [
505
- { rail: 4, live: 'full', tests: true },
506
- { rail: 2, live: 'full', tests: true },
507
- { rail: 2, live: 'full', tests: false },
508
- { rail: 2, live: 'short', tests: false },
509
- { rail: 1, live: false, tests: false },
510
- { rail: 1, compact: true, live: false, tests: false },
511
- ];
512
- for (const tier of tiers) {
513
- const row = renderWorkflow(workflow, live, running, depth, now, tier);
514
- if (width(row.plain) <= maxCells && row.painted.length <= MAX_LINE_CHARACTERS) return row.painted || null;
632
+ if (warning) left.push(segment(`${glyphs.warn} ${warning[0]}`, (text) => paint.fg(text, 'yellow', true), { drop: 2 }));
633
+ const right = [];
634
+ if (live) {
635
+ const spinner = glyphs.spinner[Math.floor(now / 100) % glyphs.spinner.length];
636
+ // Alone on the line, the tool in flight sits by the elbow rather than across the row.
637
+ (left.length > 0 ? right : left).push(segment(`${spinner} ${toolLabel(live.name)} ${clock(now - live.startedAt)}`, (text) => paint.fg(text, 'accent', true), { drop: 1 }));
515
638
  }
516
- if (!workflow?.stage) return null;
517
- const label = clipCells(stageLabel(workflow), Math.max(1, maxCells - 2));
518
- return depth ? `${sgr(RAIL_STOPS[1], depth, true)}${sgr(RAIL.ink, depth)}\u001b[1m ${label} \u001b[0m` : `[${label}]`;
639
+ if (left.length === 0 && right.length === 0) return null;
640
+ const elbow = { plain: `${rail.plain}${glyphs.elbow} `, painted: `${rail.painted}${paint.fg(`${glyphs.elbow} `, 'dim')}` };
641
+ return layoutLine({ left, right, separator: { plain: ` ${glyphs.dot} `, painted: paint.fg(` ${glyphs.dot} `, 'dim') }, rail: elbow, maxCells, ellipsis: glyphs.ellipsis });
642
+ }
643
+
644
+ function figuresLine({ status, glyphs, paint, handoff, maxCells, rail }) {
645
+ return layoutLine({
646
+ left: figureLeads(figureSegments(status, { glyphs, paint, handoff }), glyphs, paint, false),
647
+ separator: { plain: ' ', painted: ' ' },
648
+ rail,
649
+ maxCells,
650
+ ellipsis: glyphs.ellipsis,
651
+ });
519
652
  }
520
653
 
521
- const input = await readInput();
522
- try {
523
- recordContextForHandoff(input);
524
- } catch {
525
- // The row must always render; a failed record only means this refresh adds nothing for the handoff.
526
- }
527
- const cwd = typeof input?.cwd === 'string' ? input.cwd : '';
528
- let record = null;
529
- try {
530
- record = readHudRecordForCwd(cwd);
531
- } catch {
532
- record = null;
533
- }
534
- const discipline = record?.discipline ?? null;
535
- const model = plainText(input?.model?.display_name, 'grok');
536
- const colorSetting = process.env.LITGROK_HUD_COLOR;
537
- const depth = colorSetting === '0' || Object.hasOwn(process.env, 'NO_COLOR') ? 0 : colorSetting === '256' ? 256 : colorSetting === '16' ? 16 : 24;
538
- // Grok sets COLUMNS to the width of the row; without it the row keeps to 79 cells.
539
- const columns = Number.parseInt(process.env.COLUMNS ?? '', 10);
540
- const maxCells = Number.isFinite(columns) && columns >= 20 ? Math.min(columns, 1000) - 1 : 79;
541
- const row = formatRow({
542
- discipline,
543
- model,
544
- effort: effortLevel(input?.effort?.level),
545
- context: contextText(input?.context_window),
546
- version: installedVersion(),
547
- }, depth, maxCells);
548
- // The ribbon is a second line, shown only while a skill is active and a turn runs, has just ended or
549
- // needs attention. The workflow row follows it once the session has a workflow stage, or while a skill
550
- // is active and there is a tool in flight or a test verdict to show. LITGROK_HUD_LINES=1 keeps the
551
- // compact row alone.
552
- let band = null;
553
- let workflowLine = null;
554
- if (process.env.LITGROK_HUD_LINES !== '1') {
654
+ function render(input) {
655
+ try {
656
+ recordContextForHandoff(input);
657
+ } catch {
658
+ // The row must always render; a failed record only means this refresh adds nothing for the handoff.
659
+ }
660
+ const cwd = typeof input?.cwd === 'string' ? input.cwd : '';
661
+ let record = null;
662
+ try {
663
+ record = readHudRecordForCwd(cwd);
664
+ } catch {
665
+ record = null;
666
+ }
667
+ const discipline = record?.discipline ?? null;
668
+ const colorSetting = process.env.LITGROK_HUD_COLOR;
669
+ const depth = colorSetting === '0' || Object.hasOwn(process.env, 'NO_COLOR') ? 0 : colorSetting === '256' ? 256 : colorSetting === '16' ? 16 : 24;
670
+ const paint = painter(depth);
671
+ const glyphSetting = process.env.LITGROK_HUD_GLYPHS;
672
+ const glyphs = Object.hasOwn(GLYPHS, glyphSetting ?? '') ? GLYPHS[glyphSetting] : GLYPHS.basic;
673
+ const nerd = glyphSetting === 'nerd';
674
+ // Grok sets COLUMNS to the width of the row; without it the row keeps to 79 cells.
675
+ const columns = Number.parseInt(process.env.COLUMNS ?? '', 10);
676
+ const maxCells = Number.isFinite(columns) && columns >= 20 ? Math.min(columns, 1000) - 1 : 79;
677
+ let handoff = { active: false, percent: null };
678
+ try {
679
+ handoff = resolveAutoHandoff({ env: process.env, dirs: statusDirs(input) });
680
+ } catch {
681
+ handoff = { active: false, percent: null };
682
+ }
683
+ const version = installedVersion();
684
+ const shared = { status: input, discipline, version, glyphs, paint, nerd, handoff, maxCells };
685
+ if (process.env.LITGROK_HUD_LINES === '1') return [compactRow(shared)];
686
+
555
687
  const now = Date.now();
556
688
  let activity = null;
557
689
  try {
@@ -559,22 +691,35 @@ if (process.env.LITGROK_HUD_LINES !== '1') {
559
691
  } catch {
560
692
  activity = null;
561
693
  }
562
- const turn = turnState(input, activity, now);
563
- const { running } = turn;
564
- if (discipline) {
565
- try {
566
- const state = bandState(input, now, activity, turn);
567
- if (state) band = formatBand(discipline, state, depth, process.env.LITGROK_HUD_GLYPHS === 'nerd', maxCells);
568
- } catch {
569
- band = null;
570
- }
571
- }
694
+ let workflow = null;
572
695
  try {
573
- const workflow = readWorkflow(input);
574
- const live = running ? activity?.live ?? null : null;
575
- if (workflow?.stage || (discipline && (live || workflow?.test))) workflowLine = formatWorkflow(workflow, live, running, depth, now, maxCells);
696
+ workflow = readWorkflow(input);
576
697
  } catch {
577
- workflowLine = null;
698
+ workflow = null;
578
699
  }
700
+ // The card shows while a discipline is active or the session has a workflow stage; otherwise the
701
+ // HUD is the single row.
702
+ if (!discipline && !workflow?.stage) return [compactRow(shared)];
703
+ const turn = turnState(input, activity, now);
704
+ const phase = phaseOf(now, activity, turn);
705
+ const warning = warningText(input, activity, handoff);
706
+ const live = turn.running ? activity?.live ?? null : null;
707
+ const rail = railFor(paint, glyphs);
708
+ const lines = [titleLine({ ...shared, phase, warning, activity: phase.phase === 'idle' ? null : activity, rail })];
709
+ if (workflow?.stage) lines.push(mapLine(workflow, { paint, glyphs, nerd, running: turn.running, now }, rail, maxCells));
710
+ const detail = detailLine({ workflow, live, warning, glyphs, paint, now, maxCells, rail });
711
+ if (detail) lines.push(detail);
712
+ lines.push(figuresLine({ ...shared, rail }));
713
+ return lines;
714
+ }
715
+
716
+
717
+ const input = await readInput();
718
+ let lines;
719
+ try {
720
+ lines = render(input);
721
+ } catch {
722
+ // Never a blank row and never a stack trace: the brand alone says the HUD is alive.
723
+ lines = [`[🔥LIT${installedVersion() ? ` v${installedVersion()}` : ''}] | ${plainText(input?.model?.display_name, 'grok')}`];
579
724
  }
580
- process.stdout.write(`${[row, band, workflowLine].filter(Boolean).join('\n')}\n`);
725
+ process.stdout.write(`${lines.join('\n')}\n`);