SLOPSHOPPER

starfleet-panel

An LCARS status panel under the Claude Code prompt, in the style of a 24th-century Starfleet console; follows red-alert's alert condition.

newspinnercommandtoastprocesstimer
★ 1v0.3.0MITupdated 2026-10-03dukechain2333/starfleet-panel/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · starfleet-panel
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lcars ⎿ starfleet-panel: LCARS STATUS REPORT · STARDATE 79769.9 · 01:53 ⎿ starfleet-panel: ⎿ starfleet-panel: HELM (model, effort) OPUS 5.5 ⎿ starfleet-panel: CORE (context window) 49% used · 97K of 200K tokens ⎿ starfleet-panel: DILITHIUM (5-hour limit) 69% left ⎿ starfleet-panel: ANTIMATTER (7-day limit) no reading (shows after the first response on a subscription) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ▛ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▜ STANDBY HELM CORE DILITHIUM SECTOR CREW SHIP TIME 79769.9 OPUS 5.5 ▊▊▊▊▊▊▊▊▊▊▊▊ 49% ▊▊▊▊▊▊▊▊▊▊▊▊ 69% /work/app dev@dev 01:53

Draws

Prompt hint
⟨Claude Code's own drawing⟩ ▛ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀ STANDBY HELM CORE DILITHIUM SECTO 79769.9 OPUS 5.5 ▊▊▊▊▊▊▊▊▊▊▊▊ 49% ▊▊▊▊▊▊▊▊▊▊▊▊ 69% /work
README

starfleet-panel

An LCARS status panel for Claude Code. The readouts that usually sit under the prompt (model, effort, context window, rate limits, directory, git branch) are drawn as an ops console from The Next Generation: elbows at both ends, a header bar cut into one colored segment per readout, the labels under their segments and the values under the labels. It's built to sit beside red-alert. When red-alert sounds the klaxon, the panel goes to RED ALERT with it.

starfleet-panel under the Claude Code prompt: the bridge layout, a red alert with red-alert, and the compact layout

  • Everything at a glance, LCARS style. Model and effort (as a warp factor), segmented meters for the context window and the 5-hour limit, the 7-day limit, countdowns to each reset, the git branch and working directory, and the stardate.
  • Made for red-alert. It shows red-alert's condition (green, muted, offline). While an alert is up, it repaints the whole frame in the alert's reds and blinks with red-alert's klaxon for as long as it animates. The two mods never touch each other's screen space, keys, commands or state.
  • Fits any width. The columns share the width out. As the terminal narrows, the meters shrink first, then the least important columns go, and below 64 columns the frame becomes a one-row strip. Wide terminals gain the ENV, ENERGY and CREW columns.
  • Local and free. It makes no model calls and no network requests. The figures come from the same in-process data the status line uses.

The readouts

Every readout has a Starfleet name and a plain one, and you pick which the panel uses: /lcars plain or /lcars starfleet (saved, like the Terminology row in /config). The plain terms read as an ordinary status line: the date instead of the stardate, CONTEXT 42% used, 5H LIMIT 72% left, ALERTS ONLINE.

Starfleet termPlain termWhat it shows
STANDBY / ENGAGEDIDLE / WORKINGthe sidebar's upper block: whether Claude is working
stardatedate (OCT 02)the sidebar's lower block; the stardate is TNG's broadcast reckoning: 41000 when the show began in 1987, a thousand units a year
HELMMODELthe model (OPUS 5.5) and reasoning effort as a warp factor: low IMPULSE, medium WARP 5, high WARP 7, xhigh WARP 9, max WARP 9.6
CORECONTEXTcontext window used (42%; plain 42% used), with a segmented meter
DILITHIUM5H LIMITthe 5-hour limit: how much is left (plain 72% left), a meter, and the time to the reset (T-2H14M; plain reset 2h14m)
ANTIMATTER7D LIMITthe 7-day limit: how much is left and the time to the reset
COURSE / SECTORGIT / DIRthe git branch (or @<sha> when detached) over the working directory, ~ for home; SECTOR outside a repository
ENVENVthe active conda env (not base) or virtualenv, on wide terminals
ENERGYCOSTthe session's cost, on wide terminals (off by default)
CREWUSERuser@host, on wide terminals (can be turned off)
CONDITIONALERTSthe end block: red-alert's state, GREEN (plain ONLINE), MUTED, OFFLINE or the alert; without red-alert, SHIP TIME (plain TIME) over the clock

Readings turn yellow at 60% used and red at 80%. These are the thresholds of the classic statusLine script the panel replaces. The 5-hour and 7-day limits appear once Claude Code has a reading for them: on a Pro or Max subscription, after the first response.

With red-alert

Install both and they share one console: red-alert's band above the prompt and this panel below it, in the same LCARS palette.

red-alert's stateThe panel
onlinethe end block reads CONDITION / GREEN
mutedCONDITION / MUTED
offlineCONDITION / OFFLINE
an alert is upthe whole frame turns the alert's reds and the end block reads RED ALERT (or the level's title)
a red or yellow alert animatingthe state block and the end block blink with the klaxon, then hold steady until you silence or dismiss the alert (0, red-alert's key)
not installedthe end block keeps SHIP TIME; everything else works the same

The mods don't conflict because each one draws, binds and stores only what it owns:

red-alertstarfleet-panel
Drawsthe band above the prompt, the /alert console, its tool's rowthe hint line under the prompt, nothing else
Command/alert/lcars
Tools and system promptthe alert tool and a short alert policynone
Keys0 silences; band items via ctrl+x tabnone: no buttons, no hotkeys
Stateits ownits own; reads red-alert's link and active, never writes them

The panel learns of a new alert the moment red-alert records it. It does this with an observe-only hook on red-alert's state, which passes every value through exactly as red-alert wrote it. Set followRedAlert to false to ignore red-alert altogether.

Install

git clone https://github.com/dukechain2333/starfleet-panel.git
cd starfleet-panel
./install.sh

This copies the mod to ~/.claude/skills/starfleet-panel. Start a new Claude Code session and it loads as starfleet-panel@skills-dir. Use ./install.sh --link to symlink the checkout instead while you work on it, and ./uninstall.sh to remove it.

The repository is also a plugin marketplace:

claude plugin marketplace add dukechain2333/starfleet-panel
claude plugin install starfleet-panel@starfleet-panel

Retire your statusLine command. If ~/.claude/settings.json has a statusLine entry, its line keeps drawing above the panel and you see the same figures twice. Remove the entry to let the panel take over. The panel tells you once, the installer reminds you, and /lcars mentions it while one is set.

Requirements: Claude Code with mod support (function-hook plugins; built and tested on 2.1.288), and a terminal with true color and a monospace font that has the block elements (▄ ▀ ▌ ▗). Any modern terminal font does.

Using it

The panel needs nothing from you. It refreshes the context window and limits every 2 seconds. It re-reads the directory, branch, env and settings after every turn and every 30 seconds.

  • Layouts: bridge, the three-row ops console (the default); compact, one row in the style of red-alert's idle strip; off, only Claude Code's own hint line. Bridge falls back to compact on terminals narrower than 64 columns and in the desktop app.
  • /lcars prints a full status report. It lists every reading beside what its Starfleet name stands for, which makes it a legend too:
  LCARS STATUS REPORT · STARDATE 80753.1 · 22:32

  HELM        (model, effort)      OPUS 5.5 · xhigh effort (warp 9)
  CORE        (context window)     4% used · 43K of 1M tokens
  DILITHIUM   (5-hour limit)       95% left · resets in 4h18m
  ANTIMATTER  (7-day limit)        63% left · resets in 4h28m
  ENERGY      (session cost)       $0.35
  SECTOR      (working directory)  ~/starfleet_panel
  COURSE      (git branch)         main
  ENV         (python env)         none
  CONDITION   (red-alert)          condition green
  • /lcars plain | starfleet switches the terms, and keeps the choice for new sessions.
  • /lcars bridge | compact | off switches the layout for this session. /lcars reset goes back to the setting.

Settings

Set these in /config (or claude plugin configure starfleet-panel):

SettingDefault
layoutbridgebridge, compact or off
labels (Terminology)starfleetstarfleet (HELM, CORE, DILITHIUM, stardate…) or plain (MODEL, CONTEXT, 5H LIMIT, date…); /lcars plain and /lcars starfleet set it too
showCostfalseadd the ENERGY column (the session's cost) on wide terminals
showUserHosttrueadd the CREW column (user@host) on wide terminals
followRedAlerttrueshow red-alert's condition and repaint with its alerts
refreshSeconds2how often the context window and limits are re-read

How it works

The mod draws Claude Code's PromptHint site, the hint line under the prompt. It returns its own rows together with the engine's line, so the mode pill (⏵⏵ auto mode on) and hints such as esc to interrupt stay live above the frame. Its figures come from $.session.usage() and $.session.model(), which are free in-process calls. The effort is the one each main-loop request was actually sent with, recorded by an observe-only turn.step hook that leaves the response stream untouched. Before the first request, the effort comes from your settings.

Development

claude plugin validate plugin     # manifest and hooks, as the engine reads them
claude plugin test plugin         # the tests: layouts, red-alert states, /lcars
claude --plugin-dir plugin        # try it; saving a file reloads it
npx -p typescript tsc -p plugin   # type-check, once a session has loaded the mod
                                  # (it lays the types in plugin/.claude-plugin/types)

Layout: plugin/hooks/register.tsx (hooks, sensors, drawing), plugin/hooks/panel.ts (the layouts and the report), plugin/hooks/lcars.ts (palette and row fitting), plugin/hooks/readouts.ts (stardate, warp factor, gauges, cell widths), plugin/types/index.d.ts (the state contract), plugin/tests/.

docs/preview.svg comes from a real session. Capture it with tmux capture-pane -e -p -t <session> > capture.ans, then convert it with python3 docs/ansi2svg.py capture.ans docs/preview.svg --rows <first>-<last>.

Credits

The panel borrows the look of LCARS, the computer interface designed by Michael Okuda for Star Trek: The Next Generation. This is an unofficial fan project. Star Trek and its marks belong to their respective owners.

License

MIT

Source 5 files
hooks/register.tsx 448 lines
1// starfleet-panel: an LCARS status panel under the Claude Code prompt.
2//
3// - draws the panel at the prompt's hint line (`PromptHint`), under the
4//   engine's own line (the mode pill and hints), which it keeps: the model
5//   and effort (as a warp factor), the context window, the 5-hour and 7-day
6//   limits, the directory, git branch and Python env, and the stardate;
7// - follows red-alert (github.com/dukechain2333/red-alert) when it is loaded:
8//   reads its state, never writes it, and repaints the frame in the alert's
9//   color while red-alert's band shows one;
10// - answers /lcars with a full report, or switches the layout.
11//
12// Sharing a session with red-alert: red-alert owns the band above the prompt
13// (`AbovePrompt`), its pane, its tool, /alert and its status entry; this mod
14// draws only under the prompt, adds no Buttons or hotkeys (red-alert's `0`
15// stays its own), no tool and no system-prompt text.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
19
20import type { PanelLayout, Readings, RedAlertActive, RedAlertLink } from '../types'
21import { LCARS } from './lcars'
22import type { Piece } from './lcars'
23import { BRIDGE_MIN_WIDTH, bridgeRows, compactRow, conditionFor, reportText } from './panel'
24import type { Condition, LabelSet, PanelView } from './panel'
25
26const COMMAND = 'lcars'
27const USAGE = 'Usage: /lcars [report | bridge | compact | off | reset | starfleet | plain]'
28/** Cells the prompt footer keeps at its edges: two on each side. */
29const MARGIN = 4
30const SLOW_MS = 30_000
31const BLINK_MS = 500
32const NOTICE_KEY = 'statusLineNoticeShown'
33
34const readings = atom({ plugin: 'starfleet-panel', key: 'readings' } as const, null)
35const clock = atom({ plugin: 'starfleet-panel', key: 'clock' } as const, 0)
36const blink = atom({ plugin: 'starfleet-panel', key: 'blink' } as const, false)
37const layoutOverride = atom({ plugin: 'starfleet-panel', key: 'layout' } as const, null)
38
39// red-alert's values: read only.
40const RED_ALERT_LINK = { plugin: 'red-alert', key: 'link' } as const
41const RED_ALERT_ACTIVE = { plugin: 'red-alert', key: 'active' } as const
42
43type Settings = {
44  layout: PanelLayout
45  labels: LabelSet
46  showCost: boolean
47  showUserHost: boolean
48  followRedAlert: boolean
49  refreshMs: number
50}
51
52/** What changes rarely: read at the start, after each turn and every 30 s. */
53type Surroundings = {
54  cwd: string
55  home: string
56  branch: string | null
57  pyenv: string | null
58  user: string
59  host: string
60  settingsEffort: (model: string) => string | null
61}
62
63// The module's own variables: they start over when the module reloads, while
64// everything drawn from lives in $.state.
65let settings: Settings = settingsFrom({})
66let surroundings: Surroundings | null = null
67let host: string | null = null
68let hasStatusLine = false
69/** The effort the main loop's last model request was sent with; undefined before the first. */
70let stepEffort: string | null | undefined
71let isRefreshing = false
72let fastTimer: Timer | undefined
73let slowTimer: Timer | undefined
74let blinkTimer: Timer | undefined
75
76function settingsFrom(options: PluginOptions): Settings {
77  const pick = <T extends string>(key: string, allowed: readonly T[], fallback: T): T => {
78    const value = String(options[key] ?? '').trim().toLowerCase()
79    return (allowed as readonly string[]).includes(value) ? (value as T) : fallback
80  }
81  const seconds = Number(options.refreshSeconds)
82  return {
83    layout: pick('layout', ['bridge', 'compact', 'off'] as const, 'bridge'),
84    labels: pick('labels', ['starfleet', 'plain'] as const, 'starfleet'),
85    showCost: options.showCost === true,
86    showUserHost: options.showUserHost !== false,
87    followRedAlert: options.followRedAlert !== false,
88    refreshMs: Math.round((Number.isFinite(seconds) && seconds >= 1 ? seconds : 2) * 1000),
89  }
90}
91
92function errorText(error: unknown): string {
93  return error instanceof Error ? error.message : String(error)
94}
95
96// ---------------------------------------------------------------------------
97// Sensors
98// ---------------------------------------------------------------------------
99
100async function gitBranch($: EngineInterface, cwd: string): Promise<string | null> {
101  try {
102    const ref = await $.process.run(['git', '--no-optional-locks', 'symbolic-ref', '--short', '-q', 'HEAD'], {
103      cwd,
104      timeoutMs: 3000,
105    })
106    if (ref.exitCode === 0 && ref.stdout.trim()) return ref.stdout.trim()
107    const head = await $.process.run(['git', '--no-optional-locks', 'rev-parse', '--short', 'HEAD'], {
108      cwd,
109      timeoutMs: 3000,
110    })
111    return head.exitCode === 0 && head.stdout.trim() ? `@${head.stdout.trim()}` : null
112  } catch {
113    return null
114  }
115}
116
117async function hostName($: EngineInterface): Promise<string> {
118  if (host !== null) return host
119  try {
120    const run = await $.process.run(['hostname'], { timeoutMs: 3000 })
121    host = run.exitCode === 0 ? (run.stdout.trim().split('.')[0] ?? '') : ''
122  } catch {
123    host = ''
124  }
125  return host
126}
127
128async function pythonEnv($: EngineInterface): Promise<string | null> {
129  const conda = await $.env.get('CONDA_DEFAULT_ENV')
130  if (conda && conda !== 'base') return conda
131  const venv = await $.env.get('VIRTUAL_ENV')
132  return venv ? (venv.split('/').filter(Boolean).pop() ?? null) : null
133}
134
135/** The effort settings give a model: its own `modelSettings` entry, else `effortLevel`. */
136function effortFromSettings(merged: Readonly<Record<string, unknown>>): (model: string) => string | null {
137  const perModel = merged.modelSettings
138  const global = typeof merged.effortLevel === 'string' ? merged.effortLevel : null
139  return model => {
140    if (perModel && typeof perModel === 'object') {
141      const entry = (perModel as Record<string, unknown>)[model]
142      if (entry && typeof entry === 'object') {
143        const level = (entry as Record<string, unknown>).effortLevel
144        if (typeof level === 'string') return level
145      }
146    }
147    return global
148  }
149}
150
151async function readSurroundings($: EngineInterface): Promise<Surroundings> {
152  const cwd = await $.session.cwd()
153  const [branch, pyenv, user, home, name, merged] = await Promise.all([
154    gitBranch($, cwd),
155    pythonEnv($),
156    $.env.get('USER'),
157    $.env.get('HOME'),
158    hostName($),
159    $.settings.read().catch(() => ({}) as Readonly<Record<string, unknown>>),
160  ])
161  hasStatusLine = merged.statusLine !== undefined && merged.statusLine !== null
162  return {
163    cwd,
164    home: home ?? '',
165    branch,
166    pyenv,
167    user: user ?? '',
168    host: name,
169    settingsEffort: effortFromSettings(merged),
170  }
171}
172
173function toLimit(limit: { percentUsed: number; resetsAt?: string } | undefined): Readings['fiveHour'] {
174  if (!limit) return null
175  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
176  return { used: limit.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
177}
178
179/** Reads every sensor; with `isSlow`, the directory, branch, env and settings too. */
180async function refresh($: EngineInterface, isSlow: boolean): Promise<void> {
181  if (isRefreshing) return
182  isRefreshing = true
183  try {
184    if (isSlow || surroundings === null) surroundings = await readSurroundings($)
185    const near = surroundings
186    const [usage, model] = await Promise.all([$.session.usage(), $.session.model()])
187    const limits = usage.rateLimits
188    const fresh: Readings = {
189      model,
190      effort: stepEffort !== undefined ? stepEffort : near.settingsEffort(model),
191      context: {
192        percent: usage.context.percent ?? null,
193        tokens: usage.context.tokens ?? null,
194        window: usage.context.window || null,
195      },
196      fiveHour: toLimit(limits.find(limit => limit.kind === 'five_hour')),
197      sevenDay: toLimit(limits.find(limit => limit.kind === 'seven_day')),
198      costUsd: usage.cost?.usd ?? null,
199      cwd: near.cwd,
200      home: near.home,
201      branch: near.branch,
202      pyenv: near.pyenv,
203      user: near.user,
204      host: near.host,
205    }
206    const current = await read($, readings)
207    if (JSON.stringify(current) !== JSON.stringify(fresh)) {
208      await update($, readings, () => fresh)
209    }
210    const minute = Math.floor((await $.clock.now()) / 60_000) * 60_000
211    if (minute !== (await read($, clock))) {
212      await update($, clock, () => minute)
213    }
214    await syncBlink($)
215  } catch (error) {
216    $.ui.log(`starfleet-panel: sensor sweep failed: ${errorText(error)}`, { to: 'debug' })
217  } finally {
218    isRefreshing = false
219  }
220}
221
222// ---------------------------------------------------------------------------
223// red-alert, read only
224// ---------------------------------------------------------------------------
225
226async function redAlertLink($: EngineInterface): Promise<RedAlertLink | null> {
227  if (!settings.followRedAlert) return null
228  try {
229    return (await $.state.get(RED_ALERT_LINK)).value ?? null
230  } catch {
231    return null
232  }
233}
234
235async function redAlertActive($: EngineInterface): Promise<RedAlertActive | null> {
236  if (!settings.followRedAlert) return null
237  try {
238    return (await $.state.get(RED_ALERT_ACTIVE)).value ?? null
239  } catch {
240    return null
241  }
242}
243
244function alertTitle(active: RedAlertActive): string {
245  return active.title || `${active.level.toUpperCase()} ALERT`
246}
247
248function conditionOf(link: RedAlertLink | null, active: RedAlertActive | null): Condition | null {
249  if (active) {
250    const title = alertTitle(active)
251    return { label: title, short: title.length <= 10 ? title : active.level.toUpperCase().slice(0, 10), color: active.color }
252  }
253  if (!link) return null
254  if (link.checkedAt === 0) return conditionFor('linking', settings.labels, LCARS.tan)
255  if (!link.online) return conditionFor('offline', settings.labels, LCARS.red)
256  if (link.mute) return conditionFor('muted', settings.labels, LCARS.peach)
257  return conditionFor('green', settings.labels, LCARS.green)
258}
259
260/** Blinks the state and condition blocks while red-alert animates a red or yellow alert, as every console on the ship does. */
261async function syncBlink($: EngineInterface): Promise<void> {
262  const active = await redAlertActive($)
263  const now = await $.clock.now()
264  const isBlinking = active !== null && active.style !== 'sweep' && now < active.animateUntil
265  if (isBlinking) {
266    blinkTimer ??= $.clock.every(BLINK_MS, () => void update($, blink, phase => !phase))
267    return
268  }
269  blinkTimer?.cancel()
270  blinkTimer = undefined
271  // the phase outlives a reload, the timer does not: never leave it dark
272  if (await read($, blink)) await update($, blink, () => false)
273}
274
275// ---------------------------------------------------------------------------
276// Drawing
277// ---------------------------------------------------------------------------
278
279async function viewOf($: EngineInterface, isWorking: boolean): Promise<PanelView> {
280  const [values, , isDark, link, active, now] = await Promise.all([
281    read($, readings),
282    read($, clock),
283    read($, blink),
284    redAlertLink($),
285    redAlertActive($),
286    $.clock.now(),
287  ])
288  return {
289    readings: values,
290    now,
291    isWorking,
292    labels: settings.labels,
293    showCost: settings.showCost,
294    showUserHost: settings.showUserHost,
295    alert: active ? { title: alertTitle(active), level: active.level, color: active.color } : null,
296    isBlinkDark: isDark,
297    condition: conditionOf(link, active),
298  }
299}
300
301/** A piece's style as Text props, leaving out what it does not set. */
302function textProps(piece: Piece): Record<string, string | boolean> {
303  const props: Record<string, string | boolean> = {}
304  if (piece.color) props.color = piece.color
305  if (piece.backgroundColor) props.backgroundColor = piece.backgroundColor
306  if (piece.bold) props.bold = true
307  if (piece.dimColor) props.dimColor = true
308  return props
309}
310
311async function report($: EngineInterface): Promise<string> {
312  await refresh($, true)
313  const [override, merged] = await Promise.all([read($, layoutOverride), $.settings.read().catch(() => ({}))])
314  return reportText(await viewOf($, false), {
315    layout: override ?? settings.layout,
316    isLayoutOverridden: override !== null,
317    hasStatusLine: 'statusLine' in merged && merged.statusLine !== null,
318    redAlert: !settings.followRedAlert ? 'ignored' : (await redAlertLink($)) ? 'followed' : 'absent',
319  })
320}
321
322// ---------------------------------------------------------------------------
323// Hooks
324// ---------------------------------------------------------------------------
325
326export const register: Register = (on, options) => {
327  settings = settingsFrom(options)
328
329  on('session.start', async ($, e, next) => {
330    await $.command.register({
331      name: COMMAND,
332      description: 'LCARS status report, or switch the status panel layout',
333      argumentHint: '[report | bridge | compact | off | reset | starfleet | plain]',
334      immediate: true,
335    })
336    await Promise.race([refresh($, true), $.clock.sleep(1500)])
337    fastTimer?.cancel()
338    slowTimer?.cancel()
339    fastTimer = $.clock.every(settings.refreshMs, () => void refresh($, false))
340    slowTimer = $.clock.every(SLOW_MS, () => void refresh($, true))
341    if (hasStatusLine && (await $.store.get(NOTICE_KEY)) !== true) {
342      $.ui.toast(
343        'starfleet-panel: your statusLine command still draws its own line. Remove "statusLine" from settings to let the LCARS panel take over (/lcars has the details).',
344        { timeoutMs: 9000 },
345      )
346      await $.store.set(NOTICE_KEY, true)
347    }
348    return next(e)
349  })
350
351  // A /clear ends the conversation but not the process, and no session.start
352  // follows it: the timers run on, and the next sweep reads the fresh figures.
353  on('session.end', ($, e, next) => {
354    if (e.reason !== 'clear') {
355      fastTimer?.cancel()
356      slowTimer?.cancel()
357      blinkTimer?.cancel()
358    }
359    return next(e)
360  })
361
362  // The effort each main-loop request is really sent with, after any downgrade
363  // for the model; observed only, the stream passes through untouched.
364  on('turn.step', async function* ($, e, next) {
365    const result = yield* next(e)
366    if (e.agentId === undefined) {
367      stepEffort = e.effort === undefined ? null : String(e.effort)
368      void refresh($, false)
369    }
370    return result
371  })
372
373  // Claude may have switched branches or directories during the turn.
374  on('turn.complete', async ($, e, next) => {
375    const result = await next(e)
376    void refresh($, true)
377    return result
378  })
379
380  // Starts the blink the moment red-alert raises an alert. Observe only: the
381  // value passes through as red-alert wrote it.
382  on('state.set', { plugin: 'red-alert', key: 'active' }, async ($, e, next) => {
383    const result = await next(e)
384    void syncBlink($).catch(() => null)
385    return result
386  })
387
388  on('command.run', { command: COMMAND }, async ($, e) => {
389    const word = e.args.trim().toLowerCase()
390    switch (word) {
391      case '':
392      case 'report':
393      case 'status':
394        return { text: await report($) }
395      case 'bridge':
396      case 'compact':
397      case 'off':
398        await update($, layoutOverride, () => word)
399        return { text: `LCARS panel: ${word}, for this session. The default is starfleet-panel's "Panel layout" in /config.` }
400      case 'starfleet':
401      case 'plain': {
402        // the terms are a setting: written as /config writes it, kept for new sessions
403        const set = await $.config.set({ key: `${$.plugin.name}.labels`, value: word })
404        return 'deny' in set && set.deny
405          ? { text: `Could not switch the terms: ${set.deny}. Set "Terminology" in /config instead.` }
406          : {
407              text:
408                word === 'plain'
409                  ? 'LCARS panel: plain terms (MODEL, CONTEXT, 5H LIMIT, date…). Saved; /lcars starfleet switches back.'
410                  : 'LCARS panel: Starfleet terms (HELM, CORE, DILITHIUM, stardate…). Saved; /lcars plain switches back.',
411            }
412      }
413      case 'reset':
414      case 'auto':
415        await update($, layoutOverride, () => null)
416        return { text: `LCARS panel follows the setting again: ${settings.layout}.` }
417      default:
418        return { text: USAGE }
419    }
420  })
421
422  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
423    const layout = (await read($, layoutOverride)) ?? settings.layout
424    if (layout === 'off') return next(e)
425    const [hint, view] = await Promise.all([next(e), viewOf($, e.props.isWorking)])
426    // With the engine's own line in the tree, the engine draws that line (the
427    // mode pill, the hints) first and gives the rows below it the full width.
428    const { Box, Text } = $.ui.resolve(e)
429    const width = Math.max(24, (e.viewport?.columns ?? 100) - MARGIN)
430    const rows =
431      e.surface === 'terminal' && layout === 'bridge' && width >= BRIDGE_MIN_WIDTH
432        ? bridgeRows(view, width)
433        : [compactRow(view, width)]
434    return (
435      <Box flexDirection="column">
436        {hint}
437        {rows.map((row, i) => (
438          <Box key={`lcars:row:${i}`} flexDirection="row">
439            {row.map(piece => (
440              <Text {...textProps(piece)} wrap="truncate">{piece.text}</Text>
441            ))}
442          </Box>
443        ))}
444      </Box>
445    )
446  })
447}
448
hooks/lcars.ts 195 lines
1// LCARS drawing primitives: the palette, the frame colors, and rows of styled
2// pieces fitted to a width. Pure: the hooks module turns pieces into <Text>.
3
4import { cellWidth, clip } from './readouts'
5
6/**
7 * LCARS console colors. The shared ones are red-alert's own values, so the
8 * panel under the prompt and red-alert's band above it read as one console.
9 */
10export const LCARS = {
11  orange: '#FF9900',
12  sand: '#FFCC99',
13  peach: '#FF9966',
14  lavender: '#CC99CC',
15  violet: '#9999FF',
16  blue: '#99CCFF',
17  tan: '#CC9966',
18  green: '#66DD99',
19  yellow: '#FFCC33',
20  red: '#FF5555',
21  ink: '#000000',
22} as const
23
24/** The colors a frame is painted in. */
25export type Frame = {
26  /** The elbows and the sidebar. */
27  primary: string
28  /** The rule of the compact strip, and the end block when red-alert is absent. */
29  secondary: string
30  /** The sidebar's lower block. */
31  block: string
32  /** Labels printed on black. */
33  label: string
34  /** The header segments, one per column, while an alert repaints the frame. */
35  shades: readonly string[]
36}
37
38export const STANDARD_FRAME: Frame = {
39  primary: LCARS.orange,
40  secondary: LCARS.lavender,
41  block: LCARS.peach,
42  label: LCARS.tan,
43  shades: [],
44}
45
46type Rgb = readonly [number, number, number]
47
48function rgb(color: string): Rgb {
49  const n = Number.parseInt(color.replace('#', '').slice(0, 6), 16)
50  return Number.isNaN(n) ? [255, 153, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255]
51}
52
53function channel(v: number): string {
54  return Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
55}
56
57/** Blends `a` toward `b` by `t` (0..1). */
58export function mix(a: string, b: string, t: number): string {
59  const [ar, ag, ab] = rgb(a)
60  const [br, bg, bb] = rgb(b)
61  const k = Math.max(0, Math.min(1, t))
62  return `#${channel(ar + (br - ar) * k)}${channel(ag + (bg - ag) * k)}${channel(ab + (bb - ab) * k)}`.toUpperCase()
63}
64
65/** The frame repainted in an alert's color, as every console on the ship turns red. */
66export function alertFrame(color: string): Frame {
67  return {
68    primary: color,
69    secondary: mix(color, LCARS.ink, 0.45),
70    block: mix(color, LCARS.ink, 0.45),
71    label: mix(color, '#FFFFFF', 0.55),
72    shades: [
73      mix(color, '#FFFFFF', 0.35),
74      color,
75      mix(color, LCARS.ink, 0.35),
76      mix(color, '#FFFFFF', 0.15),
77      mix(color, LCARS.ink, 0.15),
78    ],
79  }
80}
81
82/** One run of text in one style. */
83export type Piece = {
84  text: string
85  color?: string
86  backgroundColor?: string
87  bold?: boolean
88  dimColor?: boolean
89}
90
91/**
92 * Pieces kept or dropped together; the lowest `priority` goes first when room
93 * runs out. Items that name the same `group` are dropped together.
94 */
95export type Item = { pieces: Piece[]; priority: number; group?: string }
96
97/** A row to fit: fixed ends, droppable items, and a filler that takes what is left. */
98export type RowSpec = {
99  left: Piece[]
100  items: Item[]
101  /** Where among the items the filler goes (0 = before the first). */
102  fillAt: number
103  fill: (cells: number) => Piece[]
104  right: Piece[]
105  /** The filler's least width; items are dropped to keep it. */
106  minFill?: number
107}
108
109export function widthOf(pieces: readonly Piece[]): number {
110  return pieces.reduce((n, piece) => n + cellWidth(piece.text), 0)
111}
112
113/** Pieces cut to `max` cells, the last one kept ends in `…`. */
114export function clipPieces(pieces: readonly Piece[], max: number): Piece[] {
115  const out: Piece[] = []
116  let left = max
117  for (const piece of pieces) {
118    if (left <= 0) break
119    const width = cellWidth(piece.text)
120    if (width <= left) {
121      out.push(piece)
122      left -= width
123    } else {
124      out.push({ ...piece, text: clip(piece.text, left) })
125      left = 0
126    }
127  }
128  return out
129}
130
131function sameStyle(a: Piece, b: Piece): boolean {
132  return a.color === b.color && a.backgroundColor === b.backgroundColor && a.bold === b.bold && a.dimColor === b.dimColor
133}
134
135/** Joins neighbours of one style, so a row draws as few <Text> runs as it can. */
136export function merge(pieces: readonly Piece[]): Piece[] {
137  const out: Piece[] = []
138  for (const piece of pieces) {
139    if (piece.text === '') continue
140    const last = out[out.length - 1]
141    if (last && sameStyle(last, piece)) {
142      out[out.length - 1] = { ...last, text: last.text + piece.text }
143    } else {
144      out.push({ ...piece })
145    }
146  }
147  return out
148}
149
150/**
151 * Fits a row to exactly `width` cells: drops the lowest-priority items until
152 * the rest fit beside the filler's least width, then gives the filler what is
153 * left. When even the fixed ends do not fit, they are cut.
154 */
155export function fitRow(spec: RowSpec, width: number): Piece[] {
156  const minFill = spec.minFill ?? 0
157  const fixed = widthOf(spec.left) + widthOf(spec.right)
158  if (fixed + minFill > width) {
159    return merge(clipPieces([...spec.left, ...spec.right], width))
160  }
161  const kept = spec.items.map(() => true)
162  const widths = spec.items.map(item => widthOf(item.pieces))
163  const used = () => widths.reduce((n, w, i) => (kept[i] ? n + w : n), fixed)
164  while (used() + minFill > width) {
165    let drop = -1
166    spec.items.forEach((item, i) => {
167      const current = spec.items[drop]
168      if (kept[i] && (drop === -1 || (current !== undefined && item.priority <= current.priority))) drop = i
169    })
170    if (drop === -1) break
171    const group = spec.items[drop]?.group
172    spec.items.forEach((item, i) => {
173      if (i === drop || (group !== undefined && item.group === group)) kept[i] = false
174    })
175  }
176  const pieces: Piece[] = [...spec.left]
177  const filler = spec.fill(Math.max(0, width - used()))
178  spec.items.forEach((item, i) => {
179    if (i === spec.fillAt) pieces.push(...filler)
180    if (kept[i]) pieces.push(...item.pieces)
181  })
182  if (spec.fillAt >= spec.items.length) pieces.push(...filler)
183  pieces.push(...spec.right)
184  return merge(pieces)
185}
186
187/** A rounded LCARS pill: `▐ LABEL ▌`, ink on color. */
188export function pill(label: string, color: string): Piece[] {
189  return [
190    { text: '▐', color },
191    { text: ` ${label} `, color: LCARS.ink, backgroundColor: color, bold: true },
192    { text: '▌', color },
193  ]
194}
195
hooks/panel.ts 550 lines
1// The panel: what the readings look like as LCARS rows, and as the /lcars
2// report. Pure functions of a PanelView, so the tests can check each layout
3// at any width without a session.
4
5import type { Readings } from '../types'
6import { LCARS, STANDARD_FRAME, alertFrame, clipPieces, fitRow, merge, mix, pill, widthOf } from './lcars'
7import type { Frame, Item, Piece } from './lcars'
8import {
9  cellWidth,
10  clip,
11  clipStart,
12  clockTime,
13  countdown,
14  gauge,
15  homePath,
16  modelName,
17  shortDate,
18  spanText,
19  stardate,
20  tokenCount,
21  warpFactor,
22} from './readouts'
23
24export type LabelSet = 'starfleet' | 'plain'
25
26/**
27 * red-alert's state as the panel shows it, in its color: `label` in full
28 * (`CONDITION GREEN`), `short` for the end block (`GREEN`, at most 10 cells).
29 */
30export type Condition = { label: string; short: string; color: string }
31
32/** Everything one drawing of the panel depends on. */
33export type PanelView = {
34  readings: Readings | null
35  now: number
36  isWorking: boolean
37  labels: LabelSet
38  showCost: boolean
39  showUserHost: boolean
40  /** red-alert's alert while its band shows one: the frame takes its color. */
41  alert: { title: string; level: string; color: string } | null
42  /** The dark half of the blink while a red or yellow alert animates. */
43  isBlinkDark: boolean
44  /** red-alert's condition; null when red-alert is not loaded or not followed. */
45  condition: Condition | null
46}
47
48/**
49 * The words the panel uses, Starfleet's or plain ones: every label, the date
50 * in the sidebar, the end block's words, and how a percentage and a reset read.
51 */
52export const LABELS = {
53  starfleet: {
54    helm: 'HELM',
55    core: 'CORE',
56    fiveHour: 'DILITHIUM',
57    sevenDay: 'ANTIMATTER',
58    cost: 'ENERGY',
59    sector: 'SECTOR',
60    course: 'COURSE',
61    env: 'ENV',
62    crew: 'CREW',
63    idle: 'STANDBY',
64    working: 'ENGAGED',
65    condition: 'CONDITION',
66    clock: 'SHIP TIME',
67    loading: 'SENSORS',
68    loadingValue: 'INITIALIZING',
69    used: '',
70    left: '',
71    report: 'LCARS STATUS REPORT',
72    date: (ms: number) => stardate(ms),
73    reportDate: (ms: number) => `STARDATE ${stardate(ms)}`,
74    reset: (ms: number) => countdown(ms),
75  },
76  plain: {
77    helm: 'MODEL',
78    core: 'CONTEXT',
79    fiveHour: '5H LIMIT',
80    sevenDay: '7D LIMIT',
81    cost: 'COST',
82    sector: 'DIR',
83    course: 'GIT',
84    env: 'ENV',
85    crew: 'USER',
86    idle: 'IDLE',
87    working: 'WORKING',
88    condition: 'ALERTS',
89    clock: 'TIME',
90    loading: 'STATUS',
91    loadingValue: 'LOADING',
92    used: ' used',
93    left: ' left',
94    report: 'STATUS REPORT',
95    date: (ms: number) => shortDate(ms),
96    reportDate: (ms: number) => shortDate(ms),
97    reset: (ms: number) => `reset ${spanText(ms)}`,
98  },
99} as const
100
101/** red-alert's states, in each set of words: the full label, and the end block's short one. */
102const CONDITIONS = {
103  starfleet: {
104    linking: ['LINKING', 'LINKING'],
105    offline: ['ALERTS OFFLINE', 'OFFLINE'],
106    muted: ['GREEN · MUTED', 'MUTED'],
107    green: ['CONDITION GREEN', 'GREEN'],
108  },
109  plain: {
110    linking: ['ALERTS LINKING', 'LINKING'],
111    offline: ['ALERTS OFFLINE', 'OFFLINE'],
112    muted: ['ALERTS MUTED', 'MUTED'],
113    green: ['ALERTS ONLINE', 'ONLINE'],
114  },
115} as const
116
117/** red-alert's state when no alert is up, as the panel words it. */
118export function conditionFor(state: keyof (typeof CONDITIONS)['plain'], labels: LabelSet, color: string): Condition {
119  const [label, short] = CONDITIONS[labels][state]
120  return { label, short, color }
121}
122
123type Labels = (typeof LABELS)[LabelSet]
124
125/** The left sidebar's width in cells, and the right end block's. */
126const SIDEBAR = 10
127const END_BLOCK = 11
128/** What the elbows take beside the columns: sidebar, fillet and gap; gap, fillet and end block. */
129const LEFT_SPAN = SIDEBAR + 2
130const RIGHT_SPAN = END_BLOCK + 2
131/** Below this width the bridge frame gives way to the one-row strip. */
132export const BRIDGE_MIN_WIDTH = 60
133/** The compact strip's sidebar pill: fits `RED ALERT` and `ENGAGED`. */
134const PILL_ROOM = 9
135
136/** Calm, caution, danger: the thresholds of the status line this panel replaces. */
137export function levelColor(percentUsed: number): string {
138  if (percentUsed >= 80) return LCARS.red
139  if (percentUsed >= 60) return LCARS.yellow
140  return LCARS.blue
141}
142
143function frameOf(view: PanelView): Frame {
144  return view.alert ? alertFrame(view.alert.color) : STANDARD_FRAME
145}
146
147/** A block's colors: ink on its color, inverted on the dark half of an alert's blink. */
148function blockStyle(view: PanelView, color: string): Omit<Piece, 'text'> {
149  return view.alert && view.isBlinkDark
150    ? { color, backgroundColor: mix(color, LCARS.ink, 0.75), bold: true }
151    : { color: LCARS.ink, backgroundColor: color, bold: true }
152}
153
154/** `label` right-aligned in a block `width` cells wide, as LCARS numbers its blocks. */
155function blockText(label: string, width: number): string {
156  return `${clip(label, width - 2).padStart(width - 1)} `
157}
158
159// ---------------------------------------------------------------------------
160// The bridge layout
161// ---------------------------------------------------------------------------
162
163/** One column of the bridge frame: a header segment, a label row, a value row. */
164type Column = {
165  priority: number
166  /** Shown only while every column fits at its preferred width. */
167  isOptional: boolean
168  /** Its header segment and label, while no alert repaints the frame. */
169  color: string
170  /** Widths in cells, each with one cell of air after the content. */
171  min: number
172  pref: number
173  label: (room: number) => Piece[]
174  value: (room: number) => Piece[]
175}
176
177function column(spec: Omit<Column, 'min' | 'pref' | 'isOptional'> & { min: number; pref: number; isOptional?: boolean }): Column {
178  return { ...spec, isOptional: spec.isOptional ?? false, min: spec.min + 1, pref: Math.max(spec.min, spec.pref) + 1 }
179}
180
181/** A meter and its percentage in `room` cells; the meter shrinks first, then goes. */
182function metered(percent: number, text: string, color: string, room: number): Piece[] {
183  const cells = Math.min(12, room - 1 - text.length)
184  const value: Piece = { text, color, bold: true }
185  if (cells < 3) return [value]
186  const g = gauge(percent, cells)
187  return [{ text: g.lit, color }, { text: g.dark, color: mix(color, LCARS.ink, 0.72) }, { text: ' ' }, value]
188}
189
190/** The readings as columns, in the order they stand. */
191function columnsOf(view: PanelView, labels: Labels, frame: Frame): Column[] {
192  const r = view.readings
193  const tint = (color: string) => (view.alert ? frame.label : color)
194  if (!r) {
195    return [
196      column({
197        priority: 10,
198        color: LCARS.orange,
199        min: 12,
200        pref: 12,
201        label: () => [{ text: labels.loading, color: tint(LCARS.orange) }],
202        value: () => [{ text: labels.loadingValue, color: frame.label }],
203      }),
204    ]
205  }
206  const columns: Column[] = []
207  const minor = (text: string): Piece => ({ text, color: view.alert ? frame.label : LCARS.tan })
208
209  const model = modelName(r.model)
210  const effort = view.labels === 'starfleet' ? warpFactor(r.effort) : (r.effort?.toUpperCase() ?? null)
211  columns.push(
212    column({
213      priority: 10,
214      color: LCARS.peach,
215      min: Math.min(14, Math.max(labels.helm.length, cellWidth(model))),
216      pref: Math.max(labels.helm.length, cellWidth(model) + (effort ? effort.length + 1 : 0)),
217      label: () => [{ text: labels.helm, color: tint(LCARS.peach) }],
218      value: room => {
219        const name: Piece = { text: clip(model, room), color: LCARS.blue, bold: true }
220        return effort && cellWidth(model) + 1 + effort.length <= room
221          ? [name, { text: ' ' }, { text: effort, color: LCARS.peach, bold: true }]
222          : [name]
223      },
224    }),
225  )
226
227  const ctx = r.context.percent
228  const ctxText = ctx === null ? '--' : `${Math.round(ctx)}%${labels.used}`
229  const ctxColor = ctx === null ? frame.label : levelColor(ctx)
230  columns.push(
231    column({
232      priority: 9,
233      color: LCARS.blue,
234      min: Math.max(labels.core.length, 5 + ctxText.length),
235      pref: Math.max(labels.core.length, 11 + ctxText.length),
236      label: () => [{ text: labels.core, color: tint(LCARS.blue) }],
237      value: room => metered(ctx ?? 0, ctxText, ctxColor, room),
238    }),
239  )
240
241  const windows = [
242    { limit: r.fiveHour, label: labels.fiveHour, priority: 8, color: LCARS.lavender, meter: 12 },
243    { limit: r.sevenDay, label: labels.sevenDay, priority: 6, color: LCARS.violet, meter: 0 },
244  ]
245  for (const w of windows) {
246    if (!w.limit) continue
247    const left = Math.max(0, Math.round(100 - w.limit.used))
248    const text = `${left}%${labels.left}`
249    const color = levelColor(w.limit.used)
250    const reset = w.limit.resetsAt === null ? '' : labels.reset(w.limit.resetsAt - view.now)
251    const hasMeter = w.meter > 0
252    columns.push(
253      column({
254        priority: w.priority,
255        color: w.color,
256        min: Math.max(w.label.length, hasMeter ? 5 + text.length : text.length),
257        pref: hasMeter
258          ? Math.max(w.label.length + (reset ? reset.length + 1 : 0), w.meter + 1 + text.length)
259          : Math.max(w.label.length, text.length + (reset ? reset.length + 1 : 0)),
260        label: room => {
261          const name: Piece = { text: w.label, color: tint(w.color) }
262          return hasMeter && reset && w.label.length + 1 + reset.length <= room ? [name, { text: ' ' }, minor(reset)] : [name]
263        },
264        value: room => {
265          if (hasMeter) return metered(left, text, color, room)
266          const value: Piece = { text, color, bold: true }
267          return reset && text.length + 1 + reset.length <= room ? [value, { text: ' ' }, minor(reset)] : [value]
268        },
269      }),
270    )
271  }
272
273  const path = homePath(r.cwd, r.home)
274  const where = r.branch ? `${labels.course} ${r.branch}` : labels.sector
275  const wherePref = Math.min(40, Math.max(cellWidth(where), cellWidth(path)))
276  columns.push(
277    column({
278      priority: 7,
279      color: LCARS.sand,
280      min: Math.min(12, wherePref),
281      pref: wherePref,
282      label: () =>
283        r.branch
284          ? [{ text: `${labels.course} `, color: tint(LCARS.sand) }, { text: r.branch, color: LCARS.violet, bold: true }]
285          : [{ text: labels.sector, color: tint(LCARS.sand) }],
286      value: room => [{ text: clipStart(path, room), color: LCARS.sand, bold: true }],
287    }),
288  )
289
290  const optional = (priority: number, color: string, label: string, value: Piece) => {
291    const room = Math.min(24, Math.max(label.length, cellWidth(value.text)))
292    columns.push(
293      column({
294        priority,
295        isOptional: true,
296        color,
297        min: room,
298        pref: room,
299        label: () => [{ text: label, color: tint(color) }],
300        value: width => [{ ...value, text: clip(value.text, width) }],
301      }),
302    )
303  }
304  if (r.pyenv) optional(5, LCARS.tan, labels.env, { text: r.pyenv, color: LCARS.peach, bold: true })
305  if (view.showCost && r.costUsd !== null) {
306    optional(4, LCARS.orange, labels.cost, { text: `$${r.costUsd.toFixed(2)}`, color: LCARS.sand, bold: true })
307  }
308  if (view.showUserHost && r.user) {
309    optional(3, LCARS.violet, labels.crew, { text: r.host ? `${r.user}@${r.host}` : r.user, color: frame.label })
310  }
311  return columns
312}
313
314/**
315 * Picks the columns that fit `room` cells and sizes them: optional columns
316 * leave first, while the rest do not fit at their preferred widths; then the
317 * least important go until the rest fit at their least. Each column grows to
318 * its preferred width, most important first, and what is left is shared out.
319 */
320function allocate(columns: readonly Column[], room: number): { column: Column; width: number }[] {
321  const total = (list: readonly Column[], key: 'min' | 'pref') =>
322    list.reduce((n, c) => n + c[key], 0) + Math.max(0, list.length - 1)
323  const withoutLowest = (list: readonly Column[], isCandidate: (c: Column) => boolean) => {
324    let lowest = -1
325    list.forEach((c, i) => {
326      const current = list[lowest]
327      if (isCandidate(c) && (current === undefined || c.priority < current.priority)) lowest = i
328    })
329    return lowest === -1 ? list : list.filter((_, i) => i !== lowest)
330  }
331  let active = [...columns]
332  while (total(active, 'pref') > room) {
333    const next = withoutLowest(active, c => c.isOptional)
334    if (next === active) break
335    active = [...next]
336  }
337  while (total(active, 'min') > room && active.length > 1) active = [...withoutLowest(active, () => true)]
338
339  const widths = active.map(c => c.min)
340  let extra = room - total(active, 'min')
341  const byImportance = active.map((_, i) => i).sort((a, b) => (active[b]?.priority ?? 0) - (active[a]?.priority ?? 0))
342  for (const i of byImportance) {
343    const add = Math.max(0, Math.min(extra, (active[i]?.pref ?? 0) - (widths[i] ?? 0)))
344    widths[i] = (widths[i] ?? 0) + add
345    extra -= add
346  }
347  const each = Math.floor(extra / active.length)
348  const last = active.length - 1
349  return active.map((c, i) => ({ column: c, width: (widths[i] ?? 0) + each + (i === last ? extra - each * active.length : 0) }))
350}
351
352/** `pieces` in a column `width` cells wide: cut to leave one cell of air, then padded. */
353function cell(pieces: Piece[], width: number): Piece[] {
354  const kept = clipPieces(pieces, width - 1)
355  return [...kept, { text: ' '.repeat(Math.max(0, width - widthOf(kept))) }]
356}
357
358/** The end block: red-alert's condition, or the ship's time when red-alert is absent. */
359function endBlock(view: PanelView, frame: Frame, labels: Labels): { color: string; top: string; bottom: string } {
360  if (view.condition) return { color: view.condition.color, top: labels.condition, bottom: view.condition.short }
361  return { color: frame.secondary, top: labels.clock, bottom: clockTime(view.now) }
362}
363
364/**
365 * The bridge layout, an LCARS ops console in three rows: elbows at both
366 * ends, a header bar cut into one colored segment per column, the labels
367 * under their segments and the values under the labels. The sidebar holds
368 * Claude's state over the stardate; the end block, red-alert's condition.
369 *
370 *   ██████████▛ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▜███████████
371 *     STANDBY   HELM            CORE            DILITHIUM T-2H14M  ANTIMATTER  COURSE main          CONDITION
372 *     80753.1   OPUS 5.5 WARP 9 ▊▊▊▊▊▊▊▊▊▊ 42%  ▊▊▊▊▊▊▊▊▊▊▊▊ 72%   88% T-3D4H  ~/starfleet_panel       GREEN
373 */
374export function bridgeRows(view: PanelView, width: number): Piece[][] {
375  const frame = frameOf(view)
376  const labels = LABELS[view.labels]
377  const columns = allocate(columnsOf(view, labels, frame), width - LEFT_SPAN - RIGHT_SPAN)
378  const across = (draw: (entry: { column: Column; width: number }, i: number) => Piece[]) =>
379    columns.flatMap((entry, i) => [...(i > 0 ? [{ text: ' ' }] : []), ...draw(entry, i)])
380  const segment = (c: Column, i: number) => (view.alert ? (frame.shades[i % frame.shades.length] ?? frame.primary) : c.color)
381  const end = endBlock(view, frame, labels)
382
383  // The blocks' tops are solid cells, so they meet the rows below whatever the
384  // terminal's line spacing; the bars hang from the top edge, flush with them.
385  const top: Piece[] = [
386    { text: ' '.repeat(SIDEBAR), backgroundColor: frame.primary },
387    { text: '▛', color: frame.primary },
388    { text: ' ' },
389    ...across(({ column: c, width: w }, i) => [{ text: '▀'.repeat(w), color: segment(c, i) }]),
390    { text: ' ' },
391    { text: '▜', color: end.color },
392    { text: ' '.repeat(END_BLOCK), backgroundColor: end.color },
393  ]
394  const middle: Piece[] = [
395    { text: blockText(view.isWorking ? labels.working : labels.idle, SIDEBAR), ...blockStyle(view, frame.primary) },
396    { text: '  ' },
397    ...across(({ column: c, width: w }) => cell(c.label(w - 1), w)),
398    { text: '  ' },
399    { text: blockText(end.top, END_BLOCK), ...blockStyle(view, end.color) },
400  ]
401  const bottom: Piece[] = [
402    {
403      text: blockText(labels.date(view.now), SIDEBAR),
404      color: view.alert ? frame.label : LCARS.ink,
405      backgroundColor: frame.block,
406      bold: true,
407    },
408    { text: '  ' },
409    ...across(({ column: c, width: w }) => cell(c.value(w - 1), w)),
410    { text: '  ' },
411    { text: blockText(end.bottom, END_BLOCK), ...blockStyle(view, end.color) },
412  ]
413  return [merge(top), merge(middle), merge(bottom)]
414}
415
416// ---------------------------------------------------------------------------
417// The compact layout
418// ---------------------------------------------------------------------------
419
420function sidebarLabel(view: PanelView, labels: Labels): string {
421  if (view.alert) {
422    const title = view.alert.title.toUpperCase()
423    return clip(cellWidth(title) <= PILL_ROOM ? title : view.alert.level.toUpperCase(), PILL_ROOM)
424  }
425  return view.isWorking ? labels.working : labels.idle
426}
427
428/** The readings as droppable items for the one-row strip. */
429function stripItems(view: PanelView, labels: Labels, frame: Frame): Item[] {
430  const r = view.readings
431  if (!r) return [{ priority: 10, pieces: [{ text: `${labels.loading} ${labels.loadingValue}`, color: frame.label }] }]
432  const gap = { text: '  ' }
433  const items: Item[] = [{ priority: 10, pieces: [{ text: modelName(r.model), color: LCARS.blue, bold: true }] }]
434  const effort = view.labels === 'starfleet' ? warpFactor(r.effort) : (r.effort?.toUpperCase() ?? null)
435  if (effort) items.push({ priority: 5, pieces: [{ text: ' ' }, { text: effort, color: LCARS.peach, bold: true }] })
436  const reading = (label: string, text: string, color: string, priority: number) =>
437    items.push({ priority, pieces: [gap, { text: `${label} `, color: frame.label }, { text, color, bold: true }] })
438  const ctx = r.context.percent
439  reading(labels.core, ctx === null ? '--' : `${Math.round(ctx)}%${labels.used}`, ctx === null ? frame.label : levelColor(ctx), 9)
440  if (r.fiveHour) reading(labels.fiveHour, `${Math.max(0, Math.round(100 - r.fiveHour.used))}%${labels.left}`, levelColor(r.fiveHour.used), 8)
441  if (r.sevenDay) reading(labels.sevenDay, `${Math.max(0, Math.round(100 - r.sevenDay.used))}%${labels.left}`, levelColor(r.sevenDay.used), 7)
442  if (r.branch) {
443    items.push({
444      priority: 4,
445      pieces: [gap, { text: `${labels.course} `, color: frame.label }, { text: clip(r.branch, 24), color: LCARS.violet, bold: true }],
446    })
447  }
448  return items
449}
450
451/**
452 * The compact layout: one row, drawn as red-alert's idle strip is.
453 *
454 *   ▐ STANDBY ▌ OPUS 5.5 WARP 9  CORE 42%  DILITHIUM 72%  COURSE main ━━━━━━━ ▐ GREEN ▌
455 */
456export function compactRow(view: PanelView, width: number): Piece[] {
457  const frame = frameOf(view)
458  const labels = LABELS[view.labels]
459  const style = blockStyle(view, frame.primary)
460  const items = stripItems(view, labels, frame)
461  return fitRow(
462    {
463      left: [
464        { text: '▐', color: style.backgroundColor },
465        { text: ` ${sidebarLabel(view, labels)} `, ...style },
466        { text: '▌', color: style.backgroundColor },
467        { text: ' ' },
468      ],
469      items,
470      fillAt: items.length,
471      fill: cells =>
472        cells >= 3
473          ? [{ text: ' ' }, { text: '━'.repeat(cells - 2), color: frame.secondary }, { text: ' ' }]
474          : [{ text: ' '.repeat(cells) }],
475      right: view.condition ? pill(view.condition.short, view.condition.color) : [],
476      minFill: 2,
477    },
478    width,
479  )
480}
481
482// ---------------------------------------------------------------------------
483// The /lcars report
484// ---------------------------------------------------------------------------
485
486export type ReportExtras = {
487  layout: string
488  isLayoutOverridden: boolean
489  hasStatusLine: boolean
490  redAlert: 'followed' | 'absent' | 'ignored'
491}
492
493/** The full readout in words, each Starfleet label beside what it stands for. */
494export function reportText(view: PanelView, extras: ReportExtras): string {
495  const labels = LABELS[view.labels]
496  const r = view.readings
497  const line = (label: string, meaning: string, value: string) =>
498    `${label.padEnd(11)} ${`(${meaning})`.padEnd(20)} ${value}`
499  const out = [`${labels.report} · ${labels.reportDate(view.now)} · ${clockTime(view.now)}`, '']
500  if (!r) {
501    out.push('Sensors are still initializing.')
502  } else {
503    const warp = warpFactor(r.effort)
504    out.push(
505      line(labels.helm, 'model, effort', `${modelName(r.model)}${r.effort ? ` · ${r.effort} effort${warp ? ` (${warp.toLowerCase()})` : ''}` : ''}`),
506    )
507    const ctx = r.context
508    out.push(
509      line(
510        labels.core,
511        'context window',
512        ctx.percent === null
513          ? `no reading yet${ctx.window ? ` · ${tokenCount(ctx.window)} window` : ''}`
514          : `${Math.round(ctx.percent)}% used${ctx.tokens !== null && ctx.window ? ` · ${tokenCount(ctx.tokens)} of ${tokenCount(ctx.window)} tokens` : ''}`,
515      ),
516    )
517    const windowLine = (label: string, meaning: string, limit: Readings['fiveHour']) =>
518      line(
519        label,
520        meaning,
521        limit
522          ? `${Math.max(0, Math.round(100 - limit.used))}% left${limit.resetsAt !== null ? ` · resets in ${spanText(limit.resetsAt - view.now)}` : ''}`
523          : 'no reading (shows after the first response on a subscription)',
524      )
525    out.push(windowLine(labels.fiveHour, '5-hour limit', r.fiveHour))
526    out.push(windowLine(labels.sevenDay, '7-day limit', r.sevenDay))
527    out.push(line(labels.cost, 'session cost', r.costUsd === null ? 'not tracked' : `$${r.costUsd.toFixed(2)}`))
528    out.push(line(labels.sector, 'working directory', homePath(r.cwd, r.home)))
529    out.push(line(labels.course, 'git branch', r.branch ?? 'not a git repository'))
530    out.push(line(labels.env, 'python env', r.pyenv ?? 'none'))
531  }
532  const condition =
533    extras.redAlert === 'ignored'
534      ? 'not followed (followRedAlert is off)'
535      : view.condition
536        ? view.condition.label.toLowerCase()
537        : 'red-alert is not loaded'
538  out.push(line(labels.condition, 'red-alert', condition))
539  out.push(
540    '',
541    `Panel: ${extras.layout}${extras.isLayoutOverridden ? ' (this session; /lcars reset follows the setting)' : ''} · terms: ${view.labels} · /lcars bridge | compact | off · /lcars starfleet | plain`,
542  )
543  if (extras.hasStatusLine) {
544    out.push(
545      'Note: a statusLine command is also configured, so its own line shows beside this panel. Remove "statusLine" from your settings to let the panel take over.',
546    )
547  }
548  return out.join('\n')
549}
550
hooks/readouts.ts 173 lines
1// Readouts: pure conversions from what the session reports to what the panel
2// prints. No `$` here, so every function is a plain value in, value out.
3
4/** Reasoning effort as a warp factor: the harder Claude thinks, the faster the ship. */
5const WARP: Record<string, string> = {
6  low: 'IMPULSE',
7  medium: 'WARP 5',
8  high: 'WARP 7',
9  xhigh: 'WARP 9',
10  max: 'WARP 9.6',
11}
12
13/** `claude-opus-5-5` → `OPUS 5.5`; `opus[1m]` → `OPUS 1M`; `Sonnet 4.5` → `SONNET 4.5`. */
14export function modelName(model: string): string {
15  const raw = model.trim()
16  if (!raw) return 'NO MODEL'
17  const isLong = /\[1m\]/i.test(raw)
18  const base = raw.replace(/\[1m\]/i, '').trim()
19  const modern = /^(?:claude-)?([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(base)
20  const legacy = /^claude-(\d+)(?:-(\d+))?-([a-z]+)(?:-\d{8})?$/i.exec(base)
21  let name: string
22  if (modern) {
23    name = `${modern[1]} ${modern[2]}${modern[3] ? `.${modern[3]}` : ''}`
24  } else if (legacy) {
25    name = `${legacy[3]} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`
26  } else {
27    name = base.replace(/^claude-/i, '').replace(/-/g, ' ')
28  }
29  return `${name.toUpperCase()}${isLong ? ' 1M' : ''}`
30}
31
32/** The effort level as a warp factor, or null when the model takes none. */
33export function warpFactor(effort: string | null): string | null {
34  if (effort === null || effort === '') return null
35  return WARP[effort.toLowerCase()] ?? `WARP ${effort.toUpperCase()}`
36}
37
38/**
39 * The stardate by TNG's broadcast reckoning: 41000 when the show began in
40 * 1987, a thousand units a year, the year's fraction after the point.
41 */
42export function stardate(ms: number): string {
43  const date = new Date(ms)
44  const year = date.getFullYear()
45  const start = new Date(year, 0, 1).getTime()
46  const end = new Date(year + 1, 0, 1).getTime()
47  const value = 41000 + (year - 1987) * 1000 + ((ms - start) / (end - start)) * 1000
48  return (Math.floor(value * 10) / 10).toFixed(1)
49}
50
51const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
52
53/** The local date, `OCT 02`: the plain counterpart of the stardate. */
54export function shortDate(ms: number): string {
55  const date = new Date(ms)
56  return `${MONTHS[date.getMonth()] ?? ''} ${String(date.getDate()).padStart(2, '0')}`
57}
58
59/** Local wall-clock time, `HH:MM`. */
60export function clockTime(ms: number): string {
61  const date = new Date(ms)
62  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
63}
64
65/** Time left as `T-2H14M`, `T-3D4H`, `T-45M`. */
66export function countdown(ms: number): string {
67  const minutes = Math.max(0, Math.ceil(ms / 60000))
68  if (minutes >= 1440) {
69    const days = Math.floor(minutes / 1440)
70    const hours = Math.floor((minutes % 1440) / 60)
71    return `T-${days}D${hours ? `${hours}H` : ''}`
72  }
73  if (minutes >= 60) {
74    const hours = Math.floor(minutes / 60)
75    const rest = minutes % 60
76    return `T-${hours}H${rest ? `${rest}M` : ''}`
77  }
78  return `T-${minutes}M`
79}
80
81/** The same span in words, for the /lcars report: `2h14m`, `3d4h`. */
82export function spanText(ms: number): string {
83  return countdown(ms).slice(2).toLowerCase()
84}
85
86/** `96000` → `96K`; `1200000` → `1.2M`. */
87export function tokenCount(tokens: number): string {
88  if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
89  if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`
90  return String(Math.round(tokens))
91}
92
93/** A segmented LCARS meter, `cells` wide: the lit segments and the dark ones. */
94export function gauge(percent: number, cells: number): { lit: string; dark: string } {
95  const lit = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
96  return { lit: '▊'.repeat(lit), dark: '▊'.repeat(cells - lit) }
97}
98
99/** `/home/me/src/app` → `~/src/app` when `home` is `/home/me`. */
100export function homePath(path: string, home: string): string {
101  if (home && (path === home || path.startsWith(`${home}/`))) return `~${path.slice(home.length)}`
102  return path
103}
104
105// ---------------------------------------------------------------------------
106// Terminal cell widths
107// ---------------------------------------------------------------------------
108
109function charWidth(code: number): number {
110  if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0
111  if ((code >= 0x300 && code <= 0x36f) || (code >= 0x200b && code <= 0x200f) || (code >= 0xfe00 && code <= 0xfe0f)) {
112    return 0
113  }
114  if (
115    (code >= 0x1100 && code <= 0x115f) ||
116    (code >= 0x2e80 && code <= 0x303e) ||
117    (code >= 0x3041 && code <= 0x33ff) ||
118    (code >= 0x3400 && code <= 0x4dbf) ||
119    (code >= 0x4e00 && code <= 0x9fff) ||
120    (code >= 0xa000 && code <= 0xa4cf) ||
121    (code >= 0xac00 && code <= 0xd7a3) ||
122    (code >= 0xf900 && code <= 0xfaff) ||
123    (code >= 0xfe30 && code <= 0xfe4f) ||
124    (code >= 0xff00 && code <= 0xff60) ||
125    (code >= 0xffe0 && code <= 0xffe6) ||
126    (code >= 0x1f300 && code <= 0x1f64f) ||
127    (code >= 0x1f900 && code <= 0x1f9ff) ||
128    (code >= 0x20000 && code <= 0x3fffd)
129  ) {
130    return 2
131  }
132  return 1
133}
134
135/** Cells `text` takes in a terminal: CJK and emoji two, combining marks none. */
136export function cellWidth(text: string): number {
137  let width = 0
138  for (const char of text) width += charWidth(char.codePointAt(0) ?? 0)
139  return width
140}
141
142/** `text` cut to at most `max` cells, ending in `…` when cut. */
143export function clip(text: string, max: number): string {
144  if (max <= 0) return ''
145  if (cellWidth(text) <= max) return text
146  let out = ''
147  let width = 0
148  for (const char of text) {
149    const w = charWidth(char.codePointAt(0) ?? 0)
150    if (width + w > max - 1) break
151    out += char
152    width += w
153  }
154  return `${out}…`
155}
156
157/** `text` cut from the left to at most `max` cells: `…/deep/path`. */
158export function clipStart(text: string, max: number): string {
159  if (max <= 0) return ''
160  if (cellWidth(text) <= max) return text
161  const chars = [...text]
162  let out = ''
163  let width = 0
164  for (let i = chars.length - 1; i >= 0; i -= 1) {
165    const char = chars[i] ?? ''
166    const w = charWidth(char.codePointAt(0) ?? 0)
167    if (width + w > max - 1) break
168    out = char + out
169    width += w
170  }
171  return `…${out}`
172}
173
types/index.d.ts 74 lines
1/** Which panel the mod draws under the prompt. */
2export type PanelLayout = 'bridge' | 'compact' | 'off'
3
4/** One rate-limit window, as `$.session.usage()` last reported it. */
5export type LimitReading = {
6  /** Percent of the window used, 0 to 100 (past 100 on an exceeded spend limit). */
7  used: number
8  /** When the window resets, in milliseconds since the epoch; null when unknown. */
9  resetsAt: number | null
10}
11
12/** Everything the panel reads out, refreshed by the mod's timers and turn events. */
13export type Readings = {
14  /** The main loop's model as /model shows it (`claude-opus-5-5`, `opus`). */
15  model: string
16  /** `low` … `max`, or null for a model without an effort setting. */
17  effort: string | null
18  /** The context window's fill; `percent` and `tokens` are null until the first response. */
19  context: { percent: number | null; tokens: number | null; window: number | null }
20  fiveHour: LimitReading | null
21  sevenDay: LimitReading | null
22  /** US dollars this session, as /cost totals it. */
23  costUsd: number | null
24  cwd: string
25  home: string
26  branch: string | null
27  /** The active conda env (not `base`) or virtualenv. */
28  pyenv: string | null
29  user: string
30  host: string
31}
32
33/** red-alert's daemon link, as much of it as the panel reads. */
34export type RedAlertLink = {
35  online: boolean
36  /** Milliseconds since the epoch of the last check; 0 before the first. */
37  checkedAt: number
38  mute: { until: number | null } | null
39}
40
41/** The alert red-alert's band is showing, as much of it as the panel reads. */
42export type RedAlertActive = {
43  id: string
44  level: string
45  color: string
46  style: 'sweep' | 'pulse' | 'klaxon'
47  title: string
48  /** Milliseconds since the epoch: the band animates at least until then. */
49  animateUntil: number
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'starfleet-panel': {
55      readings: Readings | null
56      /** The minute the panel last ticked, in epoch milliseconds: redraws the clock and countdowns. */
57      clock: number
58      /** The blink phase while a red or yellow alert animates. */
59      blink: boolean
60      /** A layout picked with /lcars for this session; null follows the setting. */
61      layout: PanelLayout | null
62    }
63    /**
64     * red-alert's own values (github.com/dukechain2333/red-alert), read and
65     * never written, so the panel follows its alert condition. A subset of its
66     * contract; absent (`undefined`) when red-alert is not loaded.
67     */
68    'red-alert': {
69      link: RedAlertLink | null
70      active: RedAlertActive | null
71    }
72  }
73}
74