tokenmaw 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -1,21 +1,154 @@
1
- /** A quiet, static wordmark. Center against the window, not its scrollback. */
1
+ /** A big animated wordmark: theme-gradient block letters with a clean ping-ponging shine; the phosphor theme rains glyphs through the background instead. */
2
+ import { activeTuiTheme } from './theme.js';
3
+ const GLYPH_LETTERS = [
4
+ ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
5
+ [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
6
+ ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝'],
7
+ ];
8
+ const LOGO = (() => {
9
+ const letters = GLYPH_LETTERS.map((rows) => {
10
+ const widest = Math.max(...rows.map((row) => row.length));
11
+ return rows.map((row) => row.padEnd(widest));
12
+ });
13
+ const rows = [];
14
+ for (let line = 0; line < 6; line++)
15
+ rows.push(letters.map((letter) => letter[line]).join(' '));
16
+ return rows;
17
+ })();
18
+ const LOGO_WIDTH = LOGO[0].length;
19
+ const BRAND = 'T O K E N M A W';
20
+ const TAGLINE = 'multi-agent coding runtime';
21
+ const RAIN_GLYPHS = '01ハヒクシアウトナニヌネモリ';
22
+ function hexToRgb(hex) {
23
+ const value = hex.replace('#', '');
24
+ return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)];
25
+ }
26
+ function mix(from, to, t) {
27
+ const a = hexToRgb(from);
28
+ const b = hexToRgb(to);
29
+ return '#' + a.map((channel, index) => Math.round(channel + (b[index] - channel) * t).toString(16).padStart(2, '0')).join('');
30
+ }
31
+ /** Stateless pseudo-randomness keyed by frame and cell, so the render stays a pure function. */
32
+ function flicker(frame, seed) {
33
+ let x = (Math.imul(frame + 1, 2654435761) ^ Math.imul(seed + 1, 97531)) >>> 0;
34
+ x ^= x >>> 16;
35
+ x = Math.imul(x, 0x45d9f3b);
36
+ x ^= x >>> 16;
37
+ x = Math.imul(x, 0x45d9f3b);
38
+ x ^= x >>> 16;
39
+ return (x >>> 0) / 4294967296;
40
+ }
2
41
  export function renderWelcome(width, height, terminalHeight = height, frame = 0) {
3
42
  width = Math.max(1, Math.floor(width));
4
43
  height = Math.max(1, Math.floor(height));
5
44
  const rows = Array.from({ length: height }, () => '');
6
- const wordmark = width >= 9 ? 'C O D E R' : 'CODER'.slice(0, width);
7
45
  const center = Math.max(0, Math.min(height - 1, Math.floor((terminalHeight - 1) / 2)));
8
- const left = ' '.repeat(Math.max(0, Math.floor((width - wordmark.length) / 2)));
9
- rows[center] = `${left}{white-fg}{bold}${wordmark}{/bold}{/white-fg}`;
10
- if (center + 2 < height && width >= 9) {
11
- // A four-second, eased breath in one subdued hue, with a slight spatial
12
- // falloff. No discrete moving cell or white flash at the turning points.
13
- const breath = (1 - Math.cos((frame % 80) / 80 * Math.PI * 2)) / 2;
46
+ const palette = activeTuiTheme().markdown;
47
+ const cycle = frame % 80;
48
+ const logoTop = center - 3;
49
+ if (width >= LOGO_WIDTH + 3 && logoTop >= 0 && logoTop + LOGO.length <= height) {
50
+ // One clean motion layer for every theme: the shine rides diagonal "/"
51
+ // stripes (distance along u = x + rise · y) and ping-pongs end to end.
52
+ // Its phase advances at a rate that itself sways, so the band lingers at
53
+ // the ends, then dashes across — never a flat metronome. No other motion
54
+ // touches the letterforms.
55
+ const rise = 2;
56
+ const span = LOGO_WIDTH + rise * (LOGO.length - 1);
57
+ const theta = 0.04 * frame + 3.5 * Math.sin(frame / 170);
58
+ const ride = Math.sin(theta);
59
+ const bandCenter = span / 2 + ride * (span / 2 + 6);
60
+ const bandWidth = 18;
61
+ const glowBoost = 0.7;
62
+ // The phosphor theme swaps the empty space around the mark for glyph
63
+ // rain: per-column heads fall the full height of the screen, trailing a
64
+ // fading wake. The letterforms themselves stay pristine on top.
65
+ const effect = activeTuiTheme().name === 'matrix' ? 'rain' : 'shine';
66
+ const RAIN_TRAIL = 7;
67
+ const rainCycle = height + RAIN_TRAIL;
68
+ const rainHead = [];
69
+ const rainPass = [];
70
+ for (let column = 0; column < width; column++) {
71
+ const speed = 0.12 + flicker(column * 13 + 5, 401) * 0.23;
72
+ const phase = flicker(column * 13 + 7, 402) * 240;
73
+ const travel = frame * speed + phase;
74
+ rainHead.push(Math.floor(travel % rainCycle));
75
+ rainPass.push(Math.floor(travel / rainCycle));
76
+ }
77
+ const rainAt = (row, x) => {
78
+ if (effect !== 'rain')
79
+ return ' ';
80
+ const depth = rainHead[x] - row;
81
+ if (depth < 0 || depth > RAIN_TRAIL)
82
+ return ' ';
83
+ // Wet/dry gates and glyphs are keyed to the column's current pass, not
84
+ // the frame: characters hold steady while the trail covers them and only
85
+ // re-roll when the head wraps around — the rain shimmers, not flickers.
86
+ if (flicker(rainPass[x] * 89 + 7, row * 31 + x) <= 0.25)
87
+ return ' ';
88
+ const slot = flicker(rainPass[x] * 97 + 13, row * 53 + x) * RAIN_GLYPHS.length;
89
+ const tick = Math.floor(frame / 8);
90
+ const mutated = flicker(tick * 71 + 3, row * 53 + x) > 0.94;
91
+ const glyph = RAIN_GLYPHS[Math.floor(mutated ? slot + 5.5 : slot) % RAIN_GLYPHS.length];
92
+ const color = depth === 0 ? palette.text : mix(mix(palette.accent, '#000000', 0.55), palette.text, 1 - depth / RAIN_TRAIL);
93
+ return `{${color}-fg}${glyph}{/${color}-fg}`;
94
+ };
95
+ const rainPad = (row, from, to) => {
96
+ let pad = '';
97
+ for (let x = from; x < to; x++)
98
+ pad += rainAt(row, x);
99
+ return pad;
100
+ };
101
+ const leftCells = Math.floor((width - LOGO_WIDTH) / 2);
102
+ LOGO.forEach((template, rowIndex) => {
103
+ const row = logoTop + rowIndex;
104
+ let line = '';
105
+ for (let column = 0; column < LOGO_WIDTH; column++) {
106
+ const diagonal = column + rowIndex * rise;
107
+ const dx = diagonal - bandCenter;
108
+ const glow = Math.exp(-(dx * dx) / bandWidth);
109
+ const base = mix(palette.accent, palette.heading, diagonal / span);
110
+ const intensity = Math.min(0.95, glow * glowBoost);
111
+ const color = intensity > 0.03 ? mix(base, palette.headingStrong, intensity) : base;
112
+ const glyph = template[column];
113
+ if (glyph === ' ') {
114
+ line += rainAt(row, leftCells + column);
115
+ continue;
116
+ }
117
+ line += `{${color}-fg}${glyph}{/${color}-fg}`;
118
+ }
119
+ rows[row] = `${rainPad(row, 0, leftCells)}${line}${rainPad(row, leftCells + LOGO_WIDTH, width)}`;
120
+ });
121
+ const brandRow = logoTop + LOGO.length + 1;
122
+ if (brandRow < height && BRAND.length <= width) {
123
+ const brandLeft = Math.floor((width - BRAND.length) / 2);
124
+ const brand = `{${palette.headingStrong}-fg}{bold}${BRAND}{/bold}{/${palette.headingStrong}-fg}`;
125
+ rows[brandRow] = `${rainPad(brandRow, 0, brandLeft)}${brand}${rainPad(brandRow, brandLeft + BRAND.length, width)}`;
126
+ }
127
+ const taglineRow = brandRow + 1;
128
+ if (taglineRow < height && TAGLINE.length <= width) {
129
+ const taglineLeft = Math.floor((width - TAGLINE.length) / 2);
130
+ const tagline = `{${palette.muted}-fg}${TAGLINE}{/${palette.muted}-fg}`;
131
+ rows[taglineRow] = `${rainPad(taglineRow, 0, taglineLeft)}${tagline}${rainPad(taglineRow, taglineLeft + TAGLINE.length, width)}`;
132
+ }
133
+ for (let row = 0; row < height; row++) {
134
+ if (rows[row] !== '')
135
+ continue;
136
+ let line = '';
137
+ for (let column = 0; column < width; column++)
138
+ line += rainAt(row, column);
139
+ rows[row] = line;
140
+ }
141
+ return rows;
142
+ }
143
+ const wordmark = width >= 17 ? BRAND : 'MAW'.slice(0, Math.min(3, width));
144
+ rows[center] = `${' '.repeat(Math.max(0, Math.floor((width - wordmark.length) / 2)))}{white-fg}{bold}${wordmark}{/bold}{/white-fg}`;
145
+ if (center + 2 < height && width >= 5) {
146
+ // A four-second, eased breath derived from the theme accent, with a
147
+ // slight spatial falloff and no discrete moving cell.
148
+ const breath = (1 - Math.cos(cycle / 80 * Math.PI * 2)) / 2;
14
149
  const rule = [0, 1, 2].map((index) => {
15
150
  const intensity = breath * (index === 1 ? 1 : 0.85);
16
- const low = [48, 66, 72];
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('');
151
+ const color = mix(mix(palette.accent, '#000000', 0.65), palette.accent, intensity);
19
152
  return `{${color}-fg}─{/${color}-fg}`;
20
153
  }).join('');
21
154
  rows[center + 2] = `${' '.repeat(Math.floor((width - 3) / 2))}${rule}`;
@@ -263,7 +263,7 @@ TUI 不再模拟任务管理器,而采用现代桌面聊天应用布局:
263
263
  - [ ] main 的“相关工作复用还是新建 coordinator”由 LLM/spec 决定,Runtime 尚未提供语义相似度或去重兜底。
264
264
  - [ ] TUI 已现代化但仍是 Blessed 单体界面,尚未拆成可复用组件;暂不提供 Web 客户端。
265
265
  - [ ] 多用户、多进程服务化和远程 agent 执行尚未实现。
266
- - [ ] Agent 级 token/cost 统计和完整 trace 导出尚未实现。
266
+ - [x] Agent 级 token/延迟统计已记录并在 TUI 状态栏展示;完整 trace 导出仍待后续补充。
267
267
 
268
268
  ## 10. 验收标准
269
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaw",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "clean": "node -e \"const fs=require('fs'),path=require('path'),p=path.resolve('dist');if(path.basename(p)!=='dist')throw new Error('unsafe clean target');fs.rmSync(p,{recursive:true,force:true})\"",
14
- "build": "npm run clean && tsc -p tsconfig.json",
14
+ "build": "npm run clean && tsc -p tsconfig.json && node -e \"const fs=require('fs');const p='dist/cli.js';if(fs.existsSync(p))fs.chmodSync(p,0o755)\"",
15
15
  "start": "node dist/cli.js",
16
16
  "dev": "tsx src/cli.ts",
17
17
  "test": "node --import tsx/esm --test tests/*.test.ts tests/**/*.test.ts",