1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
|
# Performance Panel Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A performance panel dropping from the island's status pill (CPU, RAM, temperatures, GPU, disks, network, public IP check), replacing four conky widgets.
**Architecture:** `Perf.js` holds every parser and calculation as pure functions, tested under node. `Perf.qml` is a singleton that reads `/proc` and `/sys` through `FileView`s on timers and runs `df`, the fdinfo scan, `ip` and `curl` as `Process`es; it exposes plain properties and one `severity`. `PerfPanel.qml` renders them as a three-column card grid in the status pill's capsule, like the sound and controls panels. `Prefs.qml` gains `pinnedIp`.
**Tech Stack:** Quickshell 0.3.1 QML, QtQuick.Layouts, node for JS tests, coreutils `timeout`, `/usr/bin/df`, `curl`, `ip`.
**Spec:** `docs/superpowers/specs/2026-10-13-performance-panel-design.md`
---
## Read first
- `AGENTS.md`: "The rule that bites first", "Verifying, and how not to", "Reloading", and the per-component notes. In short: the process is `qs`; never `pkill -f`; never start a detached `qs` from a tool call; read the running shell with `qs log -p ~/Programming/GIT/quickshell/desktop -t 40`; a new directory may need `./reload.sh desktop` from the user's terminal.
- `desktop/island/StatusPill.qml`, `Island.qml`, `ControlsPanel.qml`, `Capsule.qml`: the panel mechanism this copies.
- `desktop/island/test_controls.js`: how a `.pragma library` file is loaded under node.
- `desktop/Prefs.qml`: the preferences singleton this extends.
- The conky sources being ported, for reference only: `~/Programming/GIT/conky-theme-udt/widgets/{system,gpu,disks,network}.lua`, `lib/data.lua`, `bin/disks-sample.sh`, `bin/pubip-sample.sh`.
Measured on this machine while planning:
- `FileView` with `blockLoading: true` reads `/proc/stat` and hwmon files and returns fresh contents after each `reload()` (probed with a throwaway shell).
- hwmon names present: `acpitz`, `nvme`, `k10temp`, `gigabyte_wmi`, `prom21_xhci`, `xe`. Their `hwmonN` numbers are not stable across boots.
- The fdinfo scan (`grep -sH ... /proc/[0-9]*/fdinfo/*`) takes 14ms and prints lines like `/proc/12191/fdinfo/10:drm-resident-vram0:\t87064 KiB`; one client id appears on several fds.
- `timeout 5 /usr/bin/df -P -B1 / /home /data /mnt/nfs/Library /mnt/nfs/shared` prints a header and five rows; NFS rows start `<server>:/export`.
- `ip -4 -o addr show br0` prints one line containing `inet <addr>/24`.
- `~/.cache/udt/pubip.txt` holds `ip <addr>` and `fetched <epoch>` lines.
- `candy-icons`, the active icon theme, has `utilities-system-monitor`. Inconsolata Nerd Font has U+F318, U+F015, U+F02CA, U+F06F3 (mount glyphs, conky's) and U+F0403 (pin).
Never commit a real IP address, hostname or the user's pinned address: tests use documentation addresses (`192.0.2.x`, `198.51.100.x`, `203.0.113.x`). A gitleaks hook blocks personal data and home paths.
GPLv2 header, used in every new file (`// ` prefix):
```
Copyright (C) 2026 Danilo M. <danix@danix.xyz>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
```
The code blocks below write it as `// <GPLv2 header>`; expand it in full. Compare `desktop/island/Controls.js` (`.pragma library` first, then the header).
## Files
| File | Action | Responsibility |
| --- | --- | --- |
| `docs/superpowers/specs/2026-10-13-performance-panel-design.md` | modify | two details settled while planning |
| `desktop/island/perf/Perf.js` | create | parsers, rates, bands, IP check, formatting |
| `desktop/island/perf/test_perf.js` | create | node test for Perf.js |
| `desktop/Prefs.qml` | modify | `pinnedIp`, `setPinnedIp()` |
| `desktop/island/perf/Perf.qml` | create | singleton: sampling and state |
| `desktop/island/perf/PerfPanel.qml` | create | the card grid |
| `desktop/island/StatusPill.qml` | modify | the icon, hosts the panel, gates `detailed` |
| `desktop/island/Island.qml` | modify | the `"perf"` panel |
| `desktop/island/README.md`, `AGENTS.md` | modify | docs |
| `~/Programming/GIT/conky-theme-udt/...` | modify | remove the four widgets (separate repo) |
---
### Task 1: Spec corrections
**Files:**
- Modify: `docs/superpowers/specs/2026-10-13-performance-panel-design.md`
Two details changed while planning: `IconImage` cannot be recoloured without an effect layer, so the warning is a coloured dot on the icon rather than a tint; and a stale public IP is a dash, with no age shown, which is all the spec's own Errors rule asked for.
- [ ] **Step 1: Edit "The icon"**
Replace the paragraph starting `` `utilities-system-monitor` through `Quickshell.iconPath` `` and the line after the table starting `Neutral when ok` with:
```markdown
`utilities-system-monitor` through `Quickshell.iconPath`, placed before the
controls icon in the status pill. A dot on its top-right corner shows the
worst severity; `IconImage` cannot be recoloured without an effect layer, and
a dot reads as well:
```
and
```markdown
No dot when ok, a `Theme.yellow` dot on warn, a `Theme.red` dot on crit. Not
the accent, which udt can snap onto a state colour. CPU load is left out:
spikes are normal and would flicker the dot, and sustained load shows up as
heat.
```
(The sentence `CPU load is left out...` that followed is now part of the replacement; delete the old copy.)
- [ ] **Step 2: Edit the public IP table**
In `## Public IP`, change the last table row to:
```markdown
| fetch failed, or value stale | ok; the last value while under 4 hours old, a dash after |
```
- [ ] **Step 3: Commit**
```bash
git add docs/superpowers/specs/2026-10-13-performance-panel-design.md
git commit -m "docs(spec): performance warning is a dot, stale IP a dash
IconImage cannot be recoloured without an effect layer, so the severity
shows as a dot on the icon's corner. A stale public IP is a dash with no
age, as the Errors section already said.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: Perf.js
**Files:**
- Create: `desktop/island/perf/Perf.js`
- Test: `desktop/island/perf/test_perf.js`
- [ ] **Step 1: Write the failing test**
`desktop/island/perf/test_perf.js`:
```js
// <GPLv2 header>
// Run: node desktop/island/perf/test_perf.js
// Perf.js is a QML library, so its .pragma line is stripped before node
// evaluates it, as test_controls.js does for Controls.js.
const fs = require("fs");
const assert = require("assert");
const src = fs.readFileSync(__dirname + "/Perf.js", "utf8")
.replace(/^\.pragma library$/m, "");
const P = new Function(src + "\nreturn { cpuTimes, load, meminfo, df, vram, rate, " +
"hwmonMap, lanAddr, cpuModel, band, worst, isIPv4, parsePubip, formatPubip, " +
"ipCheck, due, push, bytes, speed };")();
// cpuTimes: aggregate first, then cores; idle is idle + iowait.
const stat = [
"cpu 100 0 50 800 50 0 0 0 0 0",
"cpu0 50 0 25 400 25 0 0 0 0 0",
"cpu1 50 0 25 400 25 0 0 0 0 0",
"intr 12345",
"ctxt 999",
].join("\n");
const t = P.cpuTimes(stat);
assert.strictEqual(t.length, 3);
assert.deepStrictEqual(t[0], { total: 1000, idle: 850 });
assert.deepStrictEqual(t[1], { total: 500, idle: 425 });
assert.deepStrictEqual(P.cpuTimes(""), []);
// load: busy share of the delta; no prev, or no time passed, is null.
assert.strictEqual(P.load({ total: 1000, idle: 850 }, { total: 1100, idle: 925 }), 25);
assert.strictEqual(P.load(undefined, { total: 1, idle: 1 }), null);
assert.strictEqual(P.load({ total: 5, idle: 5 }, { total: 5, idle: 5 }), null);
assert.strictEqual(P.load({ total: 0, idle: 0 }, { total: 100, idle: 0 }), 100);
// meminfo: used is total - available, in bytes.
const mem = "MemTotal: 32000000 kB\nMemFree: 1000 kB\nMemAvailable: 24000000 kB\n";
assert.deepStrictEqual(P.meminfo(mem), { total: 32000000 * 1024, used: 8000000 * 1024 });
assert.strictEqual(P.meminfo("MemTotal: 5 kB\n"), null);
// df: header skipped, NFS host from the device field, bytes as numbers.
const dfOut = [
"Filesystem 1-blocks Used Available Capacity Mounted on",
"/dev/mapper/vg-root 263088050176 143921766400 105727733760 58% /",
"192.0.2.11:/Volume1/Library 3926310780928 3185272160256 541516365824 86% /mnt/nfs/Library",
].join("\n");
const d = P.df(dfOut);
assert.strictEqual(d.length, 2);
assert.deepStrictEqual(d[0], { mount: "/", host: "", size: 263088050176, used: 143921766400, pct: 58 });
assert.strictEqual(d[1].host, "192.0.2.11");
assert.strictEqual(d[1].mount, "/mnt/nfs/Library");
assert.deepStrictEqual(P.df(""), []);
// vram: summed once per xe client id, units honoured, other drivers ignored.
const fd = [
"/proc/1/fdinfo/7:drm-driver:\txe",
"/proc/1/fdinfo/7:drm-client-id:\t38",
"/proc/1/fdinfo/7:drm-resident-vram0:\t1024 KiB",
"/proc/1/fdinfo/10:drm-driver:\txe",
"/proc/1/fdinfo/10:drm-client-id:\t38",
"/proc/1/fdinfo/10:drm-resident-vram0:\t1024 KiB",
"/proc/2/fdinfo/5:drm-driver:\txe",
"/proc/2/fdinfo/5:drm-client-id:\t40",
"/proc/2/fdinfo/5:drm-resident-vram0:\t2 MiB",
"/proc/3/fdinfo/4:drm-driver:\tamdgpu",
"/proc/3/fdinfo/4:drm-client-id:\t1",
"/proc/3/fdinfo/4:drm-resident-vram0:\t9 GiB",
].join("\n");
assert.strictEqual(P.vram(fd), 1024 * 1024 + 2 * 1024 * 1024);
assert.strictEqual(P.vram(""), null);
// rate: bytes per second; a counter that went backwards is null.
assert.strictEqual(P.rate(1000, 3000, 2), 1000);
assert.strictEqual(P.rate(3000, 1000, 1), null);
assert.strictEqual(P.rate(null, 1000, 1), null);
assert.strictEqual(P.rate(1000, 2000, 0), null);
// hwmonMap: chip name -> directory, first one wins.
const names = "/sys/class/hwmon/hwmon1/name:nvme\n/sys/class/hwmon/hwmon5/name:xe\n/sys/class/hwmon/hwmon7/name:nvme\n";
assert.deepStrictEqual(P.hwmonMap(names),
{ nvme: "/sys/class/hwmon/hwmon1", xe: "/sys/class/hwmon/hwmon5" });
// lanAddr: the address without its prefix length.
assert.strictEqual(P.lanAddr("4: br0 inet 192.0.2.5/24 brd 192.0.2.255 scope global br0\n"), "192.0.2.5");
assert.strictEqual(P.lanAddr(""), "");
// cpuModel: vendor, trademarks and the core-count tail dropped.
assert.strictEqual(P.cpuModel("processor\t: 0\nmodel name\t: AMD Ryzen 9 7950X 16-Core Processor\n"), "Ryzen 9 7950X");
assert.strictEqual(P.cpuModel("model name\t: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz\n"), "Core i7-1165G7 @ 2.80GHz");
assert.strictEqual(P.cpuModel(""), "");
// band and worst: null is "" (unknown), which never raises the worst.
assert.strictEqual(P.band(10, 25, 75), "ok");
assert.strictEqual(P.band(25, 25, 75), "warn");
assert.strictEqual(P.band(75, 25, 75), "crit");
assert.strictEqual(P.band(null, 25, 75), "");
assert.strictEqual(P.worst(["ok", "", "warn"]), "warn");
assert.strictEqual(P.worst(["warn", "crit", "ok"]), "crit");
assert.strictEqual(P.worst(["", ""]), "ok");
assert.strictEqual(P.worst([]), "ok");
// isIPv4
assert.ok(P.isIPv4("203.0.113.5"));
assert.ok(!P.isIPv4("203.0.113.256"));
assert.ok(!P.isIPv4("<html>"));
assert.ok(!P.isIPv4(""));
// The pubip cache, in conky's format, both ways.
assert.deepStrictEqual(P.parsePubip("ip 203.0.113.5\nfetched 1700000000\n"), { ip: "203.0.113.5", fetched: 1700000000 });
assert.strictEqual(P.parsePubip("ip nonsense\nfetched 1\n"), null);
assert.strictEqual(P.parsePubip(""), null);
assert.strictEqual(P.formatPubip("203.0.113.5", 1700000000), "ip 203.0.113.5\nfetched 1700000000\n");
assert.deepStrictEqual(P.parsePubip(P.formatPubip("198.51.100.7", 42)), { ip: "198.51.100.7", fetched: 42 });
// ipCheck: no pin ok; match ok; mismatch only when fresh; stale is a dash.
const now = 1700010000;
const fresh = { ip: "203.0.113.5", fetched: now - 60 };
const stale = { ip: "198.51.100.7", fetched: now - 5 * 3600 };
assert.deepStrictEqual(P.ipCheck(fresh, now, ""), { ip: "203.0.113.5", mismatch: false });
assert.deepStrictEqual(P.ipCheck(fresh, now, "203.0.113.5"), { ip: "203.0.113.5", mismatch: false });
assert.deepStrictEqual(P.ipCheck(fresh, now, "198.51.100.7"), { ip: "203.0.113.5", mismatch: true });
assert.deepStrictEqual(P.ipCheck(stale, now, "203.0.113.5"), { ip: "", mismatch: false });
assert.deepStrictEqual(P.ipCheck(null, now, "203.0.113.5"), { ip: "", mismatch: false });
// due: no value, or one at least `every` seconds old.
assert.ok(P.due(null, now, 1800));
assert.ok(P.due({ ip: "x", fetched: now - 1800 }, now, 1800));
assert.ok(!P.due({ ip: "x", fetched: now - 1799 }, now, 1800));
// push: newest last, capped.
assert.deepStrictEqual(P.push([1, 2, 3], 4, 3), [2, 3, 4]);
assert.deepStrictEqual(P.push([], 1, 3), [1]);
// bytes and speed
assert.strictEqual(P.bytes(512), "512B");
assert.strictEqual(P.bytes(1536), "1.5K");
assert.strictEqual(P.bytes(9.1 * 1024 ** 3), "9.1G");
assert.strictEqual(P.bytes(null), "--");
assert.strictEqual(P.speed(1024 * 1024 * 1.2), "1.2M/s");
assert.strictEqual(P.speed(null), "--");
console.log("test_perf: ok");
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node desktop/island/perf/test_perf.js`
Expected: FAIL with `ENOENT: no such file or directory` for `Perf.js`.
- [ ] **Step 3: Write the implementation**
`desktop/island/perf/Perf.js`:
```js
.pragma library
// <GPLv2 header>
// The performance panel's parsing and arithmetic, kept pure so it runs under
// node (test_perf.js). Perf.qml does the reading; everything here takes text
// or numbers and returns values. A reading that cannot be made is null, and
// the panel draws null as a dash.
const UNITS = { "": 1, B: 1, KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3 };
// A public IP older than this is not shown: by then the fetch has failed
// for hours, and an address that may no longer be ours is worse than a dash.
const STALE = 4 * 3600;
// One {total, idle} per cpu line of /proc/stat; index 0 is the aggregate,
// then the cores in file order. idle counts iowait, as conky did.
function cpuTimes(stat) {
const out = [];
for (const line of stat.split("\n")) {
const m = /^cpu\d*\s+(.*)$/.exec(line);
if (!m) continue;
const v = m[1].trim().split(/\s+/).map(Number);
if (v.length < 5) continue;
out.push({ total: v.reduce((a, b) => a + b, 0), idle: v[3] + v[4] });
}
return out;
}
// Percent busy between two samples.
function load(prev, cur) {
if (!prev || !cur) return null;
const dt = cur.total - prev.total;
if (dt <= 0) return null;
return Math.max(0, Math.min(100, 100 * (1 - (cur.idle - prev.idle) / dt)));
}
// {total, used} in bytes; used is MemTotal - MemAvailable.
function meminfo(text) {
const kb = key => {
const m = new RegExp("^" + key + ":\\s+(\\d+)", "m").exec(text);
return m ? Number(m[1]) * 1024 : null;
};
const total = kb("MemTotal"), avail = kb("MemAvailable");
if (total === null || avail === null) return null;
return { total: total, used: total - avail };
}
// Rows of `df -P -B1`. The header fails the percent match and is skipped.
// host is the NFS server from the device field, "" for a local disk, so the
// panel labels shares without an address in the repo.
function df(text) {
const out = [];
for (const line of text.split("\n")) {
const m = /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(\S+)\s*$/.exec(line);
if (!m) continue;
const h = /^([^/:]+):/.exec(m[1]);
out.push({ mount: m[6], host: h ? h[1] : "", size: Number(m[2]),
used: Number(m[3]), pct: Number(m[5]) });
}
return out;
}
// VRAM in bytes from the fdinfo scan: drm-resident-vram0 of every xe fd,
// counted once per drm-client-id because a client holds several fds.
function vram(text) {
const fds = {};
for (const line of text.split("\n")) {
const m = /^([^:]*):(drm-[\w-]+):\s*(.*)$/.exec(line);
if (!m) continue;
(fds[m[1]] = fds[m[1]] || {})[m[2]] = m[3];
}
const seen = {};
let total = null;
for (const path in fds) {
const fd = fds[path];
const id = fd["drm-client-id"];
const n = /^(\d+)\s*(\w*)/.exec(fd["drm-resident-vram0"] || "");
if (fd["drm-driver"] !== "xe" || !id || !n || seen[id]) continue;
seen[id] = true;
total = (total || 0) + Number(n[1]) * (UNITS[n[2]] || 1);
}
return total;
}
// Bytes per second between two counter readings. A counter that went
// backwards (interface reset) gives null rather than a negative spike.
function rate(prev, cur, secs) {
if (prev === null || cur === null || !(secs > 0) || cur < prev) return null;
return (cur - prev) / secs;
}
// `grep -H . /sys/class/hwmon/hwmon*/name` -> {chip: dir}. hwmonN numbers
// change across boots, so chips are found by name.
function hwmonMap(text) {
const map = {};
for (const line of text.split("\n")) {
const m = /^(.*)\/name:(.+)$/.exec(line);
if (m && !(m[2] in map)) map[m[2]] = m[1];
}
return map;
}
// `ip -4 -o addr show <iface>` -> the first address, without its prefix.
function lanAddr(text) {
const m = /\binet (\d+\.\d+\.\d+\.\d+)\//.exec(text);
return m ? m[1] : "";
}
// The first model name in /proc/cpuinfo, trimmed the way conky trimmed it.
function cpuModel(cpuinfo) {
const m = /^model name\s*:\s*(.+)$/m.exec(cpuinfo);
if (!m) return "";
return m[1].replace(/\((R|TM|tm)\)/g, "")
.replace(/^\s*\d+th Gen\s+/, "")
.replace(/^\s*(AMD|Intel)\s+/, "")
.replace(/\s+\d+-Core Processor.*$/, "")
.replace(/\s+/g, " ").trim();
}
// "ok", "warn", "crit", or "" for a value that could not be read.
function band(v, warn, crit) {
if (v === null || v === undefined) return "";
return v >= crit ? "crit" : v >= warn ? "warn" : "ok";
}
// The worst of a list of bands; unknowns never raise it.
function worst(bands) {
if (bands.indexOf("crit") >= 0) return "crit";
if (bands.indexOf("warn") >= 0) return "warn";
return "ok";
}
function isIPv4(s) {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) && s.split(".").every(o => Number(o) <= 255);
}
// conky's cache format: "ip <addr>" and "fetched <epoch>" lines.
function parsePubip(text) {
const ip = /^ip (\S+)$/m.exec(text), at = /^fetched (\d+)$/m.exec(text);
return ip && at && isIPv4(ip[1]) ? { ip: ip[1], fetched: Number(at[1]) } : null;
}
function formatPubip(ip, fetched) {
return "ip " + ip + "\nfetched " + fetched + "\n";
}
// What the panel shows and whether the icon warns. Only a fresh value can
// mismatch: a failed fetch leaves the old one to go stale, never to alarm.
function ipCheck(pub, now, pin) {
const fresh = !!pub && now - pub.fetched < STALE;
return { ip: fresh ? pub.ip : "", mismatch: fresh && !!pin && pub.ip !== pin };
}
// Whether a fetch is due: no value yet, or one at least `every` seconds old.
function due(pub, now, every) {
return !pub || now - pub.fetched >= every;
}
// A history with v appended, oldest dropped past n.
function push(list, v, n) {
const out = list.concat([v]);
return out.length > n ? out.slice(out.length - n) : out;
}
function bytes(n) {
if (n === null || n === undefined) return "--";
const u = ["B", "K", "M", "G", "T"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : String(Math.round(n))) + u[i];
}
function speed(n) {
return n === null || n === undefined ? "--" : bytes(n) + "/s";
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node desktop/island/perf/test_perf.js`
Expected: `test_perf: ok`
- [ ] **Step 5: Commit**
```bash
git add desktop/island/perf/Perf.js desktop/island/perf/test_perf.js
git commit -m "feat(perf): pure parsing and arithmetic for the performance panel
/proc/stat deltas, meminfo, df rows, the fdinfo VRAM sum, byte rates,
hwmon lookup by chip name, the pubip cache in conky's format, and the
bands that colour the panel and the icon. A reading that cannot be
made is null, and null never raises the worst band, so a missing
sensor cannot raise an alarm. Only a fresh public IP can mismatch the
pinned one; a failed fetch goes stale instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Prefs.pinnedIp
**Files:**
- Modify: `desktop/Prefs.qml`
- [ ] **Step 1: Add the property, the setter and the parse**
In `desktop/Prefs.qml`, after `property var controls: Controls.defaults()` add:
```qml
// The public IP the user pinned from the performance panel; "" for none.
property string pinnedIp: ""
```
Replace `function setControls(list) { ... }` with:
```qml
function setControls(list) {
root.controls = Controls.normalize(list);
root.write(Object.assign({}, root.doc, { controls: root.controls }));
}
function setPinnedIp(ip) {
if (ip === root.pinnedIp) return;
root.pinnedIp = ip;
const perf = Object.assign({}, root.doc.perf, { pinnedIp: ip });
root.write(Object.assign({}, root.doc, { perf: perf }));
}
function write(out) {
root.doc = out;
file.setText(JSON.stringify(out, null, 2) + "\n");
}
```
In `reparse()`, after the `if (JSON.stringify(next) ...` line add:
```qml
const pin = parsed.perf && typeof parsed.perf.pinnedIp === "string" ? parsed.perf.pinnedIp : "";
if (pin !== root.pinnedIp) root.pinnedIp = pin;
```
In the `FileView`, change `onLoadFailed` to:
```qml
onLoadFailed: { root.doc = {}; root.controls = Controls.defaults(); root.pinnedIp = ""; }
```
- [ ] **Step 2: Check the running shell**
Run: `sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop -t 40 2>&1 | grep -v "host portal\|playerctld"`
Expected: a `Reloading configuration...` / `Configuration Loaded` pair after the edit and no `ReferenceError`, `TypeError` or `SyntaxError` naming `Prefs`.
- [ ] **Step 3: Commit**
```bash
git add desktop/Prefs.qml
git commit -m "feat(desktop): Prefs holds a pinned public IP
perf.pinnedIp in desktop.json, set from the performance panel's pin
button and written only on change. The address lives in the user's
state file, never in the repo. setControls and setPinnedIp share one
write so both keep keys this version does not know.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Perf.qml
**Files:**
- Create: `desktop/island/perf/Perf.qml`
- [ ] **Step 1: Write the singleton**
`desktop/island/perf/Perf.qml`:
```qml
// <GPLv2 header>
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import QtQml
import "../.."
import "Perf.js" as P
// The numbers behind the performance panel and the status pill's icon. CPU,
// RAM, temperatures and disks are sampled always, so the icon can warn while
// the panel is closed; per-core load, the network rate, VRAM and the fan only
// while `detailed`, which the status pill binds to the panel being open.
// /proc and /sys are FileViews reloaded on a timer, no subprocess; df, the
// fdinfo scan, ip and curl are Processes.
Singleton {
id: root
property bool detailed: false
// ponytail: host constants, as conky had them. One mount per NFS server
// (Library and Slackware are one export, so are shared and backup); br0
// is a bridge, so VM-to-host copies show in the graph; xe puts no VRAM
// capacity in sysfs. Edit here when the hardware changes.
readonly property var mounts: ["/", "/home", "/data", "/mnt/nfs/Library", "/mnt/nfs/shared"]
readonly property string iface: "br0"
readonly property real vramTotal: 12 * 1024 * 1024 * 1024
// Conky's table. Ceilings are per sensor: 70C is unremarkable on a CPU
// package and alarming on an NVMe. GPU and VRAM are the GPU card's; the
// rest are the CPU card's rows.
readonly property var sensors: [
{ chip: "k10temp", file: "temp1_input", label: "CPU", warn: 75, crit: 90 },
{ chip: "k10temp", file: "temp3_input", label: "CCD1", warn: 75, crit: 90 },
{ chip: "nvme", file: "temp1_input", label: "NVME", warn: 60, crit: 70 },
{ chip: "gigabyte_wmi", file: "temp2_input", label: "BOARD", warn: 60, crit: 70 },
{ chip: "gigabyte_wmi", file: "temp3_input", label: "BOARD", warn: 60, crit: 70 },
{ chip: "xe", file: "temp2_input", label: "GPU", warn: 75, crit: 85 },
{ chip: "xe", file: "temp3_input", label: "VRAM", warn: 80, crit: 90 },
]
property var hwmon: ({}) // chip name -> /sys/class/hwmon/hwmonN
property string cpuModel: ""
property var load: null // aggregate percent
property var cores: [] // percent per core, while detailed
property var mem: null // {total, used} bytes
property var temps: [] // [{label, value, warn, crit}], value null if unread
property var disks: [] // Perf.js df rows
property var vram: null // bytes, while detailed
property var fan: null // rpm, while detailed
property var rx: [] // bytes/s, newest last, while detailed
property var tx: []
property string lan: ""
property var pub: null // {ip, fetched}
property real now: Date.now() / 1000
readonly property var ip: P.ipCheck(root.pub, root.now, Prefs.pinnedIp)
// The icon's dot. CPU load is left out: spikes would flicker it, and
// sustained load shows up as heat.
readonly property string severity: P.worst(
root.temps.map(t => P.band(t.value, t.warn, t.crit))
.concat([P.band(root.mem ? 100 * root.mem.used / root.mem.total : null, 85, 95)])
.concat(root.disks.map(d => P.band(d.pct, 90, 95)))
.concat([root.ip.mismatch ? "crit" : "ok"]))
readonly property string pubipPath:
(Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/udt/pubip.txt"
property var prevCpu: []
property var prevNet: null // {rx, tx, t}
function num(view) {
const v = parseInt(view.text(), 10);
return isNaN(v) ? null : v;
}
function fetchPubip() {
pubProc.running = false;
pubProc.running = true;
}
onDetailedChanged: {
if (detailed) {
if (!root.lan) lanProc.running = true;
if (P.due(root.pub, Date.now() / 1000, 1800)) root.fetchPubip();
} else {
root.cores = [];
root.rx = [];
root.tx = [];
root.prevNet = null;
root.vram = null;
root.fan = null;
}
}
// --- /proc and /sys ---------------------------------------------------
FileView { id: stat; path: "/proc/stat"; blockLoading: true }
FileView { id: meminfo; path: "/proc/meminfo"; blockLoading: true }
FileView {
path: "/proc/cpuinfo"
blockLoading: true
onLoaded: root.cpuModel = P.cpuModel(text())
}
Instantiator {
id: tempViews
model: root.sensors
delegate: FileView {
required property var modelData
path: root.hwmon[modelData.chip] ? root.hwmon[modelData.chip] + "/" + modelData.file : ""
blockLoading: true
printErrors: false
}
}
FileView {
id: fanView
path: root.hwmon.xe ? root.hwmon.xe + "/fan1_input" : ""
blockLoading: true
printErrors: false
}
FileView {
id: rxView
path: "/sys/class/net/" + root.iface + "/statistics/rx_bytes"
blockLoading: true
printErrors: false
}
FileView {
id: txView
path: "/sys/class/net/" + root.iface + "/statistics/tx_bytes"
blockLoading: true
printErrors: false
}
Process {
running: true
command: ["sh", "-c", "grep -H . /sys/class/hwmon/hwmon*/name"]
stdout: StdioCollector { onStreamFinished: root.hwmon = P.hwmonMap(text) }
}
// CPU, RAM and temperatures always; the fan and VRAM while detailed.
Timer {
interval: 2000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
root.now = Date.now() / 1000;
stat.reload();
const cur = P.cpuTimes(stat.text());
root.load = P.load(root.prevCpu[0], cur[0]);
root.cores = root.detailed
? cur.slice(1).map((c, i) => P.load(root.prevCpu[i + 1], c) ?? 0)
: [];
root.prevCpu = cur;
meminfo.reload();
root.mem = P.meminfo(meminfo.text());
const t = [];
for (let i = 0; i < root.sensors.length; i++) {
const s = root.sensors[i];
const v = tempViews.objectAt(i);
let value = null;
if (v && v.path) {
v.reload();
const n = root.num(v);
value = n === null ? null : n / 1000;
}
t.push({ label: s.label, value: value, warn: s.warn, crit: s.crit });
}
root.temps = t;
if (root.detailed) {
if (fanView.path) { fanView.reload(); root.fan = root.num(fanView); }
vramProc.running = false;
vramProc.running = true;
}
}
}
// ponytail: a shell glob over every fd on the system, ~14ms; only while
// the panel is open. Only the user's own processes are readable, so a
// system service's GPU use is not counted, as in conky.
Process {
id: vramProc
command: ["sh", "-c", "grep -sH -e '^drm-driver' -e '^drm-client-id' "
+ "-e '^drm-resident-vram0' /proc/[0-9]*/fdinfo/* 2>/dev/null"]
stdout: StdioCollector { onStreamFinished: root.vram = P.vram(text) }
}
// --- network ------------------------------------------------------------
Timer {
interval: 1000
running: root.detailed
repeat: true
triggeredOnStart: true
onTriggered: {
rxView.reload();
txView.reload();
const cur = { rx: root.num(rxView), tx: root.num(txView), t: Date.now() / 1000 };
const p = root.prevNet;
if (p) {
const r = P.rate(p.rx, cur.rx, cur.t - p.t);
const s = P.rate(p.tx, cur.tx, cur.t - p.t);
if (r !== null) root.rx = P.push(root.rx, r, 60);
if (s !== null) root.tx = P.push(root.tx, s, 60);
}
root.prevNet = cur;
}
}
Process {
id: lanProc
command: ["ip", "-4", "-o", "addr", "show", root.iface]
stdout: StdioCollector { onStreamFinished: root.lan = P.lanAddr(text) }
}
// --- disks --------------------------------------------------------------
// df under timeout: an unreachable NFS server blocks statfs, and the
// whole call then yields nothing, so the last rows are kept.
Timer {
interval: 60000
running: true
repeat: true
triggeredOnStart: true
onTriggered: { dfProc.running = false; dfProc.running = true; }
}
Process {
id: dfProc
command: ["timeout", "5", "/usr/bin/df", "-P", "-B1"].concat(root.mounts)
stdout: StdioCollector {
onStreamFinished: {
const rows = P.df(text);
if (rows.length) root.disks = rows;
}
}
}
// --- public IP ----------------------------------------------------------
// conky's cache file, so the address survives a restart and the fetch
// keeps conky's 30 minute pace. Checked every minute, fetched when due.
FileView {
id: pubFile
path: root.pubipPath
atomicWrites: true
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.pub = P.parsePubip(text())
}
Timer {
interval: 60000
running: true
repeat: true
onTriggered: if (P.due(root.pub, Date.now() / 1000, 1800)) root.fetchPubip()
}
// Anything but an IPv4 address (a failure, an error page) leaves the
// cache alone: the old value then goes stale rather than mismatching.
Process {
id: pubProc
command: ["curl", "-fsS", "--max-time", "10", "https://ipinfo.io/ip"]
stdout: StdioCollector {
onStreamFinished: {
const ip = text.trim();
if (!P.isIPv4(ip)) return;
const at = Math.floor(Date.now() / 1000);
root.pub = { ip: ip, fetched: at };
pubFile.setText(P.formatPubip(ip, at));
}
}
}
}
```
- [ ] **Step 2: Commit**
Nothing imports it yet, so the running shell cannot load it; Task 6 checks it live.
```bash
git add desktop/island/perf/Perf.qml
git commit -m "feat(perf): the performance service
CPU, RAM, temperatures and disks are sampled always, so the island's
icon can warn with the panel closed; per-core load, the network rate,
VRAM and the GPU fan only while the panel is open. hwmon chips are
found by name, since their numbers move across boots. df runs under
timeout so a dead NFS server keeps the last rows rather than blocking.
The public IP keeps conky's cache file and 30 minute pace.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: PerfPanel.qml
**Files:**
- Create: `desktop/island/perf/PerfPanel.qml`
- [ ] **Step 1: Write the panel**
`desktop/island/perf/PerfPanel.qml`:
```qml
// <GPLv2 header>
import Quickshell
import QtQuick
import QtQuick.Layouts
import "../.."
import "Perf.js" as P
// CPU, GPU, network and disks as cards in three columns: CPU down the left,
// GPU and network beside it, disks under those two. Numbers come from Perf;
// colours follow conky's bands, so they read as they did there.
GridLayout {
id: panel
columns: 3
columnSpacing: 8
rowSpacing: 8
readonly property real col: (width - 2 * columnSpacing) / 3
readonly property var gpuTemp: sensor("GPU")
readonly property var vramTemp: sensor("VRAM")
function tone(v, warn, crit) {
const b = P.band(v, warn, crit);
return b === "crit" ? Theme.red : b === "warn" ? Theme.yellow : b === "ok" ? Theme.green : Theme.subtext;
}
function deg(v) { return v === null ? "--" : Math.round(v) + "°"; }
function sensor(label) {
return Perf.temps.find(t => t.label === label) || { value: null, warn: 0, crit: 0 };
}
function pct(part, whole) { return part === null || !whole ? null : 100 * part / whole; }
function last(list) { return list.length ? list[list.length - 1] : null; }
// Conky's glyphs, re-checked in Inconsolata Nerd Font.
function glyph(d) {
if (d.host) return String.fromCodePoint(0xF06F3); // network share
return String.fromCodePoint({ "/": 0xF318, "/home": 0xF015 }[d.mount] ?? 0xF02CA);
}
component Label: Text {
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
color: Theme.text
}
// A label left, a value right, an optional glyph before the label.
component Pair: Item {
property string glyph: ""
property string label: ""
property string value: ""
property color labelColor: Theme.subtext
property color valueColor: Theme.text
width: parent ? parent.width : 0
implicitHeight: valueText.implicitHeight
Row {
spacing: 6
Text {
visible: text !== ""
text: parent.parent.glyph
font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 }
color: parent.parent.labelColor
}
Label { text: parent.parent.label; color: parent.parent.labelColor }
}
Label {
id: valueText
anchors.right: parent.right
text: parent.value
color: parent.valueColor
}
}
component Bar: Rectangle {
property real frac: 0
property color fill: Theme.green
width: parent ? parent.width : 0
height: 5
radius: 3
color: Theme.surfaceAlt
Rectangle {
width: parent.width * Math.max(0, Math.min(1, parent.frac))
height: parent.height
radius: 3
color: parent.fill
}
}
component Card: Rectangle {
default property alias content: body.data
property string title: ""
property string headline: ""
property color headlineColor: Theme.text
radius: 10
color: Qt.alpha(Theme.surface, 0.6)
implicitHeight: body.implicitHeight + 20
Layout.fillHeight: true
Layout.alignment: Qt.AlignTop
Column {
id: body
x: 10
y: 10
width: parent.width - 20
spacing: 5
Item {
width: parent.width
height: Math.max(titleText.implicitHeight, headText.implicitHeight)
Text {
id: titleText
anchors.verticalCenter: parent.verticalCenter
text: parent.parent.parent.title
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true }
color: Theme.subtext
}
Text {
id: headText
anchors.right: parent.right
text: parent.parent.parent.headline
font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 4; bold: true }
color: parent.parent.parent.headlineColor
}
}
}
}
// --- CPU ----------------------------------------------------------------
Card {
Layout.row: 0
Layout.column: 0
Layout.rowSpan: 2
Layout.preferredWidth: panel.col
title: "CPU"
headline: Perf.load === null ? "--" : Math.round(Perf.load) + "%"
headlineColor: panel.tone(Perf.load, 25, 75)
Label {
width: parent.width
text: Perf.cpuModel
color: Theme.subtext
elide: Text.ElideRight
}
// One bar per core, rising from a common baseline.
Row {
id: eq
width: parent.width
height: 40
spacing: 2
Repeater {
model: Perf.cores
delegate: Item {
required property var modelData
width: (eq.width - eq.spacing * (Perf.cores.length - 1)) / Perf.cores.length
height: eq.height
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(1, parent.height * modelData / 100)
radius: 1
color: panel.tone(modelData, 25, 75)
}
}
}
}
Pair {
label: "RAM"
value: Perf.mem ? P.bytes(Perf.mem.used) + " / " + P.bytes(Perf.mem.total) : "--"
}
Bar {
frac: Perf.mem ? Perf.mem.used / Perf.mem.total : 0
fill: panel.tone(panel.pct(Perf.mem ? Perf.mem.used : null, Perf.mem ? Perf.mem.total : 0), 25, 75)
}
Repeater {
model: Perf.temps.filter(t => t.label !== "GPU" && t.label !== "VRAM")
delegate: Pair {
required property var modelData
label: modelData.label
value: panel.deg(modelData.value)
valueColor: panel.tone(modelData.value, modelData.warn, modelData.crit)
}
}
}
// --- GPU ----------------------------------------------------------------
// No utilisation: xe exposes no busy percentage.
Card {
Layout.row: 0
Layout.column: 1
Layout.preferredWidth: panel.col
title: "GPU · ARC B580"
headline: panel.deg(panel.gpuTemp.value)
headlineColor: panel.tone(panel.gpuTemp.value, panel.gpuTemp.warn, panel.gpuTemp.crit)
Pair {
label: "VRAM"
value: Perf.vram === null ? "--" : P.bytes(Perf.vram) + " / " + P.bytes(Perf.vramTotal)
}
Bar {
frac: (Perf.vram ?? 0) / Perf.vramTotal
fill: panel.tone(panel.pct(Perf.vram, Perf.vramTotal), 25, 75)
}
Pair {
label: "VRAM temp"
value: panel.deg(panel.vramTemp.value)
valueColor: panel.tone(panel.vramTemp.value, panel.vramTemp.warn, panel.vramTemp.crit)
}
Pair {
label: "Fan"
value: Perf.fan === null ? "--" : Perf.fan + " rpm"
}
}
// --- network ------------------------------------------------------------
Card {
Layout.row: 0
Layout.column: 2
Layout.preferredWidth: panel.col
title: "NET · " + Perf.iface
Canvas {
id: graph
width: parent.width
height: 44
onPaint: {
const ctx = getContext("2d");
ctx.reset();
const max = Math.max(1, ...Perf.rx, ...Perf.tx);
const line = (data, color) => {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
data.forEach((v, i) => {
const x = width * i / 59;
const y = height - 1 - (height - 2) * v / max;
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
};
line(Perf.rx, Theme.teal);
line(Perf.tx, Theme.peach);
}
Connections {
target: Perf
function onRxChanged() { graph.requestPaint(); }
function onTxChanged() { graph.requestPaint(); }
}
}
Pair {
label: "↓ " + P.speed(panel.last(Perf.rx))
labelColor: Theme.teal
value: "↑ " + P.speed(panel.last(Perf.tx))
valueColor: Theme.peach
}
Pair { label: "LAN"; value: Perf.lan || "--" }
// The public IP, red when it differs from the pinned one. The pin
// stores the address shown; it is lit while they are the same.
Item {
width: parent.width
height: pubText.implicitHeight
Label { text: "Public"; color: Theme.subtext }
Label {
id: pubText
anchors { right: pin.left; rightMargin: 6 }
text: Perf.ip.ip || "--"
color: Perf.ip.mismatch ? Theme.red : Theme.text
}
Text {
id: pin
anchors { right: parent.right; verticalCenter: pubText.verticalCenter }
text: String.fromCodePoint(0xF0403)
font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 }
color: Perf.ip.ip !== "" && Perf.ip.ip === Prefs.pinnedIp ? Theme.text : Theme.overlay
MouseArea {
anchors.fill: parent
anchors.margins: -4
enabled: Perf.ip.ip !== ""
cursorShape: Qt.PointingHandCursor
onClicked: Prefs.setPinnedIp(Perf.ip.ip)
}
}
}
}
// --- disks --------------------------------------------------------------
Card {
Layout.row: 1
Layout.column: 1
Layout.columnSpan: 2
Layout.preferredWidth: 2 * panel.col + panel.columnSpacing
title: "DISKS"
Repeater {
model: Perf.disks
delegate: Column {
required property var modelData
width: parent.width
spacing: 2
Pair {
glyph: panel.glyph(modelData)
label: modelData.host || modelData.mount
value: modelData.pct + "% · " + P.bytes(modelData.used) + " / " + P.bytes(modelData.size)
valueColor: panel.tone(modelData.pct, 25, 75)
}
Bar {
frac: modelData.pct / 100
fill: panel.tone(modelData.pct, 25, 75)
}
}
}
}
}
```
- [ ] **Step 2: Commit**
Nothing hosts it yet; Task 6 checks it live.
```bash
git add desktop/island/perf/PerfPanel.qml
git commit -m "feat(perf): the performance panel's card grid
CPU with per-core bars, RAM and temperatures down the left; GPU and
network beside it; disks under those two. Colours follow conky's bands
so the numbers read as they did there. The pin beside the public IP
stores the address shown, and the address turns red when a fresh one
differs from the pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Wire it into the status pill and the island
**Files:**
- Modify: `desktop/island/StatusPill.qml`
- Modify: `desktop/island/Island.qml`
- [ ] **Step 1: StatusPill.qml**
Add `import "perf"` after `import ".."`.
Change the header comment's first line from `// Controls icon, volume icon, tray, layout flag. The controls and volume` to `// Performance icon, controls icon, volume icon, tray, layout flag. The` and rewrap so it reads:
```qml
// Performance icon, controls icon, volume icon, tray, layout flag. The
// performance, controls and volume icons open their panels in this capsule;
// the tray is a plain tray; the flag switches layout on scroll and ignores
// clicks.
```
After `signal openControls()` add:
```qml
signal openPerf()
```
Replace the `expanded`, `panelWidth` and `panelHeight` lines with:
```qml
expanded: panel === "sound" || panel === "controls" || panel === "perf"
restWidth: restRow.implicitWidth + 24
panelWidth: panel === "perf" ? 600 : 320
panelHeight: (panel === "controls" ? controlsPanel.implicitHeight
: panel === "perf" ? perfPanel.implicitHeight
: soundPanel.implicitHeight) + 28
// The detailed sampling (per-core, network, VRAM) runs only while the
// panel shows it.
Binding { target: Perf; property: "detailed"; value: pill.panel === "perf" }
```
(`restWidth` is unchanged; it is repeated only to keep the block whole.)
In `restRow`, before the `IconImage` whose source is `"preferences-system"`, add:
```qml
// The worst severity as a dot on the corner: IconImage cannot be
// recoloured without an effect layer. Never the accent (AGENTS.md).
Item {
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
IconImage {
anchors.fill: parent
source: Quickshell.iconPath("utilities-system-monitor", true)
}
Rectangle {
anchors { right: parent.right; top: parent.top; rightMargin: -2; topMargin: -2 }
width: 8
height: 8
radius: 4
visible: Perf.severity !== "ok"
color: Perf.severity === "crit" ? Theme.red : Theme.yellow
border.width: 1
border.color: Theme.base
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: pill.openPerf()
}
}
```
After the `ControlsPanel { ... }` block add:
```qml
PerfPanel {
id: perfPanel
x: 14
y: 14
width: 600 - 28
opacity: pill.panel === "perf" ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 160 } }
}
```
- [ ] **Step 2: Island.qml**
Change `// "", "sound", "controls" or "calendar".` to `// "", "sound", "controls", "perf" or "calendar".`
In `StatusPill { ... }`, after `onOpenControls: win.toggleControls()` add:
```qml
onOpenPerf: win.panel = win.panel === "perf" ? "" : "perf"
```
- [ ] **Step 3: Run the JS tests**
Run: `node desktop/island/perf/test_perf.js && node desktop/island/test_controls.js && node desktop/island/test_logic.js`
Expected: each prints its ok line, nothing else.
- [ ] **Step 4: Check the running shell**
Run: `sleep 2; qs log -p ~/Programming/GIT/quickshell/desktop -t 40 2>&1 | grep -v "host portal\|playerctld"`
Expected: a `Reloading configuration...` / `Configuration Loaded` pair after the edit and no `ReferenceError`, `TypeError`, `is not a type`, `Cannot assign`, `SyntaxError` or `unresolvable import` naming `perf`, `Perf`, `PerfPanel`, `StatusPill`, `Island` or `Prefs`.
If the log shows `Ignoring unresolvable import` for `perf`, `Perf is not defined`, or the shell did not reload, ask the user to run `./reload.sh desktop` from their terminal (AGENTS.md, Reloading: a new directory is not indexed by hot reload), then read the log again. Do not start `qs` yourself.
Then confirm the IP fetch is not stacking: `pgrep -cx curl`
Expected: `0` (a `1` caught mid-fetch is fine; repeat once). Not `pgrep -f`: it matches the checking shell's own command line (AGENTS.md).
- [ ] **Step 5: Commit**
```bash
git add desktop/island/StatusPill.qml desktop/island/Island.qml
git commit -m "feat(island): the status pill opens the performance panel
A system monitor icon before the controls icon opens the panel in the
status pill's capsule at 600px. A dot on its corner shows the worst of
temperatures, RAM, disks and the public IP check, yellow or red, so a
problem shows without opening anything. The detailed sampling runs only
while the panel is open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Check with the user
No code. Conky is still running its four widgets, so the panel can be compared against them side by side. Ask the user to check on screen, and fix what they report before Task 8.
- The status pill shows the system monitor icon before the controls icon, with no dot when all is well.
- Click it: the panel opens at 600px, the status pill does not overlap the clock pill, Escape and a click outside close it.
- CPU: load, model, per-core bars moving, RAM, five temperature rows; compare with conky's system card.
- GPU: package temperature, VRAM used of 12G, VRAM temperature, fan; compare with conky's GPU card.
- Network: the graph fills over a minute, rates move during a download, LAN address shown, public IP shown.
- Disks: five rows, the two NFS shares labelled by server address, percentages matching conky's rings.
- Pin: click it, the pin lights. `desktop.json` gains `perf.pinnedIp` (the user can look; it is their file). No dot.
- Mismatch, by hand and reverted after: in the user's terminal, `cp ~/.cache/udt/pubip.txt /tmp/pubip.bak && printf 'ip 203.0.113.9\nfetched %s\n' "$(date +%s)" > ~/.cache/udt/pubip.txt`. The public IP turns red and the icon gets a red dot within two seconds. Restore with `mv /tmp/pubip.bak ~/.cache/udt/pubip.txt`; the red clears.
---
### Task 8: Docs
**Files:**
- Modify: `desktop/island/README.md`
- Modify: `AGENTS.md`
- [ ] **Step 1: README**
In `desktop/island/README.md`, in the status pill's description, add the performance icon as the first item of the rest row, and add a section after the controls panel section:
```markdown
## Performance panel
The system monitor icon, first in the status pill, opens a card grid of the
figures the conky dashboard used to show: CPU load with a bar per core, the
CPU model, RAM and five temperatures; the Arc B580's temperatures, VRAM and
fan (no utilisation, xe exposes none); download and upload over the last
minute, the LAN address and the public IP; and the five mounts with their
usage. `perf/Perf.qml` reads them; `perf/Perf.js` holds the parsing, tested by
`node desktop/island/perf/test_perf.js`.
A dot on the icon shows the worst of these, yellow or red:
| Input | Yellow | Red |
| --- | --- | --- |
| each temperature | its warn | its crit |
| RAM | 85% | 95% |
| each disk | 90% | 95% |
| public IP | | differs from the pinned one |
CPU load is left out, since spikes would flicker it. The pin beside the public
IP stores the address shown as `perf.pinnedIp` in
`$XDG_STATE_HOME/quickshell/desktop.json`; the check exists because the VPN
into the LAN depends on the ISP's fixed address. The fetch is
`curl https://ipinfo.io/ip` every 30 minutes into `~/.cache/udt/pubip.txt`,
the file conky's sampler used. A failed fetch is never a mismatch: the old
value goes stale after 4 hours and shows as a dash.
CPU, RAM, temperatures and disks are sampled always, so the dot works with
the panel closed. Per-core load, the network rate, VRAM (a scan of every
fdinfo, ~14ms) and the fan run only while the panel is open. The mount list,
`br0` and the 12 GiB VRAM size are constants in `Perf.qml`.
```
- [ ] **Step 2: AGENTS.md**
In `AGENTS.md`, `## What this is`, change the island line to:
```
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
controls panel (SUPER alone), calendar, performance
```
Add to `## Per-component notes`, after the khal/calctl notes:
```markdown
- **`FileView` reads `/proc` and `/sys` fine, but only on `reload()`.** With
`blockLoading: true`, `text()` straight after `reload()` is the fresh
contents, which is how `Perf.qml` samples `/proc/stat` and hwmon every 2s
without a subprocess. hwmon numbers move across boots; look chips up by
their `name` file.
```
Add any other gotcha found during Tasks 2 to 7 the same way.
- [ ] **Step 3: Commit**
```bash
git add desktop/island/README.md AGENTS.md
git commit -m "docs: the performance panel
What the panel shows, the dot's thresholds, why the public IP is
pinned, and what is sampled always versus only while open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Remove the conky widgets
Only after the user confirmed Task 7. This is the separate `~/Programming/GIT/conky-theme-udt` repo; its commits go there. `~/.config/conky` links into it, and conky runs as `conky -c ~/.config/conky/conky.conf`.
**Files (in `~/Programming/GIT/conky-theme-udt`):**
- Delete: `widgets/system.lua`, `widgets/gpu.lua`, `widgets/disks.lua`, `widgets/network.lua`, `bin/disks-sample.sh`, `bin/pubip-sample.sh`
- Modify: `dashboard.lua`, `conky.conf`, `lib/data.lua`, and that repo's README/AGENTS if they list the widgets
- [ ] **Step 1: Read the repo's own docs**
Run: `cd ~/Programming/GIT/conky-theme-udt && git status --short && ls && grep -n -i "system\|gpu\|disks\|network\|pubip" README.md AGENTS.md CLAUDE.md 2>/dev/null`
Stop and ask the user if the tree is not clean.
- [ ] **Step 2: Remove the widgets and samplers**
```bash
cd ~/Programming/GIT/conky-theme-udt
git rm widgets/system.lua widgets/gpu.lua widgets/disks.lua widgets/network.lua \
bin/disks-sample.sh bin/pubip-sample.sh
```
In `dashboard.lua`, delete the four `layout` entries whose `widget` is `'system'`, `'gpu'`, `'disks'` and `'network'`, and the comments above them that describe only them (`-- Under system, with slackware beside it...` goes with `network`). Leave every other entry where it is.
In `conky.conf`, delete `${texeci 60 ~/.config/conky/bin/disks-sample.sh}` and `${texeci 1800 ~/.config/conky/bin/pubip-sample.sh}` from `conky.text`. If `dashboard.lua` or `conky.conf` requires a widget by a list rather than the layout table, remove those four names there too: `grep -n "'system'\|'gpu'\|'disks'\|'network'" dashboard.lua`.
- [ ] **Step 3: Remove readers only those widgets used**
For each `function M.<name>` in `lib/data.lua`, check its remaining callers:
```bash
cd ~/Programming/GIT/conky-theme-udt
for f in $(grep -o '^function M\.[a-z_]*' lib/data.lua | sed 's/function M\.//'); do
n=$(grep -rn "data\.$f\b\|M\.$f\b" --include=*.lua . | grep -v "^./lib/data.lua:.*function M\.$f" | wc -l)
echo "$n $f"
done | sort -n
```
A `0` is unused now: delete that function and its comment block. Re-run until no new `0` appears (a reader can be used only by another removed reader). Also check `lib/card.lua` for icons or helpers named only for those widgets (`card.ICON.system`, `card.ICON.disks`, …) with `grep -rn "ICON\.\(system\|gpu\|disks\|network\)" --include=*.lua .`; remove the ones with no remaining caller.
- [ ] **Step 4: Check conky still loads**
Conky reloads its Lua on config change. Run: `sleep 3; pgrep -ax conky`
Expected: the `conky -c` line is still there. Then ask the user to look at the dashboard: the remaining eight widgets draw, the right side is empty, and no widget shows a Lua error notice. If conky died, ask the user to restart it from their terminal; do not start it from a tool call.
- [ ] **Step 5: Update that repo's docs**
Remove the four widgets and the two samplers from that repo's README (and AGENTS.md if it lists them), with one line saying they moved to the quickshell island's performance panel.
- [ ] **Step 6: Commit in that repo**
```bash
cd ~/Programming/GIT/conky-theme-udt
git add -A
git commit -m "feat: system, gpu, disks and network move to the quickshell island
The quickshell island's performance panel now shows CPU, RAM,
temperatures, the GPU, disks and network, and warns from the status
pill when something runs hot, fills up, or the public IP drifts from
the pinned one. Two dashboards drawing the same numbers would drift
apart, so the four widgets go, with the disks and public IP samplers.
The island keeps writing ~/.cache/udt/pubip.txt in the same format.
The freed space is left empty for the user to rearrange.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
Push both repos only when the user asks.
|