ccstatusline 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -4,11 +4,12 @@ A customizable status line formatter for Claude Code CLI that displays model inf
4
4
 
5
5
  ## Features
6
6
 
7
- - 📊 **Real-time metrics** - Display model name, git branch, token usage, and more
7
+ - 📊 **Real-time metrics** - Display model name, git branch, token usage, session duration, and more
8
8
  - 🎨 **Fully customizable** - Choose what to display and customize colors
9
+ - 📐 **Multi-line support** - Configure up to 3 status lines
9
10
  - 🖥️ **Interactive TUI** - Built-in configuration interface using React/Ink
10
11
  - 🚀 **Cross-platform** - Works with both Bun and Node.js
11
- - 📏 **80-character format** - Perfectly sized for Claude Code CLI integration
12
+ - 📏 **Auto-width detection** - Automatically adapts to terminal width with flex separators
12
13
 
13
14
  ## Quick Start
14
15
 
@@ -45,12 +46,15 @@ Once configured, ccstatusline automatically formats your Claude Code status line
45
46
 
46
47
  - **Model Name** - Shows the current Claude model (e.g., "Claude 3.5 Sonnet")
47
48
  - **Git Branch** - Displays current git branch name
49
+ - **Git Changes** - Shows uncommitted insertions/deletions (e.g., "+42,-10")
50
+ - **Session Clock** - Shows elapsed time since session start (e.g., "2hr 15m")
48
51
  - **Tokens Input** - Shows input tokens used
49
52
  - **Tokens Output** - Shows output tokens used
50
53
  - **Tokens Cached** - Shows cached tokens used
51
54
  - **Tokens Total** - Shows total tokens used
52
55
  - **Context Length** - Shows current context length in tokens
53
56
  - **Context Percentage** - Shows percentage of context limit used
57
+ - **Terminal Width** - Shows detected terminal width (for debugging)
54
58
  - **Separator** - Visual divider between items (|)
55
59
  - **Flex Separator** - Expands to fill available space
56
60
 
@@ -60,36 +64,38 @@ The configuration file at `~/.config/ccstatusline/settings.json` looks like:
60
64
 
61
65
  ```json
62
66
  {
63
- "items": [
64
- {
65
- "type": "model",
66
- "color": "cyan"
67
- },
68
- {
69
- "type": "separator",
70
- "color": "gray"
71
- },
72
- {
73
- "type": "git-branch",
74
- "color": "green"
75
- },
76
- {
77
- "type": "separator",
78
- "color": "gray"
79
- },
80
- {
81
- "type": "tokens-total",
82
- "color": "yellow"
83
- },
84
- {
85
- "type": "flex-separator",
86
- "color": "gray"
87
- },
88
- {
89
- "type": "context-percentage",
90
- "color": "blue"
91
- }
92
- ]
67
+ "lines": [
68
+ [
69
+ {
70
+ "id": "1",
71
+ "type": "model",
72
+ "color": "cyan"
73
+ },
74
+ {
75
+ "id": "2",
76
+ "type": "separator"
77
+ },
78
+ {
79
+ "id": "3",
80
+ "type": "git-branch",
81
+ "color": "magenta"
82
+ },
83
+ {
84
+ "id": "4",
85
+ "type": "separator"
86
+ },
87
+ {
88
+ "id": "5",
89
+ "type": "session-clock",
90
+ "color": "blue"
91
+ }
92
+ ]
93
+ ],
94
+ "colors": {
95
+ "model": "cyan",
96
+ "gitBranch": "magenta",
97
+ "separator": "dim"
98
+ }
93
99
  }
94
100
  ```
95
101
 
@@ -2,13 +2,14 @@
2
2
 
3
3
  // src/ccstatusline.ts
4
4
  import chalk2 from "chalk";
5
- import { execSync } from "child_process";
5
+ import { execSync as execSync2 } from "child_process";
6
6
 
7
7
  // src/tui.tsx
8
8
  import { useState, useEffect } from "react";
9
9
  import { render, Box, Text, useInput, useApp } from "ink";
10
10
  import SelectInput from "ink-select-input";
11
11
  import chalk from "chalk";
12
+ import { execSync } from "child_process";
12
13
 
13
14
  // src/config.ts
14
15
  import * as fs from "fs";
@@ -21,12 +22,16 @@ var mkdir2 = fs.promises?.mkdir || promisify(fs.mkdir);
21
22
  var CONFIG_DIR = path.join(os.homedir(), ".config", "ccstatusline");
22
23
  var SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
23
24
  var DEFAULT_SETTINGS = {
24
- items: [
25
- { id: "1", type: "model", color: "cyan" },
26
- { id: "2", type: "separator" },
27
- { id: "3", type: "git-branch", color: "magenta" },
28
- { id: "4", type: "separator" },
29
- { id: "5", type: "git-changes", color: "yellow" }
25
+ lines: [
26
+ [
27
+ { id: "1", type: "model", color: "cyan" },
28
+ { id: "2", type: "separator" },
29
+ { id: "3", type: "terminal-width", color: "dim" },
30
+ { id: "4", type: "separator" },
31
+ { id: "5", type: "git-branch", color: "magenta" },
32
+ { id: "6", type: "separator" },
33
+ { id: "7", type: "git-changes", color: "yellow" }
34
+ ]
30
35
  ],
31
36
  colors: {
32
37
  model: "cyan",
@@ -40,12 +45,29 @@ async function loadSettings() {
40
45
  return DEFAULT_SETTINGS;
41
46
  }
42
47
  const content = await readFile2(SETTINGS_PATH, "utf-8");
43
- const loaded = JSON.parse(content);
48
+ let loaded;
49
+ try {
50
+ loaded = JSON.parse(content);
51
+ } catch (parseError) {
52
+ console.error("Failed to parse settings.json, using defaults");
53
+ return DEFAULT_SETTINGS;
54
+ }
44
55
  if (loaded.elements || loaded.layout) {
45
56
  return migrateOldSettings(loaded);
46
57
  }
58
+ if (loaded.items && !loaded.lines) {
59
+ loaded.lines = [loaded.items];
60
+ delete loaded.items;
61
+ }
62
+ if (loaded.lines) {
63
+ if (!Array.isArray(loaded.lines)) {
64
+ loaded.lines = [[]];
65
+ }
66
+ loaded.lines = loaded.lines.slice(0, 3);
67
+ }
47
68
  return { ...DEFAULT_SETTINGS, ...loaded };
48
- } catch {
69
+ } catch (error) {
70
+ console.error("Error loading settings:", error);
49
71
  return DEFAULT_SETTINGS;
50
72
  }
51
73
  }
@@ -69,7 +91,7 @@ function migrateOldSettings(old) {
69
91
  });
70
92
  }
71
93
  return {
72
- items,
94
+ lines: [items],
73
95
  colors: old.colors || DEFAULT_SETTINGS.colors
74
96
  };
75
97
  }
@@ -105,14 +127,14 @@ async function saveClaudeSettings(settings) {
105
127
  }
106
128
  async function isInstalled() {
107
129
  const settings = await loadClaudeSettings();
108
- return settings.statusLine?.command === "npx -y ccstatusline@latest";
130
+ return settings.statusLine?.command === "npx -y ccstatusline@latest" && (settings.statusLine.padding === 0 || settings.statusLine.padding === undefined);
109
131
  }
110
132
  async function installStatusLine() {
111
133
  const settings = await loadClaudeSettings();
112
134
  settings.statusLine = {
113
135
  type: "command",
114
136
  command: "npx -y ccstatusline@latest",
115
- padding: 1
137
+ padding: 0
116
138
  };
117
139
  await saveClaudeSettings(settings);
118
140
  }
@@ -130,8 +152,38 @@ async function getExistingStatusLine() {
130
152
 
131
153
  // src/tui.tsx
132
154
  import { jsxDEV } from "react/jsx-dev-runtime";
133
- var StatusLinePreview = ({ items, terminalWidth }) => {
134
- const width = 80;
155
+ function canDetectTerminalWidth() {
156
+ try {
157
+ const tty = execSync("ps -o tty= -p $(ps -o ppid= -p $$)", {
158
+ encoding: "utf8",
159
+ stdio: ["pipe", "pipe", "ignore"],
160
+ shell: "/bin/sh"
161
+ }).trim();
162
+ if (tty && tty !== "??" && tty !== "?") {
163
+ const width = execSync(`stty size < /dev/${tty} | awk '{print $2}'`, {
164
+ encoding: "utf8",
165
+ stdio: ["pipe", "pipe", "ignore"],
166
+ shell: "/bin/sh"
167
+ }).trim();
168
+ const parsed = parseInt(width, 10);
169
+ if (!isNaN(parsed) && parsed > 0) {
170
+ return true;
171
+ }
172
+ }
173
+ } catch {}
174
+ try {
175
+ const width = execSync("tput cols 2>/dev/null", {
176
+ encoding: "utf8",
177
+ stdio: ["pipe", "pipe", "ignore"]
178
+ }).trim();
179
+ const parsed = parseInt(width, 10);
180
+ return !isNaN(parsed) && parsed > 0;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ var renderSingleLine = (items, terminalWidth, widthDetectionAvailable) => {
186
+ const width = widthDetectionAvailable ? terminalWidth : null;
135
187
  const elements = [];
136
188
  let hasFlexSeparator = false;
137
189
  items.forEach((item) => {
@@ -172,8 +224,18 @@ var StatusLinePreview = ({ items, terminalWidth }) => {
172
224
  const ctxPctColor = chalk[item.color || "cyan"] || chalk.cyan;
173
225
  elements.push(ctxPctColor("Ctx: 9.3%"));
174
226
  break;
227
+ case "session-clock":
228
+ const sessionColor = chalk[item.color || "blue"] || chalk.blue;
229
+ elements.push(sessionColor("Session: 2hr 15m"));
230
+ break;
231
+ case "terminal-width":
232
+ const termColor = chalk[item.color || "dim"] || chalk.dim;
233
+ const detectedWidth = canDetectTerminalWidth() ? terminalWidth : "??";
234
+ elements.push(termColor(`Term: ${detectedWidth}`));
235
+ break;
175
236
  case "separator":
176
- elements.push(chalk.dim(" | "));
237
+ const sepChar = item.character || "|";
238
+ elements.push(chalk.dim(` ${sepChar} `));
177
239
  break;
178
240
  case "flex-separator":
179
241
  elements.push("FLEX");
@@ -182,7 +244,7 @@ var StatusLinePreview = ({ items, terminalWidth }) => {
182
244
  }
183
245
  });
184
246
  let statusLine = "";
185
- if (hasFlexSeparator) {
247
+ if (hasFlexSeparator && width) {
186
248
  const parts = [[]];
187
249
  let currentPart = 0;
188
250
  for (let i = 0;i < items.length; i++) {
@@ -218,12 +280,17 @@ var StatusLinePreview = ({ items, terminalWidth }) => {
218
280
  }
219
281
  }
220
282
  } else {
221
- statusLine = elements.filter((e) => e !== "FLEX").join("");
283
+ statusLine = elements.map((e) => e === "FLEX" ? chalk.dim(" | ") : e).join("");
222
284
  }
285
+ return statusLine;
286
+ };
287
+ var StatusLinePreview = ({ lines, terminalWidth }) => {
288
+ const widthDetectionAvailable = canDetectTerminalWidth();
223
289
  const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76);
224
290
  const topLine = chalk.dim("╭" + "─".repeat(Math.max(0, boxWidth - 2)) + "╮");
225
291
  const middleLine = chalk.dim("│") + " > " + " ".repeat(Math.max(0, boxWidth - 5)) + chalk.dim("│");
226
292
  const bottomLine = chalk.dim("╰" + "─".repeat(Math.max(0, boxWidth - 2)) + "╯");
293
+ const renderedLines = lines.map((lineItems) => lineItems.length > 0 ? renderSingleLine(lineItems, boxWidth, widthDetectionAvailable) : "").filter((line) => line !== "");
227
294
  return /* @__PURE__ */ jsxDEV(Box, {
228
295
  flexDirection: "column",
229
296
  children: [
@@ -236,9 +303,9 @@ var StatusLinePreview = ({ items, terminalWidth }) => {
236
303
  /* @__PURE__ */ jsxDEV(Text, {
237
304
  children: bottomLine
238
305
  }, undefined, false, undefined, this),
239
- /* @__PURE__ */ jsxDEV(Text, {
240
- children: statusLine
241
- }, undefined, false, undefined, this)
306
+ renderedLines.map((line, index) => /* @__PURE__ */ jsxDEV(Text, {
307
+ children: line
308
+ }, index, false, undefined, this))
242
309
  ]
243
310
  }, undefined, true, undefined, this);
244
311
  };
@@ -263,9 +330,53 @@ var ConfirmDialog = ({ message, onConfirm, onCancel }) => {
263
330
  ]
264
331
  }, undefined, true, undefined, this);
265
332
  };
333
+ var LineSelector = ({ lines, onSelect, onBack }) => {
334
+ const items = [
335
+ { label: `\uD83D\uDCDD Line 1${lines[0] && lines[0].length > 0 ? ` (${lines[0].length} items)` : " (empty)"}`, value: 0 },
336
+ { label: `\uD83D\uDCDD Line 2${lines[1] && lines[1].length > 0 ? ` (${lines[1].length} items)` : " (empty)"}`, value: 1 },
337
+ { label: `\uD83D\uDCDD Line 3${lines[2] && lines[2].length > 0 ? ` (${lines[2].length} items)` : " (empty)"}`, value: 2 },
338
+ { label: "← Back", value: -1 }
339
+ ];
340
+ const handleSelect = (item) => {
341
+ if (item.value === -1) {
342
+ onBack();
343
+ } else {
344
+ onSelect(item.value);
345
+ }
346
+ };
347
+ useInput((input, key) => {
348
+ if (key.escape) {
349
+ onBack();
350
+ }
351
+ });
352
+ return /* @__PURE__ */ jsxDEV(Box, {
353
+ flexDirection: "column",
354
+ children: [
355
+ /* @__PURE__ */ jsxDEV(Text, {
356
+ bold: true,
357
+ children: "Select Line to Edit"
358
+ }, undefined, false, undefined, this),
359
+ /* @__PURE__ */ jsxDEV(Text, {
360
+ dimColor: true,
361
+ children: "Choose which status line to configure (up to 3 lines supported)"
362
+ }, undefined, false, undefined, this),
363
+ /* @__PURE__ */ jsxDEV(Text, {
364
+ dimColor: true,
365
+ children: "Press ESC to go back"
366
+ }, undefined, false, undefined, this),
367
+ /* @__PURE__ */ jsxDEV(Box, {
368
+ marginTop: 1,
369
+ children: /* @__PURE__ */ jsxDEV(SelectInput, {
370
+ items,
371
+ onSelect: handleSelect
372
+ }, undefined, false, undefined, this)
373
+ }, undefined, false, undefined, this)
374
+ ]
375
+ }, undefined, true, undefined, this);
376
+ };
266
377
  var MainMenu = ({ onSelect, isClaudeInstalled, hasChanges }) => {
267
378
  const items = [
268
- { label: "\uD83D\uDCDD Edit Status Line Items", value: "items" },
379
+ { label: "\uD83D\uDCDD Edit Lines", value: "lines" },
269
380
  { label: "\uD83C\uDFA8 Configure Colors", value: "colors" },
270
381
  { label: isClaudeInstalled ? "\uD83D\uDDD1️ Uninstall from Claude Code" : "\uD83D\uDCE6 Install to Claude Code", value: "install" }
271
382
  ];
@@ -291,9 +402,10 @@ var MainMenu = ({ onSelect, isClaudeInstalled, hasChanges }) => {
291
402
  ]
292
403
  }, undefined, true, undefined, this);
293
404
  };
294
- var ItemsEditor = ({ items, onUpdate, onBack }) => {
405
+ var ItemsEditor = ({ items, onUpdate, onBack, lineNumber }) => {
295
406
  const [selectedIndex, setSelectedIndex] = useState(0);
296
407
  const [moveMode, setMoveMode] = useState(false);
408
+ const separatorChars = ["|", "-", " "];
297
409
  useInput((input, key) => {
298
410
  if (moveMode) {
299
411
  if (key.upArrow && selectedIndex > 0) {
@@ -328,13 +440,15 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
328
440
  "git-branch",
329
441
  "git-changes",
330
442
  "separator",
331
- "flex-separator",
332
443
  "tokens-input",
333
444
  "tokens-output",
334
445
  "tokens-cached",
335
446
  "tokens-total",
336
447
  "context-length",
337
- "context-percentage"
448
+ "context-percentage",
449
+ "session-clock",
450
+ "terminal-width",
451
+ "flex-separator"
338
452
  ];
339
453
  const currentItem = items[selectedIndex];
340
454
  if (currentItem) {
@@ -354,13 +468,15 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
354
468
  "git-branch",
355
469
  "git-changes",
356
470
  "separator",
357
- "flex-separator",
358
471
  "tokens-input",
359
472
  "tokens-output",
360
473
  "tokens-cached",
361
474
  "tokens-total",
362
475
  "context-length",
363
- "context-percentage"
476
+ "context-percentage",
477
+ "session-clock",
478
+ "terminal-width",
479
+ "flex-separator"
364
480
  ];
365
481
  const currentItem = items[selectedIndex];
366
482
  if (currentItem) {
@@ -381,7 +497,9 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
381
497
  id: Date.now().toString(),
382
498
  type: "separator"
383
499
  };
384
- onUpdate([...items, newItem]);
500
+ const newItems = [...items, newItem];
501
+ onUpdate(newItems);
502
+ setSelectedIndex(newItems.length - 1);
385
503
  } else if (input === "i") {
386
504
  const newItem = {
387
505
  id: Date.now().toString(),
@@ -397,6 +515,19 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
397
515
  if (selectedIndex >= newItems.length && selectedIndex > 0) {
398
516
  setSelectedIndex(selectedIndex - 1);
399
517
  }
518
+ } else if (input === "c") {
519
+ onUpdate([]);
520
+ setSelectedIndex(0);
521
+ } else if (input === " " && items.length > 0) {
522
+ const currentItem = items[selectedIndex];
523
+ if (currentItem && currentItem.type === "separator") {
524
+ const currentChar = currentItem.character || "|";
525
+ const currentCharIndex = separatorChars.indexOf(currentChar);
526
+ const nextChar = separatorChars[(currentCharIndex + 1) % separatorChars.length];
527
+ const newItems = [...items];
528
+ newItems[selectedIndex] = { ...currentItem, character: nextChar };
529
+ onUpdate(newItems);
530
+ }
400
531
  } else if (key.escape) {
401
532
  onBack();
402
533
  }
@@ -410,10 +541,13 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
410
541
  return chalk.magenta("Git Branch");
411
542
  case "git-changes":
412
543
  return chalk.yellow("Git Changes");
413
- case "separator":
414
- return chalk.dim("Separator |");
544
+ case "separator": {
545
+ const char = item.character || "|";
546
+ const charDisplay = char === " " ? "(space)" : char;
547
+ return chalk.dim(`Separator ${charDisplay}`);
548
+ }
415
549
  case "flex-separator":
416
- return chalk.yellow("Flex Separator ─────");
550
+ return chalk.yellow("Flex Separator");
417
551
  case "tokens-input":
418
552
  return chalk.yellow("Tokens Input");
419
553
  case "tokens-output":
@@ -426,15 +560,23 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
426
560
  return chalk.cyan("Context Length");
427
561
  case "context-percentage":
428
562
  return chalk.cyan("Context %");
563
+ case "session-clock":
564
+ return chalk.blue("Session Clock");
565
+ case "terminal-width":
566
+ return chalk.dim("Terminal Width");
429
567
  }
430
568
  };
569
+ const hasFlexSeparator = items.some((item) => item.type === "flex-separator");
570
+ const widthDetectionAvailable = canDetectTerminalWidth();
431
571
  return /* @__PURE__ */ jsxDEV(Box, {
432
572
  flexDirection: "column",
433
573
  children: [
434
574
  /* @__PURE__ */ jsxDEV(Text, {
435
575
  bold: true,
436
576
  children: [
437
- "Edit Status Line Items ",
577
+ "Edit Line ",
578
+ lineNumber,
579
+ " ",
438
580
  moveMode && /* @__PURE__ */ jsxDEV(Text, {
439
581
  color: "yellow",
440
582
  children: "[MOVE MODE]"
@@ -446,8 +588,21 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
446
588
  children: "↑↓ to move item, ESC or Enter to exit move mode"
447
589
  }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV(Text, {
448
590
  dimColor: true,
449
- children: "↑↓ select, ←→ change type, Enter to move, (a)dd, (i)nsert, (d)elete, ESC back"
591
+ children: "↑↓ select, ←→ change type, Space edit separator, Enter to move, (a)dd, (i)nsert, (d)elete, (c)lear line, ESC back"
450
592
  }, undefined, false, undefined, this),
593
+ hasFlexSeparator && !widthDetectionAvailable && /* @__PURE__ */ jsxDEV(Box, {
594
+ marginTop: 1,
595
+ children: [
596
+ /* @__PURE__ */ jsxDEV(Text, {
597
+ color: "yellow",
598
+ children: "⚠ Note: Terminal width detection is currently unavailable in your environment."
599
+ }, undefined, false, undefined, this),
600
+ /* @__PURE__ */ jsxDEV(Text, {
601
+ dimColor: true,
602
+ children: " Flex separators will act as normal separators until width detection is available."
603
+ }, undefined, false, undefined, this)
604
+ ]
605
+ }, undefined, true, undefined, this),
451
606
  /* @__PURE__ */ jsxDEV(Box, {
452
607
  marginTop: 1,
453
608
  flexDirection: "column",
@@ -461,7 +616,11 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
461
616
  index === selectedIndex ? moveMode ? "◆ " : "▶ " : " ",
462
617
  index + 1,
463
618
  ". ",
464
- getItemDisplay(item)
619
+ getItemDisplay(item),
620
+ item.type === "separator" && index === selectedIndex && !moveMode && /* @__PURE__ */ jsxDEV(Text, {
621
+ dimColor: true,
622
+ children: " (Space to edit)"
623
+ }, undefined, false, undefined, this)
465
624
  ]
466
625
  }, undefined, true, undefined, this)
467
626
  }, item.id, false, undefined, this))
@@ -470,7 +629,7 @@ var ItemsEditor = ({ items, onUpdate, onBack }) => {
470
629
  }, undefined, true, undefined, this);
471
630
  };
472
631
  var ColorMenu = ({ items, onUpdate, onBack }) => {
473
- const colorableItems = items.filter((item) => ["model", "git-branch", "tokens-input", "tokens-output", "tokens-cached", "tokens-total", "context-length", "context-percentage"].includes(item.type));
632
+ const colorableItems = items.filter((item) => ["model", "git-branch", "tokens-input", "tokens-output", "tokens-cached", "tokens-total", "context-length", "context-percentage", "session-clock"].includes(item.type));
474
633
  const [selectedIndex, setSelectedIndex] = useState(0);
475
634
  useInput((input, key) => {
476
635
  if (key.escape) {
@@ -531,6 +690,8 @@ var ColorMenu = ({ items, onUpdate, onBack }) => {
531
690
  return "Context Length";
532
691
  case "context-percentage":
533
692
  return "Context Percentage";
693
+ case "session-clock":
694
+ return "Session Clock";
534
695
  default:
535
696
  return item.type;
536
697
  }
@@ -632,11 +793,18 @@ var App = () => {
632
793
  const [originalSettings, setOriginalSettings] = useState(null);
633
794
  const [hasChanges, setHasChanges] = useState(false);
634
795
  const [screen, setScreen] = useState("main");
796
+ const [selectedLine, setSelectedLine] = useState(0);
635
797
  const [confirmDialog, setConfirmDialog] = useState(null);
636
798
  const [isClaudeInstalled, setIsClaudeInstalled] = useState(false);
637
799
  const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
638
800
  useEffect(() => {
639
801
  loadSettings().then((loadedSettings) => {
802
+ if (!loadedSettings.lines) {
803
+ loadedSettings.lines = [[]];
804
+ }
805
+ while (loadedSettings.lines.length < 3) {
806
+ loadedSettings.lines.push([]);
807
+ }
640
808
  setSettings(loadedSettings);
641
809
  setOriginalSettings(JSON.parse(JSON.stringify(loadedSettings)));
642
810
  });
@@ -687,7 +855,7 @@ A status line is already configured: "${existing}"
687
855
  Replace it with npx -y ccstatusline@latest?`;
688
856
  } else if (existing === "npx -y ccstatusline@latest") {
689
857
  message = `ccstatusline is already installed in ~/.claude/settings.json
690
- Reinstall it?`;
858
+ Update it with the latest options?`;
691
859
  } else {
692
860
  message = `This will modify ~/.claude/settings.json to add ccstatusline.
693
861
  Continue?`;
@@ -706,8 +874,8 @@ Continue?`;
706
874
  };
707
875
  const handleMainMenuSelect = async (value) => {
708
876
  switch (value) {
709
- case "items":
710
- setScreen("items");
877
+ case "lines":
878
+ setScreen("lines");
711
879
  break;
712
880
  case "colors":
713
881
  setScreen("colors");
@@ -726,8 +894,14 @@ Continue?`;
726
894
  break;
727
895
  }
728
896
  };
729
- const updateItems = (items) => {
730
- setSettings({ ...settings, items });
897
+ const updateLine = (lineIndex, items) => {
898
+ const newLines = [...settings.lines || []];
899
+ newLines[lineIndex] = items;
900
+ setSettings({ ...settings, lines: newLines });
901
+ };
902
+ const handleLineSelect = (lineIndex) => {
903
+ setSelectedLine(lineIndex);
904
+ setScreen("items");
731
905
  };
732
906
  return /* @__PURE__ */ jsxDEV(Box, {
733
907
  flexDirection: "column",
@@ -749,7 +923,7 @@ Continue?`;
749
923
  }, undefined, false, undefined, this)
750
924
  }, undefined, false, undefined, this),
751
925
  /* @__PURE__ */ jsxDEV(StatusLinePreview, {
752
- items: settings.items,
926
+ lines: settings.lines || [[]],
753
927
  terminalWidth
754
928
  }, undefined, false, undefined, this),
755
929
  /* @__PURE__ */ jsxDEV(Box, {
@@ -760,14 +934,32 @@ Continue?`;
760
934
  isClaudeInstalled,
761
935
  hasChanges
762
936
  }, undefined, false, undefined, this),
763
- screen === "items" && /* @__PURE__ */ jsxDEV(ItemsEditor, {
764
- items: settings.items,
765
- onUpdate: updateItems,
937
+ screen === "lines" && /* @__PURE__ */ jsxDEV(LineSelector, {
938
+ lines: settings.lines || [[]],
939
+ onSelect: handleLineSelect,
766
940
  onBack: () => setScreen("main")
767
941
  }, undefined, false, undefined, this),
942
+ screen === "items" && settings.lines && /* @__PURE__ */ jsxDEV(ItemsEditor, {
943
+ items: settings.lines[selectedLine] || [],
944
+ onUpdate: (items) => updateLine(selectedLine, items),
945
+ onBack: () => setScreen("lines"),
946
+ lineNumber: selectedLine + 1
947
+ }, undefined, false, undefined, this),
768
948
  screen === "colors" && /* @__PURE__ */ jsxDEV(ColorMenu, {
769
- items: settings.items,
770
- onUpdate: updateItems,
949
+ items: settings.lines?.flat() || [],
950
+ onUpdate: (items) => {
951
+ const newLines = settings.lines || [[]];
952
+ let flatIndex = 0;
953
+ for (let lineIndex = 0;lineIndex < newLines.length; lineIndex++) {
954
+ for (let itemIndex = 0;itemIndex < newLines[lineIndex].length; itemIndex++) {
955
+ if (flatIndex < items.length) {
956
+ newLines[lineIndex][itemIndex] = items[flatIndex];
957
+ flatIndex++;
958
+ }
959
+ }
960
+ }
961
+ setSettings({ ...settings, lines: newLines });
962
+ },
771
963
  onBack: () => setScreen("main")
772
964
  }, undefined, false, undefined, this),
773
965
  screen === "confirm" && confirmDialog && /* @__PURE__ */ jsxDEV(ConfirmDialog, {
@@ -814,9 +1006,40 @@ async function readStdin() {
814
1006
  return null;
815
1007
  }
816
1008
  }
1009
+ function getTerminalWidth() {
1010
+ try {
1011
+ const tty = execSync2("ps -o tty= -p $(ps -o ppid= -p $$)", {
1012
+ encoding: "utf8",
1013
+ stdio: ["pipe", "pipe", "ignore"],
1014
+ shell: "/bin/sh"
1015
+ }).trim();
1016
+ if (tty && tty !== "??" && tty !== "?") {
1017
+ const width = execSync2(`stty size < /dev/${tty} | awk '{print $2}'`, {
1018
+ encoding: "utf8",
1019
+ stdio: ["pipe", "pipe", "ignore"],
1020
+ shell: "/bin/sh"
1021
+ }).trim();
1022
+ const parsed = parseInt(width, 10);
1023
+ if (!isNaN(parsed) && parsed > 0) {
1024
+ return parsed;
1025
+ }
1026
+ }
1027
+ } catch {}
1028
+ try {
1029
+ const width = execSync2("tput cols 2>/dev/null", {
1030
+ encoding: "utf8",
1031
+ stdio: ["pipe", "pipe", "ignore"]
1032
+ }).trim();
1033
+ const parsed = parseInt(width, 10);
1034
+ if (!isNaN(parsed) && parsed > 0) {
1035
+ return parsed;
1036
+ }
1037
+ } catch {}
1038
+ return null;
1039
+ }
817
1040
  function getGitBranch() {
818
1041
  try {
819
- const branch = execSync("git branch --show-current 2>/dev/null", {
1042
+ const branch = execSync2("git branch --show-current 2>/dev/null", {
820
1043
  encoding: "utf8",
821
1044
  stdio: ["pipe", "pipe", "ignore"]
822
1045
  }).trim();
@@ -829,11 +1052,11 @@ function getGitChanges() {
829
1052
  try {
830
1053
  let totalInsertions = 0;
831
1054
  let totalDeletions = 0;
832
- const unstagedStat = execSync("git diff --shortstat 2>/dev/null", {
1055
+ const unstagedStat = execSync2("git diff --shortstat 2>/dev/null", {
833
1056
  encoding: "utf8",
834
1057
  stdio: ["pipe", "pipe", "ignore"]
835
1058
  }).trim();
836
- const stagedStat = execSync("git diff --cached --shortstat 2>/dev/null", {
1059
+ const stagedStat = execSync2("git diff --cached --shortstat 2>/dev/null", {
837
1060
  encoding: "utf8",
838
1061
  stdio: ["pipe", "pipe", "ignore"]
839
1062
  }).trim();
@@ -857,6 +1080,58 @@ function getGitChanges() {
857
1080
  return null;
858
1081
  }
859
1082
  }
1083
+ async function getSessionDuration(transcriptPath) {
1084
+ try {
1085
+ if (!fs3.existsSync(transcriptPath)) {
1086
+ return null;
1087
+ }
1088
+ const content = await readFile6(transcriptPath, "utf-8");
1089
+ const lines = content.trim().split(`
1090
+ `).filter((line) => line.trim());
1091
+ if (lines.length === 0) {
1092
+ return null;
1093
+ }
1094
+ let firstTimestamp = null;
1095
+ let lastTimestamp = null;
1096
+ for (const line of lines) {
1097
+ try {
1098
+ const data = JSON.parse(line);
1099
+ if (data.timestamp) {
1100
+ firstTimestamp = new Date(data.timestamp);
1101
+ break;
1102
+ }
1103
+ } catch {}
1104
+ }
1105
+ for (let i = lines.length - 1;i >= 0; i--) {
1106
+ try {
1107
+ const data = JSON.parse(lines[i]);
1108
+ if (data.timestamp) {
1109
+ lastTimestamp = new Date(data.timestamp);
1110
+ break;
1111
+ }
1112
+ } catch {}
1113
+ }
1114
+ if (!firstTimestamp || !lastTimestamp) {
1115
+ return null;
1116
+ }
1117
+ const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
1118
+ const totalMinutes = Math.floor(durationMs / (1000 * 60));
1119
+ if (totalMinutes < 1) {
1120
+ return "<1m";
1121
+ }
1122
+ const hours = Math.floor(totalMinutes / 60);
1123
+ const minutes = totalMinutes % 60;
1124
+ if (hours === 0) {
1125
+ return `${minutes}m`;
1126
+ } else if (minutes === 0) {
1127
+ return `${hours}hr`;
1128
+ } else {
1129
+ return `${hours}hr ${minutes}m`;
1130
+ }
1131
+ } catch {
1132
+ return null;
1133
+ }
1134
+ }
860
1135
  async function getTokenMetrics(transcriptPath) {
861
1136
  try {
862
1137
  if (!fs3.existsSync(transcriptPath)) {
@@ -892,16 +1167,11 @@ async function getTokenMetrics(transcriptPath) {
892
1167
  return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
893
1168
  }
894
1169
  }
895
- async function renderStatusLine(data) {
896
- const settings = await loadSettings();
897
- const terminalWidth = 80;
1170
+ function renderSingleLine2(items, settings, data, tokenMetrics, sessionDuration) {
1171
+ const detectedWidth = getTerminalWidth();
1172
+ const terminalWidth = detectedWidth ? detectedWidth - 40 : null;
898
1173
  const elements = [];
899
1174
  let hasFlexSeparator = false;
900
- const hasTokenItems = settings.items.some((item) => ["tokens-input", "tokens-output", "tokens-cached", "tokens-total", "context-length", "context-percentage"].includes(item.type));
901
- let tokenMetrics = null;
902
- if (hasTokenItems && data.transcript_path) {
903
- tokenMetrics = await getTokenMetrics(data.transcript_path);
904
- }
905
1175
  const formatTokens = (count) => {
906
1176
  if (count >= 1e6)
907
1177
  return `${(count / 1e6).toFixed(1)}M`;
@@ -909,7 +1179,7 @@ async function renderStatusLine(data) {
909
1179
  return `${(count / 1000).toFixed(1)}k`;
910
1180
  return count.toString();
911
1181
  };
912
- for (const item of settings.items) {
1182
+ for (const item of items) {
913
1183
  switch (item.type) {
914
1184
  case "model":
915
1185
  if (data.model) {
@@ -969,11 +1239,25 @@ async function renderStatusLine(data) {
969
1239
  elements.push({ content: color(`Ctx: ${percentage.toFixed(1)}%`), type: "context-percentage" });
970
1240
  }
971
1241
  break;
1242
+ case "terminal-width":
1243
+ const detectedWidth2 = terminalWidth || getTerminalWidth();
1244
+ if (detectedWidth2) {
1245
+ const color = chalk2[item.color || "dim"] || chalk2.dim;
1246
+ elements.push({ content: color(`Term: ${detectedWidth2}`), type: "terminal-width" });
1247
+ }
1248
+ break;
1249
+ case "session-clock":
1250
+ if (sessionDuration) {
1251
+ const color = chalk2[item.color || "blue"] || chalk2.blue;
1252
+ elements.push({ content: color(`Session: ${sessionDuration}`), type: "session-clock" });
1253
+ }
1254
+ break;
972
1255
  case "separator":
973
1256
  const lastElement = elements[elements.length - 1];
974
1257
  if (elements.length > 0 && lastElement && lastElement.type !== "separator") {
975
1258
  const sepColor = chalk2[settings.colors.separator] || chalk2.dim;
976
- elements.push({ content: sepColor(" | "), type: "separator" });
1259
+ const sepChar = item.character || "|";
1260
+ elements.push({ content: sepColor(` ${sepChar} `), type: "separator" });
977
1261
  }
978
1262
  break;
979
1263
  case "flex-separator":
@@ -983,9 +1267,9 @@ async function renderStatusLine(data) {
983
1267
  }
984
1268
  }
985
1269
  if (elements.length === 0)
986
- return;
1270
+ return "";
987
1271
  let statusLine = "";
988
- if (hasFlexSeparator) {
1272
+ if (hasFlexSeparator && terminalWidth) {
989
1273
  const parts = [[]];
990
1274
  let currentPart = 0;
991
1275
  for (const elem of elements) {
@@ -1017,20 +1301,39 @@ async function renderStatusLine(data) {
1017
1301
  }
1018
1302
  }
1019
1303
  } else {
1020
- statusLine = elements.map((e) => e.content).join("");
1021
- const contentLength = statusLine.replace(/\x1b\[[0-9;]*m/g, "").length;
1022
- const remainingSpace = terminalWidth - contentLength;
1023
- if (remainingSpace > 0) {
1024
- statusLine = statusLine + " ".repeat(remainingSpace);
1304
+ if (hasFlexSeparator && !terminalWidth) {
1305
+ statusLine = elements.map((e) => e.type === "flex-separator" ? chalk2.dim(" | ") : e.content).join("");
1306
+ } else {
1307
+ statusLine = elements.map((e) => e.content).join("");
1025
1308
  }
1026
1309
  }
1027
- const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, "").length;
1028
- if (plainLength > 80) {
1029
- const visibleText = statusLine.replace(/\x1b\[[0-9;]*m/g, "");
1030
- const truncated = visibleText.substring(0, 77) + "...";
1031
- console.log(truncated);
1310
+ return statusLine;
1311
+ }
1312
+ async function renderStatusLine(data) {
1313
+ const settings = await loadSettings();
1314
+ let lines = [];
1315
+ if (settings.lines) {
1316
+ lines = settings.lines;
1317
+ } else if (settings.items) {
1318
+ lines = [settings.items];
1032
1319
  } else {
1033
- console.log(statusLine);
1320
+ lines = [[]];
1321
+ }
1322
+ const hasTokenItems = lines.some((line) => line.some((item) => ["tokens-input", "tokens-output", "tokens-cached", "tokens-total", "context-length", "context-percentage"].includes(item.type)));
1323
+ const hasSessionClock = lines.some((line) => line.some((item) => item.type === "session-clock"));
1324
+ let tokenMetrics = null;
1325
+ if (hasTokenItems && data.transcript_path) {
1326
+ tokenMetrics = await getTokenMetrics(data.transcript_path);
1327
+ }
1328
+ let sessionDuration = null;
1329
+ if (hasSessionClock && data.transcript_path) {
1330
+ sessionDuration = await getSessionDuration(data.transcript_path);
1331
+ }
1332
+ for (const lineItems of lines) {
1333
+ if (lineItems.length > 0) {
1334
+ const line = renderSingleLine2(lineItems, settings, data, tokenMetrics, sessionDuration);
1335
+ console.log(line);
1336
+ }
1034
1337
  }
1035
1338
  }
1036
1339
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccstatusline",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "A customizable status line formatter for Claude Code CLI",
5
5
  "module": "src/ccstatusline.ts",
6
6
  "type": "module",