aiwcli 0.13.1 → 0.13.3

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.
@@ -14,9 +14,38 @@ import * as fs from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import * as path from "node:path";
16
16
 
17
- import { CONTEXT_BASELINE_TOKENS } from "../lib-ts/base/hook-utils.js";
18
- import { getContextBySessionId, getContext, loadState, saveState } from "../lib-ts/context/context-store.js";
19
- import { findLatestPlan } from "../lib-ts/context/plan-manager.js";
17
+ // PAI infrastructure imports graceful fallback when libs aren't available
18
+ let CONTEXT_BASELINE_TOKENS = 22_600;
19
+ let getContextBySessionId: (id: string) => Record<string, unknown> | null =
20
+ () => null;
21
+ let getContext: (id: string) => Record<string, unknown> | null = () => null;
22
+ let loadState: (id: string) => Record<string, unknown> | null = () => null;
23
+ let saveState: (id: string, state: unknown) => void = () => {};
24
+ let findLatestPlan: (contextId: string) => string | null = () => null;
25
+
26
+ try {
27
+ const hookUtils = await import("../lib-ts/base/hook-utils.js");
28
+ CONTEXT_BASELINE_TOKENS = hookUtils.CONTEXT_BASELINE_TOKENS;
29
+ } catch {
30
+ /* PAI hook-utils not available */
31
+ }
32
+
33
+ try {
34
+ const ctxStore = await import("../lib-ts/context/context-store.js");
35
+ getContextBySessionId = ctxStore.getContextBySessionId;
36
+ getContext = ctxStore.getContext;
37
+ loadState = ctxStore.loadState;
38
+ saveState = ctxStore.saveState;
39
+ } catch {
40
+ /* PAI context-store not available */
41
+ }
42
+
43
+ try {
44
+ const planMgr = await import("../lib-ts/context/plan-manager.js");
45
+ findLatestPlan = planMgr.findLatestPlan;
46
+ } catch {
47
+ /* PAI plan-manager not available */
48
+ }
20
49
 
21
50
  // ---------------------------------------------------------------------------
22
51
  // Path setup
@@ -71,24 +100,26 @@ const GIT_AGE_OLD = NO_COLOR ? "" : "\u001B[38;2;99;102;241m";
71
100
  // ---------------------------------------------------------------------------
72
101
 
73
102
  function getTerminalWidth(): number {
74
- const colsEnv = process.env.COLUMNS;
75
- if (colsEnv) {
76
- const cols = parseInt(colsEnv, 10);
77
- if (cols > 0) return cols;
78
- }
79
- try {
80
- if (process.stdout.columns && process.stdout.columns > 0) {
81
- return process.stdout.columns;
82
- }
83
- } catch { /* ignore */ }
84
- return 80;
103
+ const colsEnv = process.env.COLUMNS;
104
+ if (colsEnv) {
105
+ const cols = parseInt(colsEnv, 10);
106
+ if (cols > 0) return cols;
107
+ }
108
+ try {
109
+ if (process.stdout.columns && process.stdout.columns > 0) {
110
+ return process.stdout.columns;
111
+ }
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ return 80;
85
116
  }
86
117
 
87
118
  function getDisplayMode(width: number): string {
88
- if (width < 35) return "nano";
89
- if (width < 55) return "micro";
90
- if (width < 80) return "mini";
91
- return "normal";
119
+ if (width < 35) return "nano";
120
+ if (width < 55) return "micro";
121
+ if (width < 80) return "mini";
122
+ return "normal";
92
123
  }
93
124
 
94
125
  // ---------------------------------------------------------------------------
@@ -96,28 +127,30 @@ function getDisplayMode(width: number): string {
96
127
  // ---------------------------------------------------------------------------
97
128
 
98
129
  function getBucketColor(pos: number, maxPos: number): string {
99
- if (NO_COLOR) return "";
100
- const pct = Math.floor((pos * 100) / maxPos);
101
-
102
- let b: number; let g: number; let r: number;
103
-
104
- if (pct <= 33) {
105
- r = 74 + Math.floor(((250 - 74) * pct) / 33);
106
- g = 222 + Math.floor(((204 - 222) * pct) / 33);
107
- b = 128 + Math.floor(((21 - 128) * pct) / 33);
108
- } else if (pct <= 66) {
109
- const t = pct - 33;
110
- r = 250 + Math.floor(((251 - 250) * t) / 33);
111
- g = 204 + Math.floor(((146 - 204) * t) / 33);
112
- b = 21 + Math.floor(((60 - 21) * t) / 33);
113
- } else {
114
- const t = pct - 66;
115
- r = 251 + Math.floor(((239 - 251) * t) / 34);
116
- g = 146 + Math.floor(((68 - 146) * t) / 34);
117
- b = 60 + Math.floor(((68 - 60) * t) / 34);
118
- }
119
-
120
- return `\u001B[38;2;${r};${g};${b}m`;
130
+ if (NO_COLOR) return "";
131
+ const pct = Math.floor((pos * 100) / maxPos);
132
+
133
+ let b: number;
134
+ let g: number;
135
+ let r: number;
136
+
137
+ if (pct <= 33) {
138
+ r = 74 + Math.floor(((250 - 74) * pct) / 33);
139
+ g = 222 + Math.floor(((204 - 222) * pct) / 33);
140
+ b = 128 + Math.floor(((21 - 128) * pct) / 33);
141
+ } else if (pct <= 66) {
142
+ const t = pct - 33;
143
+ r = 250 + Math.floor(((251 - 250) * t) / 33);
144
+ g = 204 + Math.floor(((146 - 204) * t) / 33);
145
+ b = 21 + Math.floor(((60 - 21) * t) / 33);
146
+ } else {
147
+ const t = pct - 66;
148
+ r = 251 + Math.floor(((239 - 251) * t) / 34);
149
+ g = 146 + Math.floor(((68 - 146) * t) / 34);
150
+ b = 60 + Math.floor(((68 - 60) * t) / 34);
151
+ }
152
+
153
+ return `\u001B[38;2;${r};${g};${b}m`;
121
154
  }
122
155
 
123
156
  // ---------------------------------------------------------------------------
@@ -125,116 +158,192 @@ function getBucketColor(pos: number, maxPos: number): string {
125
158
  // ---------------------------------------------------------------------------
126
159
 
127
160
  function renderContextBar(width: number, pct: number): [string, string] {
128
- pct = Math.max(0, Math.min(100, pct));
129
- const filled = Math.floor((pct * width) / 100);
130
- let lastColor = EMERALD;
131
- const parts: string[] = [];
132
-
133
- for (let i = 1; i <= width; i++) {
134
- if (i <= filled) {
135
- const color = getBucketColor(i, width);
136
- lastColor = color;
137
- parts.push(`${color}\u26C1${RESET}`);
138
- } else {
139
- parts.push(`${CTX_BUCKET_EMPTY}\u26C1${RESET}`);
140
- }
141
- if (width > 8) {
142
- parts.push(" ");
143
- }
144
- }
145
-
146
- return [parts.join("").trimEnd(), lastColor];
161
+ pct = Math.max(0, Math.min(100, pct));
162
+ const filled = Math.floor((pct * width) / 100);
163
+ let lastColor = EMERALD;
164
+ const parts: string[] = [];
165
+
166
+ for (let i = 1; i <= width; i++) {
167
+ if (i <= filled) {
168
+ const color = getBucketColor(i, width);
169
+ lastColor = color;
170
+ parts.push(`${color}\u26C1${RESET}`);
171
+ } else {
172
+ parts.push(`${CTX_BUCKET_EMPTY}\u26C1${RESET}`);
173
+ }
174
+ if (width > 8) {
175
+ parts.push(" ");
176
+ }
177
+ }
178
+
179
+ return [parts.join("").trimEnd(), lastColor];
147
180
  }
148
181
 
149
182
  // ---------------------------------------------------------------------------
150
- // Separator
183
+ // Display width helper — accounts for wide Unicode and ANSI escapes
151
184
  // ---------------------------------------------------------------------------
152
185
 
153
- const SEPARATOR = `${SLATE_600}${"─".repeat(72)}${RESET}`;
186
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires \x1b
187
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
188
+
189
+ function isWideChar(cp: number): boolean {
190
+ return (
191
+ cp === 0x26c1 || // ⛁ draughts man
192
+ cp === 0x23f1 || // ⏱ stopwatch
193
+ (cp >= 0x2600 && cp <= 0x26ff) || // misc symbols
194
+ (cp >= 0x2700 && cp <= 0x27bf) || // dingbats
195
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
196
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
197
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK
198
+ (cp >= 0x3000 && cp <= 0x303f) // CJK symbols
199
+ );
200
+ }
201
+
202
+ function truncateToWidth(str: string, maxWidth: number): string {
203
+ // Walk through string preserving ANSI codes (zero-width) and measuring visible chars
204
+ let w = 0;
205
+ let lastAnsiEnd = 0;
206
+ const segments: string[] = [];
207
+ const raw = str;
208
+
209
+ // Split into text and ANSI segments, tracking width
210
+ let truncated = false;
211
+ ANSI_RE.lastIndex = 0;
212
+ let match = ANSI_RE.exec(raw);
213
+ while (match !== null) {
214
+ // Text before this ANSI code
215
+ const textBefore = raw.slice(lastAnsiEnd, match.index);
216
+ for (const ch of textBefore) {
217
+ const cw = isWideChar(ch.codePointAt(0) ?? 0) ? 2 : 1;
218
+ if (w + cw > maxWidth) {
219
+ truncated = true;
220
+ break;
221
+ }
222
+ w += cw;
223
+ segments.push(ch);
224
+ }
225
+ if (truncated) break;
226
+ segments.push(match[0]); // ANSI code (zero width)
227
+ lastAnsiEnd = match.index + match[0].length;
228
+ match = ANSI_RE.exec(raw);
229
+ }
230
+ if (!truncated) {
231
+ // Remaining text after last ANSI
232
+ const remaining = raw.slice(lastAnsiEnd);
233
+ for (const ch of remaining) {
234
+ const cw = isWideChar(ch.codePointAt(0) ?? 0) ? 2 : 1;
235
+ if (w + cw > maxWidth) break;
236
+ w += cw;
237
+ segments.push(ch);
238
+ }
239
+ }
240
+ return segments.join("") + RESET;
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Separator (dynamic width)
245
+ // ---------------------------------------------------------------------------
246
+
247
+ function makeSeparator(termWidth: number): string {
248
+ const w = Math.min(termWidth, 120);
249
+ return `${SLATE_600}${"─".repeat(w)}${RESET}`;
250
+ }
154
251
 
155
252
  // ---------------------------------------------------------------------------
156
253
  // Context section
157
254
  // ---------------------------------------------------------------------------
158
255
 
159
256
  function shortenModel(name: string): string {
160
- const replacements: [string, string][] = [
161
- ["claude-opus-4-6", "opus-4.6"],
162
- ["claude-opus-4-5", "opus-4.5"],
163
- ["claude-sonnet-4", "sonnet-4"],
164
- ["claude-3-5-sonnet", "sonnet-3.5"],
165
- ["claude-3-5-haiku", "haiku-3.5"],
166
- ["claude-", ""],
167
- ];
168
- let result = name;
169
- for (const [old, replacement] of replacements) {
170
- result = result.replace(old, replacement);
171
- }
172
- return result;
257
+ const replacements: [string, string][] = [
258
+ ["claude-opus-4-6", "opus-4.6"],
259
+ ["claude-opus-4-5", "opus-4.5"],
260
+ ["claude-sonnet-4", "sonnet-4"],
261
+ ["claude-3-5-sonnet", "sonnet-3.5"],
262
+ ["claude-3-5-haiku", "haiku-3.5"],
263
+ ["claude-", ""],
264
+ ];
265
+ let result = name;
266
+ for (const [old, replacement] of replacements) {
267
+ result = result.replace(old, replacement);
268
+ }
269
+ return result;
173
270
  }
174
271
 
175
272
  function renderContext(
176
- mode: string,
177
- contextPct: number,
178
- contextK: number,
179
- maxK: number,
180
- timeDisplay: string,
181
- modelName: string,
273
+ mode: string,
274
+ termWidth: number,
275
+ contextPct: number,
276
+ contextK: number,
277
+ maxK: number,
278
+ modelName: string,
182
279
  ): void {
183
- let pctColor: string;
184
- if (contextPct <= 33) pctColor = EMERALD;
185
- else if (contextPct <= 66) pctColor = AMBER;
186
- else pctColor = ROSE;
187
-
188
- const shortModel = shortenModel(modelName);
189
-
190
- switch (mode) {
191
- case "micro": {
192
- const [bar] = renderContextBar(6, contextPct);
193
- console.log(
194
- `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
195
- `${SLATE_600}\u2502${RESET} ` +
196
- `${bar} ${pctColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k)${RESET} ` +
197
- `${CTX_ACCENT}\u23F1${RESET} ${SLATE_300}${timeDisplay}${RESET}`,
198
- );
199
-
200
- break;
201
- }
202
- case "mini": {
203
- const [bar] = renderContextBar(8, contextPct);
204
- console.log(
205
- `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
206
- `${SLATE_600}\u2502${RESET} ` +
207
- `${CTX_SECONDARY}CTX:${RESET} ${bar} ` +
208
- `${pctColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k/${maxK}k)${RESET} ` +
209
- `${CTX_ACCENT}\u23F1${RESET} ${SLATE_300}${timeDisplay}${RESET}`,
210
- );
211
-
212
- break;
213
- }
214
- case "nano": {
215
- const [bar] = renderContextBar(5, contextPct);
216
- console.log(
217
- `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
218
- `${bar} ${pctColor}${contextPct}%${RESET} ` +
219
- `${CTX_ACCENT}\u23F1${RESET} ${SLATE_300}${timeDisplay}${RESET}`,
220
- );
221
-
222
- break;
223
- }
224
- default: {
225
- const [bar, lastColor] = renderContextBar(16, contextPct);
226
- console.log(
227
- `${CTX_PRIMARY}\u25C9${RESET} ${CTX_SECONDARY}Model:${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
228
- `${SLATE_600}\u2502${RESET} ` +
229
- `${CTX_SECONDARY}Context:${RESET} ${bar} ` +
230
- `${lastColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k/${maxK}k)${RESET} ` +
231
- `${SLATE_600}\u2502${RESET} ` +
232
- `${CTX_ACCENT}\u23F1${RESET} ${SLATE_300}${timeDisplay}${RESET}`,
233
- );
234
- }
235
- }
236
-
237
- console.log(SEPARATOR);
280
+ let pctColor: string;
281
+ if (contextPct <= 33) pctColor = EMERALD;
282
+ else if (contextPct <= 66) pctColor = AMBER;
283
+ else pctColor = ROSE;
284
+
285
+ const shortModel = shortenModel(modelName);
286
+ let line: string;
287
+
288
+ switch (mode) {
289
+ case "micro": {
290
+ const [bar] = renderContextBar(6, contextPct);
291
+ line =
292
+ `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
293
+ `${SLATE_600}\u2502${RESET} ` +
294
+ `${bar} ${pctColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k)${RESET}`;
295
+
296
+ break;
297
+ }
298
+ case "mini": {
299
+ const [bar] = renderContextBar(8, contextPct);
300
+ line =
301
+ `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
302
+ `${SLATE_600}\u2502${RESET} ` +
303
+ `${CTX_SECONDARY}CTX:${RESET} ${bar} ` +
304
+ `${pctColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k/${maxK}k)${RESET}`;
305
+
306
+ break;
307
+ }
308
+ case "nano": {
309
+ const [bar] = renderContextBar(5, contextPct);
310
+ line =
311
+ `${CTX_PRIMARY}\u25C9${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
312
+ `${bar} ${pctColor}${contextPct}%${RESET}`;
313
+
314
+ break;
315
+ }
316
+ default: {
317
+ // Calculate how many bar buckets fit. Each bucket = ⛁ (2 cols) + space (1 col) = 3 cols.
318
+ // Fixed parts: "◉ Model: {model} │ Context: {pct}% ({Xk}/{Yk})"
319
+ const fixedWidth =
320
+ 2 +
321
+ 8 +
322
+ shortModel.length +
323
+ 3 +
324
+ 10 +
325
+ 1 +
326
+ `${contextPct}`.length +
327
+ 2 +
328
+ `${contextK}k/${maxK}k`.length +
329
+ 2;
330
+ const availableForBar = termWidth - fixedWidth;
331
+ const buckets = Math.max(
332
+ 4,
333
+ Math.min(24, Math.floor(availableForBar / 3)),
334
+ );
335
+
336
+ const [bar, lastColor] = renderContextBar(buckets, contextPct);
337
+ line =
338
+ `${CTX_PRIMARY}\u25C9${RESET} ${CTX_SECONDARY}Model:${RESET} ${CTX_ACCENT}${shortModel}${RESET} ` +
339
+ `${SLATE_600}\u2502${RESET} ` +
340
+ `${CTX_SECONDARY}Context:${RESET} ${bar} ` +
341
+ `${lastColor}${contextPct}%${RESET} ${SLATE_500}(${contextK}k/${maxK}k)${RESET}`;
342
+ }
343
+ }
344
+
345
+ console.log(truncateToWidth(line, termWidth));
346
+ console.log(makeSeparator(termWidth));
238
347
  }
239
348
 
240
349
  // ---------------------------------------------------------------------------
@@ -242,205 +351,214 @@ function renderContext(
242
351
  // ---------------------------------------------------------------------------
243
352
 
244
353
  interface GitStatus {
245
- branch: string;
246
- modified: number;
247
- staged: number;
248
- untracked: number;
249
- stash_count: number;
250
- ahead: number;
251
- behind: number;
252
- age_display: string;
253
- age_color: string;
354
+ branch: string;
355
+ modified: number;
356
+ staged: number;
357
+ untracked: number;
358
+ stash_count: number;
359
+ ahead: number;
360
+ behind: number;
361
+ age_display: string;
362
+ age_color: string;
254
363
  }
255
364
 
256
365
  function runGit(args: string[], cwd: string, timeout = 2000): string | null {
257
- try {
258
- const result = execFileSync("git", args, {
259
- cwd,
260
- timeout,
261
- encoding: "utf-8",
262
- stdio: ["pipe", "pipe", "pipe"],
263
- windowsHide: true,
264
- });
265
- return result.trim();
266
- } catch {
267
- return null;
268
- }
366
+ try {
367
+ const result = execFileSync("git", args, {
368
+ cwd,
369
+ timeout,
370
+ encoding: "utf-8",
371
+ stdio: ["pipe", "pipe", "pipe"],
372
+ windowsHide: true,
373
+ });
374
+ return result.trim();
375
+ } catch {
376
+ return null;
377
+ }
269
378
  }
270
379
 
271
380
  function getGitStatus(cwd: string): GitStatus | null {
272
- // One call replaces 6: branch, modified, staged, untracked, ahead/behind, and repo check.
273
- // porcelain=v2 -b gives structured header lines + per-file XY status codes.
274
- const porcelain = runGit(["status", "--porcelain=v2", "-b"], cwd);
275
- if (porcelain === null) return null;
276
-
277
- const status: GitStatus = {
278
- branch: "detached",
279
- modified: 0,
280
- staged: 0,
281
- untracked: 0,
282
- stash_count: 0,
283
- ahead: 0,
284
- behind: 0,
285
- age_display: "",
286
- age_color: GIT_AGE_FRESH,
287
- };
288
-
289
- for (const line of porcelain.split(/\r?\n/)) {
290
- if (line.startsWith("# branch.head ")) {
291
- const b = line.slice(14).trim();
292
- status.branch = b === "(detached)" ? "detached" : b;
293
- } else if (line.startsWith("# branch.ab ")) {
294
- // Format: "+<ahead> -<behind>"
295
- const m = line.match(/\+(\d+) -(\d+)/);
296
- if (m) {
297
- status.ahead = parseInt(m[1]!, 10);
298
- status.behind = parseInt(m[2]!, 10);
299
- }
300
- } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
301
- // Changed tracked file: "1 XY ..." where X=staged Y=unstaged, '.'=unchanged
302
- const x = line[2]!;
303
- const y = line[3]!;
304
- if (x !== ".") status.staged++;
305
- if (y !== ".") status.modified++;
306
- } else if (line.startsWith("? ")) {
307
- status.untracked++;
308
- }
309
- }
310
-
311
- // Stash count (no equivalent in status output)
312
- const stash = runGit(["stash", "list"], cwd);
313
- if (stash) status.stash_count = stash.split(/\r?\n/).filter(Boolean).length;
314
-
315
- // Commit age
316
- const log = runGit(["log", "-1", "--format=%ct"], cwd);
317
- if (log) {
318
- try {
319
- const lastEpoch = parseInt(log, 10);
320
- const nowEpoch = Math.floor(Date.now() / 1000);
321
- const ageSec = nowEpoch - lastEpoch;
322
- const ageMin = Math.floor(ageSec / 60);
323
- const ageHrs = Math.floor(ageSec / 3600);
324
- const ageDays = Math.floor(ageSec / 86_400);
325
-
326
- if (ageMin < 1) {
327
- status.age_display = "now";
328
- status.age_color = GIT_AGE_FRESH;
329
- } else if (ageHrs < 1) {
330
- status.age_display = `${ageMin}m`;
331
- status.age_color = GIT_AGE_FRESH;
332
- } else if (ageHrs < 24) {
333
- status.age_display = `${ageHrs}h`;
334
- status.age_color = GIT_AGE_RECENT;
335
- } else if (ageDays < 7) {
336
- status.age_display = `${ageDays}d`;
337
- status.age_color = GIT_AGE_STALE;
338
- } else {
339
- status.age_display = `${ageDays}d`;
340
- status.age_color = GIT_AGE_OLD;
341
- }
342
- } catch { /* ignore */ }
343
- }
344
-
345
- return status;
381
+ // One call replaces 6: branch, modified, staged, untracked, ahead/behind, and repo check.
382
+ // porcelain=v2 -b gives structured header lines + per-file XY status codes.
383
+ const porcelain = runGit(["status", "--porcelain=v2", "-b"], cwd);
384
+ if (porcelain === null) return null;
385
+
386
+ const status: GitStatus = {
387
+ branch: "detached",
388
+ modified: 0,
389
+ staged: 0,
390
+ untracked: 0,
391
+ stash_count: 0,
392
+ ahead: 0,
393
+ behind: 0,
394
+ age_display: "",
395
+ age_color: GIT_AGE_FRESH,
396
+ };
397
+
398
+ for (const line of porcelain.split(/\r?\n/)) {
399
+ if (line.startsWith("# branch.head ")) {
400
+ const b = line.slice(14).trim();
401
+ status.branch = b === "(detached)" ? "detached" : b;
402
+ } else if (line.startsWith("# branch.ab ")) {
403
+ // Format: "+<ahead> -<behind>"
404
+ const m = line.match(/\+(\d+) -(\d+)/);
405
+ if (m) {
406
+ status.ahead = parseInt(m[1] ?? "0", 10);
407
+ status.behind = parseInt(m[2] ?? "0", 10);
408
+ }
409
+ } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
410
+ // Changed tracked file: "1 XY ..." where X=staged Y=unstaged, '.'=unchanged
411
+ const x = line[2] ?? ".";
412
+ const y = line[3] ?? ".";
413
+ if (x !== ".") status.staged++;
414
+ if (y !== ".") status.modified++;
415
+ } else if (line.startsWith("? ")) {
416
+ status.untracked++;
417
+ }
418
+ }
419
+
420
+ // Stash count (no equivalent in status output)
421
+ const stash = runGit(["stash", "list"], cwd);
422
+ if (stash) status.stash_count = stash.split(/\r?\n/).filter(Boolean).length;
423
+
424
+ // Commit age
425
+ const log = runGit(["log", "-1", "--format=%ct"], cwd);
426
+ if (log) {
427
+ try {
428
+ const lastEpoch = parseInt(log, 10);
429
+ const nowEpoch = Math.floor(Date.now() / 1000);
430
+ const ageSec = nowEpoch - lastEpoch;
431
+ const ageMin = Math.floor(ageSec / 60);
432
+ const ageHrs = Math.floor(ageSec / 3600);
433
+ const ageDays = Math.floor(ageSec / 86_400);
434
+
435
+ if (ageMin < 1) {
436
+ status.age_display = "now";
437
+ status.age_color = GIT_AGE_FRESH;
438
+ } else if (ageHrs < 1) {
439
+ status.age_display = `${ageMin}m`;
440
+ status.age_color = GIT_AGE_FRESH;
441
+ } else if (ageHrs < 24) {
442
+ status.age_display = `${ageHrs}h`;
443
+ status.age_color = GIT_AGE_RECENT;
444
+ } else if (ageDays < 7) {
445
+ status.age_display = `${ageDays}d`;
446
+ status.age_color = GIT_AGE_STALE;
447
+ } else {
448
+ status.age_display = `${ageDays}d`;
449
+ status.age_color = GIT_AGE_OLD;
450
+ }
451
+ } catch {
452
+ /* ignore */
453
+ }
454
+ }
455
+
456
+ return status;
346
457
  }
347
458
 
348
- function renderGit(mode: string, git: GitStatus | null, dirName: string): void {
349
- const totalChanged = git ? git.modified + git.staged : 0;
350
- const statusIcon = git && (totalChanged > 0 || git.untracked > 0) ? "*" : "\u2713";
351
-
352
- switch (mode) {
353
- case "micro": {
354
- let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
355
- if (git) {
356
- line += ` ${GIT_VALUE}${git.branch}${RESET}`;
357
- if (git.age_display) {
358
- line += ` ${git.age_color}${git.age_display}${RESET}`;
359
- }
360
- line += " ";
361
- if (statusIcon === "\u2713") {
362
- line += `${GIT_CLEAN}${statusIcon}${RESET}`;
363
- } else {
364
- line += `${GIT_MODIFIED}${statusIcon}${totalChanged}${RESET}`;
365
- }
366
- }
367
- console.log(line);
368
-
369
- break;
370
- }
371
- case "mini": {
372
- let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
373
- if (git) {
374
- line += ` ${SLATE_600}\u2502${RESET} ${GIT_VALUE}${git.branch}${RESET}`;
375
- if (git.age_display) {
376
- line += ` ${SLATE_600}\u2502${RESET} ${git.age_color}${git.age_display}${RESET}`;
377
- }
378
- line += ` ${SLATE_600}\u2502${RESET} `;
379
- if (statusIcon === "\u2713") {
380
- line += `${GIT_CLEAN}${statusIcon}${RESET}`;
381
- } else {
382
- line += `${GIT_MODIFIED}${statusIcon}${totalChanged}${RESET}`;
383
- if (git.untracked > 0) {
384
- line += ` ${GIT_ADDED}+${git.untracked}${RESET}`;
385
- }
386
- }
387
- }
388
- console.log(line);
389
-
390
- break;
391
- }
392
- case "nano": {
393
- let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
394
- if (git) {
395
- line += ` ${GIT_VALUE}${git.branch}${RESET} `;
396
- if (statusIcon === "\u2713") {
397
- line += `${GIT_CLEAN}\u2713${RESET}`;
398
- } else {
399
- line += `${GIT_MODIFIED}*${totalChanged}${RESET}`;
400
- }
401
- }
402
- console.log(line);
403
-
404
- break;
405
- }
406
- default: {
407
- let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_PRIMARY}PWD:${RESET} ${GIT_DIR}${dirName}${RESET}`;
408
- if (git) {
409
- line += ` ${SLATE_600}\u2502${RESET} ` +
410
- `${GIT_PRIMARY}Branch:${RESET} ${GIT_VALUE}${git.branch}${RESET}`;
411
- if (git.age_display) {
412
- line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Age:${RESET} ${git.age_color}${git.age_display}${RESET}`;
413
- }
414
- if (git.stash_count > 0) {
415
- line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Stash:${RESET} ${GIT_STASH}${git.stash_count}${RESET}`;
416
- }
417
-
418
- if (totalChanged > 0 || git.untracked > 0) {
419
- line += ` ${SLATE_600}\u2502${RESET} `;
420
- if (totalChanged > 0) {
421
- line += `${GIT_PRIMARY}Mod:${RESET} ${GIT_MODIFIED}${totalChanged}${RESET}`;
422
- }
423
- if (git.untracked > 0) {
424
- if (totalChanged > 0) line += " ";
425
- line += `${GIT_PRIMARY}New:${RESET} ${GIT_ADDED}${git.untracked}${RESET}`;
426
- }
427
- } else {
428
- line += ` ${SLATE_600}\u2502${RESET} ${GIT_CLEAN}\u2713 clean${RESET}`;
429
- }
430
-
431
- if (git.ahead > 0 || git.behind > 0) {
432
- line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Sync:${RESET} `;
433
- if (git.ahead > 0) {
434
- line += `${GIT_CLEAN}\u2191${git.ahead}${RESET}`;
435
- }
436
- if (git.behind > 0) {
437
- line += `${GIT_STASH}\u2193${git.behind}${RESET}`;
438
- }
439
- }
440
- }
441
- console.log(line);
442
- }
443
- }
459
+ function renderGit(
460
+ mode: string,
461
+ termWidth: number,
462
+ git: GitStatus | null,
463
+ dirName: string,
464
+ ): void {
465
+ const totalChanged = git ? git.modified + git.staged : 0;
466
+ const statusIcon =
467
+ git && (totalChanged > 0 || git.untracked > 0) ? "*" : "\u2713";
468
+
469
+ switch (mode) {
470
+ case "micro": {
471
+ let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
472
+ if (git) {
473
+ line += ` ${GIT_VALUE}${git.branch}${RESET}`;
474
+ if (git.age_display) {
475
+ line += ` ${git.age_color}${git.age_display}${RESET}`;
476
+ }
477
+ line += " ";
478
+ if (statusIcon === "\u2713") {
479
+ line += `${GIT_CLEAN}${statusIcon}${RESET}`;
480
+ } else {
481
+ line += `${GIT_MODIFIED}${statusIcon}${totalChanged}${RESET}`;
482
+ }
483
+ }
484
+ console.log(truncateToWidth(line, termWidth));
485
+
486
+ break;
487
+ }
488
+ case "mini": {
489
+ let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
490
+ if (git) {
491
+ line += ` ${SLATE_600}\u2502${RESET} ${GIT_VALUE}${git.branch}${RESET}`;
492
+ if (git.age_display) {
493
+ line += ` ${SLATE_600}\u2502${RESET} ${git.age_color}${git.age_display}${RESET}`;
494
+ }
495
+ line += ` ${SLATE_600}\u2502${RESET} `;
496
+ if (statusIcon === "\u2713") {
497
+ line += `${GIT_CLEAN}${statusIcon}${RESET}`;
498
+ } else {
499
+ line += `${GIT_MODIFIED}${statusIcon}${totalChanged}${RESET}`;
500
+ if (git.untracked > 0) {
501
+ line += ` ${GIT_ADDED}+${git.untracked}${RESET}`;
502
+ }
503
+ }
504
+ }
505
+ console.log(truncateToWidth(line, termWidth));
506
+
507
+ break;
508
+ }
509
+ case "nano": {
510
+ let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_DIR}${dirName}${RESET}`;
511
+ if (git) {
512
+ line += ` ${GIT_VALUE}${git.branch}${RESET} `;
513
+ if (statusIcon === "\u2713") {
514
+ line += `${GIT_CLEAN}\u2713${RESET}`;
515
+ } else {
516
+ line += `${GIT_MODIFIED}*${totalChanged}${RESET}`;
517
+ }
518
+ }
519
+ console.log(truncateToWidth(line, termWidth));
520
+
521
+ break;
522
+ }
523
+ default: {
524
+ let line = `${GIT_PRIMARY}\u25C8${RESET} ${GIT_PRIMARY}PWD:${RESET} ${GIT_DIR}${dirName}${RESET}`;
525
+ if (git) {
526
+ line +=
527
+ ` ${SLATE_600}\u2502${RESET} ` +
528
+ `${GIT_PRIMARY}Branch:${RESET} ${GIT_VALUE}${git.branch}${RESET}`;
529
+ if (git.age_display) {
530
+ line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Age:${RESET} ${git.age_color}${git.age_display}${RESET}`;
531
+ }
532
+ if (git.stash_count > 0) {
533
+ line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Stash:${RESET} ${GIT_STASH}${git.stash_count}${RESET}`;
534
+ }
535
+
536
+ if (totalChanged > 0 || git.untracked > 0) {
537
+ line += ` ${SLATE_600}\u2502${RESET} `;
538
+ if (totalChanged > 0) {
539
+ line += `${GIT_PRIMARY}Mod:${RESET} ${GIT_MODIFIED}${totalChanged}${RESET}`;
540
+ }
541
+ if (git.untracked > 0) {
542
+ if (totalChanged > 0) line += " ";
543
+ line += `${GIT_PRIMARY}New:${RESET} ${GIT_ADDED}${git.untracked}${RESET}`;
544
+ }
545
+ } else {
546
+ line += ` ${SLATE_600}\u2502${RESET} ${GIT_CLEAN}\u2713 clean${RESET}`;
547
+ }
548
+
549
+ if (git.ahead > 0 || git.behind > 0) {
550
+ line += ` ${SLATE_600}\u2502${RESET} ${GIT_PRIMARY}Sync:${RESET} `;
551
+ if (git.ahead > 0) {
552
+ line += `${GIT_CLEAN}\u2191${git.ahead}${RESET}`;
553
+ }
554
+ if (git.behind > 0) {
555
+ line += `${GIT_STASH}\u2193${git.behind}${RESET}`;
556
+ }
557
+ }
558
+ }
559
+ console.log(truncateToWidth(line, termWidth));
560
+ }
561
+ }
444
562
  }
445
563
 
446
564
  // ---------------------------------------------------------------------------
@@ -448,116 +566,131 @@ function renderGit(mode: string, git: GitStatus | null, dirName: string): void {
448
566
  // ---------------------------------------------------------------------------
449
567
 
450
568
  function findActivePlanFile(): string | null {
451
- try {
452
- const plansDir = path.join(homedir(), ".claude", "plans");
453
- if (!fs.existsSync(plansDir)) return null;
454
- const planFiles = fs.readdirSync(plansDir)
455
- .filter(f => f.endsWith(".md"))
456
- .map(f => {
457
- const fullPath = path.join(plansDir, f);
458
- return { path: fullPath, mtime: fs.statSync(fullPath).mtimeMs };
459
- })
460
- .sort((a, b) => b.mtime - a.mtime);
461
- return planFiles.length > 0 ? planFiles[0]!.path : null;
462
- } catch {
463
- return null;
464
- }
569
+ try {
570
+ const plansDir = path.join(homedir(), ".claude", "plans");
571
+ if (!fs.existsSync(plansDir)) return null;
572
+ const planFiles = fs
573
+ .readdirSync(plansDir)
574
+ .filter((f) => f.endsWith(".md"))
575
+ .map((f) => {
576
+ const fullPath = path.join(plansDir, f);
577
+ return { path: fullPath, mtime: fs.statSync(fullPath).mtimeMs };
578
+ })
579
+ .sort((a, b) => b.mtime - a.mtime);
580
+ return planFiles.length > 0 ? (planFiles[0]?.path ?? null) : null;
581
+ } catch {
582
+ return null;
583
+ }
465
584
  }
466
585
 
467
586
  function renderContextManager(
468
- mode: string,
469
- contextId: string,
470
- contextState: Record<string, any> | null,
587
+ mode: string,
588
+ contextId: string,
589
+ contextState: Record<string, unknown> | null,
471
590
  ): void {
472
- // Strip YYMMDD-HHMM- timestamp prefix from context ID for display
473
- let displayId = contextId.replace(/^\d{6}-\d{4}-/, "");
474
- if (!displayId) displayId = contextId;
475
-
476
- // Truncate display_id per mode
477
- const maxIdLen: Record<string, number> = { nano: 14, micro: 18, mini: 22, normal: 30 };
478
- const maxLen = maxIdLen[mode] ?? 30;
479
- let truncatedId = displayId.slice(0, maxLen);
480
- if (displayId.length > maxLen) truncatedId += "\u2026";
481
-
482
- // Read state fields
483
- const stateMode = contextState?.mode ?? "idle";
484
- const statePlanPath = contextState?.plan_path ?? null;
485
-
486
- // Detect plan mode heuristic
487
- const activePlanFile = findActivePlanFile();
488
- const isPlanning = stateMode === "idle" && activePlanFile !== null;
489
-
490
- // Build mode badge
491
- let modeBadge = "";
492
- if (isPlanning) {
493
- const label = mode === "nano" ? "Plan" : "Planning";
494
- modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${AMBER}${label}${RESET}`;
495
- } else if (stateMode === "has_plan") {
496
- const label = mode === "nano" ? "Ready" : "Plan Ready";
497
- modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${EMERALD}${label}${RESET}`;
498
- } else if (stateMode === "active") {
499
- const label = "Active";
500
- modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${CTX_ACCENT}${label}${RESET}`;
501
- }
502
-
503
- // Resolve plan file path for display
504
- let planFilePath: string | null = null;
505
- if (isPlanning) {
506
- planFilePath = activePlanFile;
507
- } else if (statePlanPath) {
508
- planFilePath = statePlanPath;
509
- } else if (stateMode === "has_plan" || stateMode === "active") {
510
- try {
511
- planFilePath = findLatestPlan(contextId) ?? null;
512
- } catch { /* ignore */ }
513
- }
514
-
515
- // Build plan name (mini/normal only)
516
- let planPart = "";
517
- if ((mode === "mini" || mode === "normal") && planFilePath) {
518
- const planStem = path.basename(planFilePath, path.extname(planFilePath))
519
- .replace(/^\d{4}-\d{2}-\d{2}-(\d{4}-)?/, "");
520
- const maxPlanLen = mode === "mini" ? 20 : 30;
521
- let truncatedPlan = planStem.slice(0, maxPlanLen);
522
- if (planStem.length > maxPlanLen) truncatedPlan += "\u2026";
523
- planPart = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Plan:${RESET} ${SLATE_300}${truncatedPlan}${RESET}`;
524
- }
525
-
526
- switch (mode) {
527
- case "micro": {
528
- console.log(`${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}${modeBadge}`);
529
-
530
- break;
531
- }
532
- case "mini": {
533
- console.log(
534
- `${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}` +
535
- `${modeBadge}${planPart}`,
536
- );
537
-
538
- break;
539
- }
540
- case "nano": {
541
- console.log(`${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}${modeBadge}`);
542
-
543
- break;
544
- }
545
- default: {
546
- console.log(
547
- `${CTX_ACCENT}\u25C6${RESET} ${CTX_SECONDARY}Context:${RESET} ${SLATE_300}${truncatedId}${RESET}` +
548
- `${modeBadge}${planPart}`,
549
- );
550
- }
551
- }
591
+ // Strip YYMMDD-HHMM- timestamp prefix from context ID for display
592
+ let displayId = contextId.replace(/^\d{6}-\d{4}-/, "");
593
+ if (!displayId) displayId = contextId;
594
+
595
+ // Truncate display_id per mode
596
+ const maxIdLen: Record<string, number> = {
597
+ nano: 14,
598
+ micro: 18,
599
+ mini: 22,
600
+ normal: 30,
601
+ };
602
+ const maxLen = maxIdLen[mode] ?? 30;
603
+ let truncatedId = displayId.slice(0, maxLen);
604
+ if (displayId.length > maxLen) truncatedId += "\u2026";
605
+
606
+ // Read state fields
607
+ const stateMode = contextState?.mode ?? "idle";
608
+ const statePlanPath = contextState?.plan_path ?? null;
609
+
610
+ // Detect plan mode heuristic
611
+ const activePlanFile = findActivePlanFile();
612
+ const isPlanning = stateMode === "idle" && activePlanFile !== null;
613
+
614
+ // Build mode badge
615
+ let modeBadge = "";
616
+ if (isPlanning) {
617
+ const label = mode === "nano" ? "Plan" : "Planning";
618
+ modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${AMBER}${label}${RESET}`;
619
+ } else if (stateMode === "has_plan") {
620
+ const label = mode === "nano" ? "Ready" : "Plan Ready";
621
+ modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${EMERALD}${label}${RESET}`;
622
+ } else if (stateMode === "active") {
623
+ const label = "Active";
624
+ modeBadge = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Mode:${RESET} ${CTX_ACCENT}${label}${RESET}`;
625
+ }
626
+
627
+ // Resolve plan file path for display
628
+ let planFilePath: string | null = null;
629
+ if (isPlanning) {
630
+ planFilePath = activePlanFile;
631
+ } else if (statePlanPath) {
632
+ planFilePath = statePlanPath;
633
+ } else if (stateMode === "has_plan" || stateMode === "active") {
634
+ try {
635
+ planFilePath = findLatestPlan(contextId) ?? null;
636
+ } catch {
637
+ /* ignore */
638
+ }
639
+ }
640
+
641
+ // Build plan name (mini/normal only)
642
+ let planPart = "";
643
+ if ((mode === "mini" || mode === "normal") && planFilePath) {
644
+ const planStem = path
645
+ .basename(planFilePath, path.extname(planFilePath))
646
+ .replace(/^\d{4}-\d{2}-\d{2}-(\d{4}-)?/, "");
647
+ const maxPlanLen = mode === "mini" ? 20 : 30;
648
+ let truncatedPlan = planStem.slice(0, maxPlanLen);
649
+ if (planStem.length > maxPlanLen) truncatedPlan += "\u2026";
650
+ planPart = ` ${SLATE_600}\u2502${RESET} ${CTX_SECONDARY}Plan:${RESET} ${SLATE_300}${truncatedPlan}${RESET}`;
651
+ }
652
+
653
+ switch (mode) {
654
+ case "micro": {
655
+ console.log(
656
+ `${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}${modeBadge}`,
657
+ );
658
+
659
+ break;
660
+ }
661
+ case "mini": {
662
+ console.log(
663
+ `${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}` +
664
+ `${modeBadge}${planPart}`,
665
+ );
666
+
667
+ break;
668
+ }
669
+ case "nano": {
670
+ console.log(
671
+ `${CTX_ACCENT}\u25C6${RESET} ${SLATE_400}${truncatedId}${RESET}${modeBadge}`,
672
+ );
673
+
674
+ break;
675
+ }
676
+ default: {
677
+ console.log(
678
+ `${CTX_ACCENT}\u25C6${RESET} ${CTX_SECONDARY}Context:${RESET} ${SLATE_300}${truncatedId}${RESET}` +
679
+ `${modeBadge}${planPart}`,
680
+ );
681
+ }
682
+ }
552
683
  }
553
684
 
554
685
  function renderNoContext(mode: string): void {
555
- const warn = `${ROSE}\u26A0 ${RESET}`;
556
- if (mode === "normal") {
557
- console.log(`${warn} ${ROSE}NO CONTEXT${RESET} ${SLATE_500}\u2014 type ^ for context manager${RESET}`);
558
- } else {
559
- console.log(`${warn} ${ROSE}NO CONTEXT${RESET}`);
560
- }
686
+ const warn = `${ROSE}\u26A0 ${RESET}`;
687
+ if (mode === "normal") {
688
+ console.log(
689
+ `${warn} ${ROSE}NO CONTEXT${RESET} ${SLATE_500}\u2014 type ^ for context manager${RESET}`,
690
+ );
691
+ } else {
692
+ console.log(`${warn} ${ROSE}NO CONTEXT${RESET}`);
693
+ }
561
694
  }
562
695
 
563
696
  // ---------------------------------------------------------------------------
@@ -565,67 +698,80 @@ function renderNoContext(mode: string): void {
565
698
  // ---------------------------------------------------------------------------
566
699
 
567
700
  interface StatuslineCache {
568
- sessions?: Record<string, { context_id: string | null }>;
701
+ sessions?: Record<string, { context_id: string | null }>;
569
702
  }
570
703
 
571
704
  function loadCache(): StatuslineCache {
572
- try {
573
- if (fs.existsSync(STATUSLINE_CACHE)) {
574
- return JSON.parse(fs.readFileSync(STATUSLINE_CACHE, "utf-8"));
575
- }
576
- } catch { /* ignore */ }
577
- return {};
705
+ try {
706
+ if (fs.existsSync(STATUSLINE_CACHE)) {
707
+ return JSON.parse(fs.readFileSync(STATUSLINE_CACHE, "utf-8"));
708
+ }
709
+ } catch {
710
+ /* ignore */
711
+ }
712
+ return {};
578
713
  }
579
714
 
580
715
  function saveCache(cache: StatuslineCache): void {
581
- try {
582
- fs.mkdirSync(path.dirname(STATUSLINE_CACHE), { recursive: true });
583
- fs.writeFileSync(STATUSLINE_CACHE, JSON.stringify(cache, null, 2), "utf-8");
584
- } catch { /* ignore */ }
716
+ try {
717
+ fs.mkdirSync(path.dirname(STATUSLINE_CACHE), { recursive: true });
718
+ fs.writeFileSync(STATUSLINE_CACHE, JSON.stringify(cache, null, 2), "utf-8");
719
+ } catch {
720
+ /* ignore */
721
+ }
585
722
  }
586
723
 
587
724
  function resolveContextId(sessionId: string): string | null {
588
- if (!sessionId || sessionId === "unknown") return null;
589
-
590
- // Check cache first
591
- const cache = loadCache();
592
- const cachedEntry = cache.sessions?.[sessionId];
593
- if (cachedEntry && cachedEntry.context_id !== undefined) {
594
- return cachedEntry.context_id;
595
- }
596
-
597
- // Cache miss — look up via context manager
598
- try {
599
- const context = getContextBySessionId(sessionId);
600
- if (context) {
601
- if (!cache.sessions) cache.sessions = {};
602
- cache.sessions[sessionId] = { context_id: (context as any).id };
603
- saveCache(cache);
604
- return (context as any).id;
605
- }
606
- } catch { /* ignore */ }
607
-
608
- // Don't cache negative results — context may be bound by a later hook
609
- return null;
725
+ if (!sessionId || sessionId === "unknown") return null;
726
+
727
+ // Check cache first
728
+ const cache = loadCache();
729
+ const cachedEntry = cache.sessions?.[sessionId];
730
+ if (cachedEntry && cachedEntry.context_id !== undefined) {
731
+ return cachedEntry.context_id;
732
+ }
733
+
734
+ // Cache miss — look up via context manager
735
+ try {
736
+ const context = getContextBySessionId(sessionId);
737
+ if (context) {
738
+ if (!cache.sessions) cache.sessions = {};
739
+ const ctxId = (context as Record<string, unknown>).id as string;
740
+ cache.sessions[sessionId] = { context_id: ctxId };
741
+ saveCache(cache);
742
+ return ctxId;
743
+ }
744
+ } catch {
745
+ /* ignore */
746
+ }
747
+
748
+ // Don't cache negative results — context may be bound by a later hook
749
+ return null;
610
750
  }
611
751
 
612
- function loadContextState(contextId: string): Record<string, any> | null {
613
- try {
614
- return loadState(contextId) as Record<string, any> | null;
615
- } catch {
616
- return null;
617
- }
752
+ function loadContextState(contextId: string): Record<string, unknown> | null {
753
+ try {
754
+ return loadState(contextId) as Record<string, unknown> | null;
755
+ } catch {
756
+ return null;
757
+ }
618
758
  }
619
759
 
620
- function writeContextWindow(contextId: string, contextWindowData: Record<string, any>): void {
621
- try {
622
- const state = getContext(contextId) as Record<string, any> | null;
623
- if (state) {
624
- if (!state.last_session) state.last_session = {};
625
- state.last_session.context_remaining_pct = contextWindowData.remaining_percentage;
626
- saveState(contextId, state as any);
627
- }
628
- } catch { /* ignore */ }
760
+ function writeContextWindow(
761
+ contextId: string,
762
+ contextWindowData: Record<string, unknown>,
763
+ ): void {
764
+ try {
765
+ const state = getContext(contextId) as Record<string, unknown> | null;
766
+ if (state) {
767
+ if (!state.last_session) state.last_session = {};
768
+ state.last_session.context_remaining_pct =
769
+ contextWindowData.remaining_percentage;
770
+ saveState(contextId, state as unknown);
771
+ }
772
+ } catch {
773
+ /* ignore */
774
+ }
629
775
  }
630
776
 
631
777
  // ---------------------------------------------------------------------------
@@ -633,94 +779,82 @@ function writeContextWindow(contextId: string, contextWindowData: Record<string,
633
779
  // ---------------------------------------------------------------------------
634
780
 
635
781
  function main(): void {
636
- // Read JSON from stdin
637
- let inputData: Record<string, any>;
638
- try {
639
- inputData = JSON.parse(fs.readFileSync(0, "utf-8"));
640
- } catch {
641
- inputData = {};
642
- }
643
-
644
- // Terminal width and mode
645
- const termWidth = getTerminalWidth();
646
- const mode = getDisplayMode(termWidth);
647
-
648
- // Extract input fields
649
- const sessionId = inputData.session_id ?? "";
650
- const modelName = inputData.model?.display_name ?? "unknown";
651
- const cost = inputData.cost ?? {};
652
- const durationMs: number = cost.total_duration_ms ?? 0;
653
- const workspace = inputData.workspace ?? {};
654
- const currentDir: string = workspace.project_dir ?? process.cwd();
655
- const dirName = path.basename(currentDir);
656
-
657
- // Context window data
658
- const ctxWin = inputData.context_window ?? {};
659
- const usage = ctxWin.current_usage ?? {};
660
- const cacheRead: number = usage.cache_read_input_tokens ?? 0;
661
- const inputTokens: number = usage.input_tokens ?? 0;
662
- const cacheCreation: number = usage.cache_creation_input_tokens ?? 0;
663
- const outputTokens: number = usage.output_tokens ?? 0;
664
- const contextMax: number = ctxWin.context_window_size ?? 200_000;
665
-
666
- // Calculate context percentage
667
- const usedPct = ctxWin.used_percentage;
668
- let contextPct: number;
669
- const totalInput = cacheRead + inputTokens + cacheCreation;
670
- const contextUsed = totalInput + outputTokens + CONTEXT_BASELINE_TOKENS;
671
-
672
- if (usedPct !== undefined && usedPct !== null) {
673
- contextPct = Math.floor(usedPct);
674
- } else {
675
- contextPct = contextMax > 0 ? Math.floor((contextUsed * 100) / contextMax) : 0;
676
- }
677
-
678
- const contextK = Math.floor(contextUsed / 1000);
679
- const maxK = Math.floor(contextMax / 1000);
680
-
681
- // Format duration
682
- const durationSec = Math.floor(durationMs / 1000);
683
- let timeDisplay: string;
684
- if (durationSec >= 3600) {
685
- timeDisplay = `${Math.floor(durationSec / 3600)}h${Math.floor((durationSec % 3600) / 60)}m`;
686
- } else if (durationSec >= 60) {
687
- timeDisplay = `${Math.floor(durationSec / 60)}m${durationSec % 60}s`;
688
- } else {
689
- timeDisplay = `${durationSec}s`;
690
- }
691
-
692
- // Resolve context ID for display and persistence
693
- const contextId = resolveContextId(sessionId);
694
-
695
- // Render context section
696
- renderContext(mode, contextPct, contextK, maxK, timeDisplay, modelName);
697
-
698
- // Render PWD + git section (PWD always shown, git stats only when in a repo)
699
- const git = getGitStatus(currentDir);
700
- renderGit(mode, git, dirName);
701
-
702
- // Render context manager line (line 3) with separator
703
- console.log(SEPARATOR);
704
- if (contextId) {
705
- const contextState = loadContextState(contextId);
706
- renderContextManager(mode, contextId, contextState);
707
- } else {
708
- renderNoContext(mode);
709
- }
710
-
711
- // Persist context_window to state.json
712
- if (contextId) {
713
- writeContextWindow(contextId, {
714
- used_percentage: contextPct,
715
- remaining_percentage: 100 - contextPct,
716
- context_window_size: contextMax,
717
- tokens_used: contextUsed,
718
- total_input_tokens: totalInput,
719
- total_output_tokens: outputTokens,
720
- model: modelName,
721
- last_updated: new Date().toISOString().split(".")[0],
722
- });
723
- }
782
+ // Read JSON from stdin
783
+ let inputData: Record<string, unknown>;
784
+ try {
785
+ inputData = JSON.parse(fs.readFileSync(0, "utf-8"));
786
+ } catch {
787
+ inputData = {};
788
+ }
789
+
790
+ // Terminal width and mode
791
+ const termWidth = getTerminalWidth();
792
+ const mode = getDisplayMode(termWidth);
793
+
794
+ // Extract input fields
795
+ const sessionId = inputData.session_id ?? "";
796
+ const modelName = inputData.model?.display_name ?? "unknown";
797
+ const workspace = inputData.workspace ?? {};
798
+ const currentDir: string = workspace.project_dir ?? process.cwd();
799
+ const dirName = path.basename(currentDir);
800
+
801
+ // Context window data
802
+ const ctxWin = inputData.context_window ?? {};
803
+ const usage = ctxWin.current_usage ?? {};
804
+ const cacheRead: number = usage.cache_read_input_tokens ?? 0;
805
+ const inputTokens: number = usage.input_tokens ?? 0;
806
+ const cacheCreation: number = usage.cache_creation_input_tokens ?? 0;
807
+ const outputTokens: number = usage.output_tokens ?? 0;
808
+ const contextMax: number = ctxWin.context_window_size ?? 200_000;
809
+
810
+ // Calculate context percentage
811
+ const usedPct = ctxWin.used_percentage;
812
+ let contextPct: number;
813
+ const totalInput = cacheRead + inputTokens + cacheCreation;
814
+ const contextUsed = totalInput + outputTokens + CONTEXT_BASELINE_TOKENS;
815
+
816
+ if (usedPct !== undefined && usedPct !== null) {
817
+ contextPct = Math.floor(usedPct);
818
+ } else {
819
+ contextPct =
820
+ contextMax > 0 ? Math.floor((contextUsed * 100) / contextMax) : 0;
821
+ }
822
+
823
+ const contextK = Math.floor(contextUsed / 1000);
824
+ const maxK = Math.floor(contextMax / 1000);
825
+
826
+ // Resolve context ID for display and persistence
827
+ const contextId = resolveContextId(sessionId);
828
+
829
+ // Render context section
830
+ renderContext(mode, termWidth, contextPct, contextK, maxK, modelName);
831
+
832
+ // Render PWD + git section (PWD always shown, git stats only when in a repo)
833
+ const git = getGitStatus(currentDir);
834
+ renderGit(mode, termWidth, git, dirName);
835
+
836
+ // Render context manager line (line 3) with separator
837
+ console.log(makeSeparator(termWidth));
838
+ if (contextId) {
839
+ const contextState = loadContextState(contextId);
840
+ renderContextManager(mode, contextId, contextState);
841
+ } else {
842
+ renderNoContext(mode);
843
+ }
844
+
845
+ // Persist context_window to state.json
846
+ if (contextId) {
847
+ writeContextWindow(contextId, {
848
+ used_percentage: contextPct,
849
+ remaining_percentage: 100 - contextPct,
850
+ context_window_size: contextMax,
851
+ tokens_used: contextUsed,
852
+ total_input_tokens: totalInput,
853
+ total_output_tokens: outputTokens,
854
+ model: modelName,
855
+ last_updated: new Date().toISOString().split(".")[0],
856
+ });
857
+ }
724
858
  }
725
859
 
726
860
  main();