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

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.
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 term | Plain term | What it shows |
|---|---|---|
STANDBY / ENGAGED | IDLE / WORKING | the sidebar's upper block: whether Claude is working |
| stardate | date (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 |
HELM | MODEL | the 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 |
CORE | CONTEXT | context window used (42%; plain 42% used), with a segmented meter |
DILITHIUM | 5H LIMIT | the 5-hour limit: how much is left (plain 72% left), a meter, and the time to the reset (T-2H14M; plain reset 2h14m) |
ANTIMATTER | 7D LIMIT | the 7-day limit: how much is left and the time to the reset |
COURSE / SECTOR | GIT / DIR | the git branch (or @<sha> when detached) over the working directory, ~ for home; SECTOR outside a repository |
ENV | ENV | the active conda env (not base) or virtualenv, on wide terminals |
ENERGY | COST | the session's cost, on wide terminals (off by default) |
CREW | USER | user@host, on wide terminals (can be turned off) |
CONDITION | ALERTS | the 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.
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 state | The panel |
|---|---|
| online | the end block reads CONDITION / GREEN |
| muted | CONDITION / MUTED |
| offline | CONDITION / OFFLINE |
| an alert is up | the whole frame turns the alert's reds and the end block reads RED ALERT (or the level's title) |
| a red or yellow alert animating | the 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 installed | the 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-alert | starfleet-panel | |
|---|---|---|
| Draws | the band above the prompt, the /alert console, its tool's row | the hint line under the prompt, nothing else |
| Command | /alert | /lcars |
| Tools and system prompt | the alert tool and a short alert policy | none |
| Keys | 0 silences; band items via ctrl+x tab | none: no buttons, no hotkeys |
| State | its own | its 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.
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.
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.
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.Set these in /config (or claude plugin configure starfleet-panel):
| Setting | Default | |
|---|---|---|
layout | bridge | bridge, compact or off |
labels (Terminology) | starfleet | starfleet (HELM, CORE, DILITHIUM, stardate…) or plain (MODEL, CONTEXT, 5H LIMIT, date…); /lcars plain and /lcars starfleet set it too |
showCost | false | add the ENERGY column (the session's cost) on wide terminals |
showUserHost | true | add the CREW column (user@host) on wide terminals |
followRedAlert | true | show red-alert's condition and repaint with its alerts |
refreshSeconds | 2 | how often the context window and limits are re-read |
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.
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>.
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.
hooks/register.tsx 448 lines1// 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}
448hooks/lcars.ts 195 lines1// 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}
195hooks/panel.ts 550 lines1// 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}
550hooks/readouts.ts 173 lines1// 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}
173types/index.d.ts 74 lines1/** 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