throughline 0.3.15 → 0.3.16
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/package.json +1 -1
- package/src/terminal-size.mjs +115 -0
- package/src/terminal-size.test.mjs +164 -0
- package/src/token-monitor.mjs +71 -34
- package/src/token-monitor.test.mjs +34 -0
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-size.mjs — OSC 18t (CSI 18t) で端末に window 幅を問い合わせるユーティリティ
|
|
3
|
+
*
|
|
4
|
+
* 背景: Windows ConPTY + VSCode task terminal では `process.stdout.columns` が
|
|
5
|
+
* 起動時のサイズで凍結し、panel の resize に追従しない。`process.stdout.on('resize')`
|
|
6
|
+
* も発火しない。Node 側からは polling しても同じ値しか取れない。
|
|
7
|
+
*
|
|
8
|
+
* 対策: ANSI CSI 18t (`\x1b[18t`) シーケンスで端末に「今の幅は?」と直接訊く。
|
|
9
|
+
* 対応端末 (xterm / VSCode 1.88+ / Windows Terminal 一部) は
|
|
10
|
+
* `\x1b[8;rows;cols t` で stdin に返してくる。これを raw mode で受けてパースすれば
|
|
11
|
+
* resize 後の真の columns が取れる。
|
|
12
|
+
*
|
|
13
|
+
* 非対応端末 (古い VSCode、ConEmu 等) は返事をしない。呼び出し側はタイムアウトで
|
|
14
|
+
* フォールバック (従来の process.stdout.columns) に落とす。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const CSI_QUERY_WINDOW_SIZE = '\x1b[18t';
|
|
18
|
+
|
|
19
|
+
// xterm.js / xterm は `\x1b[8;{rows};{cols}t` で返答する(CSI t ファミリ)。
|
|
20
|
+
// 稀に `\x1b[8;{rows};{cols};W t` のようにオプショナルトークンが混ざる端末もあるため
|
|
21
|
+
// 非貪欲マッチでターミネータ 't' までを拾う。
|
|
22
|
+
const RESPONSE_RE = /\x1b\[8;(\d+);(\d+)t/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 受信バッファからサイズ応答を抽出する。純粋関数なのでテスト可能。
|
|
26
|
+
* @param {string} buf - 受信済みバイト列 (UTF-8 文字列化済み)
|
|
27
|
+
* @returns {{ cols: number, rows: number, consumedEnd: number } | null}
|
|
28
|
+
*/
|
|
29
|
+
export function parseSizeResponse(buf) {
|
|
30
|
+
const m = buf.match(RESPONSE_RE);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const rows = Number(m[1]);
|
|
33
|
+
const cols = Number(m[2]);
|
|
34
|
+
if (!Number.isFinite(rows) || !Number.isFinite(cols)) return null;
|
|
35
|
+
return { rows, cols, consumedEnd: m.index + m[0].length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* OSC 18t クエリ機構を起動する。
|
|
40
|
+
*
|
|
41
|
+
* 契約:
|
|
42
|
+
* - stdin が TTY でない、または setRawMode が使えない環境では `{ supported: false }` を即返す
|
|
43
|
+
* - stdin を raw mode にし、data listener を登録する
|
|
44
|
+
* - query() を呼ぶたびに `\x1b[18t` を stdout に書く
|
|
45
|
+
* - 応答が返ってきたら onSize({cols, rows}) を呼ぶ
|
|
46
|
+
* - Ctrl+C (0x03) を受け取ったら onInterrupt を呼ぶ (raw mode では自動 SIGINT が飛ばないため)
|
|
47
|
+
* - stop() で raw mode を解除する (shutdown 時に必須)
|
|
48
|
+
*
|
|
49
|
+
* @param {{
|
|
50
|
+
* stdin?: NodeJS.ReadableStream & { setRawMode?: (v: boolean) => unknown, isTTY?: boolean },
|
|
51
|
+
* stdout?: NodeJS.WritableStream,
|
|
52
|
+
* onSize: (size: { cols: number, rows: number }) => void,
|
|
53
|
+
* onInterrupt?: () => void,
|
|
54
|
+
* }} deps
|
|
55
|
+
* @returns {{ supported: boolean, query: () => void, stop: () => void }}
|
|
56
|
+
*/
|
|
57
|
+
export function startSizeQuery(deps) {
|
|
58
|
+
const stdin = deps.stdin ?? process.stdin;
|
|
59
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
60
|
+
const onSize = deps.onSize;
|
|
61
|
+
const onInterrupt = deps.onInterrupt;
|
|
62
|
+
|
|
63
|
+
const unsupported = { supported: false, query: () => {}, stop: () => {} };
|
|
64
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
65
|
+
return unsupported;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
stdin.setRawMode(true);
|
|
70
|
+
} catch {
|
|
71
|
+
return unsupported;
|
|
72
|
+
}
|
|
73
|
+
stdin.setEncoding?.('utf8');
|
|
74
|
+
stdin.resume?.();
|
|
75
|
+
|
|
76
|
+
let buf = '';
|
|
77
|
+
const onData = (chunk) => {
|
|
78
|
+
buf += chunk;
|
|
79
|
+
// Ctrl+C (ETX 0x03) 検出: raw mode だと自動 SIGINT 化しないので自前で拾う
|
|
80
|
+
if (onInterrupt && buf.indexOf('\x03') >= 0) {
|
|
81
|
+
try { onInterrupt(); } catch { /* shutdown path なので握り潰す */ }
|
|
82
|
+
return; // これ以上 parse しても無駄
|
|
83
|
+
}
|
|
84
|
+
// 複数応答が溜まっていたら最後の 1 件だけ採用し、バッファから消費分を落とす
|
|
85
|
+
let parsed = parseSizeResponse(buf);
|
|
86
|
+
let lastSize = null;
|
|
87
|
+
while (parsed) {
|
|
88
|
+
lastSize = { cols: parsed.cols, rows: parsed.rows };
|
|
89
|
+
buf = buf.slice(parsed.consumedEnd);
|
|
90
|
+
parsed = parseSizeResponse(buf);
|
|
91
|
+
}
|
|
92
|
+
if (lastSize) {
|
|
93
|
+
try { onSize(lastSize); } catch { /* 描画例外は呼び出し元で処理 */ }
|
|
94
|
+
}
|
|
95
|
+
// バッファが暴走しないよう頭を切る (応答は通常 12 バイト程度)
|
|
96
|
+
if (buf.length > 256) buf = buf.slice(-64);
|
|
97
|
+
};
|
|
98
|
+
stdin.on('data', onData);
|
|
99
|
+
|
|
100
|
+
let stopped = false;
|
|
101
|
+
const stop = () => {
|
|
102
|
+
if (stopped) return;
|
|
103
|
+
stopped = true;
|
|
104
|
+
try { stdin.off?.('data', onData); } catch { /* noop */ }
|
|
105
|
+
try { stdin.setRawMode(false); } catch { /* noop */ }
|
|
106
|
+
try { stdin.pause?.(); } catch { /* noop */ }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const query = () => {
|
|
110
|
+
if (stopped) return;
|
|
111
|
+
try { stdout.write(CSI_QUERY_WINDOW_SIZE); } catch { /* closed stdout の可能性 */ }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return { supported: true, query, stop };
|
|
115
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { parseSizeResponse, startSizeQuery } from './terminal-size.mjs';
|
|
5
|
+
|
|
6
|
+
// --- parseSizeResponse ---
|
|
7
|
+
|
|
8
|
+
test('parseSizeResponse: 正常な CSI 8 応答をパースする', () => {
|
|
9
|
+
const r = parseSizeResponse('\x1b[8;24;80t');
|
|
10
|
+
assert.deepEqual(r && { rows: r.rows, cols: r.cols }, { rows: 24, cols: 80 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('parseSizeResponse: 先頭にノイズがあっても応答を見つける', () => {
|
|
14
|
+
const r = parseSizeResponse('garbage\x1b[8;40;120tmore');
|
|
15
|
+
assert.ok(r);
|
|
16
|
+
assert.equal(r.rows, 40);
|
|
17
|
+
assert.equal(r.cols, 120);
|
|
18
|
+
assert.equal(r.consumedEnd, 'garbage\x1b[8;40;120t'.length);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parseSizeResponse: 応答が無ければ null', () => {
|
|
22
|
+
assert.equal(parseSizeResponse(''), null);
|
|
23
|
+
assert.equal(parseSizeResponse('\x1b[8;24t'), null); // 不完全
|
|
24
|
+
assert.equal(parseSizeResponse('not ansi at all'), null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseSizeResponse: 数値でない応答は null', () => {
|
|
28
|
+
// 実際は RE が \d+ のみマッチするので to null になる
|
|
29
|
+
assert.equal(parseSizeResponse('\x1b[8;abc;defgt'), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// --- startSizeQuery ---
|
|
33
|
+
|
|
34
|
+
function makeFakeStdin({ isTTY = true } = {}) {
|
|
35
|
+
const ee = new EventEmitter();
|
|
36
|
+
ee.isTTY = isTTY;
|
|
37
|
+
ee.setRawMode = (v) => { ee.rawMode = v; };
|
|
38
|
+
ee.setEncoding = () => {};
|
|
39
|
+
ee.resume = () => {};
|
|
40
|
+
ee.pause = () => {};
|
|
41
|
+
ee.off = (ev, fn) => ee.removeListener(ev, fn);
|
|
42
|
+
ee.rawMode = false;
|
|
43
|
+
return ee;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeFakeStdout() {
|
|
47
|
+
const writes = [];
|
|
48
|
+
return {
|
|
49
|
+
writes,
|
|
50
|
+
write(data) { writes.push(data); return true; },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test('startSizeQuery: stdin が TTY でなければ supported=false', () => {
|
|
55
|
+
const stdin = makeFakeStdin({ isTTY: false });
|
|
56
|
+
const stdout = makeFakeStdout();
|
|
57
|
+
const onSize = () => {};
|
|
58
|
+
const q = startSizeQuery({ stdin, stdout, onSize });
|
|
59
|
+
assert.equal(q.supported, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('startSizeQuery: TTY なら supported=true で raw mode を立てる', () => {
|
|
63
|
+
const stdin = makeFakeStdin({ isTTY: true });
|
|
64
|
+
const stdout = makeFakeStdout();
|
|
65
|
+
const q = startSizeQuery({ stdin, stdout, onSize: () => {} });
|
|
66
|
+
assert.equal(q.supported, true);
|
|
67
|
+
assert.equal(stdin.rawMode, true);
|
|
68
|
+
q.stop();
|
|
69
|
+
assert.equal(stdin.rawMode, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('startSizeQuery: query() で \\x1b[18t が stdout に書かれる', () => {
|
|
73
|
+
const stdin = makeFakeStdin();
|
|
74
|
+
const stdout = makeFakeStdout();
|
|
75
|
+
const q = startSizeQuery({ stdin, stdout, onSize: () => {} });
|
|
76
|
+
q.query();
|
|
77
|
+
assert.deepEqual(stdout.writes, ['\x1b[18t']);
|
|
78
|
+
q.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('startSizeQuery: stdin に応答が流れると onSize が呼ばれる', () => {
|
|
82
|
+
const stdin = makeFakeStdin();
|
|
83
|
+
const stdout = makeFakeStdout();
|
|
84
|
+
const sizes = [];
|
|
85
|
+
const q = startSizeQuery({ stdin, stdout, onSize: (s) => sizes.push(s) });
|
|
86
|
+
stdin.emit('data', '\x1b[8;24;80t');
|
|
87
|
+
assert.deepEqual(sizes, [{ rows: 24, cols: 80 }]);
|
|
88
|
+
q.stop();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('startSizeQuery: 複数応答が一度に来たら最後のサイズだけ採用する', () => {
|
|
92
|
+
const stdin = makeFakeStdin();
|
|
93
|
+
const stdout = makeFakeStdout();
|
|
94
|
+
const sizes = [];
|
|
95
|
+
const q = startSizeQuery({ stdin, stdout, onSize: (s) => sizes.push(s) });
|
|
96
|
+
// resize スパムで連続応答が来るケースを想定
|
|
97
|
+
stdin.emit('data', '\x1b[8;24;80t\x1b[8;30;100t\x1b[8;40;120t');
|
|
98
|
+
assert.deepEqual(sizes, [{ rows: 40, cols: 120 }]);
|
|
99
|
+
q.stop();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('startSizeQuery: 応答が分割到着しても連結してパースする', () => {
|
|
103
|
+
const stdin = makeFakeStdin();
|
|
104
|
+
const stdout = makeFakeStdout();
|
|
105
|
+
const sizes = [];
|
|
106
|
+
const q = startSizeQuery({ stdin, stdout, onSize: (s) => sizes.push(s) });
|
|
107
|
+
stdin.emit('data', '\x1b[8;24');
|
|
108
|
+
stdin.emit('data', ';80t');
|
|
109
|
+
assert.deepEqual(sizes, [{ rows: 24, cols: 80 }]);
|
|
110
|
+
q.stop();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('startSizeQuery: Ctrl+C (0x03) が来たら onInterrupt を呼ぶ', () => {
|
|
114
|
+
const stdin = makeFakeStdin();
|
|
115
|
+
const stdout = makeFakeStdout();
|
|
116
|
+
let interrupted = false;
|
|
117
|
+
const q = startSizeQuery({
|
|
118
|
+
stdin,
|
|
119
|
+
stdout,
|
|
120
|
+
onSize: () => {},
|
|
121
|
+
onInterrupt: () => { interrupted = true; },
|
|
122
|
+
});
|
|
123
|
+
stdin.emit('data', '\x03');
|
|
124
|
+
assert.equal(interrupted, true);
|
|
125
|
+
q.stop();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('startSizeQuery: stop() 後は query() しても書き込まない', () => {
|
|
129
|
+
const stdin = makeFakeStdin();
|
|
130
|
+
const stdout = makeFakeStdout();
|
|
131
|
+
const q = startSizeQuery({ stdin, stdout, onSize: () => {} });
|
|
132
|
+
q.stop();
|
|
133
|
+
q.query();
|
|
134
|
+
assert.deepEqual(stdout.writes, []);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('startSizeQuery: stop() を 2 度呼んでも安全 (冪等)', () => {
|
|
138
|
+
const stdin = makeFakeStdin();
|
|
139
|
+
const stdout = makeFakeStdout();
|
|
140
|
+
const q = startSizeQuery({ stdin, stdout, onSize: () => {} });
|
|
141
|
+
q.stop();
|
|
142
|
+
q.stop();
|
|
143
|
+
assert.equal(stdin.rawMode, false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('startSizeQuery: バッファが 256 バイト超えたら前方を捨てる', () => {
|
|
147
|
+
const stdin = makeFakeStdin();
|
|
148
|
+
const stdout = makeFakeStdout();
|
|
149
|
+
const sizes = [];
|
|
150
|
+
const q = startSizeQuery({ stdin, stdout, onSize: (s) => sizes.push(s) });
|
|
151
|
+
// 応答が来ない状況でゴミが 300 バイト溜まる → 次の正常応答が認識される
|
|
152
|
+
stdin.emit('data', 'x'.repeat(300));
|
|
153
|
+
stdin.emit('data', '\x1b[8;10;20t');
|
|
154
|
+
assert.deepEqual(sizes, [{ rows: 10, cols: 20 }]);
|
|
155
|
+
q.stop();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('startSizeQuery: setRawMode で throw する stdin は supported=false', () => {
|
|
159
|
+
const stdin = makeFakeStdin();
|
|
160
|
+
stdin.setRawMode = () => { throw new Error('no raw'); };
|
|
161
|
+
const stdout = makeFakeStdout();
|
|
162
|
+
const q = startSizeQuery({ stdin, stdout, onSize: () => {} });
|
|
163
|
+
assert.equal(q.supported, false);
|
|
164
|
+
});
|
package/src/token-monitor.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { statSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
|
|
25
25
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
26
|
+
import { startSizeQuery } from './terminal-size.mjs';
|
|
26
27
|
|
|
27
28
|
const REFRESH_MS = 1000;
|
|
28
29
|
// データ変化が無くても N ms ごとに再描画して「(24m ago)」表示を進める。
|
|
@@ -238,26 +239,43 @@ export function shouldForceFullRedraw(prevCols, currCols) {
|
|
|
238
239
|
return prevCols !== currCols && currCols > 0;
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// OSC 18t (startSizeQuery) で取得した最新の「端末実測幅」。
|
|
243
|
+
// Windows ConPTY + VSCode task terminal では process.stdout.columns が起動時の値で
|
|
244
|
+
// 凍結するため、resize に追従するにはこの ANSI クエリ経路が必要。
|
|
245
|
+
// undefined = 未取得 / OSC 18t 非対応環境。その場合は process.stdout.columns にフォールバック。
|
|
246
|
+
let measuredCols = undefined;
|
|
247
|
+
|
|
248
|
+
/** テスト用: OSC 18t の測定値を手動でセット/リセット */
|
|
249
|
+
export function setMeasuredColumns(v) {
|
|
250
|
+
measuredCols = v;
|
|
251
|
+
}
|
|
252
|
+
|
|
241
253
|
/**
|
|
242
254
|
* 描画に使う列幅を解決する。
|
|
243
255
|
*
|
|
244
256
|
* 優先順:
|
|
245
|
-
* 1.
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
257
|
+
* 1. OSC 18t で測った実測値 (measuredCols) が 1 以上 → それ - 1
|
|
258
|
+
* 2. stdout が TTY かつ `process.stdout.columns` が 1 以上 → それ - 1
|
|
259
|
+
* 3. `process.env.COLUMNS` が 1 以上 → それ - 1
|
|
260
|
+
* 4. それ以外 → 80 にフォールバック
|
|
249
261
|
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* 描画が永遠に積み上がるバグの真因だった。
|
|
262
|
+
* OSC 18t を最優先にする理由:
|
|
263
|
+
* Windows ConPTY + VSCode task terminal は panel resize が process.stdout.columns
|
|
264
|
+
* に届かず凍結する。毎フレーム `\x1b[18t` を端末に投げて `\x1b[8;rows;cols t` を
|
|
265
|
+
* stdin で受けると、真の現在幅が取れる (0.3.16 で導入、terminal-size.mjs 参照)。
|
|
255
266
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
267
|
+
* 非対応端末 (古い VSCode、パイプ出力) では measuredCols が undefined のままで
|
|
268
|
+
* 自動的にフォールバック経路に倒れる。
|
|
269
|
+
*
|
|
270
|
+
* 歴史 (保管):
|
|
271
|
+
* 0.3.6〜0.3.12 は `>= 40` 閾値で狭い columns を「狂った値」として 200 に倒す
|
|
272
|
+
* 挙動だったが、30 cells の panel が実在するため 200 セルの行 → 物理 7 行 wrap →
|
|
273
|
+
* CUU under-count → 積み上がり、という連鎖バグの起点だった。0.3.13 で閾値を撤廃。
|
|
259
274
|
*/
|
|
260
275
|
export function resolveColumns() {
|
|
276
|
+
if (typeof measuredCols === 'number' && measuredCols > 0) {
|
|
277
|
+
return Math.max(1, measuredCols - 1);
|
|
278
|
+
}
|
|
261
279
|
if (process.stdout.isTTY) {
|
|
262
280
|
const reported = typeof process.stdout.columns === 'number' ? process.stdout.columns : 0;
|
|
263
281
|
if (reported > 0) return Math.max(1, reported - 1);
|
|
@@ -401,11 +419,9 @@ function renderFrame(args) {
|
|
|
401
419
|
}
|
|
402
420
|
}
|
|
403
421
|
} else {
|
|
404
|
-
// runtime cols を毎フレーム出してリサイズ追従状況を可視化する (診断目的、将来削除可)
|
|
405
|
-
const runtimeCols = process.stdout.columns ?? '?';
|
|
406
422
|
const header = color(
|
|
407
423
|
ANSI.bold,
|
|
408
|
-
`[Throughline] ${filtered.length}
|
|
424
|
+
`[Throughline] ${filtered.length} セッション${args.all ? ' (--all)' : ''}`,
|
|
409
425
|
);
|
|
410
426
|
lines.push(header);
|
|
411
427
|
const now = Date.now();
|
|
@@ -559,21 +575,40 @@ export function main() {
|
|
|
559
575
|
}
|
|
560
576
|
|
|
561
577
|
process.stdout.write(ANSI.hideCursor);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
578
|
+
process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
|
|
579
|
+
|
|
580
|
+
// OSC 18t で端末に直接「今の幅は?」を問い合わせる経路を立ち上げる。
|
|
581
|
+
// Windows ConPTY + VSCode task terminal では process.stdout.columns が凍結するため、
|
|
582
|
+
// これが無いと panel を広げても content が復活しない。
|
|
583
|
+
// 応答が来るたびに measuredCols を更新 → 次フレームで resolveColumns がそれを使う。
|
|
584
|
+
const sizeQuery = startSizeQuery({
|
|
585
|
+
onSize: ({ cols }) => {
|
|
586
|
+
const prev = resolveColumns();
|
|
587
|
+
setMeasuredColumns(cols);
|
|
588
|
+
const curr = resolveColumns();
|
|
589
|
+
if (prev !== curr) {
|
|
590
|
+
// 幅が実際に変わった時だけ全再描画。同値なら needsRerender の通常経路に任せる
|
|
591
|
+
lastRenderedLines = 0;
|
|
592
|
+
resetRenderKeyCache();
|
|
593
|
+
safeRenderFrame(args);
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
onInterrupt: () => process.kill(process.pid, 'SIGINT'),
|
|
597
|
+
});
|
|
569
598
|
|
|
570
599
|
safeRenderFrame(args);
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// 1 秒 tick
|
|
600
|
+
// 起動直後に 1 回クエリを飛ばして正しい初期幅を取る
|
|
601
|
+
if (sizeQuery.supported) sizeQuery.query();
|
|
602
|
+
|
|
603
|
+
// 1 秒 tick:
|
|
604
|
+
// - OSC 18t に対応している端末なら毎回クエリを投げて resize 追従
|
|
605
|
+
// - 対応していない端末では process.stdout.columns の変化を従来通り polling
|
|
606
|
+
// - 更新が無くてもデータ変化 (needsRerender) があれば再描画
|
|
607
|
+
// - 10 秒に 1 回は (Nm ago) 表示更新のために強制再描画
|
|
575
608
|
let lastColumns = process.stdout.columns ?? 0;
|
|
576
609
|
const timer = setInterval(() => {
|
|
610
|
+
if (sizeQuery.supported) sizeQuery.query();
|
|
611
|
+
|
|
577
612
|
const currCols = process.stdout.columns ?? 0;
|
|
578
613
|
if (shouldForceFullRedraw(lastColumns, currCols)) {
|
|
579
614
|
lastColumns = currCols;
|
|
@@ -582,11 +617,7 @@ export function main() {
|
|
|
582
617
|
safeRenderFrame(args);
|
|
583
618
|
return;
|
|
584
619
|
}
|
|
585
|
-
// 状態が更新されていれば再描画。state mtime か transcript size のどちらかが
|
|
586
|
-
// 変わっていれば発火するので、Stop 完了・新 assistant エントリ追記の両方を捕捉する。
|
|
587
620
|
if (needsRerender()) safeRenderFrame(args);
|
|
588
|
-
// 1 秒刻みの「経過時間」(24m ago など) を反映するため、データに変化が無くても
|
|
589
|
-
// 10 秒に 1 回は強制再描画する。コストは state 数本の JSONL パースのみで軽い。
|
|
590
621
|
if (Date.now() - lastTimeAgoRefresh > TIME_AGO_REFRESH_MS) {
|
|
591
622
|
lastTimeAgoRefresh = Date.now();
|
|
592
623
|
safeRenderFrame(args);
|
|
@@ -614,6 +645,7 @@ export function main() {
|
|
|
614
645
|
const shutdown = (code = 0) => {
|
|
615
646
|
clearInterval(timer);
|
|
616
647
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
648
|
+
sizeQuery.stop(); // stdin raw mode を確実に解除 (残ると端末が壊れる)
|
|
617
649
|
restoreCursor();
|
|
618
650
|
process.stdout.write('\n' + color(ANSI.dim, '[Throughline] モニター終了\n'));
|
|
619
651
|
process.exit(code);
|
|
@@ -621,16 +653,20 @@ export function main() {
|
|
|
621
653
|
process.on('SIGINT', () => shutdown(0));
|
|
622
654
|
process.on('SIGTERM', () => shutdown(0));
|
|
623
655
|
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
656
|
+
// クラッシュ時もカーソルと raw mode を必ず戻す
|
|
657
|
+
const cleanupOnCrash = () => {
|
|
658
|
+
sizeQuery.stop();
|
|
627
659
|
restoreCursor();
|
|
660
|
+
};
|
|
661
|
+
process.on('exit', cleanupOnCrash);
|
|
662
|
+
process.on('uncaughtException', (err) => {
|
|
663
|
+
cleanupOnCrash();
|
|
628
664
|
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
629
665
|
process.stderr.write(`[Throughline] uncaught exception:\n${msg}\n`);
|
|
630
666
|
process.exit(1);
|
|
631
667
|
});
|
|
632
668
|
process.on('unhandledRejection', (reason) => {
|
|
633
|
-
|
|
669
|
+
cleanupOnCrash();
|
|
634
670
|
process.stderr.write(`[Throughline] unhandled rejection: ${String(reason)}\n`);
|
|
635
671
|
process.exit(1);
|
|
636
672
|
});
|
|
@@ -652,6 +688,7 @@ export const _internal = {
|
|
|
652
688
|
formatTimeAgo,
|
|
653
689
|
shouldForceFullRedraw,
|
|
654
690
|
resolveColumns,
|
|
691
|
+
setMeasuredColumns,
|
|
655
692
|
};
|
|
656
693
|
|
|
657
694
|
// --- エントリポイント自動起動 ---
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
formatTimeAgo,
|
|
17
17
|
shouldForceFullRedraw,
|
|
18
18
|
resolveColumns,
|
|
19
|
+
setMeasuredColumns,
|
|
19
20
|
} = _internal;
|
|
20
21
|
|
|
21
22
|
// state-file は projectPath を resolve + lowercase 正規化する。
|
|
@@ -453,6 +454,39 @@ test('resolveColumns: 全てのソースが無効ならフォールバック 80'
|
|
|
453
454
|
});
|
|
454
455
|
});
|
|
455
456
|
|
|
457
|
+
test('resolveColumns: OSC 18t 実測値が最優先で採用される', () => {
|
|
458
|
+
// process.stdout.columns=30 (凍結値) でも measuredCols=90 が返ってくれば 89 を返す
|
|
459
|
+
// (Windows ConPTY + VSCode task panel で panel resize に追従する経路)
|
|
460
|
+
withStdoutState({ isTTY: true, columns: 30, envColumns: undefined }, () => {
|
|
461
|
+
try {
|
|
462
|
+
setMeasuredColumns(90);
|
|
463
|
+
assert.equal(resolveColumns(), 89);
|
|
464
|
+
} finally {
|
|
465
|
+
setMeasuredColumns(undefined);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('resolveColumns: measuredCols=0 は無効値として無視する', () => {
|
|
471
|
+
withStdoutState({ isTTY: true, columns: 30, envColumns: undefined }, () => {
|
|
472
|
+
try {
|
|
473
|
+
setMeasuredColumns(0);
|
|
474
|
+
assert.equal(resolveColumns(), 29); // stdout.columns へ落ちる
|
|
475
|
+
} finally {
|
|
476
|
+
setMeasuredColumns(undefined);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('resolveColumns: measuredCols をリセットすれば従来経路に戻る', () => {
|
|
482
|
+
withStdoutState({ isTTY: true, columns: 30, envColumns: undefined }, () => {
|
|
483
|
+
setMeasuredColumns(90);
|
|
484
|
+
assert.equal(resolveColumns(), 89);
|
|
485
|
+
setMeasuredColumns(undefined);
|
|
486
|
+
assert.equal(resolveColumns(), 29);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
456
490
|
// ─── formatLine: time-ago 表示 ────────────────────────────────────
|
|
457
491
|
|
|
458
492
|
test('formatLine: updatedAt から経過時間が表示される', () => {
|