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.
@@ -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
- function formatLine({ state, usage, isActive }) {
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
- const usage = state.transcriptPath ? readLatestUsage(state.transcriptPath) : null;
334
- lines.push(formatLine({ state, usage, isActive: i === 0 }));
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
- // ターミナル幅が変わったら即座に全画面リフレッシュ(前フレームの ANSI 座標が無効化されるため)
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
+ });