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.
- package/README.md +23 -2
- package/agents/coordinator.md +2 -3
- package/agents/main.md +2 -2
- package/dist/backend.js +31 -1
- package/dist/cli.js +30 -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 +352 -21
- package/dist/runtime/agent-store.js +23 -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 +12 -0
- package/dist/ui/fullscreen-tui.js +1195 -120
- 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 +144 -11
- package/docs/architecture-revision.md +1 -1
- package/package.json +2 -2
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,154 @@
|
|
|
1
|
-
/** A
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
|
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
|
-
- [
|
|
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
|
+
"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",
|