spinuptui 1.3.0-alpha.1 → 1.3.0-alpha.2

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.
@@ -0,0 +1,146 @@
1
+ // Scheduler's module declaration: the station's clock. Its status is the
2
+ // one-question answer to "is the station alive?" — how long ago the dispatcher
3
+ // last ticked.
4
+
5
+ import { existsSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { tryLock } from "../lib/flock.ts"
8
+ import { loadConfig } from "../config.ts"
9
+ import { beaconFromConfig } from "../lib/beacon.ts"
10
+ import { nextRunText, parseWhen } from "../lib/schedule.ts"
11
+ import { listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
12
+ import { readClaim, stationName } from "../lib/schedulerGit.ts"
13
+ import { moduleStateDir, moduleWorkDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
14
+
15
+ // Three missed ticks.
16
+ export const TICK_STALE_MS = 15 * 60_000
17
+ const RECENT_FAILURE_MS = 24 * 60 * 60_000
18
+
19
+ function claimText(s: Source): string {
20
+ const holder = readClaim(s)
21
+ if (s.queueOnly) return holder ? `run by ${holder}; released here, this machine only queues` : "released here; this machine only queues"
22
+ if (!holder) return "unclaimed: the next dispatcher tick here claims it"
23
+ return holder === stationName() ? "run by this station" : `run by ${holder}; this machine only queues`
24
+ }
25
+
26
+ function tickRunning(): boolean {
27
+ try {
28
+ const lock = tryLock(join(moduleStateDir("scheduler"), "tick.lock"))
29
+ if (!lock) return true
30
+ lock.release()
31
+ } catch {
32
+ // No lock support: assume nothing is running.
33
+ }
34
+ return false
35
+ }
36
+
37
+ function age(ms: number): string {
38
+ const m = Math.round(ms / 60_000)
39
+ if (m < 1) return "just now"
40
+ if (m < 120) return `${m} min ago`
41
+ const h = Math.round(m / 60)
42
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
43
+ }
44
+
45
+ export const schedulerModule: ModuleDefinition = {
46
+ id: "scheduler",
47
+ name: "Scheduler",
48
+ summary: "Follow-ups that run themselves: the fix you shipped today gets checked tomorrow without you remembering.",
49
+ dependsOn: ["beacon"],
50
+ configKey: "scheduler",
51
+ stateDir: () => moduleStateDir("scheduler"),
52
+ workDir: () => moduleWorkDir("scheduler"),
53
+ jobs: [],
54
+ async status(): Promise<ModuleStatus> {
55
+ const cfg = loadConfig()
56
+ const d = cfg.scheduler?.dispatcher
57
+ const checks: ModuleCheck[] = []
58
+ const installed = !!d && existsSync(d.unitPath)
59
+ checks.push({
60
+ name: "dispatcher",
61
+ ok: installed,
62
+ detail: installed ? `${d!.label}, every 5 min` : d ? `unit file missing (${d.unitPath})` : "not installed",
63
+ })
64
+ const last = readLastTick()
65
+ const lastAge = last ? Date.now() - Date.parse(last.at) : null
66
+ const fresh = lastAge !== null && lastAge < TICK_STALE_MS
67
+ // A tick still holding its lock is alive: a long agent job can take 30
68
+ // minutes, and the station isn't dead while it runs.
69
+ const running = !fresh && tickRunning()
70
+ checks.push({
71
+ name: "last tick",
72
+ ok: !installed || fresh || running,
73
+ detail:
74
+ (last ? `${age(lastAge!)} (${last.via})${last.errors.length ? `, ${last.errors.length} problem${last.errors.length === 1 ? "" : "s"}: ${last.errors[0]}` : ""}` : "never") +
75
+ (running ? "; a tick is running now" : ""),
76
+ })
77
+ const now = new Date()
78
+ let active = 0
79
+ const failed: string[] = []
80
+ for (const s of listSources(cfg)) {
81
+ const exists = existsSync(s.path)
82
+ // The local folder appears on first use, so its absence is normal.
83
+ if (s.name !== "local" || exists) checks.push({ name: `source ${s.name}`, ok: exists, detail: exists ? `${s.path}${s.git ? ` (git, ${claimText(s)})` : ""}` : `${s.path} does not exist` })
84
+ if (!exists) continue
85
+ for (const j of loadJobs(s)) {
86
+ if (j.problems.length) {
87
+ failed.push(`${s.name}/${j.name} invalid`)
88
+ continue
89
+ }
90
+ const st = loadState(s, j.header.id!)
91
+ let next = "?"
92
+ try {
93
+ next = nextRunText(j.header, st, now)
94
+ } catch {
95
+ // validateJob already accepted it; leave "?".
96
+ }
97
+ if (next !== "expired" && next !== "done") active++
98
+ // A failure was paged when it happened; it stays news for a day, not
99
+ // until the job expires.
100
+ const recent = st.last_run ? now.getTime() - parseWhen(st.last_run).getTime() < RECENT_FAILURE_MS : st.last_status === "auth"
101
+ if (st.last_status && st.last_status !== "ok" && next !== "expired" && recent) failed.push(`${s.name}/${j.header.id} ${st.last_status}`)
102
+ }
103
+ }
104
+ checks.push({ name: "jobs", ok: failed.length === 0, detail: `${active} active${failed.length ? `; not ok in the last day: ${failed.slice(0, 3).join(", ")}` : ""}` })
105
+ const beacon = beaconFromConfig(cfg)
106
+ checks.push({ name: "digests", ok: beacon.channels.length > 0, detail: beacon.channels.length ? `via Beacon (${beacon.defaultChannel ?? "no default"})` : "Beacon isn't set up, so runs aren't reported" })
107
+
108
+ if (!installed && !d) {
109
+ return {
110
+ id: "scheduler",
111
+ state: "not_configured",
112
+ summary: "Dispatcher not installed",
113
+ checks,
114
+ remedy: "spinuptui scheduler install --plan",
115
+ }
116
+ }
117
+ const failing = checks.find((c) => !c.ok)
118
+ return {
119
+ id: "scheduler",
120
+ state: failing ? "degraded" : "ok",
121
+ summary: failing ? `${failing.name}: ${failing.detail}` : `ticking (${checks[1]!.detail.split(" (")[0]}), ${active} active job${active === 1 ? "" : "s"}`,
122
+ checks,
123
+ ...(failing ? { remedy: failing.name === "dispatcher" || failing.name === "last tick" ? "spinuptui scheduler install --apply" : "spinuptui scheduler list" } : {}),
124
+ }
125
+ },
126
+ }
127
+
128
+ // The next few follow-ups due, soonest first, for the Modules screen.
129
+ export function upcomingRuns(limit = 5): { source: string; id: string; next: string }[] {
130
+ const now = new Date()
131
+ const out: { source: string; id: string; next: string }[] = []
132
+ for (const s of listSources(loadConfig())) {
133
+ if (!existsSync(s.path)) continue
134
+ for (const j of loadJobs(s)) {
135
+ if (j.problems.length) continue
136
+ try {
137
+ const next = nextRunText(j.header, loadState(s, j.header.id!), now)
138
+ if (next !== "expired" && next !== "done") out.push({ source: s.name, id: j.header.id!, next })
139
+ } catch {
140
+ // Skip a job whose schedule can't be read.
141
+ }
142
+ }
143
+ }
144
+ // Fixed times sort before "every N min", which has no single next time.
145
+ return out.sort((a, b) => (a.next.startsWith("every") ? 1 : 0) - (b.next.startsWith("every") ? 1 : 0) || a.next.localeCompare(b.next)).slice(0, limit)
146
+ }
package/src/ui/App.tsx CHANGED
@@ -43,6 +43,7 @@ import { KumaSite } from "./views/KumaSite.tsx"
43
43
  import { Accounts } from "./views/Accounts.tsx"
44
44
  import { Modules } from "./views/Modules.tsx"
45
45
  import { BeaconSetup } from "./views/BeaconSetup.tsx"
46
+ import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
46
47
 
47
48
  const MIN_SPLASH_MS = 1200
48
49
 
@@ -92,7 +93,8 @@ export function App() {
92
93
  store.connectZoneTarget !== null ||
93
94
  store.dnsRecordsTarget !== null ||
94
95
  store.kumaSite !== null ||
95
- store.beaconSetupOpen
96
+ store.beaconSetupOpen ||
97
+ store.schedulerInstallOpen
96
98
  useEffect(() => {
97
99
  store.setOverlayOpen(overlayActive)
98
100
  }, [overlayActive, store])
@@ -210,6 +212,7 @@ export function App() {
210
212
 
211
213
  // Beacon's guided setup (Modules tab) owns the keyboard while open.
212
214
  if (store.beaconSetupOpen) return
215
+ if (store.schedulerInstallOpen) return
213
216
 
214
217
  // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
215
218
  if (key.shift && key.name === "a") return setShowAccounts(true)
@@ -299,6 +302,7 @@ export function App() {
299
302
  {store.dnsRecordsTarget && <DnsRecords />}
300
303
  {store.kumaSite && <KumaSite />}
301
304
  {store.beaconSetupOpen && <BeaconSetup />}
305
+ {store.schedulerInstallOpen && <SchedulerInstall />}
302
306
  {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
303
307
  <ToastStack />
304
308
  </box>
@@ -0,0 +1,42 @@
1
+ // The dashboard: boots the OpenTUI renderer and mounts either the first-run
2
+ // onboarding wizard (no token yet) or the main app wrapped in the data store.
3
+ // Loaded by src/index.tsx with a dynamic import, only once no headless command
4
+ // has claimed the arguments.
5
+
6
+ import { useCallback, useMemo, useState } from "react"
7
+ import { createCliRenderer } from "@opentui/core"
8
+ import { createRoot } from "@opentui/react"
9
+ import { hasToken, pinActiveProfile, setActiveProfile } from "../config.ts"
10
+ import { StoreProvider } from "./store.tsx"
11
+ import { App } from "./App.tsx"
12
+ import { AccountsContext, type AccountsApi } from "./accounts.tsx"
13
+ import { resetWpCoreJobs } from "./wpCoreJobs.tsx"
14
+ import { Onboarding } from "./Onboarding.tsx"
15
+ import { isDevMode } from "../dev/devMode.ts"
16
+
17
+ export async function startDashboard({ forceLogin }: { forceLogin: boolean }): Promise<void> {
18
+ function Root() {
19
+ const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
20
+ // The store is keyed by account: switching remounts it (see ui/accounts.tsx).
21
+ const [account, setAccount] = useState(() => pinActiveProfile())
22
+ const switchAccount = useCallback(async (id: string) => {
23
+ await setActiveProfile(id)
24
+ resetWpCoreJobs()
25
+ setAccount(id)
26
+ }, [])
27
+ const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
28
+ if (!configured) {
29
+ return <Onboarding onComplete={() => setConfigured(true)} />
30
+ }
31
+ return (
32
+ <AccountsContext.Provider value={accounts}>
33
+ <StoreProvider key={account}>
34
+ <App />
35
+ </StoreProvider>
36
+ </AccountsContext.Provider>
37
+ )
38
+ }
39
+
40
+ const renderer = await createCliRenderer()
41
+ createRoot(renderer).render(<Root />)
42
+ }
package/src/ui/store.tsx CHANGED
@@ -748,6 +748,9 @@ interface StoreValue extends DataState {
748
748
  // Beacon's guided setup overlay, opened from the Modules tab.
749
749
  beaconSetupOpen: boolean
750
750
  setBeaconSetupOpen: (v: boolean) => void
751
+ // Scheduler's install plan + confirm overlay, opened from the Modules tab.
752
+ schedulerInstallOpen: boolean
753
+ setSchedulerInstallOpen: (v: boolean) => void
751
754
  // Whether the "needs a local copy" (forgotten) report overlay is open, and an
752
755
  // optional stack filter (set from the selected Stacks group when opened).
753
756
  forgottenOpen: boolean
@@ -1118,6 +1121,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1118
1121
  const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
1119
1122
  const [discoverOpen, setDiscoverOpen] = useState(false)
1120
1123
  const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
1124
+ const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
1121
1125
  const [forgottenOpen, setForgottenOpen] = useState(false)
1122
1126
  const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
1123
1127
  const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
@@ -4540,6 +4544,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4540
4544
  setDiscoverOpen,
4541
4545
  beaconSetupOpen,
4542
4546
  setBeaconSetupOpen,
4547
+ schedulerInstallOpen,
4548
+ setSchedulerInstallOpen,
4543
4549
  forgottenOpen,
4544
4550
  setForgottenOpen,
4545
4551
  forgottenStack,
@@ -16,6 +16,7 @@ import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
16
16
  import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
17
17
  import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
18
18
  import { loadConfig } from "../../config.ts"
19
+ import { upcomingRuns } from "../../modules/scheduler.ts"
19
20
 
20
21
  interface Card {
21
22
  id: ModuleId
@@ -42,16 +43,16 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
42
43
 
43
44
  export function Modules({ rows }: { rows: number }) {
44
45
  const store = useStore()
45
- const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen } = store
46
+ const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen } = store
46
47
  const [selected, setSelected] = useState(0)
47
48
  const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
48
49
  const [testing, setTesting] = useState(false)
49
50
 
50
- // Recompute on open and whenever the setup overlay closes (it may have
51
- // changed the config). status() is cheap and never sends anything.
51
+ // Recompute on open and whenever a setup overlay closes (it may have changed
52
+ // the config). status() is cheap and never sends anything.
52
53
  const [tick, setTick] = useState(0)
53
54
  useEffect(() => {
54
- if (beaconSetupOpen) return
55
+ if (beaconSetupOpen || schedulerInstallOpen) return
55
56
  let live = true
56
57
  void Promise.all(MODULES.map((m) => m.status())).then((list) => {
57
58
  if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
@@ -59,7 +60,7 @@ export function Modules({ rows }: { rows: number }) {
59
60
  return () => {
60
61
  live = false
61
62
  }
62
- }, [beaconSetupOpen, tick])
63
+ }, [beaconSetupOpen, schedulerInstallOpen, tick])
63
64
 
64
65
  const isActive = route === "modules" && !inputMode && !overlayOpen
65
66
  const card = CARDS[selected]!
@@ -88,6 +89,7 @@ export function Modules({ rows }: { rows: number }) {
88
89
  case "return":
89
90
  case "s":
90
91
  if (card.id === "beacon") return setBeaconSetupOpen(true)
92
+ if (card.id === "scheduler") return setSchedulerInstallOpen(true)
91
93
  return
92
94
  case "t":
93
95
  if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
@@ -140,6 +142,7 @@ export function Modules({ rows }: { rows: number }) {
140
142
  hints={[
141
143
  { key: "↑↓/jk", label: "select" },
142
144
  ...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
145
+ ...(card.id === "scheduler" ? [{ key: "⏎", label: status && status.state !== "not_configured" ? "reinstall" : "install" }] : []),
143
146
  ...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
144
147
  ]}
145
148
  />
@@ -191,6 +194,39 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
191
194
  {card.id === "beacon" && status.state !== "not_configured" && (
192
195
  <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
193
196
  )}
197
+ {card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} />}
194
198
  </box>
195
199
  )
196
200
  }
201
+
202
+ function SchedulerDetail({ installed }: { installed: boolean }) {
203
+ const [runs] = useState(() => upcomingRuns())
204
+ return (
205
+ <>
206
+ <box style={{ height: 1 }} />
207
+ {!installed && (
208
+ <>
209
+ <text
210
+ content="Scheduler runs follow-ups on this machine while you're away: queue a check for tomorrow at 8 and the result arrives on your pager. It's also the clock the other modules run on, so turning it on is what makes this machine a station."
211
+ fg={theme.textDim}
212
+ />
213
+ <box style={{ height: 1 }} />
214
+ <text content="Press ⏎ to see exactly what it installs, then confirm." fg={theme.accent} />
215
+ <box style={{ height: 1 }} />
216
+ </>
217
+ )}
218
+ <text content={runs.length ? "Next up:" : "No follow-ups queued."} fg={theme.textDim} />
219
+ {runs.map((r) => (
220
+ <box key={`${r.source}/${r.id}`} style={{ flexDirection: "row" }}>
221
+ <text content={` ${r.next.padEnd(17)} `} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
222
+ <text content={`${r.source}/${r.id}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
223
+ </box>
224
+ ))}
225
+ <box style={{ height: 1 }} />
226
+ <text
227
+ content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
228
+ fg={theme.textFaint}
229
+ />
230
+ </>
231
+ )
232
+ }
@@ -0,0 +1,172 @@
1
+ // Scheduler's install overlay (Modules tab, ⏎ on Scheduler): shows exactly what
2
+ // `spinuptui scheduler install --plan` would write and run, and applies it on
3
+ // `y` — the confirm-before-firing pattern. Applying ends with the proving tick
4
+ // (a tick that really ran under launchd), so "done" here means the station's
5
+ // clock is running, not just that a file was written.
6
+
7
+ import { useEffect, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme } from "../../lib/theme.ts"
10
+ import { truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { applyInstall, binHasScheduler, NO_SCHEDULER, planInstall, type ApplyResult, type PlanResult } from "../../lib/schedulerInstall.ts"
15
+
16
+ type Phase = "confirm" | "applying" | "done" | "error"
17
+
18
+ export function SchedulerInstall() {
19
+ const { setSchedulerInstallOpen } = useStore()
20
+ const [plan, setPlan] = useState<PlanResult>(() => planInstall())
21
+ // Same refusal as `install --plan`: don't offer a plan whose binary has no
22
+ // Scheduler (it would launch the dashboard under launchd).
23
+ useEffect(() => {
24
+ if (!plan.ok) return
25
+ void binHasScheduler(plan).then((has) => {
26
+ if (!has) {
27
+ setPlan(NO_SCHEDULER)
28
+ setPhase("error")
29
+ }
30
+ })
31
+ // Checked once, for the plan computed on open.
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [])
34
+ const [phase, setPhase] = useState<Phase>(plan.ok ? "confirm" : "error")
35
+ const [stage, setStage] = useState("")
36
+ const [result, setResult] = useState<ApplyResult | null>(null)
37
+ const [live, setLive] = useState(true)
38
+ useEffect(() => () => setLive(false), [])
39
+
40
+ const close = () => setSchedulerInstallOpen(false)
41
+
42
+ const apply = () => {
43
+ if (!plan.ok) return
44
+ setPhase("applying")
45
+ void applyInstall(plan, (s) => live && setStage(s)).then((r) => {
46
+ setResult(r)
47
+ setPhase(r.ok ? "done" : "error")
48
+ })
49
+ }
50
+
51
+ useKeyboard((key) => {
52
+ const name = key.name ?? ""
53
+ if (name === "escape" || name === "q") return close()
54
+ if (phase === "confirm" && name === "y" && plan.ok && plan.platform === "darwin") return apply()
55
+ if (phase === "error" && name === "r" && plan.ok) return setPhase("confirm")
56
+ })
57
+
58
+ return (
59
+ <box
60
+ style={{
61
+ position: "absolute",
62
+ top: 0,
63
+ left: 0,
64
+ width: "100%",
65
+ height: "100%",
66
+ flexDirection: "column",
67
+ backgroundColor: theme.bg,
68
+ zIndex: 210,
69
+ }}
70
+ >
71
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
72
+ <text content="⏱ Scheduler " fg={theme.brand} style={{ flexShrink: 0 }} />
73
+ <text content="install the station's clock" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
74
+ </box>
75
+ <Centered>{body()}</Centered>
76
+ <StatusBar hints={hints()} showGlobal={false} />
77
+ </box>
78
+ )
79
+
80
+ function body() {
81
+ if (phase === "applying") {
82
+ return (
83
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
84
+ <box style={{ flexDirection: "row" }}>
85
+ <Spinner />
86
+ <text content={` ${stage || "Installing…"}`} fg={theme.textDim} wrapMode="none" />
87
+ </box>
88
+ <box style={{ height: 1 }} />
89
+ <text content="Install finishes with a real tick from launchd (up to a minute)." fg={theme.textFaint} wrapMode="none" />
90
+ </box>
91
+ )
92
+ }
93
+ if (phase === "done" && result?.ok) {
94
+ return (
95
+ <Panel title=" Installed " active>
96
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
97
+ <text content={`✓ ${result.label} is ticking: its first run under launchd was at ${new Date(result.tickAt).toLocaleTimeString()}.`} fg={theme.good} />
98
+ <box style={{ height: 1 }} />
99
+ <text content="Queue a follow-up from any session:" fg={theme.textDim} />
100
+ <text content={' spinuptui scheduler add <id> --kind spinuptui --when "YYYY-MM-DD 08:00" \\'} fg={theme.accent} wrapMode="none" />
101
+ <text content={' --expires YYYY-MM-DD --created-by "…" -- incidents example.com'} fg={theme.accent} wrapMode="none" />
102
+ <box style={{ height: 1 }} />
103
+ {result.notes.map((n) => (
104
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
105
+ ))}
106
+ <text content="Esc to close" fg={theme.textFaint} />
107
+ </box>
108
+ </Panel>
109
+ )
110
+ }
111
+ if (phase === "error" || !plan.ok) {
112
+ const r = !plan.ok ? plan : result && !result.ok ? result : null
113
+ return (
114
+ <Panel title=" Can't install " active>
115
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
116
+ <text content={`✕ ${r?.message ?? "Something went wrong."}`} fg={theme.bad} />
117
+ {r?.remedy && (
118
+ <>
119
+ <box style={{ height: 1 }} />
120
+ <text content={r.remedy} fg={theme.textDim} />
121
+ </>
122
+ )}
123
+ <box style={{ height: 1 }} />
124
+ <text content={plan.ok ? "Press r to see the plan again · Esc to close" : "Esc to close"} fg={theme.textFaint} />
125
+ </box>
126
+ </Panel>
127
+ )
128
+ }
129
+ const darwin = plan.platform === "darwin"
130
+ return (
131
+ <Panel title=" Install Scheduler's dispatcher " active>
132
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
133
+ <text content="Runs at login and every 5 minutes, unattended:" fg={theme.text} />
134
+ <text content={` ${truncate(plan.programArguments.join(" "), 72)}`} fg={theme.accent} wrapMode="none" />
135
+ <box style={{ height: 1 }} />
136
+ <text content={`Unit: ${truncate(plan.unitPath, 68)}`} fg={theme.textDim} wrapMode="none" />
137
+ <text content={`PATH: ${truncate(plan.env.PATH ?? "", 68)}`} fg={theme.textDim} wrapMode="none" />
138
+ <box style={{ height: 1 }} />
139
+ {plan.commands.map((c) => (
140
+ <text key={c} content={`· ${truncate(c, 72)}`} fg={theme.textDim} wrapMode="none" />
141
+ ))}
142
+ <box style={{ height: 1 }} />
143
+ {plan.notes.map((n) => (
144
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
145
+ ))}
146
+ <box style={{ height: 1 }} />
147
+ <text
148
+ content={darwin ? "Press y to install · Esc to cancel" : "Installing on Linux isn't supported yet · Esc to close"}
149
+ fg={darwin ? theme.text : theme.warn}
150
+ wrapMode="none"
151
+ />
152
+ </box>
153
+ </Panel>
154
+ )
155
+ }
156
+
157
+ function hints() {
158
+ if (phase === "confirm" && plan.ok && plan.platform === "darwin") {
159
+ return [
160
+ { key: "y", label: "install" },
161
+ { key: "esc", label: "cancel" },
162
+ ]
163
+ }
164
+ if (phase === "error" && plan.ok) {
165
+ return [
166
+ { key: "r", label: "back to plan" },
167
+ { key: "esc", label: "close" },
168
+ ]
169
+ }
170
+ return [{ key: "esc", label: "close" }]
171
+ }
172
+ }