Skip to content

Commit 094e23e

Browse files
committed
tui: LemonCrow statusline segment in run footer
1 parent edac8eb commit 094e23e

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/run/footer.view.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type {
5555
} from "./types"
5656
import type { RunTheme } from "./theme"
5757
import { modelInfo } from "./variant.shared"
58+
import { createLcStatuslineSignal } from "./lc-statusline"
5859

5960
registerOpencodeSpinner()
6061

@@ -130,6 +131,7 @@ export function RunFooterView(props: RunFooterViewProps) {
130131
}
131132
)
132133
})
134+
const lcStatusline = createLcStatuslineSignal()
133135
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
134136
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
135137
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
@@ -815,6 +817,20 @@ export function RunFooterView(props: RunFooterViewProps) {
815817
</Show>
816818

817819
<Show when={!panel() && !menu()}>
820+
<Show when={lcStatusline().length > 0}>
821+
<box width="100%" height={1} flexDirection="row" gap={0} flexShrink={0} backgroundColor="transparent">
822+
<box paddingLeft={1} paddingRight={1} flexShrink={0}>
823+
<text wrapMode="none" truncate>
824+
<span style={{ fg: theme().statusAccent, bold: true }}>{"❯ lc"}</span>
825+
</text>
826+
</box>
827+
<box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={12} paddingRight={1}>
828+
<text fg={theme().muted} wrapMode="none" truncate>
829+
{lcStatusline()}
830+
</text>
831+
</box>
832+
</box>
833+
</Show>
818834
<box
819835
width="100%"
820836
height={1}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// LemonCrow cost/savings statusline segment for the lemoncode footer.
2+
//
3+
// Mirrors integrations/claude/plugin/scripts/statusline.sh's rotating
4+
// dynamic segment (see savings_summary.savings_frames), but reads it
5+
// straight from the MCP sidecar file the running `lemoncrow mcp --host
6+
// lemoncode` server already keeps fresh --
7+
// sessions/<date>/lemoncode/<id>/statusline_frames, written by
8+
// _write_statusline_sidecar_now after every savings event -- instead of
9+
// spawning a subprocess per poll. Silently empty when LemonCrow isn't
10+
// installed or no session has written a sidecar yet.
11+
import fs from "fs"
12+
import os from "os"
13+
import path from "path"
14+
import { createSignal, onCleanup } from "solid-js"
15+
16+
const POLL_INTERVAL_MS = 5000
17+
const FRAME_ROTATE_S = 5
18+
// session_dir() (paths.py) searches the same 3-day window when resolving an
19+
// existing session's date-partitioned directory; mirrored here so a session
20+
// that started yesterday and is still being written to isn't missed.
21+
const SEARCH_DAYS = 3
22+
const HOST = "lemoncode"
23+
// eslint-disable-next-line no-control-regex
24+
const ANSI_RE = /\x1b\[[0-9;]*m/g
25+
26+
function lemoncrowRoot(): string {
27+
return process.env.LEMONCROW_ROOT || process.env.LEMONCROW_STORE_ROOT || path.join(os.homedir(), ".lemoncrow")
28+
}
29+
30+
function datePath(offsetDays: number): string {
31+
const d = new Date()
32+
d.setDate(d.getDate() - offsetDays)
33+
const y = String(d.getFullYear())
34+
const m = String(d.getMonth() + 1).padStart(2, "0")
35+
const day = String(d.getDate()).padStart(2, "0")
36+
return path.join(y, m, day)
37+
}
38+
39+
// lc-debt: picks the freshest-mtime sidecar across every "lemoncode" session
40+
// from the last SEARCH_DAYS days, not the one specific session this opencode
41+
// instance belongs to -- opencode session ids and lemoncrow MCP session ids
42+
// aren't correlated anywhere yet. Fine for the common single-session case;
43+
// upgrade path is threading opencode's sessionID into the MCP server (e.g.
44+
// via a "chat.message" plugin hook) so it can key the sidecar path exactly.
45+
function findLatestFramesFile(): string | undefined {
46+
const root = lemoncrowRoot()
47+
let best: { file: string; mtime: number } | undefined
48+
for (let offset = 0; offset < SEARCH_DAYS; offset++) {
49+
const hostDir = path.join(root, "sessions", datePath(offset), HOST)
50+
let ids: string[]
51+
try {
52+
ids = fs.readdirSync(hostDir)
53+
} catch {
54+
continue
55+
}
56+
for (const id of ids) {
57+
const framesPath = path.join(hostDir, id, "statusline_frames")
58+
let mtime: number
59+
try {
60+
mtime = fs.statSync(framesPath).mtimeMs
61+
} catch {
62+
continue
63+
}
64+
if (!best || mtime > best.mtime) best = { file: framesPath, mtime }
65+
}
66+
}
67+
return best?.file
68+
}
69+
70+
function readFrame(): string {
71+
const file = findLatestFramesFile()
72+
if (!file) return ""
73+
let content: string
74+
try {
75+
content = fs.readFileSync(file, "utf-8")
76+
} catch {
77+
return ""
78+
}
79+
const frames = content
80+
.split("\n")
81+
.map((line) => line.replace(ANSI_RE, "").trim())
82+
.filter(Boolean)
83+
if (frames.length === 0) return ""
84+
const idx = Math.floor(Date.now() / 1000 / FRAME_ROTATE_S) % frames.length
85+
return frames[idx]
86+
}
87+
88+
/** Reactive rotating LemonCrow cost/savings segment; "" when nothing to show. */
89+
export function createLcStatuslineSignal() {
90+
const [frame, setFrame] = createSignal(readFrame())
91+
const timer = setInterval(() => setFrame(readFrame()), POLL_INTERVAL_MS)
92+
onCleanup(() => clearInterval(timer))
93+
return frame
94+
}

0 commit comments

Comments
 (0)