throughline 0.3.2 → 0.3.4
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 +65 -7
- package/bin/throughline.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.mjs +263 -5
- package/src/cli/doctor.test.mjs +109 -0
- package/src/state-file.mjs +8 -2
- package/src/state-file.test.mjs +49 -0
- package/src/token-monitor.mjs +74 -6
- package/src/token-monitor.test.mjs +75 -0
- package/src/turn-processor.mjs +304 -272
- package/src/vscode-task.mjs +240 -0
- package/src/vscode-task.test.mjs +520 -0
package/src/token-monitor.mjs
CHANGED
|
@@ -24,6 +24,10 @@ import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjec
|
|
|
24
24
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
25
25
|
|
|
26
26
|
const REFRESH_MS = 1000;
|
|
27
|
+
// データ変化が無くても N ms ごとに再描画して「(24m ago)」表示を進める。
|
|
28
|
+
// 変化検知のほうが優先で、こちらはフォールバック的なタイマー。
|
|
29
|
+
const TIME_AGO_REFRESH_MS = 10_000;
|
|
30
|
+
let lastTimeAgoRefresh = Date.now();
|
|
27
31
|
|
|
28
32
|
// --- ANSI ---
|
|
29
33
|
const ANSI = {
|
|
@@ -198,7 +202,36 @@ function formatNumber(n) {
|
|
|
198
202
|
return String(Math.floor(n));
|
|
199
203
|
}
|
|
200
204
|
|
|
201
|
-
|
|
205
|
+
/**
|
|
206
|
+
* ある時刻からの経過時間を短い人間可読形式で返す。
|
|
207
|
+
* 「止まって見える」瞬間に、それがどれだけ前の値なのかを一目で示すために使う。
|
|
208
|
+
* @param {number} ms - 経過ミリ秒
|
|
209
|
+
*/
|
|
210
|
+
export function formatTimeAgo(ms) {
|
|
211
|
+
if (!Number.isFinite(ms) || ms < 0) return 'just now';
|
|
212
|
+
const sec = Math.floor(ms / 1000);
|
|
213
|
+
if (sec < 10) return 'just now';
|
|
214
|
+
if (sec < 60) return `${sec}s ago`;
|
|
215
|
+
const min = Math.floor(sec / 60);
|
|
216
|
+
if (min < 60) return `${min}m ago`;
|
|
217
|
+
const hr = Math.floor(min / 60);
|
|
218
|
+
if (hr < 24) return `${hr}h ago`;
|
|
219
|
+
const day = Math.floor(hr / 24);
|
|
220
|
+
return `${day}d ago`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* columns の変化を検知して全画面再描画すべきかを返す。
|
|
225
|
+
* process.stdout.on('resize') イベントが VSCode 統合ターミナルで発火しないケースが
|
|
226
|
+
* あるため、1 秒 tick から呼び出して polling で検知する。
|
|
227
|
+
* @param {number} prevCols
|
|
228
|
+
* @param {number} currCols
|
|
229
|
+
*/
|
|
230
|
+
export function shouldForceFullRedraw(prevCols, currCols) {
|
|
231
|
+
return prevCols !== currCols && currCols > 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function formatLine({ state, usage, isActive, now = Date.now() }) {
|
|
202
235
|
const project = basename(state.projectPath || '?');
|
|
203
236
|
const shortId = state.sessionId.slice(0, 8);
|
|
204
237
|
const tokens = usage?.tokens ?? 0;
|
|
@@ -228,8 +261,13 @@ function formatLine({ state, usage, isActive }) {
|
|
|
228
261
|
const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
|
|
229
262
|
const remCol = color(ANSI.dim, `残 ${formatNumber(remaining)}`);
|
|
230
263
|
const modelCol = usage?.model ? color(ANSI.dim, usage.model) : color(ANSI.dim, '(未取得)');
|
|
264
|
+
// 最終更新からの経過: 表示が「止まって見える」とき、それが idle なのか障害なのかを
|
|
265
|
+
// 即座に判別できるようにする。updatedAt は state.writeSessionState 時の Date.now()。
|
|
266
|
+
const ago = typeof state.updatedAt === 'number'
|
|
267
|
+
? color(ANSI.dim, `(${formatTimeAgo(now - state.updatedAt)})`)
|
|
268
|
+
: '';
|
|
231
269
|
|
|
232
|
-
return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol}${warn}`;
|
|
270
|
+
return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol} ${ago}${warn}`;
|
|
233
271
|
}
|
|
234
272
|
|
|
235
273
|
// --- フィルタ ---
|
|
@@ -328,10 +366,16 @@ function renderFrame(args) {
|
|
|
328
366
|
`[Throughline] ${filtered.length} セッション${args.all ? ' (--all)' : ''}`,
|
|
329
367
|
);
|
|
330
368
|
lines.push(header);
|
|
369
|
+
const now = Date.now();
|
|
331
370
|
for (let i = 0; i < filtered.length; i++) {
|
|
332
371
|
const state = filtered[i];
|
|
333
|
-
|
|
334
|
-
|
|
372
|
+
// Stop hook が state.usage に固定値を入れていればそれを使う(JSONL 再スキャン不要)。
|
|
373
|
+
// 旧バージョンが書いた state や usage スナップショットが取れなかったターンでは
|
|
374
|
+
// transcriptPath を直読してフォールバック。state 側の情報が 1 本化されると
|
|
375
|
+
// 「state が古い JSONL を指している」時の表示ブレが減る。
|
|
376
|
+
const usage = state.usage
|
|
377
|
+
?? (state.transcriptPath ? readLatestUsage(state.transcriptPath) : null);
|
|
378
|
+
lines.push(formatLine({ state, usage, isActive: i === 0, now }));
|
|
335
379
|
}
|
|
336
380
|
}
|
|
337
381
|
|
|
@@ -393,18 +437,40 @@ export function main() {
|
|
|
393
437
|
process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
|
|
394
438
|
|
|
395
439
|
safeRenderFrame(args);
|
|
440
|
+
// columns の最後に使った値。polling で resize 検知するために使う。
|
|
441
|
+
// VSCode 統合ターミナルは process.stdout.on('resize') が発火しないことがあり、
|
|
442
|
+
// 起動時に狭い幅だった場合に描画が崩れたまま固定される(実害ベースで確認済み)。
|
|
443
|
+
// 1 秒 tick のたびに currCols と比較してイベント不達を埋める。
|
|
444
|
+
let lastColumns = process.stdout.columns ?? 0;
|
|
396
445
|
const timer = setInterval(() => {
|
|
446
|
+
const currCols = process.stdout.columns ?? 0;
|
|
447
|
+
if (shouldForceFullRedraw(lastColumns, currCols)) {
|
|
448
|
+
lastColumns = currCols;
|
|
449
|
+
lastRenderedLines = 0;
|
|
450
|
+
resetRenderKeyCache();
|
|
451
|
+
safeRenderFrame(args);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// 状態が更新されていれば再描画。state mtime か transcript size のどちらかが
|
|
455
|
+
// 変わっていれば発火するので、Stop 完了・新 assistant エントリ追記の両方を捕捉する。
|
|
397
456
|
if (needsRerender()) safeRenderFrame(args);
|
|
457
|
+
// 1 秒刻みの「経過時間」(24m ago など) を反映するため、データに変化が無くても
|
|
458
|
+
// 10 秒に 1 回は強制再描画する。コストは state 数本の JSONL パースのみで軽い。
|
|
459
|
+
if (Date.now() - lastTimeAgoRefresh > TIME_AGO_REFRESH_MS) {
|
|
460
|
+
lastTimeAgoRefresh = Date.now();
|
|
461
|
+
safeRenderFrame(args);
|
|
462
|
+
}
|
|
398
463
|
}, REFRESH_MS);
|
|
399
464
|
|
|
400
|
-
//
|
|
401
|
-
// debounce 200ms
|
|
465
|
+
// resize イベント経路は残す: polling 前に通知が来ればより速く反応できる。
|
|
466
|
+
// debounce 200ms でドラッグ中のジッタを吸収し、polling 側との二重再描画も防ぐ。
|
|
402
467
|
let resizeTimer = null;
|
|
403
468
|
const onResize = () => {
|
|
404
469
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
405
470
|
resizeTimer = setTimeout(() => {
|
|
406
471
|
resizeTimer = null;
|
|
407
472
|
// 既存描画が新しい幅では崩れている可能性があるため座標情報を破棄して再描画
|
|
473
|
+
lastColumns = process.stdout.columns ?? 0;
|
|
408
474
|
lastRenderedLines = 0;
|
|
409
475
|
resetRenderKeyCache();
|
|
410
476
|
safeRenderFrame(args);
|
|
@@ -452,6 +518,8 @@ export const _internal = {
|
|
|
452
518
|
computeRenderKey,
|
|
453
519
|
needsRerender,
|
|
454
520
|
resetRenderKeyCache,
|
|
521
|
+
formatTimeAgo,
|
|
522
|
+
shouldForceFullRedraw,
|
|
455
523
|
};
|
|
456
524
|
|
|
457
525
|
// --- エントリポイント自動起動 ---
|
|
@@ -13,6 +13,8 @@ const {
|
|
|
13
13
|
formatNumber,
|
|
14
14
|
renderBar,
|
|
15
15
|
formatLine,
|
|
16
|
+
formatTimeAgo,
|
|
17
|
+
shouldForceFullRedraw,
|
|
16
18
|
} = _internal;
|
|
17
19
|
|
|
18
20
|
// state-file は projectPath を resolve + lowercase 正規化する。
|
|
@@ -329,3 +331,76 @@ test('formatLine: プロジェクト名に CJK が含まれてもセル幅で整
|
|
|
329
331
|
// のは難しいがクラッシュしないことと想定文字列が含まれることを最低限確認
|
|
330
332
|
assert.ok(stripColors(out).includes('プロジェクト名'));
|
|
331
333
|
});
|
|
334
|
+
|
|
335
|
+
// ─── formatTimeAgo ─────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
test('formatTimeAgo: 10 秒未満は "just now"', () => {
|
|
338
|
+
assert.equal(formatTimeAgo(0), 'just now');
|
|
339
|
+
assert.equal(formatTimeAgo(500), 'just now');
|
|
340
|
+
assert.equal(formatTimeAgo(9_500), 'just now');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('formatTimeAgo: 60 秒未満は秒表示', () => {
|
|
344
|
+
assert.equal(formatTimeAgo(15_000), '15s ago');
|
|
345
|
+
assert.equal(formatTimeAgo(59_000), '59s ago');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('formatTimeAgo: 60 分未満は分表示', () => {
|
|
349
|
+
assert.equal(formatTimeAgo(60_000), '1m ago');
|
|
350
|
+
assert.equal(formatTimeAgo(24 * 60 * 1000), '24m ago');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('formatTimeAgo: 24 時間未満は時表示', () => {
|
|
354
|
+
assert.equal(formatTimeAgo(60 * 60 * 1000), '1h ago');
|
|
355
|
+
assert.equal(formatTimeAgo(23 * 60 * 60 * 1000), '23h ago');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('formatTimeAgo: 24 時間以上は日表示', () => {
|
|
359
|
+
assert.equal(formatTimeAgo(24 * 60 * 60 * 1000), '1d ago');
|
|
360
|
+
assert.equal(formatTimeAgo(3 * 24 * 60 * 60 * 1000), '3d ago');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('formatTimeAgo: 無効値は "just now"', () => {
|
|
364
|
+
assert.equal(formatTimeAgo(NaN), 'just now');
|
|
365
|
+
assert.equal(formatTimeAgo(-1), 'just now');
|
|
366
|
+
assert.equal(formatTimeAgo(Infinity), 'just now');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── shouldForceFullRedraw ────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
test('shouldForceFullRedraw: columns 変化なしは false', () => {
|
|
372
|
+
assert.equal(shouldForceFullRedraw(80, 80), false);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('shouldForceFullRedraw: columns が増えたら true', () => {
|
|
376
|
+
assert.equal(shouldForceFullRedraw(40, 120), true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('shouldForceFullRedraw: columns が減ったら true', () => {
|
|
380
|
+
assert.equal(shouldForceFullRedraw(120, 40), true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('shouldForceFullRedraw: 片方 0 以下は false (未初期化 or 無効)', () => {
|
|
384
|
+
assert.equal(shouldForceFullRedraw(80, 0), false);
|
|
385
|
+
assert.equal(shouldForceFullRedraw(0, 80), true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ─── formatLine: time-ago 表示 ────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
test('formatLine: updatedAt から経過時間が表示される', () => {
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
const args = {
|
|
393
|
+
...makeLineArgs(0.5),
|
|
394
|
+
now,
|
|
395
|
+
};
|
|
396
|
+
args.state.updatedAt = now - 3 * 60 * 1000; // 3 分前
|
|
397
|
+
const out = stripColors(formatLine(args));
|
|
398
|
+
assert.ok(out.includes('(3m ago)'), `expected "(3m ago)" in output: ${out}`);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('formatLine: updatedAt が無ければ ago 表示は出ない', () => {
|
|
402
|
+
const args = makeLineArgs(0.5);
|
|
403
|
+
delete args.state.updatedAt;
|
|
404
|
+
const out = stripColors(formatLine(args));
|
|
405
|
+
assert.ok(!out.includes('ago'));
|
|
406
|
+
});
|