tokenmaw 0.3.0 → 0.4.1
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/README.md +22 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +46 -0
- package/dist/infra/tools.js +274 -28
- package/dist/markdown.js +83 -48
- package/dist/responses.js +7 -1
- package/dist/runtime/agent-registry.js +35 -5
- package/dist/runtime/agent-runtime.js +309 -19
- package/dist/runtime/agent-store.js +52 -0
- package/dist/runtime/file-lock.js +256 -0
- package/dist/runtime/locks.js +58 -38
- package/dist/runtime/session-timeline.js +32 -3
- package/dist/runtime/workspace-instances.js +109 -0
- package/dist/runtime/worktree.js +321 -0
- package/dist/ui/bracketed-paste.js +231 -0
- package/dist/ui/commands.js +11 -0
- package/dist/ui/fullscreen-tui.js +1282 -122
- package/dist/ui/markdown.js +19 -9
- package/dist/ui/scrollbar.js +370 -0
- package/dist/ui/syntax.js +3 -5
- package/dist/ui/theme.js +198 -0
- package/dist/ui/tui-design.js +78 -0
- package/dist/ui/welcome.js +555 -11
- package/dist/update-check.js +332 -0
- package/docs/architecture-revision.md +1 -1
- package/package.json +9 -3
package/dist/ui/tui-design.js
CHANGED
|
@@ -35,6 +35,7 @@ const TOOL_LABELS = {
|
|
|
35
35
|
search_files: 'Find files',
|
|
36
36
|
search_history: 'Search history',
|
|
37
37
|
search_text: 'Search',
|
|
38
|
+
shell: 'Shell',
|
|
38
39
|
spawn_agent: 'Start agent',
|
|
39
40
|
send_agent: 'Message agent',
|
|
40
41
|
wait_agent: 'Wait for agent',
|
|
@@ -92,3 +93,80 @@ export function visibleTimelineEntries(entries, limit = 400) {
|
|
|
92
93
|
const omitted = Math.max(0, entries.length - safeLimit);
|
|
93
94
|
return { entries: omitted ? entries.slice(omitted) : entries, omitted };
|
|
94
95
|
}
|
|
96
|
+
const WAITING_DOT = '.';
|
|
97
|
+
// One full gradient cycle in frames; divisible by 3 so the per-dot phase
|
|
98
|
+
// offsets stay evenly spaced.
|
|
99
|
+
const WAITING_CYCLE = 24;
|
|
100
|
+
const WAITING_PHASES = [0, 1 / 6, 1 / 3];
|
|
101
|
+
const isHexColor = (color) => /^#[0-9a-fA-F]{6}$/.test(color);
|
|
102
|
+
function blendHex(low, high, intensity) {
|
|
103
|
+
const channels = [0, 1, 2].map((channel) => {
|
|
104
|
+
const from = parseInt(low.slice(1 + channel * 2, 3 + channel * 2), 16);
|
|
105
|
+
const to = parseInt(high.slice(1 + channel * 2, 3 + channel * 2), 16);
|
|
106
|
+
return Math.round(from + (to - from) * intensity);
|
|
107
|
+
});
|
|
108
|
+
return `#${channels.map((value) => Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0')).join('')}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Animated ellipsis for the assistant slot while a turn has been submitted but
|
|
112
|
+
* no token has arrived. Pure and deterministic: the frame number is the only
|
|
113
|
+
* input, colors come from the caller, and nothing here touches blessed state.
|
|
114
|
+
* Dots cycle through a gradient between the two colors with per-dot phase
|
|
115
|
+
* offset (eased-cosine interpolation); non-hex colors degrade to a single tag.
|
|
116
|
+
*/
|
|
117
|
+
export function waitingIndicatorFrame(frame, colors) {
|
|
118
|
+
const safeFrame = Number.isFinite(frame) ? Math.max(0, Math.floor(frame)) : 0;
|
|
119
|
+
const accent = typeof colors?.accent === 'string' ? colors.accent : '';
|
|
120
|
+
const subtle = typeof colors?.subtle === 'string' ? colors.subtle : '';
|
|
121
|
+
if (isHexColor(accent) && isHexColor(subtle)) {
|
|
122
|
+
const dots = WAITING_PHASES.map((phase, index) => {
|
|
123
|
+
const progress = (((safeFrame % WAITING_CYCLE) / WAITING_CYCLE) + phase) % 1;
|
|
124
|
+
const eased = (1 - Math.cos(progress * Math.PI * 2)) / 2;
|
|
125
|
+
const color = blendHex(subtle, accent, eased);
|
|
126
|
+
return `{${color}-fg}${WAITING_DOT}{/${color}-fg}`;
|
|
127
|
+
});
|
|
128
|
+
return dots.join('');
|
|
129
|
+
}
|
|
130
|
+
const fallback = (isHexColor(accent) ? accent : '') || (isHexColor(subtle) ? subtle : '') || accent || subtle || 'gray';
|
|
131
|
+
return `{${fallback}-fg}...{/${fallback}-fg}`;
|
|
132
|
+
}
|
|
133
|
+
/** Braille dot glyphs, ordered like the classic "dots" spinner. */
|
|
134
|
+
const SPINNER_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
135
|
+
/** Ticks per ease cycle (~0.48s at the 60ms timer cadence). */
|
|
136
|
+
const SPINNER_CYCLE = 8;
|
|
137
|
+
/** Glyphs advanced per tick while resting (the slow phase). */
|
|
138
|
+
const SPINNER_REST = 0.35;
|
|
139
|
+
/** Extra glyphs per tick at the top of the beat (the fast phase). */
|
|
140
|
+
const SPINNER_SWING = 0.65;
|
|
141
|
+
/** Eased per-tick advance: rest → accelerate → sweep → settle. */
|
|
142
|
+
const SPINNER_STEPS = Array.from({ length: SPINNER_CYCLE }, (_, k) => SPINNER_REST + SPINNER_SWING * (1 - Math.cos((k / SPINNER_CYCLE) * Math.PI * 2)) / 2);
|
|
143
|
+
/** Glyphs swept per full ease cycle. */
|
|
144
|
+
const SPINNER_TRAVEL = SPINNER_STEPS.reduce((sum, step) => sum + step, 0);
|
|
145
|
+
/** Running position at the start of each phase within a cycle. */
|
|
146
|
+
const SPINNER_OFFSETS = SPINNER_STEPS.map((_, k) => SPINNER_STEPS.slice(0, k).reduce((sum, step) => sum + step, 0));
|
|
147
|
+
/**
|
|
148
|
+
* Eased activity spinner pacing. A fixed interval reads as either sluggish
|
|
149
|
+
* (slow enough to stay calm) or frantic (fast enough to feel alive), so the
|
|
150
|
+
* glyph index instead rides an eased cosine: quick sweeps, then a graceful
|
|
151
|
+
* pause, forever. Position is a running total of the eased per-tick advance,
|
|
152
|
+
* which keeps the spin strictly forward while its speed breathes. Pure and
|
|
153
|
+
* deterministic so callers and tests can scrub the timeline.
|
|
154
|
+
*/
|
|
155
|
+
export function spinnerGlyphFrame(tick) {
|
|
156
|
+
const safeTick = Number.isFinite(tick) ? Math.max(0, Math.floor(tick)) : 0;
|
|
157
|
+
const cycle = Math.floor(safeTick / SPINNER_CYCLE);
|
|
158
|
+
const offset = SPINNER_OFFSETS[safeTick % SPINNER_CYCLE] ?? 0;
|
|
159
|
+
const travel = cycle * SPINNER_TRAVEL + offset;
|
|
160
|
+
return Math.floor(travel % SPINNER_GLYPHS.length);
|
|
161
|
+
}
|
|
162
|
+
/** Resolved glyph for an activity tick; falls back to the first glyph. */
|
|
163
|
+
export function spinnerGlyph(tick) {
|
|
164
|
+
return SPINNER_GLYPHS[spinnerGlyphFrame(tick)] ?? SPINNER_GLYPHS[0];
|
|
165
|
+
}
|
|
166
|
+
/** A turn is pending with no streamed token yet: show the waiting ellipsis. */
|
|
167
|
+
export function isWaitingForFirstToken(state) {
|
|
168
|
+
if (state.sessionHasTimeline) {
|
|
169
|
+
return state.pendingTurns > 0 && state.streamingEntries === 0 && state.runningTimelineEntries === 0;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
package/dist/ui/welcome.js
CHANGED
|
@@ -1,21 +1,565 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** The big animated wordmark. Every theme plays its own one-shot opening
|
|
2
|
+
* act — under a second, each with a distinct mechanic — before the loop
|
|
3
|
+
* takes over: a ping-ponging shine for most themes, glyph rain for the
|
|
4
|
+
* phosphor one. All motion is a pure function of the frame, the mark is
|
|
5
|
+
* born from nothing (frame 0 paints nothing anywhere), each cell is
|
|
6
|
+
* monotonic — once painted, never erased — and the intro's final frame
|
|
7
|
+
* always equals the settled loop frame, so the handoff is invisible. */
|
|
8
|
+
import { activeTuiTheme } from './theme.js';
|
|
9
|
+
const GLYPH_LETTERS = [
|
|
10
|
+
['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
|
|
11
|
+
[' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
12
|
+
['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝'],
|
|
13
|
+
];
|
|
14
|
+
const LOGO = (() => {
|
|
15
|
+
const letters = GLYPH_LETTERS.map((rows) => {
|
|
16
|
+
const widest = Math.max(...rows.map((row) => row.length));
|
|
17
|
+
return rows.map((row) => row.padEnd(widest));
|
|
18
|
+
});
|
|
19
|
+
const rows = [];
|
|
20
|
+
for (let line = 0; line < 6; line++)
|
|
21
|
+
rows.push(letters.map((letter) => letter[line]).join(' '));
|
|
22
|
+
return rows;
|
|
23
|
+
})();
|
|
24
|
+
const LOGO_WIDTH = LOGO[0].length;
|
|
25
|
+
const BRAND = 'T O K E N M A W';
|
|
26
|
+
const TAGLINE = 'multi-agent coding runtime';
|
|
27
|
+
const RAIN_GLYPHS = '01ハヒクシアウトナニヌネモリ';
|
|
28
|
+
/** The whole opening act must finish inside a second: at 20 fps that is
|
|
29
|
+
* exactly 20 frames. The intro covers frames 0..19; the loop's own clock
|
|
30
|
+
* would render frame 20 with the shine band just off the left edge —
|
|
31
|
+
* exactly where frame 19 hands over — so the seam is invisible. */
|
|
32
|
+
export const INTRO_DURATION = 20;
|
|
33
|
+
export function hexToRgb(hex) {
|
|
34
|
+
const value = hex.replace('#', '');
|
|
35
|
+
return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)];
|
|
36
|
+
}
|
|
37
|
+
export function mix(from, to, t) {
|
|
38
|
+
const a = hexToRgb(from);
|
|
39
|
+
const b = hexToRgb(to);
|
|
40
|
+
return '#' + a.map((channel, index) => Math.round(channel + (b[index] - channel) * t).toString(16).padStart(2, '0')).join('');
|
|
41
|
+
}
|
|
42
|
+
/** Stateless pseudo-randomness keyed by frame and cell, so the render stays a pure function. */
|
|
43
|
+
function flicker(frame, seed) {
|
|
44
|
+
let x = (Math.imul(frame + 1, 2654435761) ^ Math.imul(seed + 1, 97531)) >>> 0;
|
|
45
|
+
x ^= x >>> 16;
|
|
46
|
+
x = Math.imul(x, 0x45d9f3b);
|
|
47
|
+
x ^= x >>> 16;
|
|
48
|
+
x = Math.imul(x, 0x45d9f3b);
|
|
49
|
+
x ^= x >>> 16;
|
|
50
|
+
return (x >>> 0) / 4294967296;
|
|
51
|
+
}
|
|
52
|
+
/** Stateless hash → [0,1), keyed by the seed alone. Chain seeds for variety. */
|
|
53
|
+
export function hash01(seed) {
|
|
54
|
+
let x = (Math.imul(seed + 1, 2654435761) ^ Math.imul(0x9e3779b9, 97531)) >>> 0;
|
|
55
|
+
x ^= x >>> 16;
|
|
56
|
+
x = Math.imul(x, 0x45d9f3b);
|
|
57
|
+
x ^= x >>> 16;
|
|
58
|
+
x = Math.imul(x, 0x45d9f3b);
|
|
59
|
+
x ^= x >>> 16;
|
|
60
|
+
return (x >>> 0) / 4294967296;
|
|
61
|
+
}
|
|
62
|
+
/** Width of the shine's gaussian band, in diagonal cells. */
|
|
63
|
+
export const SHINE_BAND_WIDTH = 18;
|
|
64
|
+
/** Peak glow the band paints over the settled gradient. */
|
|
65
|
+
export const SHINE_GLOW_BOOST = 0.7;
|
|
66
|
+
/**
|
|
67
|
+
* Center of the shine band along the diagonal axis for loop-frame u. The band
|
|
68
|
+
* ping-pongs with a swaying rate: it lingers at the ends, then dashes across.
|
|
69
|
+
* At u = 0 the band sits just off the left edge — the loop always re-enters
|
|
70
|
+
* from the left, and the default theme's brush intro is exactly this phase's
|
|
71
|
+
* first sweep, so the handoff is seamless.
|
|
72
|
+
*/
|
|
73
|
+
export function shineBandCenter(u, span) {
|
|
74
|
+
const theta = 0.04 * u + 3.5 * Math.sin(u / 170) - Math.PI / 2;
|
|
75
|
+
return span / 2 + Math.sin(theta) * (span / 2 + 6);
|
|
76
|
+
}
|
|
77
|
+
/** The loop's settled cell with the shine band centered at `bandCenter`. */
|
|
78
|
+
function cellAtBand(context, bandCenter) {
|
|
79
|
+
const dx = context.diagonal - bandCenter;
|
|
80
|
+
const glow = Math.exp(-(dx * dx) / SHINE_BAND_WIDTH);
|
|
81
|
+
const intensity = Math.min(0.95, glow * SHINE_GLOW_BOOST);
|
|
82
|
+
const color = intensity > 0.03 ? mix(context.base, context.headingStrong, intensity) : context.base;
|
|
83
|
+
return { glyph: context.glyph, color };
|
|
84
|
+
}
|
|
85
|
+
/** The loop state an intro must land on: the shine band where it sits at the
|
|
86
|
+
* moment the intro hands over. Every intro converges to this. */
|
|
87
|
+
function settledCell(context) {
|
|
88
|
+
return cellAtBand(context, shineBandCenter(context.duration, context.span));
|
|
89
|
+
}
|
|
90
|
+
/** True while the welcome intro is playing and cells should be handed to the
|
|
91
|
+
* intro renderer instead of the settled shine loop. */
|
|
92
|
+
export function introActive(frame) {
|
|
93
|
+
return frame < INTRO_DURATION;
|
|
94
|
+
}
|
|
95
|
+
/** Deterministic global birth order for a logo cell: 0..N-1 across the whole
|
|
96
|
+
* mark, from a fixed hash of the cell coordinates. Intros that key off
|
|
97
|
+
* "when this cell is due" add their own shaping on top of this. */
|
|
98
|
+
function birthOrder(context, seed) {
|
|
99
|
+
const cells = context.width * 6;
|
|
100
|
+
return Math.floor(hash01(Math.floor((context.rowIndex * context.width + context.column) * 7.13 + seed * 7919)) * cells);
|
|
101
|
+
}
|
|
102
|
+
/** Per-theme opening acts, keyed by theme name. Each is a pure function of
|
|
103
|
+
* the frame, paints nothing at frame 0, and converges on the settled cell by
|
|
104
|
+
* the handoff frame. */
|
|
105
|
+
const intros = {
|
|
106
|
+
// aurora — the shine band is the brush: a reveal front sweeps left to
|
|
107
|
+
// right across the mark, painting the settled palette in its wake with a
|
|
108
|
+
// faint trailing sheen at the edge. The front starts off the left edge so
|
|
109
|
+
// frame 0 is blank, and the colors are the settled ones, so the handoff
|
|
110
|
+
// is invisible.
|
|
111
|
+
aurora: {
|
|
112
|
+
cell(context) {
|
|
113
|
+
const front = (context.frame / (context.duration - 1)) * (context.span + 4) - 2;
|
|
114
|
+
if (context.diagonal > front)
|
|
115
|
+
return null; // ahead of the brush: still nothing
|
|
116
|
+
const dx = front - context.diagonal;
|
|
117
|
+
const sheen = Math.exp(-(dx * dx) / 6) * 0.45;
|
|
118
|
+
const settled = settledCell(context);
|
|
119
|
+
const color = sheen > 0.02 ? mix(settled.color, context.accent, sheen) : settled.color;
|
|
120
|
+
return { glyph: context.glyph, color };
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
// midnight — a narrow spotlight sweeps across a black stage; each cell the
|
|
124
|
+
// beam touches blazes white-hot at birth, then cools into the settled
|
|
125
|
+
// palette. Left to right, one pass.
|
|
126
|
+
midnight: {
|
|
127
|
+
cell(context) {
|
|
128
|
+
const front = context.progress * (context.span + 10) - 5;
|
|
129
|
+
if (context.diagonal > front)
|
|
130
|
+
return null;
|
|
131
|
+
const dx = context.diagonal - front;
|
|
132
|
+
const birth = Math.exp(-(dx * dx) / 3);
|
|
133
|
+
const settled = settledCell(context);
|
|
134
|
+
const color = birth > 0.02 ? mix(settled.color, '#ffffff', Math.min(1, birth * 1.4)) : settled.color;
|
|
135
|
+
return { glyph: context.glyph, color };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
// nord — an aurora curtain: a cold green-white wave washes left to right
|
|
139
|
+
// through the mark, each row's edge slightly offset so the reveal ripples
|
|
140
|
+
// like light on ice.
|
|
141
|
+
nord: {
|
|
142
|
+
cell(context) {
|
|
143
|
+
const wave = context.progress * (context.span + 14) - 7;
|
|
144
|
+
const jitter = (context.rnd(1) - 0.5) * 5 + context.rowIndex * 2.2;
|
|
145
|
+
const edge = wave + jitter;
|
|
146
|
+
if (context.diagonal > edge)
|
|
147
|
+
return null;
|
|
148
|
+
const dx = context.diagonal - edge;
|
|
149
|
+
const glow = Math.exp(-(dx * dx) / 6);
|
|
150
|
+
const settled = settledCell(context);
|
|
151
|
+
const color = glow > 0.02 ? mix(settled.color, '#a8ffda', glow * 0.8) : settled.color;
|
|
152
|
+
return { glyph: context.glyph, color };
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
// dracula — an ink bleed: dark violet droplets surface at fixed points and
|
|
156
|
+
// spread outward as circles until the mark is flooded; each cell is born
|
|
157
|
+
// wet-glossy pink and dries into the settled gradient.
|
|
158
|
+
dracula: {
|
|
159
|
+
cell(context) {
|
|
160
|
+
const seeds = [3, 11, 23, 37, 53];
|
|
161
|
+
let born;
|
|
162
|
+
for (const seed of seeds) {
|
|
163
|
+
const cx = context.rnd(seed * 2) * context.span;
|
|
164
|
+
const cy = context.rnd(seed * 3) * 6;
|
|
165
|
+
const dist = Math.hypot(context.diagonal - cx, (context.rowIndex - cy) * 1.6);
|
|
166
|
+
const start = context.rnd(seed) * 0.3;
|
|
167
|
+
const reach = start + (dist / Math.max(1, context.span * 0.75)) * 0.6;
|
|
168
|
+
if (context.progress >= reach && (born === undefined || reach < born))
|
|
169
|
+
born = reach;
|
|
170
|
+
}
|
|
171
|
+
if (born === undefined)
|
|
172
|
+
return null;
|
|
173
|
+
const wet = Math.max(0, 1 - (context.progress - born) / 0.14);
|
|
174
|
+
const settled = settledCell(context);
|
|
175
|
+
const color = wet > 0 ? mix(settled.color, '#ff79c6', wet * 0.75) : settled.color;
|
|
176
|
+
return { glyph: context.glyph, color };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
// dawn — a sunrise: cells wake from the bottom row upward, each igniting
|
|
180
|
+
// gold as the light line passes and cooling into the settled pastel.
|
|
181
|
+
dawn: {
|
|
182
|
+
cell(context) {
|
|
183
|
+
const due = 0.06 + ((5 - context.rowIndex) / 6) * 0.72 + (context.rnd(2) - 0.5) * 0.06;
|
|
184
|
+
if (context.progress < due)
|
|
185
|
+
return null;
|
|
186
|
+
const heat = Math.max(0, 1 - (context.progress - due) / 0.15);
|
|
187
|
+
const settled = settledCell(context);
|
|
188
|
+
const color = heat > 0.02 ? mix(settled.color, '#ffb347', heat * 0.7) : settled.color;
|
|
189
|
+
return { glyph: context.glyph, color };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
// solarized — a darkroom print developing: cells surface as a faint cyan
|
|
193
|
+
// ghost that deepens into full contrast, center cells first like a
|
|
194
|
+
// vignette developing from the middle out.
|
|
195
|
+
solarized: {
|
|
196
|
+
cell(context) {
|
|
197
|
+
const vignette = 1 - Math.abs(context.diagonal - context.span / 2) / context.span;
|
|
198
|
+
const due = 0.1 + (1 - vignette) * 0.5;
|
|
199
|
+
if (context.progress < due)
|
|
200
|
+
return null;
|
|
201
|
+
const develop = Math.min(1, (context.progress - due) / 0.2);
|
|
202
|
+
const settled = settledCell(context);
|
|
203
|
+
return { glyph: context.glyph, color: mix(mix(context.accent, '#000000', 0.8), settled.color, develop) };
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
// rose-pine — petals fall: pale rose petals drift down through the mark's
|
|
207
|
+
// bounding box and stick where they land, filling the letterforms from
|
|
208
|
+
// random contact points until every cell has settled.
|
|
209
|
+
'rose-pine': {
|
|
210
|
+
cell(context) {
|
|
211
|
+
const seeds = [7, 19, 31, 43, 59, 71];
|
|
212
|
+
let landed;
|
|
213
|
+
for (const seed of seeds) {
|
|
214
|
+
const drift = context.rnd(seed) * context.span;
|
|
215
|
+
const delay = context.rnd(seed * 5) * 0.24;
|
|
216
|
+
const fall = delay + (1 - (context.rowIndex + (context.rnd(seed * 3) - 0.5) * 2) / 6) * 0.4;
|
|
217
|
+
if (context.progress >= fall && Math.abs(context.diagonal - drift) < 7 + context.progress * 10) {
|
|
218
|
+
if (landed === undefined || fall < landed)
|
|
219
|
+
landed = fall;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (landed === undefined)
|
|
223
|
+
return null;
|
|
224
|
+
const settle = Math.max(0, 1 - (context.progress - landed) / 0.12);
|
|
225
|
+
const settled = settledCell(context);
|
|
226
|
+
const color = settle > 0 ? mix(settled.color, '#eb6f92', settle * 0.65) : settled.color;
|
|
227
|
+
return { glyph: context.glyph, color };
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
// tokyo-night — neon ignition: the sign flickers on like a faulty neon
|
|
231
|
+
// tube — cells pop in a random order, sputtering briefly before holding
|
|
232
|
+
// steady.
|
|
233
|
+
'tokyo-night': {
|
|
234
|
+
cell(context) {
|
|
235
|
+
const due = 0.04 + (birthOrder(context, 13) / (context.width * 6)) * 0.62;
|
|
236
|
+
if (context.progress < due)
|
|
237
|
+
return null;
|
|
238
|
+
const fresh = context.progress - due;
|
|
239
|
+
if (fresh < 0.14) {
|
|
240
|
+
const sputter = context.rnd(Math.floor(fresh * 140) * 13 + context.rowIndex * 31 + context.column);
|
|
241
|
+
const settled = settledCell(context);
|
|
242
|
+
// A dying tube dims to near-black instead of vanishing: the cell
|
|
243
|
+
// stays painted, the neon still reads as sputtering.
|
|
244
|
+
if (sputter < 0.4)
|
|
245
|
+
return { glyph: context.glyph, color: mix(settled.color, '#16161e', 0.88) };
|
|
246
|
+
return { glyph: context.glyph, color: mix(settled.color, '#7dcfff', 0.5) };
|
|
247
|
+
}
|
|
248
|
+
return settledCell(context);
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
// catppuccin-mocha — a ripple: a soft ring expands from the center of the
|
|
252
|
+
// mark, painting cells as it passes and leaving a brief pastel afterglow.
|
|
253
|
+
'catppuccin-mocha': {
|
|
254
|
+
cell(context) {
|
|
255
|
+
const ring = context.progress * (context.span * 0.75 + 8);
|
|
256
|
+
const dist = Math.hypot(context.diagonal - context.span / 2, (context.rowIndex - 2.5) * 2.4);
|
|
257
|
+
if (dist > ring)
|
|
258
|
+
return null;
|
|
259
|
+
const afterglow = Math.max(0, 1 - (ring - dist) / 7);
|
|
260
|
+
const settled = settledCell(context);
|
|
261
|
+
const color = afterglow > 0.02 ? mix(settled.color, '#cba6f7', afterglow * 0.55) : settled.color;
|
|
262
|
+
return { glyph: context.glyph, color };
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
// catppuccin-latte — a coffee pour: the cup fills bottom-up like an
|
|
266
|
+
// espresso being pulled, the surface line wobbling as it rises; each
|
|
267
|
+
// dunked cell flashes with a steamed-milk sheen that fades back into
|
|
268
|
+
// the settled pastel as the surface moves on.
|
|
269
|
+
'catppuccin-latte': {
|
|
270
|
+
cell(context) {
|
|
271
|
+
const level = -0.4 + context.progress * 6 + Math.sin(context.progress * 12 + context.column / 6) * 0.2;
|
|
272
|
+
if (context.rowIndex + (context.rnd(4) - 0.5) * 0.5 > level)
|
|
273
|
+
return null;
|
|
274
|
+
const flash = Math.exp(-Math.max(0, level - context.rowIndex) / 1.5);
|
|
275
|
+
const settled = settledCell(context);
|
|
276
|
+
const color = flash > 0.02 ? mix(settled.color, context.accent, flash * 0.6) : settled.color;
|
|
277
|
+
return { glyph: context.glyph, color };
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
// gruvbox-dark — a CRT power-on: the mark snaps in as a horizontal line
|
|
281
|
+
// that stretches vertically from the center outward, phosphor-bright at
|
|
282
|
+
// the growing edges, then relaxes into the settled warm palette.
|
|
283
|
+
'gruvbox-dark': {
|
|
284
|
+
cell(context) {
|
|
285
|
+
const half = context.progress * 4.2;
|
|
286
|
+
if (Math.abs(context.rowIndex - 2.5) > half)
|
|
287
|
+
return null;
|
|
288
|
+
const edge = Math.abs(Math.abs(context.rowIndex - 2.5) - half);
|
|
289
|
+
const bloom = edge < 0.9 ? 1 - edge / 0.9 : 0;
|
|
290
|
+
const settled = settledCell(context);
|
|
291
|
+
const color = bloom > 0 ? mix(settled.color, '#fe8019', bloom * 0.8) : settled.color;
|
|
292
|
+
return { glyph: context.glyph, color };
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
// one-dark — a typewriter: the mark is typed cell by cell, left to right,
|
|
296
|
+
// top to bottom, each keystroke landing full-strength with no fade.
|
|
297
|
+
'one-dark': {
|
|
298
|
+
cell(context) {
|
|
299
|
+
const order = context.rowIndex * context.width + context.column;
|
|
300
|
+
const total = 6 * context.width;
|
|
301
|
+
const typed = Math.floor((context.progress / 0.94) * total);
|
|
302
|
+
if (order >= typed)
|
|
303
|
+
return null; // strictly after 0: nothing is typed at frame 0
|
|
304
|
+
const settled = settledCell(context);
|
|
305
|
+
const fresh = order === typed - 1 ? 1 : 0;
|
|
306
|
+
const color = fresh ? mix(settled.color, '#98c379', 0.45) : settled.color;
|
|
307
|
+
return { glyph: context.glyph, color };
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
// monokai — a pixel mosaic: cells pop in as dim colored blocks in random
|
|
311
|
+
// order, dwell a few ticks scintillating, then resolve one by one into
|
|
312
|
+
// the true glyph with a brief bright flash.
|
|
313
|
+
monokai: {
|
|
314
|
+
cell(context) {
|
|
315
|
+
const pop = 0.05 + (birthOrder(context, 9) / (context.width * 6)) * 0.55;
|
|
316
|
+
if (context.progress <= pop)
|
|
317
|
+
return null;
|
|
318
|
+
const resolve = pop + 0.18;
|
|
319
|
+
const settled = settledCell(context);
|
|
320
|
+
if (context.progress <= resolve) {
|
|
321
|
+
const tick = Math.floor(context.progress * context.duration / 4);
|
|
322
|
+
const shades = ['░', '▒', '▓'];
|
|
323
|
+
return { glyph: shades[Math.floor(context.rnd(tick * 3 + 2) * 3)], color: mix(context.base, '#1e1f1c', 0.55 + context.rnd(5) * 0.25) };
|
|
324
|
+
}
|
|
325
|
+
const flash = Math.max(0, 1 - (context.progress - resolve) / 0.08);
|
|
326
|
+
const color = flash > 0 ? mix(settled.color, '#f8f8f2', flash * 0.85) : settled.color;
|
|
327
|
+
return { glyph: context.glyph, color };
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
// kanagawa — a sumi-e wash: a wide soft brush draws the mark in three
|
|
331
|
+
// passes (top, middle, bottom bands), each stroke wet and dark at the
|
|
332
|
+
// front, drying lighter toward the tail.
|
|
333
|
+
kanagawa: {
|
|
334
|
+
cell(context) {
|
|
335
|
+
const band = Math.floor(context.rowIndex / 2);
|
|
336
|
+
const strokeFront = (context.progress * 1.15 - band * 0.22) * (context.span + 8) - 4;
|
|
337
|
+
if (context.diagonal > strokeFront)
|
|
338
|
+
return null;
|
|
339
|
+
const dx = context.diagonal - strokeFront;
|
|
340
|
+
const wet = Math.exp(-(dx * dx) / 26);
|
|
341
|
+
const settled = settledCell(context);
|
|
342
|
+
const color = wet > 0.02 ? mix(settled.color, '#1f1f28', Math.min(0.5, wet * 0.5)) : settled.color;
|
|
343
|
+
return { glyph: context.glyph, color };
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
// everforest — fog lift: dense pale fog envelops nothing yet — the fog
|
|
347
|
+
// edge retreats to the left as time passes, and cells emerge in its wake
|
|
348
|
+
// from the right. Each cell brightens as the haze burns off.
|
|
349
|
+
everforest: {
|
|
350
|
+
cell(context) {
|
|
351
|
+
const drift = context.progress * (context.span + 16) - 8;
|
|
352
|
+
const wisp = (context.rnd(6) - 0.5) * 6 + context.rowIndex * 1.7;
|
|
353
|
+
if (context.diagonal > drift + wisp)
|
|
354
|
+
return null;
|
|
355
|
+
const thin = Math.max(0, 1 - (drift + wisp - context.diagonal) / 8);
|
|
356
|
+
const settled = settledCell(context);
|
|
357
|
+
const color = thin > 0.02 ? mix(settled.color, '#d3c6aa', thin * 0.5) : settled.color;
|
|
358
|
+
return { glyph: context.glyph, color };
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
// synthwave — VHS tear-in: the mark materializes out of tracking noise,
|
|
362
|
+
// rows tearing in top to bottom with chromatic aberration, stabilizing
|
|
363
|
+
// row by row until the tape locks.
|
|
364
|
+
synthwave: {
|
|
365
|
+
cell(context) {
|
|
366
|
+
const rowLock = 0.06 + (context.rowIndex / 6) * 0.6;
|
|
367
|
+
if (context.progress < rowLock)
|
|
368
|
+
return null;
|
|
369
|
+
const tear = Math.max(0, 1 - (context.progress - rowLock) / 0.14);
|
|
370
|
+
if (tear > 0 && context.rnd(Math.floor(context.progress * context.duration) * 17 + context.rowIndex * 41) < tear * 0.5)
|
|
371
|
+
return null;
|
|
372
|
+
const settled = settledCell(context);
|
|
373
|
+
const chroma = tear > 0 ? tear * 0.6 : 0;
|
|
374
|
+
const color = chroma > 0 ? mix(settled.color, chroma * 2 > 1 ? '#36f9f6' : '#fe4450', chroma) : settled.color;
|
|
375
|
+
return { glyph: context.glyph, color };
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
// matrix — rain condensation: glyph rain falls through the mark's bounding
|
|
379
|
+
// box and the letterforms condense where the rain strikes, as if the code
|
|
380
|
+
// itself is condensing out of the downpour.
|
|
381
|
+
matrix: {
|
|
382
|
+
cell(context) {
|
|
383
|
+
const strike = 0.06 + context.rnd(8) * 0.6;
|
|
384
|
+
if (context.progress < strike)
|
|
385
|
+
return null;
|
|
386
|
+
const settle = Math.max(0, 1 - (context.progress - strike) / 0.1);
|
|
387
|
+
const settled = settledCell(context);
|
|
388
|
+
const color = settle > 0 ? mix(settled.color, '#c8ffd9', settle * 0.7) : settled.color;
|
|
389
|
+
return { glyph: context.glyph, color };
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
// solarized-light — blueprint develop: a scanning bar sweeps down over
|
|
393
|
+
// blank paper; behind it the ink darkens rapidly from nothing into the
|
|
394
|
+
// finished solarized-light palette.
|
|
395
|
+
'solarized-light': {
|
|
396
|
+
cell(context) {
|
|
397
|
+
const scan = -1.5 + context.progress * 9;
|
|
398
|
+
if (context.rowIndex > scan)
|
|
399
|
+
return null;
|
|
400
|
+
const fresh = Math.max(0, 1 - (context.rowIndex - scan) / 1.4);
|
|
401
|
+
const settled = settledCell(context);
|
|
402
|
+
const color = fresh > 0.02 ? mix(settled.color, '#268bd2', fresh * 0.6) : settled.color;
|
|
403
|
+
return { glyph: context.glyph, color };
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
// github-light — skeleton shimmer: a pale wireframe flickers in for a
|
|
407
|
+
// couple of beats, then the fill floods in left to right with a crisp
|
|
408
|
+
// leading edge, converting the skeleton in place — nothing is ever
|
|
409
|
+
// erased — ending at full contrast.
|
|
410
|
+
'github-light': {
|
|
411
|
+
cell(context) {
|
|
412
|
+
const settled = settledCell(context);
|
|
413
|
+
const skeleton = { glyph: context.glyph, color: mix('#f6f8fa', '#d0d7de', 0.4) };
|
|
414
|
+
if (context.progress < 0.02)
|
|
415
|
+
return null;
|
|
416
|
+
if (context.progress < 0.28) {
|
|
417
|
+
// The wireframe breathes: bright beats and dim beats, but a cell
|
|
418
|
+
// stays painted once it has first appeared.
|
|
419
|
+
const sputter = context.rnd(Math.floor(context.progress * 100) * 29 + context.rowIndex * 17 + context.column);
|
|
420
|
+
if (sputter < 0.45)
|
|
421
|
+
return { glyph: context.glyph, color: mix('#f6f8fa', '#d0d7de', 0.75) };
|
|
422
|
+
return skeleton;
|
|
423
|
+
}
|
|
424
|
+
const front = (context.progress - 0.28) / 0.72 * (context.span + 6) - 3;
|
|
425
|
+
if (context.diagonal > front)
|
|
426
|
+
return skeleton; // not yet flooded: skeleton holds
|
|
427
|
+
const fresh = Math.max(0, 1 - (front - context.diagonal) / 4);
|
|
428
|
+
const color = fresh > 0.02 ? mix(settled.color, '#0969da', fresh * 0.5) : settled.color;
|
|
429
|
+
return { glyph: context.glyph, color };
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
};
|
|
2
433
|
export function renderWelcome(width, height, terminalHeight = height, frame = 0) {
|
|
3
434
|
width = Math.max(1, Math.floor(width));
|
|
4
435
|
height = Math.max(1, Math.floor(height));
|
|
5
436
|
const rows = Array.from({ length: height }, () => '');
|
|
6
|
-
const wordmark = width >= 9 ? 'C O D E R' : 'CODER'.slice(0, width);
|
|
7
437
|
const center = Math.max(0, Math.min(height - 1, Math.floor((terminalHeight - 1) / 2)));
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
438
|
+
const palette = activeTuiTheme().markdown;
|
|
439
|
+
const cycle = frame % 80;
|
|
440
|
+
const logoTop = center - 3;
|
|
441
|
+
if (width >= LOGO_WIDTH + 3 && logoTop >= 0 && logoTop + LOGO.length <= height) {
|
|
442
|
+
const rise = 2;
|
|
443
|
+
const span = LOGO_WIDTH + rise * (LOGO.length - 1);
|
|
444
|
+
// Loop phase: the shine ping-pongs with a swaying rate, re-entering from
|
|
445
|
+
// the left each cycle, so the band lingers at the ends then dashes across.
|
|
446
|
+
const bandCenter = shineBandCenter(frame, span);
|
|
447
|
+
// The phosphor theme swaps the empty space around the mark for glyph
|
|
448
|
+
// rain: per-column heads fall the full height of the screen, trailing a
|
|
449
|
+
// fading wake. The letterforms themselves stay pristine on top.
|
|
450
|
+
const effect = activeTuiTheme().name === 'matrix' ? 'rain' : 'shine';
|
|
451
|
+
const RAIN_TRAIL = 7;
|
|
452
|
+
const rainCycle = height + RAIN_TRAIL;
|
|
453
|
+
const rainHead = [];
|
|
454
|
+
const rainPass = [];
|
|
455
|
+
for (let column = 0; column < width; column++) {
|
|
456
|
+
const speed = 0.12 + flicker(column * 13 + 5, 401) * 0.23;
|
|
457
|
+
const phase = flicker(column * 13 + 7, 402) * 240;
|
|
458
|
+
const travel = frame * speed + phase;
|
|
459
|
+
rainHead.push(Math.floor(travel % rainCycle));
|
|
460
|
+
rainPass.push(Math.floor(travel / rainCycle));
|
|
461
|
+
}
|
|
462
|
+
const rainAt = (row, x) => {
|
|
463
|
+
if (effect !== 'rain')
|
|
464
|
+
return ' ';
|
|
465
|
+
const depth = rainHead[x] - row;
|
|
466
|
+
if (depth < 0 || depth > RAIN_TRAIL)
|
|
467
|
+
return ' ';
|
|
468
|
+
// Wet/dry gates and glyphs are keyed to the column's current pass, not
|
|
469
|
+
// the frame: characters hold steady while the trail covers them and only
|
|
470
|
+
// re-roll when the head wraps around — the rain shimmers, not flickers.
|
|
471
|
+
if (flicker(rainPass[x] * 89 + 7, row * 31 + x) <= 0.25)
|
|
472
|
+
return ' ';
|
|
473
|
+
const slot = flicker(rainPass[x] * 97 + 13, row * 53 + x) * RAIN_GLYPHS.length;
|
|
474
|
+
const tick = Math.floor(frame / 8);
|
|
475
|
+
const mutated = flicker(tick * 71 + 3, row * 53 + x) > 0.94;
|
|
476
|
+
const glyph = RAIN_GLYPHS[Math.floor(mutated ? slot + 5.5 : slot) % RAIN_GLYPHS.length];
|
|
477
|
+
const color = depth === 0 ? palette.text : mix(mix(palette.accent, '#000000', 0.55), palette.text, 1 - depth / RAIN_TRAIL);
|
|
478
|
+
return `{${color}-fg}${glyph}{/${color}-fg}`;
|
|
479
|
+
};
|
|
480
|
+
const rainPad = (row, from, to) => {
|
|
481
|
+
let pad = '';
|
|
482
|
+
for (let x = from; x < to; x++)
|
|
483
|
+
pad += rainAt(row, x);
|
|
484
|
+
return pad;
|
|
485
|
+
};
|
|
486
|
+
const leftCells = Math.floor((width - LOGO_WIDTH) / 2);
|
|
487
|
+
const themeName = activeTuiTheme().name;
|
|
488
|
+
const intro = intros[themeName];
|
|
489
|
+
LOGO.forEach((template, rowIndex) => {
|
|
490
|
+
const row = logoTop + rowIndex;
|
|
491
|
+
let line = '';
|
|
492
|
+
for (let column = 0; column < LOGO_WIDTH; column++) {
|
|
493
|
+
const diagonal = column + rowIndex * rise;
|
|
494
|
+
const glyph = template[column];
|
|
495
|
+
if (glyph === ' ') {
|
|
496
|
+
line += rainAt(row, leftCells + column);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const base = mix(palette.accent, palette.heading, diagonal / span);
|
|
500
|
+
if (intro && introActive(frame)) {
|
|
501
|
+
const context = {
|
|
502
|
+
frame,
|
|
503
|
+
duration: INTRO_DURATION,
|
|
504
|
+
progress: frame / INTRO_DURATION,
|
|
505
|
+
rowIndex,
|
|
506
|
+
column,
|
|
507
|
+
diagonal,
|
|
508
|
+
span,
|
|
509
|
+
width: LOGO_WIDTH,
|
|
510
|
+
glyph,
|
|
511
|
+
base,
|
|
512
|
+
accent: palette.accent,
|
|
513
|
+
heading: palette.heading,
|
|
514
|
+
headingStrong: palette.headingStrong,
|
|
515
|
+
text: palette.text,
|
|
516
|
+
rnd: (seed) => hash01(seed * 7919 + rowIndex * 131 + column * 7 + 5),
|
|
517
|
+
};
|
|
518
|
+
const painted = intro.cell(context);
|
|
519
|
+
if (painted)
|
|
520
|
+
line += `{${painted.color}-fg}${painted.glyph}{/${painted.color}-fg}`;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
// Loop: the settled gradient with the shine riding on top.
|
|
524
|
+
const dx = diagonal - bandCenter;
|
|
525
|
+
const glow = Math.exp(-(dx * dx) / SHINE_BAND_WIDTH);
|
|
526
|
+
const intensity = Math.min(0.95, glow * SHINE_GLOW_BOOST);
|
|
527
|
+
const color = intensity > 0.03 ? mix(base, palette.headingStrong, intensity) : base;
|
|
528
|
+
line += `{${color}-fg}${glyph}{/${color}-fg}`;
|
|
529
|
+
}
|
|
530
|
+
rows[row] = `${rainPad(row, 0, leftCells)}${line}${rainPad(row, leftCells + LOGO_WIDTH, width)}`;
|
|
531
|
+
});
|
|
532
|
+
const brandRow = logoTop + LOGO.length + 1;
|
|
533
|
+
if (brandRow < height && BRAND.length <= width) {
|
|
534
|
+
const brandLeft = Math.floor((width - BRAND.length) / 2);
|
|
535
|
+
const brand = `{${palette.headingStrong}-fg}{bold}{/bold}{/${palette.headingStrong}-fg}{white-fg}{bold}${BRAND}{/bold}{/white-fg}`;
|
|
536
|
+
rows[brandRow] = `${rainPad(brandRow, 0, brandLeft)}${brand}${rainPad(brandRow, brandLeft + BRAND.length, width)}`;
|
|
537
|
+
}
|
|
538
|
+
const taglineRow = brandRow + 1;
|
|
539
|
+
if (taglineRow < height && TAGLINE.length <= width) {
|
|
540
|
+
const taglineLeft = Math.floor((width - TAGLINE.length) / 2);
|
|
541
|
+
const tagline = `{${palette.muted}-fg}${TAGLINE}{/${palette.muted}-fg}`;
|
|
542
|
+
rows[taglineRow] = `${rainPad(taglineRow, 0, taglineLeft)}${tagline}${rainPad(taglineRow, taglineLeft + TAGLINE.length, width)}`;
|
|
543
|
+
}
|
|
544
|
+
for (let row = 0; row < height; row++) {
|
|
545
|
+
if (rows[row] !== '')
|
|
546
|
+
continue;
|
|
547
|
+
let line = '';
|
|
548
|
+
for (let column = 0; column < width; column++)
|
|
549
|
+
line += rainAt(row, column);
|
|
550
|
+
rows[row] = line;
|
|
551
|
+
}
|
|
552
|
+
return rows;
|
|
553
|
+
}
|
|
554
|
+
const wordmark = width >= 17 ? BRAND : 'MAW'.slice(0, Math.min(3, width));
|
|
555
|
+
rows[center] = `${' '.repeat(Math.max(0, Math.floor((width - wordmark.length) / 2)))}{white-fg}{bold}${wordmark}{/bold}{/white-fg}`;
|
|
556
|
+
if (center + 2 < height && width >= 5) {
|
|
557
|
+
// A four-second, eased breath derived from the theme accent, with a
|
|
558
|
+
// slight spatial falloff and no discrete moving cell.
|
|
559
|
+
const breath = (1 - Math.cos(cycle / 80 * Math.PI * 2)) / 2;
|
|
14
560
|
const rule = [0, 1, 2].map((index) => {
|
|
15
561
|
const intensity = breath * (index === 1 ? 1 : 0.85);
|
|
16
|
-
const
|
|
17
|
-
const high = [91, 135, 146];
|
|
18
|
-
const color = '#' + low.map((value, channel) => Math.round(value + (high[channel] - value) * intensity).toString(16).padStart(2, '0')).join('');
|
|
562
|
+
const color = mix(mix(palette.accent, '#000000', 0.65), palette.accent, intensity);
|
|
19
563
|
return `{${color}-fg}─{/${color}-fg}`;
|
|
20
564
|
}).join('');
|
|
21
565
|
rows[center + 2] = `${' '.repeat(Math.floor((width - 3) / 2))}${rule}`;
|