clauddy 1.23.1 → 1.25.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/README.md +59 -7
- package/bin/clauddy.js +12 -2
- package/codex.js +220 -0
- package/main.js +84 -7
- package/package.json +3 -1
- package/preload.js +5 -1
- package/renderer/index.html +152 -68
- package/renderer/motion.css +120 -2
- package/renderer/pet.js +612 -44
- package/renderer/style.css +814 -53
- package/renderer/voice.js +243 -0
package/renderer/pet.js
CHANGED
|
@@ -136,6 +136,9 @@ function fmtTokens(t) {
|
|
|
136
136
|
}
|
|
137
137
|
function fmtReset(ms) {
|
|
138
138
|
if (!ms || ms <= 0) return 'now'
|
|
139
|
+
// past a day, hours are the useful grain: "6d 16h", not "160h 57m"
|
|
140
|
+
if (ms >= 86400000)
|
|
141
|
+
return `${Math.floor(ms / 86400000)}d ${Math.floor((ms % 86400000) / 3600000)}h`
|
|
139
142
|
const h = Math.floor(ms / 3600000)
|
|
140
143
|
const m = Math.floor((ms % 3600000) / 60000)
|
|
141
144
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
@@ -468,28 +471,474 @@ function celebrate() {
|
|
|
468
471
|
for (let i = 0; i < 24; i++) spawnConfetti(i)
|
|
469
472
|
}
|
|
470
473
|
|
|
474
|
+
// ---- voice ------------------------------------------------------------------
|
|
475
|
+
// The bubble speaks on transitions only, at most once per REMARK_GAP_MS. The
|
|
476
|
+
// headline moments (the day's first hello, a fresh window, catching fire) may
|
|
477
|
+
// jump that queue, and only they blip: a noise on every remark wears out far
|
|
478
|
+
// faster than the remark itself.
|
|
479
|
+
const REMARK_GAP_MS = 10 * 60000
|
|
480
|
+
const MUTE_MS = 60 * 60000
|
|
481
|
+
const blipper = Voice.createBlipper(() => {
|
|
482
|
+
const AC = globalThis.AudioContext || globalThis.webkitAudioContext
|
|
483
|
+
return AC ? new AC() : null
|
|
484
|
+
})
|
|
485
|
+
let lastRemarkAt = 0
|
|
486
|
+
let bubbleTimer = null
|
|
487
|
+
|
|
488
|
+
const talkOn = () => currentConfig.talk !== false
|
|
489
|
+
const mutedNow = () => currentConfig.soundMutedUntil > Date.now()
|
|
490
|
+
// opt-in, and never where it would surprise someone: the menu-bar popover, the
|
|
491
|
+
// mini face, or the hour after a mute
|
|
492
|
+
function soundOn() {
|
|
493
|
+
if (!currentConfig.sound || currentConfig.mode === 'menubar') return false
|
|
494
|
+
if (document.body.classList.contains('collapsed')) return false
|
|
495
|
+
return !mutedNow()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function moodOf(st) {
|
|
499
|
+
return st === 'stressed' ? 'fire' : st === 'sleeping' || st === 'tired' ? 'sleepy' : 'normal'
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// `line` is a remark from voice.js ({ text, short }) or a plain string. The
|
|
503
|
+
// mini face has room for a glance, not a sentence, so it gets the short one.
|
|
504
|
+
// `force` is the simulator's: it skips the gates but still respects the mute
|
|
505
|
+
function say(line, { headline = false, mood = 'normal', force = false } = {}) {
|
|
506
|
+
if (!line) return false
|
|
507
|
+
const collapsed = document.body.classList.contains('collapsed')
|
|
508
|
+
const text = typeof line === 'string' ? line : collapsed ? line.short : line.text
|
|
509
|
+
if (!force) {
|
|
510
|
+
const b = document.body.classList
|
|
511
|
+
if (!talkOn() || b.contains('settings-open')) return false
|
|
512
|
+
if (!headline && Date.now() - lastRemarkAt < REMARK_GAP_MS) return false
|
|
513
|
+
}
|
|
514
|
+
lastRemarkAt = Date.now()
|
|
515
|
+
const bubble = el('bubble')
|
|
516
|
+
el('bubble-text').textContent = text
|
|
517
|
+
bubble.classList.remove('leaving')
|
|
518
|
+
placeBubble()
|
|
519
|
+
bubble.hidden = false
|
|
520
|
+
fitSize() // collapsed, the bubble is in the flow: the window grows to fit it
|
|
521
|
+
clearTimeout(bubbleTimer)
|
|
522
|
+
bubbleTimer = setTimeout(hideBubble, Math.max(4500, text.length * 70 + 2500))
|
|
523
|
+
// the mouth moves along, whether or not there's sound
|
|
524
|
+
for (let i = 0; i < Math.min(4, Math.ceil(text.length / 18)); i++) setTimeout(chomp, i * 260)
|
|
525
|
+
if ((headline || force) && soundOn()) blipper.play(text, mood)
|
|
526
|
+
return true
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// expanded, the bubble floats over the scene beside the pet; collapsed there's
|
|
530
|
+
// no room beside it, so it moves above the pet, into the card's flow
|
|
531
|
+
function placeBubble() {
|
|
532
|
+
const bubble = el('bubble')
|
|
533
|
+
const stage = el('stage')
|
|
534
|
+
if (document.body.classList.contains('collapsed')) {
|
|
535
|
+
if (bubble.nextElementSibling !== stage) stage.before(bubble)
|
|
536
|
+
} else if (bubble.parentElement !== stage) stage.appendChild(bubble)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function hideBubble() {
|
|
540
|
+
clearTimeout(bubbleTimer)
|
|
541
|
+
const bubble = el('bubble')
|
|
542
|
+
if (bubble.hidden) return
|
|
543
|
+
bubble.classList.add('leaving')
|
|
544
|
+
bubbleTimer = setTimeout(() => {
|
|
545
|
+
bubble.hidden = true
|
|
546
|
+
bubble.classList.remove('leaving')
|
|
547
|
+
fitSize()
|
|
548
|
+
}, 180)
|
|
549
|
+
}
|
|
550
|
+
el('bubble').addEventListener('click', (e) => {
|
|
551
|
+
e.stopPropagation()
|
|
552
|
+
hideBubble()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// once-a-day remarks, keyed by local date; memory is the fallback when storage
|
|
556
|
+
// isn't there, so a broken profile can't make the pet repeat itself
|
|
557
|
+
const toldMemo = {}
|
|
558
|
+
const dayKey = () => new Date().toDateString()
|
|
559
|
+
function toldToday(key) {
|
|
560
|
+
try {
|
|
561
|
+
if (localStorage.getItem(key) === dayKey()) return true
|
|
562
|
+
} catch {}
|
|
563
|
+
return toldMemo[key] === dayKey()
|
|
564
|
+
}
|
|
565
|
+
function markToday(key) {
|
|
566
|
+
toldMemo[key] = dayKey()
|
|
567
|
+
try {
|
|
568
|
+
localStorage.setItem(key, dayKey())
|
|
569
|
+
} catch {}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// what the pet has noticed so far — the transitions are all relative to it
|
|
573
|
+
const AWAY_MS = 2 * 3600000
|
|
574
|
+
const STREAK_MS = 90 * 60000
|
|
575
|
+
const STREAK_GAP_MS = 10 * 60000
|
|
576
|
+
let greeted = false
|
|
577
|
+
let heardConfig = false
|
|
578
|
+
let lastLivePct = null // Claude's session %, null until a live reading lands
|
|
579
|
+
let awayMs = 0
|
|
580
|
+
let streakFrom = 0
|
|
581
|
+
let lastWorkAt = 0
|
|
582
|
+
let streakTold = false
|
|
583
|
+
|
|
584
|
+
// what the current Claude window has been like, for the recap when it closes
|
|
585
|
+
// (#31). The peak is the session's tokens just before the rollover — after it
|
|
586
|
+
// the counter starts over. Only the time the pet actually watched counts.
|
|
587
|
+
const TALLY_GAP_MS = 15000 // a longer gap is the machine asleep, not work
|
|
588
|
+
let tally = { peak: 0, activeMs: 0, acts: {}, at: 0 }
|
|
589
|
+
function noteSession(d) {
|
|
590
|
+
const now = Date.now()
|
|
591
|
+
const dt = tally.at ? Math.min(now - tally.at, TALLY_GAP_MS) : 0
|
|
592
|
+
tally.at = now
|
|
593
|
+
if (d.active) {
|
|
594
|
+
tally.activeMs += dt
|
|
595
|
+
if (d.activity) tally.acts[d.activity] = (tally.acts[d.activity] || 0) + dt
|
|
596
|
+
}
|
|
597
|
+
tally.peak = Math.max(tally.peak, d.session?.tokens || 0)
|
|
598
|
+
}
|
|
599
|
+
function takeRecap(was) {
|
|
600
|
+
const top = Object.entries(tally.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || null
|
|
601
|
+
const r = { was, tokens: tally.peak, activeMs: tally.activeMs, top }
|
|
602
|
+
tally = { peak: 0, activeMs: 0, acts: {}, at: tally.at }
|
|
603
|
+
return r
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Codex, followed whichever tab is on screen. Its logs only move while it
|
|
607
|
+
// runs, so a stale reading keeps the baseline: the next fresh one is compared
|
|
608
|
+
// against the last number actually seen.
|
|
609
|
+
let lastCodexPct = null
|
|
610
|
+
function listenCodex() {
|
|
611
|
+
const c = codexData
|
|
612
|
+
if (!codexOn() || !c) {
|
|
613
|
+
lastCodexPct = null
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const pct = c.session?.pct
|
|
617
|
+
if (pct == null || codexStale(c)) return
|
|
618
|
+
const was = lastCodexPct
|
|
619
|
+
lastCodexPct = pct
|
|
620
|
+
if (was == null) return
|
|
621
|
+
const fireAt = currentConfig.fireThreshold ?? 90
|
|
622
|
+
const reset = c.session.resetMs
|
|
623
|
+
if (was - pct > 25) say(Voice.codexResetLine(was), { headline: true })
|
|
624
|
+
else if (was < 100 && pct >= 100)
|
|
625
|
+
say(Voice.codexMaxedLine(fmtResetClock(reset)), { mood: 'sleepy' })
|
|
626
|
+
else if (was < fireAt && pct >= fireAt && pct < 100)
|
|
627
|
+
say(Voice.codexFireLine(pct, reset, fmtReset), { headline: true, mood: 'fire' })
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// called by paint() with the state it just drew. The Claude remarks follow the
|
|
631
|
+
// Claude view; Codex has its own, above.
|
|
632
|
+
function listen(before, st, { isCodex, liveOn, sp, sessReset, proj }) {
|
|
633
|
+
// the saved config decides whether it talks at all, and it lands just after
|
|
634
|
+
// the first usage tick: speaking before it would ignore a "talk: false"
|
|
635
|
+
if (!heardConfig) return
|
|
636
|
+
const d = lastData
|
|
637
|
+
const mood = moodOf(st)
|
|
638
|
+
if (!greeted) {
|
|
639
|
+
greeted = true
|
|
640
|
+
if (!toldToday('clauddy.greeted')) {
|
|
641
|
+
markToday('clauddy.greeted')
|
|
642
|
+
const line = Voice.greetingLine(d.days30, new Date().getHours(), fmtTokens)
|
|
643
|
+
if (say(line, { headline: true, mood })) return
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
listenCodex()
|
|
647
|
+
if (isCodex) return
|
|
648
|
+
const now = Date.now()
|
|
649
|
+
|
|
650
|
+
if (liveOn) {
|
|
651
|
+
const fireAt = currentConfig.fireThreshold ?? 90
|
|
652
|
+
const was = lastLivePct
|
|
653
|
+
lastLivePct = sp
|
|
654
|
+
// a first reading is a baseline: launching at 95% is not "catching fire"
|
|
655
|
+
if (was != null && was < 100 && sp >= 100) {
|
|
656
|
+
say(Voice.maxedLine(fmtResetClock(sessReset)), { mood: 'sleepy' })
|
|
657
|
+
} else if (was != null && was < fireAt && sp >= fireAt && sp < 100) {
|
|
658
|
+
const eta = proj?.kind === 'eta' ? proj.ms : null
|
|
659
|
+
say(Voice.fireLine(sp, eta, sessReset, fmtReset), { headline: true, mood: 'fire' })
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
lastLivePct = null // logged out, or switching: the next reading starts over
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (st === 'sleeping' && Number.isFinite(d.lastActivityMs))
|
|
666
|
+
awayMs = Math.max(awayMs, d.lastActivityMs)
|
|
667
|
+
else if (before === 'sleeping') {
|
|
668
|
+
if (awayMs >= AWAY_MS) say(Voice.welcomeLine(awayMs, liveOn ? sp : null, fmtReset))
|
|
669
|
+
awayMs = 0
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (st === 'working') {
|
|
673
|
+
if (!streakFrom || now - lastWorkAt > STREAK_GAP_MS) {
|
|
674
|
+
streakFrom = now
|
|
675
|
+
streakTold = false
|
|
676
|
+
}
|
|
677
|
+
lastWorkAt = now
|
|
678
|
+
if (!streakTold && now - streakFrom >= STREAK_MS)
|
|
679
|
+
streakTold = say(Voice.streakLine(now - streakFrom, fmtReset))
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const record = Voice.recordLine(d.days30, fmtTokens)
|
|
683
|
+
if (record && !toldToday('clauddy.record') && say(record, { mood })) markToday('clauddy.record')
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// `./pet say <kind>` previews one remark — real numbers where there are some,
|
|
687
|
+
// plausible ones where the moment hasn't happened
|
|
688
|
+
function sampleLine(kind) {
|
|
689
|
+
const days = lastData?.days30?.length ? [...lastData.days30] : new Array(30).fill(0)
|
|
690
|
+
const pct = realUsage?.session?.pct
|
|
691
|
+
const reset = realUsage?.session?.resetMs ?? 72 * 60000
|
|
692
|
+
if (kind === 'fire') return Voice.fireLine(Math.max(pct ?? 0, 91), 40 * 60000, reset, fmtReset)
|
|
693
|
+
if (kind === 'reset') {
|
|
694
|
+
const t = tally.peak ? tally : { peak: 34e6, activeMs: 4 * 3600000 + 12 * 60000, acts: {} }
|
|
695
|
+
const top = Object.entries(t.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'editing'
|
|
696
|
+
return Voice.recapLine(
|
|
697
|
+
{ was: 92, tokens: t.peak, activeMs: t.activeMs, top },
|
|
698
|
+
fmtTokens,
|
|
699
|
+
fmtReset,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
if (kind === 'codex')
|
|
703
|
+
return Voice.codexFireLine(91, codexData?.session?.resetMs ?? reset, fmtReset)
|
|
704
|
+
if (kind === 'maxed') return Voice.maxedLine(fmtResetClock(reset))
|
|
705
|
+
if (kind === 'welcome') return Voice.welcomeLine(3 * 3600000 + 12 * 60000, pct ?? null, fmtReset)
|
|
706
|
+
if (kind === 'streak') return Voice.streakLine(95 * 60000, fmtReset)
|
|
707
|
+
if (kind === 'record') {
|
|
708
|
+
days[days.length - 1] = Math.max(2e6, Math.max(...days.slice(0, -1)) * 1.15)
|
|
709
|
+
return Voice.recordLine(days, fmtTokens)
|
|
710
|
+
}
|
|
711
|
+
if (!days[days.length - 2]) days[days.length - 2] = 12e6 // no yesterday yet: pretend one
|
|
712
|
+
return Voice.greetingLine(days, new Date().getHours(), fmtTokens)
|
|
713
|
+
}
|
|
714
|
+
function sayDebug(kind) {
|
|
715
|
+
const st = [...document.body.classList].find((c) => c.startsWith('state-'))?.slice(6)
|
|
716
|
+
const mood =
|
|
717
|
+
kind === 'fire' || kind === 'codex' ? 'fire' : kind === 'maxed' ? 'sleepy' : moodOf(st)
|
|
718
|
+
return say(sampleLine(kind), { force: true, mood })
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// mute sits in the title bar while the voice is on: one click before a call
|
|
722
|
+
let unmuteTimer = null
|
|
723
|
+
function paintMute() {
|
|
724
|
+
const btn = el('mute')
|
|
725
|
+
// menu-bar mode is always silent, so there is nothing there to mute
|
|
726
|
+
btn.hidden = !(currentConfig.sound && talkOn()) || currentConfig.mode === 'menubar'
|
|
727
|
+
document.body.classList.toggle('has-mute', !btn.hidden) // the chip makes room for it
|
|
728
|
+
const muted = mutedNow()
|
|
729
|
+
btn.classList.toggle('muted', muted)
|
|
730
|
+
btn.title = muted
|
|
731
|
+
? `Muted until ${new Date(currentConfig.soundMutedUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} — click to unmute`
|
|
732
|
+
: 'Mute the voice for 1 hour'
|
|
733
|
+
clearTimeout(unmuteTimer)
|
|
734
|
+
if (muted) unmuteTimer = setTimeout(paintMute, currentConfig.soundMutedUntil - Date.now() + 50)
|
|
735
|
+
}
|
|
736
|
+
el('mute').addEventListener('click', (e) => {
|
|
737
|
+
e.stopPropagation()
|
|
738
|
+
const until = mutedNow() ? 0 : Date.now() + MUTE_MS
|
|
739
|
+
currentConfig = { ...currentConfig, soundMutedUntil: until } // repaint now, main confirms
|
|
740
|
+
paintMute()
|
|
741
|
+
window.api.saveConfig({ soundMutedUntil: until })
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
// ---- services ---------------------------------------------------------------
|
|
745
|
+
// Claude and Codex are monitored side by side, but the panel shows one at a
|
|
746
|
+
// time: the tabs under the pet pick which one owns the chip, the pet, the
|
|
747
|
+
// meters, the breakdowns and the Usage arrow.
|
|
748
|
+
const PROVIDER_KEY = 'clauddy.provider'
|
|
749
|
+
let codexData = null // last Codex payload; null while Codex isn't enabled
|
|
750
|
+
let pickedProvider = (() => {
|
|
751
|
+
try {
|
|
752
|
+
return localStorage.getItem(PROVIDER_KEY) === 'codex' ? 'codex' : 'claude'
|
|
753
|
+
} catch {
|
|
754
|
+
return 'claude'
|
|
755
|
+
}
|
|
756
|
+
})()
|
|
757
|
+
|
|
758
|
+
const claudeOn = () => document.body.classList.contains('auth-on')
|
|
759
|
+
const codexOn = () => !!currentConfig.codex
|
|
760
|
+
const bothOn = () => claudeOn() && codexOn()
|
|
761
|
+
// the service on screen: the pick, unless that one isn't there any more
|
|
762
|
+
function viewProvider() {
|
|
763
|
+
if (!codexOn()) return 'claude'
|
|
764
|
+
if (!claudeOn()) return 'codex'
|
|
765
|
+
return pickedProvider
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function pickProvider(p) {
|
|
769
|
+
if (p === pickedProvider) return
|
|
770
|
+
pickedProvider = p
|
|
771
|
+
try {
|
|
772
|
+
localStorage.setItem(PROVIDER_KEY, p)
|
|
773
|
+
} catch {}
|
|
774
|
+
paint()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const STALE_MS = 15 * 60000
|
|
778
|
+
const CODEX_SLEEP_MS = 5 * 60000 // main's default sleepThresholdMs
|
|
779
|
+
// Codex logs are only written while it runs, so a reading can be hours old
|
|
780
|
+
const codexStale = (c) =>
|
|
781
|
+
!!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
|
|
782
|
+
|
|
783
|
+
// Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
|
|
784
|
+
function codexView(c) {
|
|
785
|
+
const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
|
|
786
|
+
return {
|
|
787
|
+
d: {
|
|
788
|
+
active: c.active,
|
|
789
|
+
activity: null,
|
|
790
|
+
// no data is not the same as asleep
|
|
791
|
+
sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
|
|
792
|
+
tokensPerMin: 0,
|
|
793
|
+
today: { tokens: c.tokensToday || 0 },
|
|
794
|
+
session: { tokens: c.tokens5h || 0 },
|
|
795
|
+
week: { tokens: c.tokensWeek || 0 },
|
|
796
|
+
byModel: c.byModel || [],
|
|
797
|
+
byProject: c.byProject || [],
|
|
798
|
+
days30: c.days30 || [],
|
|
799
|
+
monthTokens: c.monthTokens ?? null,
|
|
800
|
+
},
|
|
801
|
+
sess: c.session,
|
|
802
|
+
week: c.weekly,
|
|
803
|
+
stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// a Codex meter caption: its reset, or how old the reading is when that's what matters
|
|
808
|
+
function codexSub(w, tokens, stale) {
|
|
809
|
+
const t = `${fmtTokens(tokens)} tokens`
|
|
810
|
+
if (!w) return `limits not recorded yet · ${t}`
|
|
811
|
+
if (w.pct == null) return `waiting for a fresh reading · ${t}`
|
|
812
|
+
if (stale) {
|
|
813
|
+
const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)} · ` : ''
|
|
814
|
+
return `${at}read ${fmtReset(Date.now() - stale)} ago`
|
|
815
|
+
}
|
|
816
|
+
const reset = w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)} · ` : ''
|
|
817
|
+
return `${reset}${t}`
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
|
|
821
|
+
const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
|
|
822
|
+
|
|
823
|
+
// tabs + the collapsed pair: both services' session %, whichever is on screen
|
|
824
|
+
function paintServices(view) {
|
|
825
|
+
const dual = bothOn()
|
|
826
|
+
document.body.classList.toggle('dual', dual)
|
|
827
|
+
document.body.classList.toggle('view-codex', view === 'codex')
|
|
828
|
+
// collapsed with both: when the selected service's session resets
|
|
829
|
+
const resetMs = view === 'codex' ? codexData?.session?.resetMs : realUsage?.session?.resetMs
|
|
830
|
+
el('mini-reset').hidden = !dual || resetMs == null
|
|
831
|
+
el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
|
|
832
|
+
el('service-panel').setAttribute('role', dual ? 'tabpanel' : 'region')
|
|
833
|
+
if (dual) {
|
|
834
|
+
el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
|
|
835
|
+
el('service-panel').removeAttribute('aria-label')
|
|
836
|
+
} else {
|
|
837
|
+
el('service-panel').removeAttribute('aria-labelledby')
|
|
838
|
+
el('service-panel').setAttribute('aria-label', `${view === 'codex' ? 'Codex' : 'Claude'} usage`)
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
const cl = realUsage
|
|
842
|
+
const cx = codexData
|
|
843
|
+
const rows = {
|
|
844
|
+
claude: {
|
|
845
|
+
pct: cl?.session?.pct ?? null,
|
|
846
|
+
week: cl?.week?.pct ?? null,
|
|
847
|
+
active: !!lastData?.active,
|
|
848
|
+
},
|
|
849
|
+
codex: {
|
|
850
|
+
pct: cx?.session?.pct ?? null,
|
|
851
|
+
week: cx?.weekly?.pct ?? null,
|
|
852
|
+
active: !!cx?.active,
|
|
853
|
+
stale: codexStale(cx),
|
|
854
|
+
},
|
|
855
|
+
}
|
|
856
|
+
for (const [p, r] of Object.entries(rows)) {
|
|
857
|
+
const on = p === view
|
|
858
|
+
const urgent = Math.max(r.pct ?? 0, r.week ?? 0) >= warnAt()
|
|
859
|
+
const tab = el(`tab-${p}`)
|
|
860
|
+
tab.setAttribute('aria-selected', String(on))
|
|
861
|
+
tab.tabIndex = on ? 0 : -1
|
|
862
|
+
tab.classList.toggle('active', r.active)
|
|
863
|
+
tab.classList.toggle('urgent', urgent)
|
|
864
|
+
tab.querySelector('.tab-alert').hidden = !urgent
|
|
865
|
+
el(`tab-${p}-value`).textContent = pctText(r.pct)
|
|
866
|
+
const name = p === 'claude' ? 'Claude' : 'Codex'
|
|
867
|
+
tab.title = `${name} session ${pctText(r.pct)} · weekly ${pctText(r.week)}${r.active ? ' · working' : ''}${r.stale ? ' · not updated recently' : ''}`
|
|
868
|
+
const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
|
|
869
|
+
line.setAttribute('aria-pressed', String(on))
|
|
870
|
+
line.title = tab.title
|
|
871
|
+
line.setAttribute('aria-label', `Show ${tab.title}`)
|
|
872
|
+
line.classList.toggle('urgent', urgent)
|
|
873
|
+
tab.setAttribute('aria-label', `Show ${tab.title}`)
|
|
874
|
+
el(`mini-${p}-value`).textContent = pctText(r.pct)
|
|
875
|
+
const bar = el(`mini-${p}-bar`)
|
|
876
|
+
bar.style.width = `${Math.min(r.pct ?? 0, 100)}%`
|
|
877
|
+
bar.classList.toggle('hot', (r.pct ?? 0) >= 80)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
for (const b of document.querySelectorAll('#harness-tabs button, .mini-source')) {
|
|
882
|
+
b.addEventListener('click', (e) => {
|
|
883
|
+
e.stopPropagation()
|
|
884
|
+
pickProvider(b.dataset.provider)
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
el('harness-tabs').addEventListener('keydown', (e) => {
|
|
888
|
+
const providers = ['claude', 'codex']
|
|
889
|
+
const current = providers.indexOf(e.target.dataset.provider)
|
|
890
|
+
if (current < 0) return
|
|
891
|
+
let next
|
|
892
|
+
if (e.key === 'ArrowRight') next = (current + 1) % providers.length
|
|
893
|
+
else if (e.key === 'ArrowLeft') next = (current + providers.length - 1) % providers.length
|
|
894
|
+
else if (e.key === 'Home') next = 0
|
|
895
|
+
else if (e.key === 'End') next = providers.length - 1
|
|
896
|
+
else return
|
|
897
|
+
e.preventDefault()
|
|
898
|
+
pickProvider(providers[next])
|
|
899
|
+
el(`tab-${providers[next]}`).focus()
|
|
900
|
+
})
|
|
901
|
+
|
|
471
902
|
// main render
|
|
472
903
|
let prevState = null
|
|
473
|
-
|
|
904
|
+
const prevPct = {} // per service: a switch must not read as a reset
|
|
474
905
|
let lastData = null
|
|
475
906
|
function render(d) {
|
|
476
907
|
lastData = d
|
|
477
|
-
|
|
478
|
-
|
|
908
|
+
noteSession(d)
|
|
909
|
+
// the burn trail is Claude's, fed on every Claude tick whatever is on screen
|
|
910
|
+
const cl = realUsage
|
|
911
|
+
burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
|
|
912
|
+
paint()
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function paint() {
|
|
916
|
+
setPlan(el('codex-plan'), codexData?.plan)
|
|
917
|
+
const d0 = lastData
|
|
918
|
+
if (!d0) return
|
|
919
|
+
const view = viewProvider()
|
|
920
|
+
paintServices(view)
|
|
921
|
+
const isCodex = view === 'codex' && !!codexData
|
|
922
|
+
const cv = isCodex ? codexView(codexData) : null
|
|
923
|
+
const d = isCodex ? cv.d : d0
|
|
924
|
+
// % comes only from the connected account (or Codex's own logs) — no estimates
|
|
925
|
+
const liveOn = isCodex || !!realUsage
|
|
479
926
|
document.body.classList.toggle('live', liveOn)
|
|
480
|
-
const sessPct = liveOn ? realUsage.session.pct : 0
|
|
481
|
-
const sessReset = liveOn ? realUsage.session.resetMs : null
|
|
927
|
+
const sessPct = isCodex ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
|
|
928
|
+
const sessReset = isCodex ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
|
|
482
929
|
const sessActive = liveOn && sessReset != null
|
|
483
|
-
const wkPct = liveOn ? realUsage.week.pct : 0
|
|
484
|
-
const wkReset = liveOn ? realUsage.week.resetMs : null
|
|
930
|
+
const wkPct = isCodex ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
|
|
931
|
+
const wkReset = isCodex ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
|
|
932
|
+
const sp = sessPct ?? 0
|
|
933
|
+
const wp = wkPct ?? 0
|
|
485
934
|
|
|
486
935
|
const fireAt = currentConfig.fireThreshold ?? 90
|
|
487
936
|
let st =
|
|
488
|
-
liveOn &&
|
|
937
|
+
liveOn && sp >= 100
|
|
489
938
|
? 'tired'
|
|
490
939
|
: d.active
|
|
491
940
|
? 'working'
|
|
492
|
-
: liveOn &&
|
|
941
|
+
: liveOn && sp >= fireAt
|
|
493
942
|
? 'stressed'
|
|
494
943
|
: d.sleeping
|
|
495
944
|
? 'sleeping'
|
|
@@ -519,6 +968,7 @@ function render(d) {
|
|
|
519
968
|
for (const a of acts) {
|
|
520
969
|
document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
|
|
521
970
|
}
|
|
971
|
+
const before = prevState
|
|
522
972
|
if (prevState && prevState !== st) {
|
|
523
973
|
if (st === 'sleeping') oneShot('drowse', 700)
|
|
524
974
|
else if (prevState === 'sleeping') oneShot('wake', 700)
|
|
@@ -542,32 +992,38 @@ function render(d) {
|
|
|
542
992
|
? `${fmtTokens(d.tokensPerMin)} tok/min`
|
|
543
993
|
: `${fmtTokens(d.today.tokens)} tokens today`
|
|
544
994
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
995
|
+
const was = prevPct[view]
|
|
996
|
+
if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
|
|
997
|
+
celebrate()
|
|
998
|
+
// Codex's own reset is called out by listenCodex, on screen or not
|
|
999
|
+
if (!isCodex) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
|
|
1000
|
+
}
|
|
1001
|
+
prevPct[view] = sessPct
|
|
1002
|
+
el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
|
|
1003
|
+
setLevel(el('session-pct'), liveOn ? sp : 0)
|
|
549
1004
|
const mini = el('mini-pct')
|
|
550
|
-
mini.textContent = liveOn ?
|
|
551
|
-
mini.classList.toggle('high', liveOn &&
|
|
1005
|
+
mini.textContent = liveOn ? pctText(sessPct) : '—'
|
|
1006
|
+
mini.classList.toggle('high', liveOn && sp >= 80)
|
|
552
1007
|
// the collapsed ring: how much of the session is already spent
|
|
553
1008
|
const rf = el('ring-fill')
|
|
554
|
-
rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(
|
|
555
|
-
rf.classList.toggle('high',
|
|
1009
|
+
rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sp, 100) / 100)}`
|
|
1010
|
+
rf.classList.toggle('high', sp >= 80)
|
|
556
1011
|
const rp = el('ring-pct')
|
|
557
1012
|
rp.textContent = mini.textContent
|
|
558
|
-
rp.classList.toggle('high', liveOn &&
|
|
1013
|
+
rp.classList.toggle('high', liveOn && sp >= 80)
|
|
559
1014
|
const sf = el('session-fill')
|
|
560
|
-
sf.style.width = `${
|
|
561
|
-
sf.classList.toggle('high',
|
|
562
|
-
setLevel(sf,
|
|
563
|
-
el('session-sub').textContent =
|
|
564
|
-
?
|
|
565
|
-
:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1015
|
+
sf.style.width = `${sp}%`
|
|
1016
|
+
sf.classList.toggle('high', sp >= 80)
|
|
1017
|
+
setLevel(sf, sp)
|
|
1018
|
+
el('session-sub').textContent = isCodex
|
|
1019
|
+
? codexSub(cv.sess, d.session.tokens, cv.stale)
|
|
1020
|
+
: sessActive
|
|
1021
|
+
? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
|
|
1022
|
+
: 'no active session'
|
|
1023
|
+
|
|
1024
|
+
// where this pace is taking you — Claude's only: Codex has no trail to read
|
|
569
1025
|
const proj =
|
|
570
|
-
sessActive &&
|
|
1026
|
+
!isCodex && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
|
|
571
1027
|
const pe = el('session-proj')
|
|
572
1028
|
pe.hidden = !proj
|
|
573
1029
|
pe.classList.toggle('tight', proj?.kind === 'eta')
|
|
@@ -576,22 +1032,24 @@ function render(d) {
|
|
|
576
1032
|
proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
|
|
577
1033
|
}
|
|
578
1034
|
|
|
579
|
-
el('week-pct').textContent =
|
|
580
|
-
setLevel(el('week-pct'),
|
|
1035
|
+
el('week-pct').textContent = pctText(liveOn ? wkPct : 0)
|
|
1036
|
+
setLevel(el('week-pct'), wp)
|
|
581
1037
|
const wf = el('week-fill')
|
|
582
|
-
wf.style.width = `${
|
|
583
|
-
wf.classList.toggle('high',
|
|
584
|
-
setLevel(wf,
|
|
585
|
-
el('week-sub').textContent =
|
|
586
|
-
|
|
1038
|
+
wf.style.width = `${wp}%`
|
|
1039
|
+
wf.classList.toggle('high', wp >= 80)
|
|
1040
|
+
setLevel(wf, wp)
|
|
1041
|
+
el('week-sub').textContent = isCodex
|
|
1042
|
+
? codexSub(cv.week, d.week.tokens, cv.stale)
|
|
1043
|
+
: wkReset != null
|
|
587
1044
|
? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
|
|
588
1045
|
: `${fmtTokens(d.week.tokens)} tokens · last 7 days`
|
|
589
1046
|
|
|
590
|
-
renderScoped(liveOn ? realUsage.scoped || [] : [], d.byModel || [])
|
|
1047
|
+
renderScoped(!isCodex && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
|
|
591
1048
|
renderModels(d.byModel || [])
|
|
592
1049
|
renderProjects(d.byProject || [])
|
|
593
1050
|
renderHeat(d.days30 || [])
|
|
594
|
-
el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
|
|
1051
|
+
el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
|
|
1052
|
+
paintChip()
|
|
595
1053
|
|
|
596
1054
|
currentRate = d.tokensPerMin || 0
|
|
597
1055
|
if (st === 'working') {
|
|
@@ -604,6 +1062,7 @@ function render(d) {
|
|
|
604
1062
|
stopEating()
|
|
605
1063
|
}
|
|
606
1064
|
|
|
1065
|
+
listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
|
|
607
1066
|
fitSize()
|
|
608
1067
|
}
|
|
609
1068
|
|
|
@@ -614,7 +1073,9 @@ function fitSize() {
|
|
|
614
1073
|
requestAnimationFrame(() => {
|
|
615
1074
|
const collapsed = document.body.classList.contains('collapsed')
|
|
616
1075
|
const zoom = Number.parseFloat(document.body.style.zoom) || 1
|
|
617
|
-
|
|
1076
|
+
// The dual-service dock trades height for a little horizontal room.
|
|
1077
|
+
const dual = document.body.classList.contains('dual')
|
|
1078
|
+
const w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
|
|
618
1079
|
// offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
|
|
619
1080
|
// empirically against this Electron build) — so measure the unzoomed content
|
|
620
1081
|
// height, then scale the whole thing (content + margin) ourselves
|
|
@@ -640,6 +1101,7 @@ window.api.onDebugState((o) => {
|
|
|
640
1101
|
const s = o?.state
|
|
641
1102
|
if (s === 'poke') return pokePet()
|
|
642
1103
|
if (s === 'celebrate') return celebrate()
|
|
1104
|
+
if (s === 'say') return sayDebug(o.kind)
|
|
643
1105
|
debugState = s === 'auto' || s === 'clear' || !s ? null : s
|
|
644
1106
|
if (lastData) render(lastData)
|
|
645
1107
|
})
|
|
@@ -651,9 +1113,18 @@ window.api.onError((msg) => {
|
|
|
651
1113
|
window.api.onConfig((cfg) => {
|
|
652
1114
|
currentConfig = cfg || {}
|
|
653
1115
|
document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
|
|
1116
|
+
document.body.classList.toggle('codex-on', !!currentConfig.codex)
|
|
1117
|
+
if (!currentConfig.codex) codexData = null
|
|
1118
|
+
heardConfig = true
|
|
1119
|
+
paintMute()
|
|
1120
|
+
paint()
|
|
654
1121
|
applyZoom(currentConfig.zoom)
|
|
655
1122
|
fitSize()
|
|
656
1123
|
})
|
|
1124
|
+
window.api.onCodex((c) => {
|
|
1125
|
+
codexData = c || null
|
|
1126
|
+
paint()
|
|
1127
|
+
})
|
|
657
1128
|
window.api.onRealUsage((u) => {
|
|
658
1129
|
realUsage = u || null
|
|
659
1130
|
if (lastData) render(lastData)
|
|
@@ -669,6 +1140,7 @@ window.api.onAuthState((s) => {
|
|
|
669
1140
|
endLogin()
|
|
670
1141
|
showProfile(null)
|
|
671
1142
|
}
|
|
1143
|
+
paint() // losing Claude can leave Codex as the only service
|
|
672
1144
|
if (document.body.classList.contains('settings-open')) fitSize()
|
|
673
1145
|
})
|
|
674
1146
|
|
|
@@ -689,12 +1161,24 @@ function paintChip() {
|
|
|
689
1161
|
const active = (a?.accounts || []).find((x) => x.id === a?.active)
|
|
690
1162
|
const email = p?.email || (active?.connected ? active.label : null)
|
|
691
1163
|
// the settings block says who is connected, not just that someone is: the
|
|
692
|
-
//
|
|
1164
|
+
// logo identifies the service, followed by the same plan badge and email.
|
|
693
1165
|
el('acc-ok').textContent = email || 'Connected'
|
|
694
|
-
el('acc-sub')
|
|
695
|
-
el('acc-
|
|
1166
|
+
setPlan(el('acc-sub'), p?.plan)
|
|
1167
|
+
el('acc-ok').title = email || 'Connected'
|
|
696
1168
|
const chip = el('account-chip')
|
|
697
1169
|
const mini = el('mini-acct')
|
|
1170
|
+
// the Codex view never borrows Claude's identity: its logs carry no email
|
|
1171
|
+
if (viewProvider() === 'codex') {
|
|
1172
|
+
const plan = codexData?.plan || null
|
|
1173
|
+
el('ac-email').textContent = 'Codex'
|
|
1174
|
+
chip.title = 'Codex on this computer'
|
|
1175
|
+
setPlan(el('ac-plan'), plan)
|
|
1176
|
+
chip.hidden = false
|
|
1177
|
+
el('mini-acct-name').textContent = 'Codex'
|
|
1178
|
+
setPlan(el('mini-acct-plan'), plan)
|
|
1179
|
+
mini.hidden = false
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
698
1182
|
if (!email) {
|
|
699
1183
|
chip.hidden = true
|
|
700
1184
|
mini.hidden = true
|
|
@@ -832,6 +1316,8 @@ function toggleAccountMenu() {
|
|
|
832
1316
|
|
|
833
1317
|
el('account-chip').addEventListener('click', (e) => {
|
|
834
1318
|
e.stopPropagation()
|
|
1319
|
+
// Codex has no account switcher: its chip leads to its Settings block
|
|
1320
|
+
if (viewProvider() === 'codex') return openSettings()
|
|
835
1321
|
toggleAccountMenu()
|
|
836
1322
|
})
|
|
837
1323
|
// clicking anywhere else — the pet, the gear, another app — puts it away
|
|
@@ -890,7 +1376,7 @@ window.api.onAuthResult((r) => {
|
|
|
890
1376
|
}, 6000)
|
|
891
1377
|
celebrate()
|
|
892
1378
|
} else {
|
|
893
|
-
// failed:
|
|
1379
|
+
// failed: restore the connection card so a retry is one click away
|
|
894
1380
|
document.body.classList.remove('awaiting')
|
|
895
1381
|
const e = r?.error || ''
|
|
896
1382
|
el('acc-msg').textContent = /429|rate_limit/i.test(e)
|
|
@@ -900,6 +1386,26 @@ window.api.onAuthResult((r) => {
|
|
|
900
1386
|
fitSize()
|
|
901
1387
|
})
|
|
902
1388
|
|
|
1389
|
+
// Keep the main view focused on limits; history is an explicit, remembered choice.
|
|
1390
|
+
const DETAILS_KEY = 'clauddy.details-open'
|
|
1391
|
+
function setDetailsOpen(open) {
|
|
1392
|
+
el('usage-details').hidden = !open
|
|
1393
|
+
el('details-toggle').setAttribute('aria-expanded', String(open))
|
|
1394
|
+
fitSize()
|
|
1395
|
+
}
|
|
1396
|
+
try {
|
|
1397
|
+
setDetailsOpen(localStorage.getItem(DETAILS_KEY) === 'true')
|
|
1398
|
+
} catch {
|
|
1399
|
+
setDetailsOpen(false)
|
|
1400
|
+
}
|
|
1401
|
+
el('details-toggle').addEventListener('click', () => {
|
|
1402
|
+
const open = el('usage-details').hidden
|
|
1403
|
+
setDetailsOpen(open)
|
|
1404
|
+
try {
|
|
1405
|
+
localStorage.setItem(DETAILS_KEY, String(open))
|
|
1406
|
+
} catch {}
|
|
1407
|
+
})
|
|
1408
|
+
|
|
903
1409
|
// collapsible panel sections — the widget is a desktop pet, not a dashboard, so
|
|
904
1410
|
// each breakdown can be folded away and the choice is remembered per machine
|
|
905
1411
|
const SEC_KEY = 'clauddy.folded'
|
|
@@ -955,7 +1461,36 @@ for (const head of document.querySelectorAll('.sec-head')) {
|
|
|
955
1461
|
for (const id of readFolded()) toggleSection(id, true)
|
|
956
1462
|
|
|
957
1463
|
el('close').addEventListener('click', () => window.api.quit())
|
|
958
|
-
el('usage').addEventListener('click', () => window.api.openUsage())
|
|
1464
|
+
el('usage').addEventListener('click', () => window.api.openUsage(viewProvider()))
|
|
1465
|
+
|
|
1466
|
+
// Settings → Codex: Connect looks for a local session and turns monitoring on if found
|
|
1467
|
+
function endCodexSetup() {
|
|
1468
|
+
document.body.classList.remove('codex-setup')
|
|
1469
|
+
el('codex-setup').hidden = true
|
|
1470
|
+
el('codex-hint').textContent = 'Track Codex alongside Claude, in this pet.'
|
|
1471
|
+
}
|
|
1472
|
+
el('codex-connect').addEventListener('click', () => {
|
|
1473
|
+
el('codex-hint').textContent = 'Looking…'
|
|
1474
|
+
window.api.codexDetect()
|
|
1475
|
+
})
|
|
1476
|
+
el('codex-retry').addEventListener('click', () => window.api.codexDetect())
|
|
1477
|
+
// Connect is the consent: found → monitoring starts, not found → say why
|
|
1478
|
+
window.api.onCodexDetected((r) => {
|
|
1479
|
+
if (r?.found) {
|
|
1480
|
+
endCodexSetup()
|
|
1481
|
+
window.api.codexEnable(true)
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
document.body.classList.add('codex-setup')
|
|
1485
|
+
el('codex-setup').hidden = false
|
|
1486
|
+
el('codex-hint').textContent = 'No session — sign in to Codex'
|
|
1487
|
+
fitSize()
|
|
1488
|
+
})
|
|
1489
|
+
el('codex-setup-cancel').addEventListener('click', () => {
|
|
1490
|
+
endCodexSetup()
|
|
1491
|
+
fitSize()
|
|
1492
|
+
})
|
|
1493
|
+
el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
|
|
959
1494
|
|
|
960
1495
|
// account login (browser flow)
|
|
961
1496
|
el('acc-connect').addEventListener('click', () => window.api.authStart())
|
|
@@ -1052,6 +1587,8 @@ function snapshotSettings() {
|
|
|
1052
1587
|
t2: el('set-t2').value,
|
|
1053
1588
|
fire: el('set-fire').value,
|
|
1054
1589
|
zoom: el('set-zoom').value,
|
|
1590
|
+
talk: el('set-talk').checked,
|
|
1591
|
+
sound: el('set-sound').checked,
|
|
1055
1592
|
})
|
|
1056
1593
|
}
|
|
1057
1594
|
function refreshSaveDirty() {
|
|
@@ -1070,6 +1607,9 @@ function clearSaveDirty() {
|
|
|
1070
1607
|
function reflectAlertsOn() {
|
|
1071
1608
|
el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
|
|
1072
1609
|
}
|
|
1610
|
+
function reflectTalkOn() {
|
|
1611
|
+
el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
|
|
1612
|
+
}
|
|
1073
1613
|
// zoom applies as you step it, so the widget shows the size right away;
|
|
1074
1614
|
// Cancel puts the saved value back
|
|
1075
1615
|
function previewZoom() {
|
|
@@ -1089,9 +1629,13 @@ function populateSettings() {
|
|
|
1089
1629
|
el('set-t2').value = th[1] != null ? th[1] : 95
|
|
1090
1630
|
el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
|
|
1091
1631
|
el('set-zoom').value = c.zoom != null ? c.zoom : 100
|
|
1632
|
+
el('set-talk').checked = c.talk !== false
|
|
1633
|
+
el('set-sound').checked = !!c.sound
|
|
1634
|
+
reflectTalkOn()
|
|
1092
1635
|
clearSaveDirty() // fields now match the saved config
|
|
1093
1636
|
}
|
|
1094
1637
|
function openSettings() {
|
|
1638
|
+
hideBubble()
|
|
1095
1639
|
document.body.classList.remove('collapsed')
|
|
1096
1640
|
populateSettings()
|
|
1097
1641
|
document.body.classList.add('settings-open')
|
|
@@ -1105,6 +1649,7 @@ let closingTimer = null
|
|
|
1105
1649
|
function closeSettings() {
|
|
1106
1650
|
if (!document.body.classList.contains('settings-open')) return
|
|
1107
1651
|
abandonLogin()
|
|
1652
|
+
endCodexSetup()
|
|
1108
1653
|
document.body.classList.remove('settings-open')
|
|
1109
1654
|
document.body.classList.add('settings-closing')
|
|
1110
1655
|
clearTimeout(closingTimer)
|
|
@@ -1132,11 +1677,24 @@ for (const b of document.querySelectorAll('.num-btn')) {
|
|
|
1132
1677
|
})
|
|
1133
1678
|
}
|
|
1134
1679
|
// light up Save whenever an editable field changes
|
|
1135
|
-
for (const id of [
|
|
1680
|
+
for (const id of [
|
|
1681
|
+
'set-alerts',
|
|
1682
|
+
'set-t1',
|
|
1683
|
+
'set-t2',
|
|
1684
|
+
'set-fire',
|
|
1685
|
+
'set-zoom',
|
|
1686
|
+
'set-talk',
|
|
1687
|
+
'set-sound',
|
|
1688
|
+
]) {
|
|
1136
1689
|
el(id).addEventListener('input', refreshSaveDirty)
|
|
1137
1690
|
el(id).addEventListener('change', refreshSaveDirty)
|
|
1138
1691
|
}
|
|
1139
1692
|
el('set-alerts').addEventListener('change', reflectAlertsOn)
|
|
1693
|
+
el('set-talk').addEventListener('change', reflectTalkOn)
|
|
1694
|
+
// turning the voice on plays a sample, so you hear what you just agreed to
|
|
1695
|
+
el('set-sound').addEventListener('change', () => {
|
|
1696
|
+
if (el('set-sound').checked) blipper.play('Hello there!')
|
|
1697
|
+
})
|
|
1140
1698
|
el('set-zoom').addEventListener('input', previewZoom)
|
|
1141
1699
|
// display-mode segmented control (floating pet | menu bar)
|
|
1142
1700
|
for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
@@ -1161,17 +1719,21 @@ el('set-save').addEventListener('click', () => {
|
|
|
1161
1719
|
.sort((a, b) => a - b),
|
|
1162
1720
|
fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
|
|
1163
1721
|
zoom,
|
|
1722
|
+
talk: el('set-talk').checked,
|
|
1723
|
+
sound: el('set-sound').checked,
|
|
1164
1724
|
})
|
|
1165
1725
|
clearSaveDirty()
|
|
1166
1726
|
document.body.classList.remove('settings-open')
|
|
1167
1727
|
fitSize()
|
|
1168
1728
|
})
|
|
1169
1729
|
el('min').addEventListener('click', () => {
|
|
1730
|
+
hideBubble()
|
|
1170
1731
|
document.body.classList.toggle('collapsed')
|
|
1171
1732
|
fitSize()
|
|
1172
1733
|
})
|
|
1173
1734
|
// double-click the pet to collapse / expand
|
|
1174
1735
|
el('pet').addEventListener('dblclick', () => {
|
|
1736
|
+
hideBubble()
|
|
1175
1737
|
document.body.classList.toggle('collapsed')
|
|
1176
1738
|
fitSize()
|
|
1177
1739
|
})
|
|
@@ -1217,6 +1779,12 @@ if (typeof module === 'object' && module.exports) {
|
|
|
1217
1779
|
showProfile,
|
|
1218
1780
|
renderAccounts,
|
|
1219
1781
|
renderAccountMenu,
|
|
1782
|
+
paint,
|
|
1783
|
+
pickProvider,
|
|
1220
1784
|
burn,
|
|
1785
|
+
say,
|
|
1786
|
+
hideBubble,
|
|
1787
|
+
soundOn,
|
|
1788
|
+
blipper,
|
|
1221
1789
|
}
|
|
1222
1790
|
}
|