throughline 0.2.0 → 0.3.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.
@@ -68,25 +68,52 @@ export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル
68
68
  export function readAllSessionStates() {
69
69
  if (!existsSync(STATE_DIR)) return [];
70
70
  const now = Date.now();
71
- const entries = readdirSync(STATE_DIR);
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(STATE_DIR);
74
+ } catch (err) {
75
+ if (err.code === 'ENOENT') return [];
76
+ process.stderr.write(`[state-file] readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
77
+ return [];
78
+ }
72
79
  const results = [];
73
80
  for (const name of entries) {
74
81
  if (!name.endsWith('.json')) continue;
75
82
  const file = join(STATE_DIR, name);
76
- const raw = readFileSync(file, 'utf8');
83
+
84
+ // 常駐 monitor を落とさないため IO 例外は吸収する。
85
+ // ENOENT: 削除 race、EACCES/EPERM: 一時的権限問題、いずれも skip して次フレームで再試行
86
+ let raw;
87
+ try {
88
+ raw = readFileSync(file, 'utf8');
89
+ } catch (err) {
90
+ if (err.code !== 'ENOENT') {
91
+ process.stderr.write(`[state-file] read failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
92
+ }
93
+ continue;
94
+ }
95
+
77
96
  let parsed;
78
97
  try {
79
98
  parsed = JSON.parse(raw);
80
99
  } catch (err) {
81
100
  // JSON 破損は状態ファイル固有の冪等な状況(次ターンで再生成される)
82
101
  process.stderr.write(`[state-file] corrupt state file ${name}, deleting: ${err.message}\n`);
83
- unlinkSync(file);
102
+ try {
103
+ unlinkSync(file);
104
+ } catch {
105
+ // 削除失敗も握りつぶす(次回削除される)
106
+ }
84
107
  continue;
85
108
  }
86
109
  const age = now - (parsed.updatedAt ?? 0);
87
110
  if (age > STALE_DELETE_MS) {
88
111
  // 24h 超: ハード削除(無制限蓄積防止)
89
- unlinkSync(file);
112
+ try {
113
+ unlinkSync(file);
114
+ } catch {
115
+ // 削除失敗は致命ではない、次回再試行
116
+ }
90
117
  continue;
91
118
  }
92
119
  parsed.stale = age > STALE_HIDE_MS;
@@ -102,15 +129,25 @@ export function readAllSessionStates() {
102
129
  export function snapshotStateMtimes() {
103
130
  const result = new Map();
104
131
  if (!existsSync(STATE_DIR)) return result;
105
- for (const name of readdirSync(STATE_DIR)) {
132
+ let entries;
133
+ try {
134
+ entries = readdirSync(STATE_DIR);
135
+ } catch (err) {
136
+ if (err.code === 'ENOENT') return result;
137
+ process.stderr.write(`[state-file] snapshot readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
138
+ return result;
139
+ }
140
+ for (const name of entries) {
106
141
  if (!name.endsWith('.json')) continue;
107
142
  // readdir と stat の間でファイルが削除される race がある。
108
- // ENOENT のみ許容してそれ以外は throw(§0)
143
+ // monitor を落とさないため ENOENT 以外の IO 例外も吸収する(EACCES/EPERM 等は一時的で次フレームで回復)
109
144
  const file = join(STATE_DIR, name);
110
145
  try {
111
146
  result.set(name, statSync(file).mtimeMs);
112
147
  } catch (err) {
113
- if (err.code !== 'ENOENT') throw err;
148
+ if (err.code !== 'ENOENT') {
149
+ process.stderr.write(`[state-file] stat failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
150
+ }
114
151
  }
115
152
  }
116
153
  return result;
@@ -0,0 +1,135 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, chmodSync, existsSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir, homedir, platform } from 'node:os';
6
+
7
+ import { normalizeProjectPath } from './state-file.mjs';
8
+
9
+ // state-file.mjs は ~/.throughline/state を直接見るので、モジュール全体を一時ディレクトリ指定で
10
+ // 差し替える薄いヘルパーを用意する。HOME 環境変数を偽装して import し直す方式で隔離する。
11
+ async function withIsolatedStateDir(testFn) {
12
+ const work = mkdtempSync(join(tmpdir(), 'tl-state-'));
13
+ const origHome = process.env.HOME;
14
+ const origUserProfile = process.env.USERPROFILE;
15
+ process.env.HOME = work;
16
+ process.env.USERPROFILE = work;
17
+ // ESM キャッシュをバイパスするため query string 付きで import
18
+ const mod = await import(`./state-file.mjs?isolated=${Date.now()}-${Math.random()}`);
19
+ const stateDir = mod.getStateDir();
20
+ mkdirSync(stateDir, { recursive: true });
21
+ try {
22
+ await testFn({ stateDir, mod });
23
+ } finally {
24
+ process.env.HOME = origHome;
25
+ process.env.USERPROFILE = origUserProfile;
26
+ rmSync(work, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ test('normalizeProjectPath: 空文字列は空文字列', () => {
31
+ assert.equal(normalizeProjectPath(''), '');
32
+ assert.equal(normalizeProjectPath(null), '');
33
+ assert.equal(normalizeProjectPath(undefined), '');
34
+ });
35
+
36
+ test('normalizeProjectPath: バックスラッシュがスラッシュになり末尾スラッシュ除去', () => {
37
+ const result = normalizeProjectPath('C:\\Users\\foo\\');
38
+ assert.ok(result.includes('/'));
39
+ assert.ok(!result.endsWith('/') || result === '/');
40
+ });
41
+
42
+ test('normalizeProjectPath: Windows では lowercase', () => {
43
+ if (platform() !== 'win32') return;
44
+ const result = normalizeProjectPath('C:\\Users\\Foo');
45
+ assert.equal(result, result.toLowerCase());
46
+ });
47
+
48
+ test('readAllSessionStates: 破損 JSON を削除して skip する', async () => {
49
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
50
+ const broken = join(stateDir, 'broken-abc.json');
51
+ const good = join(stateDir, 'good-session.json');
52
+ writeFileSync(broken, '{ not valid json');
53
+ writeFileSync(good, JSON.stringify({
54
+ sessionId: 'good-session',
55
+ projectPath: '/tmp/foo',
56
+ transcriptPath: null,
57
+ updatedAt: Date.now(),
58
+ }));
59
+ const results = mod.readAllSessionStates();
60
+ assert.equal(results.length, 1);
61
+ assert.equal(results[0].sessionId, 'good-session');
62
+ // 破損ファイルは削除されている
63
+ assert.ok(!existsSync(broken), 'corrupt file should be unlinked');
64
+ });
65
+ });
66
+
67
+ test('readAllSessionStates: 24h 超のファイルはハード削除される', async () => {
68
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
69
+ const old = join(stateDir, 'old-session.json');
70
+ const past = Date.now() - (25 * 60 * 60 * 1000); // 25h 前
71
+ writeFileSync(old, JSON.stringify({
72
+ sessionId: 'old-session',
73
+ projectPath: '/tmp/foo',
74
+ transcriptPath: null,
75
+ updatedAt: past,
76
+ }));
77
+ const results = mod.readAllSessionStates();
78
+ assert.equal(results.length, 0);
79
+ assert.ok(!existsSync(old), 'old file should be deleted');
80
+ });
81
+ });
82
+
83
+ test('readAllSessionStates: 15 分超は stale フラグ付きで返される', async () => {
84
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
85
+ const stale = join(stateDir, 'stale-session.json');
86
+ const past = Date.now() - (20 * 60 * 1000); // 20 分前
87
+ writeFileSync(stale, JSON.stringify({
88
+ sessionId: 'stale-session',
89
+ projectPath: '/tmp/foo',
90
+ transcriptPath: null,
91
+ updatedAt: past,
92
+ }));
93
+ const results = mod.readAllSessionStates();
94
+ assert.equal(results.length, 1);
95
+ assert.equal(results[0].stale, true);
96
+ });
97
+ });
98
+
99
+ test('readAllSessionStates: state ディレクトリ未作成なら空配列', async () => {
100
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
101
+ rmSync(stateDir, { recursive: true, force: true });
102
+ const results = mod.readAllSessionStates();
103
+ assert.deepEqual(results, []);
104
+ });
105
+ });
106
+
107
+ test('snapshotStateMtimes: 存在する JSON の mtime を返す', async () => {
108
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
109
+ const file = join(stateDir, 'abc.json');
110
+ writeFileSync(file, '{}');
111
+ const snap = mod.snapshotStateMtimes();
112
+ assert.equal(snap.size, 1);
113
+ assert.ok(snap.has('abc.json'));
114
+ assert.ok(typeof snap.get('abc.json') === 'number');
115
+ });
116
+ });
117
+
118
+ test('snapshotStateMtimes: .json 以外は無視', async () => {
119
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
120
+ writeFileSync(join(stateDir, 'ignored.txt'), 'hello');
121
+ writeFileSync(join(stateDir, 'abc.json'), '{}');
122
+ const snap = mod.snapshotStateMtimes();
123
+ assert.equal(snap.size, 1);
124
+ assert.ok(snap.has('abc.json'));
125
+ assert.ok(!snap.has('ignored.txt'));
126
+ });
127
+ });
128
+
129
+ test('snapshotStateMtimes: ディレクトリ未作成なら空 Map', async () => {
130
+ await withIsolatedStateDir(async ({ stateDir, mod }) => {
131
+ rmSync(stateDir, { recursive: true, force: true });
132
+ const snap = mod.snapshotStateMtimes();
133
+ assert.equal(snap.size, 0);
134
+ });
135
+ });
@@ -18,9 +18,10 @@
18
18
  */
19
19
 
20
20
  import { basename } from 'node:path';
21
+ import { stripVTControlCharacters } from 'node:util';
22
+ import { statSync, existsSync } from 'node:fs';
21
23
  import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
22
24
  import { readLatestUsage } from './transcript-usage.mjs';
23
- import { stripAnsi } from './transcript-reader.mjs';
24
25
 
25
26
  const REFRESH_MS = 1000;
26
27
 
@@ -45,60 +46,156 @@ function color(c, text) {
45
46
  return `${c}${text}${ANSI.reset}`;
46
47
  }
47
48
 
48
- /** ANSI エスケープシーケンスを除いた可視文字数を返す(サロゲートペア考慮はしない簡易版) */
49
- function visibleLength(s) {
50
- return stripAnsi(s).length;
49
+ // --- セル幅計算(ANSI / CJK / 絵文字対応、依存ゼロ) ---
50
+ //
51
+ // Node v22 には util.getStringWidth がないため、主要な East Asian Wide + 絵文字ブロックを
52
+ // 自前の範囲判定で 2 セルとして扱う。East Asian Ambiguous は 1 セル(アラビア・タイ等は
53
+ // 既存の char-count 実装と同じ扱いなので悪化しない)。
54
+ // ZWJ / VS16 は幅 0 として扱う(絵文字シーケンスの微ズレは許容)。
55
+ const ZERO_WIDTH_CODEPOINTS = new Set([0x200b, 0x200c, 0x200d, 0xfeff, 0xfe0e, 0xfe0f]);
56
+
57
+ /**
58
+ * 単一コードポイントのセル幅を返す(0 / 1 / 2)
59
+ * @param {number} cp
60
+ */
61
+ function codePointWidth(cp) {
62
+ if (cp < 0x20) return 0; // C0 制御
63
+ if (cp >= 0x7f && cp < 0xa0) return 0; // DEL / C1 制御
64
+ if (ZERO_WIDTH_CODEPOINTS.has(cp)) return 0;
65
+ // Combining marks (簡易: Combining Diacritical Marks 主要ブロック)
66
+ if (cp >= 0x0300 && cp <= 0x036f) return 0;
67
+ // Wide ranges
68
+ if (
69
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
70
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals / Kangxi
71
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana/Katakana/Bopomofo/CJK Symbols
72
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
73
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
74
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
75
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
76
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
77
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
78
+ (cp >= 0xff00 && cp <= 0xff60) || // Fullwidth
79
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
80
+ (cp >= 0x1f300 && cp <= 0x1faff) || // 絵文字主要ブロック
81
+ (cp >= 0x20000 && cp <= 0x3134f) // CJK Ext B-G
82
+ ) {
83
+ return 2;
84
+ }
85
+ return 1;
51
86
  }
52
87
 
53
88
  /**
54
- * 行をターミナル幅に収まるよう切り詰める。ANSI コードを壊さないため、
55
- * 可視文字だけを数えながらコピーし、上限に達したら reset を付けて返す。
89
+ * 文字列のセル幅合計(ANSI 剥ぎ取り済み前提ではない — 内部で剥ぐ)
90
+ * @param {string} s
91
+ */
92
+ function cellWidth(s) {
93
+ if (typeof s !== 'string') return 0;
94
+ const stripped = stripVTControlCharacters(s);
95
+ let total = 0;
96
+ for (const ch of stripped) {
97
+ total += codePointWidth(ch.codePointAt(0));
98
+ }
99
+ return total;
100
+ }
101
+
102
+ /**
103
+ * 行をセル幅で切り詰める。ANSI コードはそのまま通過させ(幅 0)、
104
+ * 可視セル幅が maxCells に達したら残りを捨てて reset を付けて返す。
105
+ * CJK 文字を跨ぐときは 1 セル余る場合があるので空白で埋める。
56
106
  * @param {string} line
57
- * @param {number} maxWidth
107
+ * @param {number} maxCells
58
108
  */
59
- function truncateToWidth(line, maxWidth) {
60
- if (maxWidth <= 0) return '';
61
- if (visibleLength(line) <= maxWidth) return line;
109
+ function truncateToCells(line, maxCells) {
110
+ if (maxCells <= 0) return '';
111
+ if (cellWidth(line) <= maxCells) return line;
62
112
  let out = '';
63
- let visible = 0;
113
+ let cells = 0;
64
114
  let i = 0;
65
- while (i < line.length && visible < maxWidth) {
66
- const ch = line[i];
67
- if (ch === '\x1b' && line[i + 1] === '[') {
68
- // ANSI sequence: copy until final byte (a-zA-Z)
69
- const end = line.slice(i).search(/[a-zA-Z]/);
70
- if (end === -1) break;
71
- out += line.slice(i, i + end + 1);
72
- i += end + 1;
115
+ while (i < line.length) {
116
+ const code = line.charCodeAt(i);
117
+ // ANSI CSI: \x1b[ ... 終端 (0x40-0x7e)
118
+ if (code === 0x1b && line.charCodeAt(i + 1) === 0x5b) {
119
+ let j = i + 2;
120
+ while (j < line.length) {
121
+ const c = line.charCodeAt(j);
122
+ if (c >= 0x40 && c <= 0x7e) { j++; break; }
123
+ j++;
124
+ }
125
+ out += line.slice(i, j);
126
+ i = j;
73
127
  continue;
74
128
  }
129
+ // コードポイント単位で取得(サロゲートペア考慮)
130
+ const cp = line.codePointAt(i);
131
+ const ch = String.fromCodePoint(cp);
132
+ const w = codePointWidth(cp);
133
+ if (cells + w > maxCells) {
134
+ // CJK 1 セル余り
135
+ if (cells < maxCells) out += ' ';
136
+ break;
137
+ }
75
138
  out += ch;
76
- visible++;
77
- i++;
139
+ cells += w;
140
+ i += ch.length;
78
141
  }
79
142
  return out + ANSI.reset;
80
143
  }
81
144
 
145
+ /**
146
+ * 文字列の末尾を半角スペースでパディングして targetCells に揃える。
147
+ * 既にはみ出している場合は truncateToCells で切り詰める。
148
+ * @param {string} s
149
+ * @param {number} targetCells
150
+ */
151
+ function padCellsEnd(s, targetCells) {
152
+ const width = cellWidth(s);
153
+ if (width === targetCells) return s;
154
+ if (width > targetCells) return truncateToCells(s, targetCells);
155
+ return s + ' '.repeat(targetCells - width);
156
+ }
157
+
82
158
  // --- CLI 引数 ---
159
+ /**
160
+ * @param {string[]} argv
161
+ * @returns {{all: boolean, session: string|null}}
162
+ * @throws {Error} --session に値が欠落している場合
163
+ */
83
164
  function parseArgs(argv) {
84
165
  const args = { all: false, session: null };
85
166
  for (let i = 0; i < argv.length; i++) {
86
- if (argv[i] === '--all') args.all = true;
87
- else if (argv[i] === '--session') args.session = argv[++i];
167
+ if (argv[i] === '--all') {
168
+ args.all = true;
169
+ } else if (argv[i] === '--session') {
170
+ const value = argv[i + 1];
171
+ if (value === undefined || value.startsWith('--')) {
172
+ throw new Error('--session requires a session id (or prefix) as next argument');
173
+ }
174
+ args.session = value;
175
+ i++;
176
+ }
88
177
  }
89
178
  return args;
90
179
  }
91
180
 
92
181
  // --- 表示 ---
93
182
  function renderBar(ratio, width = 20) {
94
- const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
183
+ // NaN 0、+Infinity 1(オーバーフロー = 満タン表示)、負値 / -Infinity は 0 にクランプ
184
+ let safe;
185
+ if (Number.isNaN(ratio)) safe = 0;
186
+ else if (ratio === Infinity) safe = 1;
187
+ else if (ratio === -Infinity) safe = 0;
188
+ else safe = Math.max(0, Math.min(1, ratio));
189
+ const filled = Math.round(safe * width);
95
190
  return '█'.repeat(filled) + '░'.repeat(width - filled);
96
191
  }
97
192
 
98
193
  function formatNumber(n) {
99
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
194
+ if (!Number.isFinite(n) || n < 0) return '0';
195
+ // 999_950 以上は toFixed(1) で "1000.0k" になってしまうので M 表記に切り上げる
196
+ if (n >= 999_500) return (n / 1_000_000).toFixed(2) + 'M';
100
197
  if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
101
- return String(n);
198
+ return String(Math.floor(n));
102
199
  }
103
200
 
104
201
  function formatLine({ state, usage, isActive }) {
@@ -116,13 +213,16 @@ function formatLine({ state, usage, isActive }) {
116
213
  ratio >= 0.7 ? ANSI.yellow :
117
214
  ANSI.green;
118
215
 
216
+ // 色覚配慮: 色だけでなく記号 / テキスト接頭辞でも重要度を示す
217
+ // 90%超: !! + 強い文言 (赤)
218
+ // 70%超: ! + 弱い文言 (黄)
119
219
  const warn =
120
- ratio >= 0.9 ? color(ANSI.red, ' /clear 強く推奨') :
121
- ratio >= 0.7 ? color(ANSI.yellow, ' そろそろ /clear') :
220
+ ratio >= 0.9 ? color(ANSI.red + ANSI.bold, ' !! /clear 強く推奨') :
221
+ ratio >= 0.7 ? color(ANSI.yellow, ' ! そろそろ /clear') :
122
222
  '';
123
223
 
124
224
  const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
125
- const projectCol = project.padEnd(18).slice(0, 18);
225
+ const projectCol = padCellsEnd(project, 18);
126
226
  const idCol = color(ANSI.dim, shortId);
127
227
  const barCol = color(barColor, bar);
128
228
  const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
@@ -133,11 +233,18 @@ function formatLine({ state, usage, isActive }) {
133
233
  }
134
234
 
135
235
  // --- フィルタ ---
236
+ /**
237
+ * セッション一覧に表示フィルタを適用する。
238
+ * ルール:
239
+ * - stale (15 分無活動) は基本非表示。--all のときのみ stale も含める
240
+ * - --session が指定されればプレフィックス一致、ただし base (stale フィルタ通過済み) 上で絞る
241
+ * - --session 無し & --all 無しのときは cwd 一致のみ残す
242
+ */
136
243
  function filterStates(states, args, cwd) {
137
- // stale (15 分無活動) は基本非表示。--all のときだけ stale も含める
138
- let base = args.all ? states : states.filter((s) => !s.stale);
244
+ const base = args.all ? states : states.filter((s) => !s.stale);
139
245
  if (args.session) {
140
- return states.filter((s) => s.sessionId === args.session || s.sessionId.startsWith(args.session));
246
+ // startsWith が完全一致も含むので === は冗長
247
+ return base.filter((s) => s.sessionId.startsWith(args.session));
141
248
  }
142
249
  if (args.all) return base;
143
250
  const normalizedCwd = normalizeProjectPath(cwd);
@@ -146,24 +253,55 @@ function filterStates(states, args, cwd) {
146
253
 
147
254
  // --- メインループ ---
148
255
  let lastRenderedLines = 0;
149
- let lastMtimes = new Map();
256
+ let lastRenderKey = '';
257
+
258
+ /**
259
+ * 再描画要否の判定キー。state ファイル群の mtime と transcript JSONL の size を
260
+ * 1 本の文字列にまとめてハッシュキーとする。キーが前回と同じなら描画スキップ。
261
+ *
262
+ * 注: transcript は JSONL append-only なので size 変化 = 新しい usage エントリ到来と
263
+ * 同義。mtime だけでは transcript 更新を検出できない(state-file の mtime は
264
+ * Stop hook のタイミングで更新され、transcript は Claude の stream 中に太る)。
265
+ */
266
+ function computeRenderKey() {
267
+ const parts = [];
268
+ // state mtimes
269
+ const mtimes = snapshotStateMtimes();
270
+ const names = Array.from(mtimes.keys()).sort();
271
+ for (const name of names) parts.push(`s:${name}:${mtimes.get(name)}`);
272
+ // transcript sizes(state ファイルを読まずに直接 stat、IO 最小化)
273
+ try {
274
+ const states = readAllSessionStates();
275
+ for (const st of states) {
276
+ if (!st.transcriptPath || !existsSync(st.transcriptPath)) continue;
277
+ try {
278
+ const size = statSync(st.transcriptPath).size;
279
+ parts.push(`t:${st.sessionId}:${size}`);
280
+ } catch {
281
+ // stat 失敗は無視(次フレームで回復)
282
+ }
283
+ }
284
+ } catch {
285
+ // readAllSessionStates 自体の失敗も 1 フレームで回復
286
+ }
287
+ return parts.join('|');
288
+ }
150
289
 
290
+ /**
291
+ * 前回と比べてキーが変化していれば true。副作用として lastRenderKey を更新する。
292
+ */
151
293
  function needsRerender() {
152
- const current = snapshotStateMtimes();
153
- if (current.size !== lastMtimes.size) {
154
- lastMtimes = current;
294
+ const key = computeRenderKey();
295
+ if (key !== lastRenderKey) {
296
+ lastRenderKey = key;
155
297
  return true;
156
298
  }
157
- for (const [name, mtime] of current) {
158
- if (lastMtimes.get(name) !== mtime) {
159
- lastMtimes = current;
160
- return true;
161
- }
162
- }
163
- // mtime は同じでも transcript JSONL のサイズが変われば再描画したい
164
- // → transcript-usage のキャッシュ判定に任せるため毎秒呼ぶ設計。
165
- // state ファイル変化なしでも再計算は走らせる(キャッシュヒット時は軽量)
166
- return true;
299
+ return false;
300
+ }
301
+
302
+ /** テスト用リセット */
303
+ function resetRenderKeyCache() {
304
+ lastRenderKey = '';
167
305
  }
168
306
 
169
307
  function renderFrame(args) {
@@ -174,9 +312,15 @@ function renderFrame(args) {
174
312
 
175
313
  const lines = [];
176
314
  if (filtered.length === 0) {
177
- lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
178
- if (!args.all) {
179
- lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} state 無し。--all で全プロジェクト表示)`));
315
+ if (args.session) {
316
+ // --session 指定で未ヒットは「存在しない」と明示(一般の "待機中" と区別)
317
+ lines.push(color(ANSI.yellow, `[Throughline] セッション "${args.session}" に一致する active セッションが見つかりません`));
318
+ lines.push(color(ANSI.dim, ' (プレフィックス指定も可。--all で stale を含めて再検索)'));
319
+ } else {
320
+ lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
321
+ if (!args.all) {
322
+ lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} に state 無し。--all で全プロジェクト表示)`));
323
+ }
180
324
  }
181
325
  } else {
182
326
  const header = color(
@@ -191,13 +335,14 @@ function renderFrame(args) {
191
335
  }
192
336
  }
193
337
 
194
- // ★ 折り返し対策: 各行を (columns - 1) 幅に切り詰めて物理 1 行に収める。
338
+ // ★ 折り返し対策: 各行を (columns - 1) セル幅に切り詰めて物理 1 行に収める。
195
339
  // こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
196
340
  // columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
341
+ // truncateToCells は CJK / 絵文字を 2 セルとして正しく数える。
197
342
  const columns = process.stdout.columns && process.stdout.columns > 10
198
343
  ? process.stdout.columns - 1
199
344
  : 120;
200
- const clipped = lines.map((l) => truncateToWidth(l, columns));
345
+ const clipped = lines.map((l) => truncateToCells(l, columns));
201
346
 
202
347
  // 前フレームを消去してから再描画:
203
348
  // 1. カーソルを前フレームの先頭行へ戻す (CUU = 行移動のみ)
@@ -213,25 +358,98 @@ function renderFrame(args) {
213
358
  }
214
359
 
215
360
  // --- 起動 ---
216
- function main() {
217
- const args = parseArgs(process.argv.slice(2));
361
+ let cursorRestored = false;
362
+ function restoreCursor() {
363
+ if (cursorRestored) return;
364
+ cursorRestored = true;
365
+ try {
366
+ process.stdout.write(ANSI.showCursor);
367
+ } catch {
368
+ // stdout がすでに閉じていても無視
369
+ }
370
+ }
371
+
372
+ function safeRenderFrame(args) {
373
+ try {
374
+ renderFrame(args);
375
+ } catch (err) {
376
+ const msg = err instanceof Error ? err.message : 'unknown error';
377
+ process.stderr.write(`[Throughline] render error: ${msg}\n`);
378
+ // 1 フレームの失敗で常駐を落とさない。次フレームで回復を試す
379
+ }
380
+ }
381
+
382
+ export function main() {
383
+ let args;
384
+ try {
385
+ args = parseArgs(process.argv.slice(2));
386
+ } catch (err) {
387
+ const msg = err instanceof Error ? err.message : 'invalid args';
388
+ process.stderr.write(`[Throughline] ${msg}\n`);
389
+ process.exit(2);
390
+ }
218
391
 
219
392
  process.stdout.write(ANSI.hideCursor);
220
393
  process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
221
394
 
222
- renderFrame(args);
395
+ safeRenderFrame(args);
223
396
  const timer = setInterval(() => {
224
- if (needsRerender()) renderFrame(args);
397
+ if (needsRerender()) safeRenderFrame(args);
225
398
  }, REFRESH_MS);
226
399
 
227
- const shutdown = () => {
400
+ // ターミナル幅が変わったら即座に全画面リフレッシュ(前フレームの ANSI 座標が無効化されるため)
401
+ // debounce 200ms でドラッグ中のジッタを吸収
402
+ let resizeTimer = null;
403
+ const onResize = () => {
404
+ if (resizeTimer) clearTimeout(resizeTimer);
405
+ resizeTimer = setTimeout(() => {
406
+ resizeTimer = null;
407
+ // 既存描画が新しい幅では崩れている可能性があるため座標情報を破棄して再描画
408
+ lastRenderedLines = 0;
409
+ resetRenderKeyCache();
410
+ safeRenderFrame(args);
411
+ }, 200);
412
+ };
413
+ if (typeof process.stdout.on === 'function') {
414
+ process.stdout.on('resize', onResize);
415
+ }
416
+
417
+ const shutdown = (code = 0) => {
228
418
  clearInterval(timer);
229
- process.stdout.write('\n' + ANSI.showCursor);
230
- process.stdout.write(color(ANSI.dim, '[Throughline] モニター終了\n'));
231
- process.exit(0);
419
+ if (resizeTimer) clearTimeout(resizeTimer);
420
+ restoreCursor();
421
+ process.stdout.write('\n' + color(ANSI.dim, '[Throughline] モニター終了\n'));
422
+ process.exit(code);
232
423
  };
233
- process.on('SIGINT', shutdown);
234
- process.on('SIGTERM', shutdown);
424
+ process.on('SIGINT', () => shutdown(0));
425
+ process.on('SIGTERM', () => shutdown(0));
426
+
427
+ // クラッシュ時もカーソルを必ず戻す
428
+ process.on('exit', restoreCursor);
429
+ process.on('uncaughtException', (err) => {
430
+ restoreCursor();
431
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
432
+ process.stderr.write(`[Throughline] uncaught exception:\n${msg}\n`);
433
+ process.exit(1);
434
+ });
435
+ process.on('unhandledRejection', (reason) => {
436
+ restoreCursor();
437
+ process.stderr.write(`[Throughline] unhandled rejection: ${String(reason)}\n`);
438
+ process.exit(1);
439
+ });
235
440
  }
236
441
 
237
- main();
442
+ // --- テスト用エクスポート(本番コードからは参照しない) ---
443
+ export const _internal = {
444
+ parseArgs,
445
+ filterStates,
446
+ cellWidth,
447
+ truncateToCells,
448
+ padCellsEnd,
449
+ formatNumber,
450
+ renderBar,
451
+ formatLine,
452
+ computeRenderKey,
453
+ needsRerender,
454
+ resetRenderKeyCache,
455
+ };