throughline 0.3.14 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -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
+ });
@@ -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. **stdout TTY** かつ `process.stdout.columns` が 1 以上
246
- * その値から 1 引いたもの(末尾列での自動改行回避)
247
- * 2. `process.env.COLUMNS` が 1 以上 → その値 - 1
248
- * 3. それ以外 → 80 にフォールバック
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
- * 歴史: 0.3.6〜0.3.12 までは `>= 40` の閾値を設けて「狂った小さい値は捨てる」挙動
251
- * だった。しかし実際の VSCode task panel30 cells 程度で起動することがあり (実測)、
252
- * 真の columns=30 なのに閾値に引っかかって 200 フォールバックに倒れ、30 cell 端末に
253
- * 200 cell の行を書いて 7 行に折り返し、`\x1b[NA` が 7 倍 under-count して
254
- * 描画が永遠に積み上がるバグの真因だった。
262
+ * OSC 18t を最優先にする理由:
263
+ * Windows ConPTY + VSCode task terminalpanel resize process.stdout.columns
264
+ * に届かず凍結する。毎フレーム `\x1b[18t` を端末に投げて `\x1b[8;rows;cols t`
265
+ * stdin で受けると、真の現在幅が取れる (0.3.16 で導入、terminal-size.mjs 参照)。
255
266
  *
256
- * 正の columns はすべて信頼する。真の幅に合わせて truncate すれば物理行 = 論理行
257
- * が保証され、CUU 戻り先が正確になる。狭い terminal ではコンテンツが切り詰められるが、
258
- * 積み上がりに比べれば遥かにマシ (ユーザーが panel を広げれば full UI が見える)。
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);
@@ -557,21 +575,40 @@ export function main() {
557
575
  }
558
576
 
559
577
  process.stdout.write(ANSI.hideCursor);
560
- // 起動時に実 runtime での TTY/columns/resolveColumns を出す。
561
- // diag と runtime で値がズレる (PTY allocation タイミング違い等) を直視できるようにするため。
562
- const startupCols = resolveColumns();
563
- const ttyFlag = process.stdout.isTTY ? 'T' : '-';
564
- process.stdout.write(color(ANSI.dim,
565
- `[Throughline] モニター起動 [${ttyFlag} cols=${process.stdout.columns ?? '?'} clip=${startupCols}] Ctrl+C で終了\n`,
566
- ));
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
+ });
567
598
 
568
599
  safeRenderFrame(args);
569
- // columns の最後に使った値。polling で resize 検知するために使う。
570
- // VSCode 統合ターミナルは process.stdout.on('resize') が発火しないことがあり、
571
- // 起動時に狭い幅だった場合に描画が崩れたまま固定される(実害ベースで確認済み)。
572
- // 1 秒 tick のたびに currCols と比較してイベント不達を埋める。
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) 表示更新のために強制再描画
573
608
  let lastColumns = process.stdout.columns ?? 0;
574
609
  const timer = setInterval(() => {
610
+ if (sizeQuery.supported) sizeQuery.query();
611
+
575
612
  const currCols = process.stdout.columns ?? 0;
576
613
  if (shouldForceFullRedraw(lastColumns, currCols)) {
577
614
  lastColumns = currCols;
@@ -580,11 +617,7 @@ export function main() {
580
617
  safeRenderFrame(args);
581
618
  return;
582
619
  }
583
- // 状態が更新されていれば再描画。state mtime か transcript size のどちらかが
584
- // 変わっていれば発火するので、Stop 完了・新 assistant エントリ追記の両方を捕捉する。
585
620
  if (needsRerender()) safeRenderFrame(args);
586
- // 1 秒刻みの「経過時間」(24m ago など) を反映するため、データに変化が無くても
587
- // 10 秒に 1 回は強制再描画する。コストは state 数本の JSONL パースのみで軽い。
588
621
  if (Date.now() - lastTimeAgoRefresh > TIME_AGO_REFRESH_MS) {
589
622
  lastTimeAgoRefresh = Date.now();
590
623
  safeRenderFrame(args);
@@ -612,6 +645,7 @@ export function main() {
612
645
  const shutdown = (code = 0) => {
613
646
  clearInterval(timer);
614
647
  if (resizeTimer) clearTimeout(resizeTimer);
648
+ sizeQuery.stop(); // stdin raw mode を確実に解除 (残ると端末が壊れる)
615
649
  restoreCursor();
616
650
  process.stdout.write('\n' + color(ANSI.dim, '[Throughline] モニター終了\n'));
617
651
  process.exit(code);
@@ -619,16 +653,20 @@ export function main() {
619
653
  process.on('SIGINT', () => shutdown(0));
620
654
  process.on('SIGTERM', () => shutdown(0));
621
655
 
622
- // クラッシュ時もカーソルを必ず戻す
623
- process.on('exit', restoreCursor);
624
- process.on('uncaughtException', (err) => {
656
+ // クラッシュ時もカーソルと raw mode を必ず戻す
657
+ const cleanupOnCrash = () => {
658
+ sizeQuery.stop();
625
659
  restoreCursor();
660
+ };
661
+ process.on('exit', cleanupOnCrash);
662
+ process.on('uncaughtException', (err) => {
663
+ cleanupOnCrash();
626
664
  const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
627
665
  process.stderr.write(`[Throughline] uncaught exception:\n${msg}\n`);
628
666
  process.exit(1);
629
667
  });
630
668
  process.on('unhandledRejection', (reason) => {
631
- restoreCursor();
669
+ cleanupOnCrash();
632
670
  process.stderr.write(`[Throughline] unhandled rejection: ${String(reason)}\n`);
633
671
  process.exit(1);
634
672
  });
@@ -650,6 +688,7 @@ export const _internal = {
650
688
  formatTimeAgo,
651
689
  shouldForceFullRedraw,
652
690
  resolveColumns,
691
+ setMeasuredColumns,
653
692
  };
654
693
 
655
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 から経過時間が表示される', () => {