@zeluizr/lattice 1.0.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
+ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0] — 2026-06-24
8
+
9
+ First open-source release as **lattice** — a full rewrite of the project
10
+ previously known as `commente.me`.
11
+
12
+ ### Added
13
+ - **Node.js / TypeScript + Ink rewrite.** The dashboard is now a proper CLI,
14
+ distributed on npm and runnable with `npx @zeluizr/lattice` or
15
+ `npm i -g @zeluizr/lattice` (command: `lattice`).
16
+ - **Per-disk panel.** One row per real mount — `/` and every volume under
17
+ `/Volumes` — with live read/write throughput (from IOKit block-storage byte
18
+ counters via `ioreg`, no sudo) and space used.
19
+ - **Git panel.** Scans a folder of repos (default: the parent of the current
20
+ directory, or `--repos <dir>`) and shows each repo's current branch with its
21
+ state — clean/dirty and commits ahead/behind upstream. Hidden when empty.
22
+ - **Internationalization** — English, Español and Português (Brasil). Chosen on
23
+ first run, persisted in `~/.config/lattice/config.json`, switchable with
24
+ `--lang`.
25
+ - **Native SMC helper** (`lattice-smc`, IOKit in C, shipped prebuilt) for CPU/GPU
26
+ temperatures and fan speeds without sudo, with graceful fallback.
27
+ - **Six Dracula Pro themes** selectable with `--theme`.
28
+ - Live panels: CPU, memory/swap, GPU, temperature/fans, network, per-disk I/O &
29
+ usage, AI token cost (today), git branches per repo, VTEX status, and top
30
+ processes.
31
+ - Flags: `--no-power`, `--no-vtex`, `--repos`, `--interval`, `--procs`,
32
+ `--icons`, `--lang`, `--theme`.
33
+ - Hotkeys: `q` quit, `p` pause, `+`/`-` refresh speed.
34
+
35
+ ### Changed
36
+ - Documentation and UI are now in English (plus es / pt-BR at runtime); the
37
+ project was previously Portuguese-only.
38
+ - Power collection uses a streaming `powermetrics` subprocess parsed in Node.
39
+ - lattice is a focused, local-only monitor: there is no embedded chat and no
40
+ network calls — every metric is read from the machine.
41
+
42
+ ### Migration
43
+ - The previous Python/Textual implementation is preserved in git under the
44
+ `v0-python` tag.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose Luiz Rodrigues
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MANIFESTO.md ADDED
@@ -0,0 +1,37 @@
1
+ # The lattice manifesto
2
+
3
+ Every machine hums with signals — cores spiking, watts flowing, silicon warming
4
+ and cooling in a rhythm you rarely see. **lattice makes that rhythm visible.**
5
+
6
+ ### 1. Your machine, your data.
7
+ Every metric is read locally and stays local. No telemetry, no accounts, no
8
+ cloud — nothing ever leaves your Mac.
9
+
10
+ ### 2. Built for the metal it runs on.
11
+ lattice is not a generic monitor with a Mac coat of paint. It speaks IOKit, SMC,
12
+ `ioreg` and `powermetrics` natively — tuned for Apple Silicon, from the M1 to
13
+ whatever comes next.
14
+
15
+ ### 3. The terminal deserves beauty.
16
+ A dashboard you stare at all day should be a pleasure to look at. Real colors,
17
+ live sparklines, considered typography. Function and form are not opposites.
18
+
19
+ ### 4. Every disk, every volume.
20
+ Storage isn't one number. lattice shows each disk on its own — `/` and every
21
+ external volume under `/Volumes` — with live read/write and space used, so you
22
+ see exactly which one is working.
23
+
24
+ ### 5. Fast, small, out of the way.
25
+ A monitor that hogs the resources it measures has failed. lattice stays light.
26
+
27
+ ### 6. Open and yours to bend.
28
+ MIT-licensed, readable, hackable. Fork it, theme it, extend it.
29
+
30
+ ### 7. In your language.
31
+ English, Español, Português — because good tools shouldn't assume where you're
32
+ from.
33
+
34
+ ---
35
+
36
+ *lattice is the ordered structure beneath the chip, made legible.*
37
+ **Watch your machine think.**
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ <div align="center">
2
+
3
+ # ◇ lattice
4
+
5
+ **A real-time terminal dashboard for macOS Apple Silicon.**
6
+
7
+ GPU · power · temps & fans · per-disk I/O · network · memory · processes ·
8
+ AI token cost. A focused monitor — every metric read locally, nothing leaves
9
+ your Mac.
10
+
11
+ [![npm](https://img.shields.io/npm/v/%40zeluizr%2Flattice?color=9580FF)](https://www.npmjs.com/package/@zeluizr/lattice)
12
+ [![license](https://img.shields.io/badge/license-MIT-FF80BF)](./LICENSE)
13
+ [![node](https://img.shields.io/badge/node-%E2%89%A518-80FFEA)](https://nodejs.org)
14
+ [![platform](https://img.shields.io/badge/macOS-Apple%20Silicon-8AFF80)](#requirements)
15
+
16
+ *Watch your machine think.* — [read the manifesto](./MANIFESTO.md)
17
+
18
+ </div>
19
+
20
+ ---
21
+
22
+ ## Install & run
23
+
24
+ No install needed — run it straight from npm:
25
+
26
+ ```bash
27
+ npx @zeluizr/lattice
28
+ ```
29
+
30
+ Or install it globally so you can just type `lattice`:
31
+
32
+ ```bash
33
+ npm i -g @zeluizr/lattice
34
+ lattice
35
+ ```
36
+
37
+ On first run, lattice asks for your language (English · Español · Português) and
38
+ remembers it. Watts need `sudo` (see below); everything else works without it.
39
+
40
+ ## Usage
41
+
42
+ ```bash
43
+ lattice # full dashboard (asks for sudo once, for watts)
44
+ lattice --no-power # skip sudo; CPU/GPU/RAM/disk/net/temps only
45
+ lattice --no-vtex # hide the VTEX panel (for non-VTEX users)
46
+ lattice --repos ~/code # git branches for the repos in ~/code
47
+ lattice --interval 2 # refresh every 2s
48
+ lattice --procs 12 # show 12 top processes
49
+ lattice --icons emoji # nerd | emoji | none
50
+ lattice --lang es # en | es | pt-BR (persists)
51
+ lattice --theme blade # pro | blade | buffy | lincoln | morbius | van-helsing
52
+ ```
53
+
54
+ | Flag | Default | Description |
55
+ |------|---------|-------------|
56
+ | `--no-power` | off | Skip `powermetrics`/sudo (no watts) |
57
+ | `--no-vtex` | off | Hide the VTEX panel (for non-VTEX users) |
58
+ | `--repos` | *(parent of cwd)* | Folder of git repos to show branches for |
59
+ | `--interval`, `-i` | `1` | Refresh interval in seconds |
60
+ | `--procs`, `-n` | `8` | Number of top processes |
61
+ | `--icons` | `nerd` | Icon style: `nerd`, `emoji`, `none` |
62
+ | `--lang` | *(asked)* | `en`, `es`, `pt-BR` |
63
+ | `--theme` | `pro` | Dracula Pro variant |
64
+
65
+ ### Hotkeys
66
+
67
+ | Key | Action |
68
+ |-----|--------|
69
+ | `q` | quit |
70
+ | `p` | pause / resume |
71
+ | `+` / `-` | faster / slower refresh |
72
+
73
+ ## What it shows
74
+
75
+ | Panel | Source | Needs sudo |
76
+ |-------|--------|:----------:|
77
+ | GPU usage & memory | `ioreg` (IOAccelerator) | — |
78
+ | CPU, RAM, swap, network, processes | system APIs | — |
79
+ | Per-disk I/O & usage — `/` and every `/Volumes/*` | `ioreg` + system APIs | — |
80
+ | Temperatures & fans | SMC via IOKit (native helper) | — |
81
+ | Power (watts), GPU freq, thermal pressure | `powermetrics` | **yes** |
82
+ | AI tokens & cost (today) | Claude Code logs (`~/.claude`) | — |
83
+ | Git branches — current branch + dirty/ahead·behind per repo | `git status` | — |
84
+ | VTEX status | VTEX CLI configstore | — |
85
+
86
+ Temperatures and fans read straight from the SMC, so they work **without sudo**.
87
+ Desktops (Mac mini / Studio) simply report no battery.
88
+
89
+ The **disks** panel breaks activity out per mount — one row each for `/` and
90
+ every volume under `/Volumes` — with live read/write throughput and space used,
91
+ so you can see exactly which disk is busy.
92
+
93
+ The **git** panel scans a folder of repositories (by default the parent of the
94
+ current directory, or pass `--repos <dir>`) and shows each repo's current branch
95
+ with its state — clean/dirty and commits ahead/behind upstream. It hides itself
96
+ when the folder has no repos.
97
+
98
+ ## Languages
99
+
100
+ lattice ships in **English**, **Español** and **Português (Brasil)**. It asks on
101
+ first run, stores your choice in `~/.config/lattice/config.json`, and you can
102
+ switch any time with `--lang`.
103
+
104
+ ## Themes
105
+
106
+ Six Dracula Pro variants — `pro` (default), `blade`, `buffy`, `lincoln`,
107
+ `morbius`, `van-helsing`. Switch with `--theme <name>` (persisted). Best in a
108
+ truecolor terminal (Warp, iTerm2, Ghostty, kitty). For Nerd Font icons, use a
109
+ patched font like MesloLGS NF — or pass `--icons emoji` / `--icons none`.
110
+
111
+ ## Passwordless watts (optional)
112
+
113
+ To skip the sudo prompt for `powermetrics`:
114
+
115
+ ```bash
116
+ sudo bash scripts/setup-sudoers.sh
117
+ ```
118
+
119
+ This adds `/etc/sudoers.d/lattice-powermetrics` allowing passwordless
120
+ `powermetrics` for your user.
121
+
122
+ ## Requirements
123
+
124
+ - **macOS on Apple Silicon** (M1/M2/M3/M4…). Intel Macs and other OSes are not
125
+ supported.
126
+ - **Node.js ≥ 18.**
127
+ - A truecolor terminal; optionally a Nerd Font for icons.
128
+
129
+ ## Contributing
130
+
131
+ Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Adding a new
132
+ language is just one file in `src/i18n/`.
133
+
134
+ ## License
135
+
136
+ MIT © Jose Luiz Rodrigues. See [LICENSE](./LICENSE).
137
+
138
+ > Previously released as **commente.me** (Python / Textual). The Node/TypeScript
139
+ > rewrite is tagged from `v0-python` onward — see [CHANGELOG](./CHANGELOG.md).
package/dist/app.js ADDED
@@ -0,0 +1,196 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { Panel } from "./components/Panel.js";
5
+ import { SystemCollector } from "./collectors/system.js";
6
+ import { DisksCollector } from "./collectors/disks.js";
7
+ import { GitCollector } from "./collectors/git.js";
8
+ import { readGpu } from "./collectors/gpu.js";
9
+ import { readSensors } from "./collectors/sensors.js";
10
+ import { PowerCollector } from "./collectors/power.js";
11
+ import { TokenCollector } from "./collectors/tokens.js";
12
+ import { readVtex } from "./collectors/vtex.js";
13
+ import { coreCell, fmtTok, humanBytes, humanRate, sparkline, statusLevel } from "./format.js";
14
+ const MAX_HIST = 60;
15
+ const push = (h, v) => [...h, v].slice(-MAX_HIST);
16
+ function timeStr() {
17
+ const d = new Date();
18
+ return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
19
+ }
20
+ export function App(props) {
21
+ const { t, pal, icon, usePower, useVtex, gitDir, topN } = props;
22
+ const { exit } = useApp();
23
+ const [sys, setSys] = useState(null);
24
+ const [disks, setDisks] = useState(null);
25
+ const [git, setGit] = useState(null);
26
+ const [gpu, setGpu] = useState(null);
27
+ const [sensors, setSensors] = useState(null);
28
+ const [power, setPower] = useState(null);
29
+ const [tokens, setTokens] = useState(null);
30
+ const [vtex, setVtex] = useState(null);
31
+ const [hCpu, setHCpu] = useState([]);
32
+ const [hMem, setHMem] = useState([]);
33
+ const [hNet, setHNet] = useState([]);
34
+ const [hGpu, setHGpu] = useState([]);
35
+ const [hTemp, setHTemp] = useState([]);
36
+ const [paused, setPaused] = useState(false);
37
+ const [interval, setIntervalState] = useState(props.interval);
38
+ const [now, setNow] = useState(timeStr());
39
+ const [cols, setCols] = useState(process.stdout.columns || 100);
40
+ const sysRef = useRef(null);
41
+ const disksRef = useRef(null);
42
+ const gitRef = useRef(null);
43
+ const tokRef = useRef(null);
44
+ const powerRef = useRef(null);
45
+ const pausedRef = useRef(false);
46
+ const mounted = useRef(true);
47
+ useEffect(() => {
48
+ pausedRef.current = paused;
49
+ }, [paused]);
50
+ // ----- init collectors + power subprocess + clock -------------------------
51
+ useEffect(() => {
52
+ mounted.current = true;
53
+ sysRef.current = new SystemCollector(topN);
54
+ disksRef.current = new DisksCollector();
55
+ gitRef.current = new GitCollector(gitDir);
56
+ tokRef.current = new TokenCollector();
57
+ if (usePower) {
58
+ powerRef.current = new PowerCollector(Math.round(props.interval * 1000));
59
+ powerRef.current.start();
60
+ }
61
+ const clock = setInterval(() => mounted.current && setNow(timeStr()), 1000);
62
+ const onResize = () => setCols(process.stdout.columns || 100);
63
+ process.stdout.on("resize", onResize);
64
+ return () => {
65
+ mounted.current = false;
66
+ clearInterval(clock);
67
+ process.stdout.off("resize", onResize);
68
+ powerRef.current?.stop();
69
+ };
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, []);
72
+ // ----- main refresh loop --------------------------------------------------
73
+ const refreshData = useCallback(async () => {
74
+ if (pausedRef.current || !sysRef.current || !disksRef.current)
75
+ return;
76
+ const [s, d, g, st] = await Promise.all([
77
+ sysRef.current.read(),
78
+ disksRef.current.read(),
79
+ readGpu(),
80
+ readSensors(),
81
+ ]);
82
+ const p = powerRef.current ? powerRef.current.read() : null;
83
+ if (!mounted.current)
84
+ return;
85
+ setSys(s);
86
+ setDisks(d);
87
+ setGpu(g);
88
+ setSensors(st);
89
+ setPower(p);
90
+ setHCpu((h) => push(h, s.cpuTotal));
91
+ setHMem((h) => push(h, s.memPercent));
92
+ setHNet((h) => push(h, (s.netRecvBps + s.netSentBps) / 1024 / 1024));
93
+ setHGpu((h) => push(h, g.utilPct ?? 0));
94
+ const tv = st.cpuTemp ?? st.gpuTemp;
95
+ if (tv != null)
96
+ setHTemp((h) => push(h, tv));
97
+ }, []);
98
+ const refreshAux = useCallback(async () => {
99
+ if (pausedRef.current || !tokRef.current)
100
+ return;
101
+ const [tk, vx, gt] = await Promise.all([
102
+ tokRef.current.read(),
103
+ useVtex ? readVtex() : Promise.resolve(null),
104
+ gitRef.current ? gitRef.current.read() : Promise.resolve(null),
105
+ ]);
106
+ if (!mounted.current)
107
+ return;
108
+ setTokens(tk);
109
+ setVtex(vx);
110
+ setGit(gt);
111
+ }, [useVtex]);
112
+ useEffect(() => {
113
+ refreshData();
114
+ const id = setInterval(refreshData, Math.max(250, interval * 1000));
115
+ return () => clearInterval(id);
116
+ }, [interval, refreshData]);
117
+ useEffect(() => {
118
+ refreshAux();
119
+ const id = setInterval(refreshAux, 3000);
120
+ return () => clearInterval(id);
121
+ }, [refreshAux]);
122
+ useInput((input) => {
123
+ if (input === "q") {
124
+ powerRef.current?.stop();
125
+ exit();
126
+ }
127
+ else if (input === "p") {
128
+ setPaused((p) => !p);
129
+ }
130
+ else if (input === "+" || input === "=") {
131
+ setIntervalState((i) => Math.max(0.25, i / 2));
132
+ }
133
+ else if (input === "-" || input === "_") {
134
+ setIntervalState((i) => Math.min(10, i * 2));
135
+ }
136
+ });
137
+ // ----- derived helpers ----------------------------------------------------
138
+ const m = 1; // marginRight per panel
139
+ const col2 = Math.max(24, Math.floor((cols - 2 * m) / 2));
140
+ const col3 = Math.max(18, Math.floor((cols - 3 * m) / 3));
141
+ const fullW = Math.max(40, cols - m);
142
+ const inner = (w) => Math.max(6, w - 4); // minus border (2) + padding (2)
143
+ const w2 = inner(col2);
144
+ const w3 = inner(col3);
145
+ const statColor = (lvl) => lvl === "ok" ? pal.green : lvl === "warn" ? pal.yellow : pal.red;
146
+ const Stat = ({ value, warn, crit, metric }) => {
147
+ const lvl = statusLevel(value, warn, crit);
148
+ return (_jsxs(Text, { color: statColor(lvl), children: ["\u25CF ", t(`status.${metric}.${lvl}`)] }));
149
+ };
150
+ const caption = (h, fmt) => {
151
+ if (!h.length)
152
+ return t("spark.collecting");
153
+ const lo = Math.min(...h);
154
+ const hi = Math.max(...h);
155
+ const body = lo === hi ? fmt(lo) : `${fmt(lo)}–${fmt(hi)}`;
156
+ return t("spark.lastMin", { range: body });
157
+ };
158
+ // ----- values -------------------------------------------------------------
159
+ const cpu = sys?.cpuTotal ?? 0;
160
+ const cores = sys?.cpuPer ?? [];
161
+ const memPct = sys?.memPercent ?? 0;
162
+ const ct = sensors?.cpuTemp ?? null;
163
+ const gt = sensors?.gpuTemp ?? null;
164
+ const util = gpu?.utilPct ?? 0;
165
+ const diskRows = disks?.disks ?? [];
166
+ const gitRepos = git?.repos ?? [];
167
+ const tokTotal = tokens ? tokens.input + tokens.output + tokens.cacheW + tokens.cacheR : 0;
168
+ const modelItems = Object.entries(tokens?.byModel ?? {}).sort((a, b) => b[1].cost - a[1].cost);
169
+ const modelParts = modelItems.slice(0, 2).map(([k, v]) => `${k} $${v.cost.toFixed(2)}`);
170
+ const modelExtra = modelItems.length > 2 ? ` +${modelItems.length - 2}` : "";
171
+ const web = tokens && (tokens.webSearch || tokens.webFetch) ? ` · web ${tokens.webSearch}/${tokens.webFetch}` : "";
172
+ const models = modelParts.length ? modelParts.join(" · ") + modelExtra + web : t("tokens.none");
173
+ const powerParts = [];
174
+ if (power && (power.cpuW != null || power.gpuW != null))
175
+ powerParts.push(`${icon("power")} ${(power.cpuW ?? 0).toFixed(1)}+${(power.gpuW ?? 0).toFixed(1)}W`);
176
+ else if (usePower)
177
+ powerParts.push(`${icon("power")} ${t("temp.waitingSudo")}`);
178
+ if (sensors?.fans?.length)
179
+ powerParts.push(`${icon("fan")} ${sensors.fans[0].rpm.toFixed(0)} rpm`);
180
+ const tempLine2 = powerParts.length ? powerParts.join(" · ") : t("temp.needsSudo");
181
+ return (_jsxs(Box, { flexDirection: "column", width: cols, children: [_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsx(Text, { color: pal.purple, bold: true, children: "\u25C7 lattice" }), _jsx(Text, { color: pal.comment, children: paused ? t("paused") : t("subtitle") }), _jsxs(Text, { color: pal.comment, children: [icon("clock"), " ", now] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("cpu")} ${t("panel.cpu")}`, color: pal.cyan, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("cpu.usage"), ": ", _jsxs(Text, { bold: true, children: [cpu.toFixed(0), "%"] }), " ", _jsx(Stat, { value: cpu, warn: 60, crit: 85, metric: "cpu" })] }), _jsxs(Text, { wrap: "truncate", children: [t("cpu.cores"), ": ", cores.length, " ", t("cpu.perCore"), ": ", cores.map(coreCell).join("")] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hCpu, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hCpu, (v) => `${v.toFixed(0)}%`) })] }), _jsxs(Panel, { title: `${icon("mem")} ${t("panel.mem")}`, color: pal.green, width: col2, children: [_jsxs(Text, { wrap: "truncate", children: [t("mem.ram"), ": ", _jsxs(Text, { bold: true, children: [memPct.toFixed(0), "%"] }), " ", _jsx(Stat, { value: memPct, warn: 75, crit: 90, metric: "mem" })] }), _jsx(Text, { wrap: "truncate", children: t("mem.used", {
182
+ used: humanBytes(sys?.memUsed),
183
+ total: humanBytes(sys?.memTotal),
184
+ swap: (sys?.swapPercent ?? 0).toFixed(0),
185
+ }) }), _jsx(Text, { color: pal.green, wrap: "truncate", children: sparkline(hMem, w2) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hMem, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("temp")} ${t("panel.temp")}`, color: pal.orange, width: col3, children: [_jsx(Text, { wrap: "truncate", children: ct != null && gt != null ? (_jsxs(_Fragment, { children: [ct.toFixed(0), "\u00B0 / ", gt.toFixed(0), "\u00B0", " ", _jsx(Stat, { value: Math.max(ct, gt), warn: 65, crit: 80, metric: "temp" })] })) : ct != null || gt != null ? (_jsxs(_Fragment, { children: [(ct ?? gt ?? 0).toFixed(0), "\u00B0C", " ", _jsx(Stat, { value: ct ?? gt ?? 0, warn: 65, crit: 80, metric: "temp" })] })) : (_jsx(Text, { color: pal.comment, children: t("temp.unavailable") })) }), _jsx(Text, { wrap: "truncate", children: tempLine2 }), _jsx(Text, { color: pal.orange, wrap: "truncate", children: sparkline(hTemp, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hTemp, (v) => `${v.toFixed(0)}°`) })] }), _jsxs(Panel, { title: `${icon("net")} ${t("panel.net")}`, color: pal.cyan, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: ["\u2193 ", humanRate(sys?.netRecvBps)] }), _jsxs(Text, { wrap: "truncate", children: ["\u2191 ", humanRate(sys?.netSentBps)] }), _jsx(Text, { color: pal.cyan, wrap: "truncate", children: sparkline(hNet, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hNet, (v) => humanRate(v * 1024 * 1024)) })] }), _jsxs(Panel, { title: `${icon("gpu")} ${t("panel.gpu")}`, color: pal.purple, width: col3, children: [_jsxs(Text, { wrap: "truncate", children: [t("gpu.usage"), ": ", _jsxs(Text, { bold: true, children: [util, "%"] }), " ", _jsx(Stat, { value: util, warn: 60, crit: 85, metric: "gpu" })] }), _jsx(Text, { wrap: "truncate", children: t("gpu.mem", { used: humanBytes(gpu?.memUsedBytes), alloc: humanBytes(gpu?.memAllocBytes) }) }), _jsx(Text, { color: pal.purple, wrap: "truncate", children: sparkline(hGpu, w3) }), _jsx(Text, { color: pal.comment, wrap: "truncate", children: caption(hGpu, (v) => `${v.toFixed(0)}%`) })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.cyan, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.cyan, bold: true, children: [icon("disk"), " ", t("panel.disks")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("disks.mount").padEnd(22), t("disks.read").padEnd(12), t("disks.write").padEnd(12), t("disks.usage")] }), diskRows.length === 0 ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : (diskRows.map((d) => {
186
+ const lvl = statusLevel(d.usePercent, 80, 92);
187
+ return (_jsxs(Text, { wrap: "truncate", children: [d.mount.slice(0, 21).padEnd(22), humanRate(d.readBps).padEnd(12), humanRate(d.writeBps).padEnd(12), _jsxs(Text, { color: statColor(lvl), children: ["\u25CF ", d.usePercent.toFixed(0), "%"] }), " ", _jsxs(Text, { color: pal.comment, children: ["(", humanBytes(d.usedBytes), "/", humanBytes(d.sizeBytes), ")"] })] }, d.mount));
188
+ }))] }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Panel, { title: `${icon("tokens")} ${t("panel.tokens")}`, color: pal.pink, width: useVtex ? col2 : fullW, children: [_jsx(Text, { wrap: "truncate", children: t("tokens.spent", { cost: (tokens?.cost ?? 0).toFixed(2), messages: tokens?.messages ?? 0 }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.tokens", {
189
+ total: fmtTok(tokTotal),
190
+ input: fmtTok(tokens?.input),
191
+ output: fmtTok(tokens?.output),
192
+ }) }), _jsx(Text, { wrap: "truncate", children: t("tokens.cache", { cw: fmtTok(tokens?.cacheW), cr: fmtTok(tokens?.cacheR) }) }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("tokens.byModel", { models }) })] }), useVtex && (_jsx(Panel, { title: `${icon("vtex")} ${t("panel.vtex")}`, color: pal.purple, width: col2, children: !vtex ? (_jsx(Text, { color: pal.comment, children: t("spark.collecting") })) : !vtex.installed ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.red, children: t("vtex.notInstalled") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.install") })] })) : vtex.loggedIn ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.green, children: t("vtex.connected") })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.account"), ": ", _jsx(Text, { bold: true, children: vtex.account })] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.user"), ": ", vtex.login || "—"] }), _jsxs(Text, { wrap: "truncate", children: [t("vtex.workspace"), ": ", vtex.workspace || "master"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t("vtex.status"), ": ", _jsx(Text, { color: pal.yellow, children: t("vtex.notConnected") })] }), _jsx(Text, { wrap: "truncate", color: pal.comment, children: t("vtex.signin") })] })) }))] }), gitRepos.length > 0 && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.green, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.green, bold: true, wrap: "truncate", children: [icon("git"), " ", t("panel.git"), git?.truncated ? _jsx(Text, { color: pal.comment, children: " (+)" }) : null] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("git.repo").padEnd(20), t("git.branch").padEnd(22), t("git.state")] }), gitRepos.map((r) => {
193
+ const label = r.detached ? "(detached)" : r.branch;
194
+ return (_jsxs(Text, { wrap: "truncate", children: [r.name.slice(0, 19).padEnd(20), label.slice(0, 21).padEnd(22), _jsxs(Text, { color: r.dirty ? pal.yellow : pal.green, children: ["\u25CF ", r.dirty ? t("git.dirty") : t("git.clean")] }), r.ahead > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2191", r.ahead] }) : null, r.behind > 0 ? _jsxs(Text, { color: pal.comment, children: [" ", "\u2193", r.behind] }) : null] }, r.name));
195
+ })] })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: pal.comment, paddingX: 1, marginRight: 1, width: fullW, children: [_jsxs(Text, { color: pal.comment, bold: true, children: [icon("proc"), " ", t("panel.procs")] }), _jsxs(Text, { color: pal.purple, bold: true, wrap: "truncate", children: [t("proc.cpu").padEnd(6), t("proc.mem").padEnd(9), t("proc.pid").padEnd(8), t("proc.name")] }), (sys?.procs ?? []).map((p) => (_jsxs(Text, { wrap: "truncate", children: [`${p.cpu.toFixed(0)}%`.padEnd(6), humanBytes(p.rss).padEnd(9), String(p.pid).padEnd(8), p.name.slice(0, 30)] }, p.pid)))] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: pal.comment, children: [_jsx(Text, { color: pal.pink, children: "q" }), " ", t("key.quit"), " \u00B7 ", _jsx(Text, { color: pal.pink, children: "p" }), " ", t("key.pause"), " \u00B7", " ", _jsx(Text, { color: pal.pink, children: "+/-" }), " ", t("key.faster"), "/", t("key.slower"), " \u00B7 ", interval.toFixed(2), "s"] }) })] }));
196
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { dirname } from "node:path";
4
+ import { render } from "ink";
5
+ import meow from "meow";
6
+ import { execa } from "execa";
7
+ import { App } from "./app.js";
8
+ import { LanguageSelect } from "./components/LanguageSelect.js";
9
+ import { loadConfig, saveConfig } from "./config.js";
10
+ import { detectLang, isLang, makeT } from "./i18n/index.js";
11
+ import { isVariant, palette } from "./theme.js";
12
+ import { isIconMode, makeIcons } from "./icons.js";
13
+ const cli = meow(`
14
+ ${"lattice"} — real-time terminal dashboard for macOS Apple Silicon
15
+
16
+ Usage
17
+ $ lattice [options]
18
+
19
+ Options
20
+ --no-power skip powermetrics/sudo (CPU/GPU/RAM/disk/net only)
21
+ --no-vtex hide the VTEX panel (for non-VTEX users)
22
+ --repos folder of git repos to show branches for
23
+ (default: the parent of the current directory)
24
+ --interval, -i refresh interval in seconds (default 1)
25
+ --procs, -n number of top processes to show (default 8)
26
+ --icons icon style: nerd | emoji | none (default nerd)
27
+ --lang language: en | es | pt-BR (asked on first run)
28
+ --theme pro | blade | buffy | lincoln | morbius | van-helsing
29
+ --version, -v
30
+ --help
31
+
32
+ Examples
33
+ $ lattice
34
+ $ lattice --no-power --icons emoji
35
+ $ lattice --lang es --theme blade
36
+ `, {
37
+ importMeta: import.meta,
38
+ description: false,
39
+ flags: {
40
+ power: { type: "boolean", default: true },
41
+ vtex: { type: "boolean", default: true },
42
+ repos: { type: "string", default: "" },
43
+ interval: { type: "number", shortFlag: "i", default: 1 },
44
+ procs: { type: "number", shortFlag: "n", default: 8 },
45
+ icons: { type: "string", default: "" },
46
+ lang: { type: "string", default: "" },
47
+ theme: { type: "string", default: "" },
48
+ },
49
+ });
50
+ async function resolveLang() {
51
+ const cfg = loadConfig();
52
+ const flag = cli.flags.lang;
53
+ if (flag && isLang(flag)) {
54
+ saveConfig({ lang: flag });
55
+ return flag;
56
+ }
57
+ if (cfg.lang)
58
+ return cfg.lang;
59
+ // First run: ask, unless stdin isn't interactive.
60
+ if (!process.stdin.isTTY) {
61
+ const detected = detectLang();
62
+ saveConfig({ lang: detected });
63
+ return detected;
64
+ }
65
+ const variant = resolveTheme();
66
+ const chosen = await new Promise((resolve) => {
67
+ const app = render(_jsx(LanguageSelect, { pal: palette(variant), onSelect: (l) => {
68
+ app.unmount();
69
+ resolve(l);
70
+ } }));
71
+ });
72
+ saveConfig({ lang: chosen });
73
+ return chosen;
74
+ }
75
+ function resolveTheme() {
76
+ const flag = cli.flags.theme;
77
+ if (flag && isVariant(flag)) {
78
+ saveConfig({ theme: flag });
79
+ return flag;
80
+ }
81
+ return loadConfig().theme ?? "pro";
82
+ }
83
+ function resolveIcons() {
84
+ const flag = cli.flags.icons;
85
+ if (flag && isIconMode(flag)) {
86
+ saveConfig({ icons: flag });
87
+ return flag;
88
+ }
89
+ return loadConfig().icons ?? "nerd";
90
+ }
91
+ async function main() {
92
+ const lang = await resolveLang();
93
+ const variant = resolveTheme();
94
+ const iconMode = resolveIcons();
95
+ const t = makeT(lang);
96
+ const pal = palette(variant);
97
+ const icon = makeIcons(iconMode);
98
+ let usePower = cli.flags.power;
99
+ // Pre-authenticate sudo BEFORE the TUI takes over the terminal.
100
+ if (usePower) {
101
+ process.stdout.write(t("cli.sudoNeed") + "\n");
102
+ try {
103
+ await execa("sudo", ["-v"], { stdio: "inherit" });
104
+ }
105
+ catch {
106
+ process.stdout.write(t("cli.sudoFail") + "\n");
107
+ usePower = false;
108
+ }
109
+ }
110
+ const { waitUntilExit } = render(_jsx(App, { t: t, pal: pal, icon: icon, lang: lang, usePower: usePower, useVtex: cli.flags.vtex, gitDir: cli.flags.repos || dirname(process.cwd()), interval: cli.flags.interval, topN: cli.flags.procs }));
111
+ await waitUntilExit();
112
+ }
113
+ main().catch((e) => {
114
+ process.stderr.write(String(e) + "\n");
115
+ process.exit(1);
116
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Per-disk activity & usage (no sudo).
3
+ *
4
+ * Lists each "real" mount — `/` and everything under `/Volumes/*` — with its
5
+ * space usage (from `systeminformation`) and live read/write throughput.
6
+ *
7
+ * Throughput comes from IOKit's cumulative byte counters, read via `ioreg`
8
+ * (same approach as gpu.ts — no sudo, non-blocking). Each APFS volume's mount
9
+ * device (e.g. `/dev/disk7s1`) maps to a whole disk (`disk7`); IOKit reports a
10
+ * `Bytes (Read)`/`Bytes (Write)` counter for that whole disk, including the
11
+ * synthesized APFS container disks. We diff successive samples to get bytes/sec,
12
+ * so the first sample reads as zero and later ones are accurate at the refresh
13
+ * interval.
14
+ */
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import si from "systeminformation";
18
+ const run = promisify(execFile);
19
+ export class DisksCollector {
20
+ /** Previous cumulative counters per whole disk, for rate computation. */
21
+ prev = new Map();
22
+ async read() {
23
+ try {
24
+ const [sizes, counters] = await Promise.all([
25
+ si.fsSize().catch(() => []),
26
+ readDiskCounters().catch(() => ({})),
27
+ ]);
28
+ const now = Date.now();
29
+ const mounts = (sizes ?? []).filter((s) => s.fs?.startsWith("/dev/disk") && (s.mount === "/" || s.mount.startsWith("/Volumes/")));
30
+ const disks = mounts.map((s) => {
31
+ const device = wholeDisk(s.fs);
32
+ const cur = counters[device];
33
+ const prev = this.prev.get(device);
34
+ let readBps = 0;
35
+ let writeBps = 0;
36
+ if (cur && prev) {
37
+ const dt = (now - prev.t) / 1000;
38
+ if (dt > 0) {
39
+ readBps = Math.max(0, (cur.r - prev.r) / dt);
40
+ writeBps = Math.max(0, (cur.w - prev.w) / dt);
41
+ }
42
+ }
43
+ return {
44
+ mount: s.mount,
45
+ device: device || s.fs,
46
+ readBps,
47
+ writeBps,
48
+ usedBytes: s.used ?? 0,
49
+ sizeBytes: s.size ?? 0,
50
+ usePercent: s.use ?? 0,
51
+ };
52
+ });
53
+ // Remember the latest counters for the next delta.
54
+ for (const [dev, c] of Object.entries(counters))
55
+ this.prev.set(dev, { r: c.r, w: c.w, t: now });
56
+ // Root first, then /Volumes alphabetically.
57
+ disks.sort((a, b) => a.mount === "/" ? -1 : b.mount === "/" ? 1 : a.mount.localeCompare(b.mount));
58
+ return { disks };
59
+ }
60
+ catch {
61
+ return { disks: [] };
62
+ }
63
+ }
64
+ }
65
+ /** `/dev/disk7s1` → `disk7` (the whole disk IOKit reports counters for). */
66
+ function wholeDisk(fs) {
67
+ const m = (fs ?? "").replace("/dev/", "").match(/^disk\d+/);
68
+ return m ? m[0] : "";
69
+ }
70
+ /**
71
+ * Cumulative read/write bytes per whole disk, from IOKit's
72
+ * IOBlockStorageDriver "Statistics". The block-driver counter uses the keys
73
+ * `Bytes (Read)`/`Bytes (Write)`; APFS filesystem-level stats use different
74
+ * keys and are ignored. The whole-disk BSD name follows its driver's stats.
75
+ */
76
+ async function readDiskCounters() {
77
+ const { stdout } = await run("ioreg", ["-r", "-c", "IOBlockStorageDriver", "-w", "0", "-l"], {
78
+ maxBuffer: 1 << 24,
79
+ });
80
+ const out = {};
81
+ let pending = null;
82
+ for (const ln of stdout.split("\n")) {
83
+ const sm = ln.match(/"Statistics" = \{[^}]*"Bytes \(Read\)"=(\d+)/);
84
+ if (sm) {
85
+ const wm = ln.match(/"Bytes \(Write\)"=(\d+)/);
86
+ pending = { r: Number(sm[1]), w: wm ? Number(wm[1]) : 0 };
87
+ continue;
88
+ }
89
+ const bm = ln.match(/"BSD Name" = "(disk\d+)"\s*$/);
90
+ if (bm && pending) {
91
+ out[bm[1]] = pending;
92
+ pending = null;
93
+ }
94
+ }
95
+ return out;
96
+ }